From fb5ced64a79589adf2ddaef13c5e3457c116e56d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:37:01 -0700 Subject: [PATCH 001/150] fix(ssh): re-arm remote file watches when the provider reconnects (#10445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ssh): re-arm remote file watches when the provider reconnects Remote file changes stopped being detected over SSH until the file was reopened. The watch pipeline itself was fine — nothing ever re-established the subscription after the transport it was made on went away. Two paths left an editor tab permanently stale: - A reconnect kills the relay's watch registrations, and the previous provider's unwatch handle belongs to the dead transport. - A connect slower than the 60s retry window made installRemoteWatcher give up for good; first deploy to a new host far exceeds that. Neither recovered, because installRemoteWatcher is only reachable from the fs:watchWorktree handler, the retry timer, and the removal-restore path, and the renderer only issues a watch for newly added targets. Reopening the file just re-read it — the watcher stayed dead. Give the layer that owns the transport the job of re-arming: registerSshFilesystemProvider now notifies subscribers, which covers both establish and reconnect since registerProviders runs on both. The watcher keeps the intent to watch in a registry that outlives any single connection, and on registration drops the stale entry (installRemoteWatcher treats an existing entry as installed and would otherwise hand back a watcher that can never fire), reinstalls, and emits overflow so consumers resync the gap. Intent is dropped on unwatch and sender destroy so a closed tab is never resurrected. Verified over SSH to a Rocky Linux 10 host: after the reconnect that previously killed it, a remote append lands in the editor in ~2s with a live remote watcher process, and a second edit in ~1.5s. * fix(ssh): drop watch intent when a remote worktree is removed A removed worktree kept its entry in the intent registry, so a reconnect landing before the renderer's unwatch would re-watch a deleted path — 60s of retries against the host and then a bogus overflow. Also covers two reinstall cases: several senders on one connection must collapse onto a single relay watch (and all of them resync), and a destroyed renderer must not be reinstalled. * fix(ssh): resync when a reconnect's watch only lands on a retry The reinstall emitted the overflow only for listeners whose first install returned 'installed'. If that attempt failed — relay-watcher.js still spawning on the fresh transport, a transient fs.watch rejection — the 1s retry restored the watch but never signalled the gap, so everything that changed while the transport was down stayed invisible: the STA-2525 symptom reappearing inside the fix for it. Thread the resync intent through the retry record so the overflow lands when the retry does. All pre-existing callers default to false, so the watch/terminal-error/restore paths are unchanged. * test(ssh): cover the resync merge when a fresh watch claims the retry slot The `resyncOnInstall ||=` merge was uncovered: removing it left every existing test green. It is load-bearing — if a second renderer joins the reinstall's failing install and reaches the retry slot first with resync=false, the whole chain stays false and the retry restores the watch without ever signalling the gap. New sibling file rather than an append: filesystem-watcher.test.ts is ~10 counted lines from the 800-line lint cap, and AGENTS.md forbids a max-lines disable. --- .../filesystem-watcher-large-batch.test.ts | 3 +- ...lesystem-watcher-local-unsubscribe.test.ts | 3 +- ...filesystem-watcher-native-capacity.test.ts | 3 +- ...system-watcher-remote-cancellation.test.ts | 3 +- .../filesystem-watcher-remote-rearm.test.ts | 88 +++++++ ...lesystem-watcher-unwatchable-roots.test.ts | 3 +- src/main/ipc/filesystem-watcher.test.ts | 237 +++++++++++++++++- src/main/ipc/filesystem-watcher.ts | 198 ++++++++++++++- .../providers/ssh-filesystem-dispatch.test.ts | 69 +++++ src/main/providers/ssh-filesystem-dispatch.ts | 21 ++ 10 files changed, 611 insertions(+), 17 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-remote-rearm.test.ts create mode 100644 src/main/providers/ssh-filesystem-dispatch.test.ts diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts index 2783ca95f0c..01f7ccc90cc 100644 --- a/src/main/ipc/filesystem-watcher-large-batch.test.ts +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -24,7 +24,8 @@ vi.mock('./filesystem-watcher-wsl', () => ({ })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() + getSshFilesystemProvider: vi.fn(), + onSshFilesystemProviderRegistered: () => () => {} })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts index 9a5c5831492..0e15ad43fc7 100644 --- a/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts +++ b/src/main/ipc/filesystem-watcher-local-unsubscribe.test.ts @@ -32,7 +32,8 @@ vi.mock('./parcel-watcher-process', async (importOriginal) => { }) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() + getSshFilesystemProvider: vi.fn(), + onSshFilesystemProviderRegistered: () => () => {} })) import { diff --git a/src/main/ipc/filesystem-watcher-native-capacity.test.ts b/src/main/ipc/filesystem-watcher-native-capacity.test.ts index 658793e2f5e..41db2d35e52 100644 --- a/src/main/ipc/filesystem-watcher-native-capacity.test.ts +++ b/src/main/ipc/filesystem-watcher-native-capacity.test.ts @@ -16,7 +16,8 @@ vi.mock('./parcel-watcher-process', () => ({ })) vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() + getSshFilesystemProvider: vi.fn(), + onSshFilesystemProviderRegistered: () => () => {} })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts index 3e22cb6b835..f4af15588be 100644 --- a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -13,7 +13,8 @@ vi.mock('fs/promises', () => ({ stat: vi.fn() })) vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: () => () => {} })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher-remote-rearm.test.ts b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts new file mode 100644 index 00000000000..869c8bebf45 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-remote-rearm.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + providerRegistrationListeners: new Set<(connectionId: string) => void>() + }) +) + +/** Drive the provider-registration hook the way a relay establish/reconnect would. */ +function emitProviderRegistered(connectionId: string): void { + for (const listener of providerRegistrationListeners) { + listener(connectionId) + } +} + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { + providerRegistrationListeners.add(listener) + return () => providerRegistrationListeners.delete(listener) + } +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('remote filesystem watcher re-arm', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('still resyncs when a fresh watch beat the failed reinstall to the retry slot', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) }) + + await handlers['fs:watchWorktree']({ sender: senderOne }, args) + + // Hold the reinstall's fs.watch open so a second renderer joins it and claims the retry slot first. + let failReinstall: (error: Error) => void = () => {} + const heldWatch = new Promise((_resolve, reject) => { + failReinstall = reject + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockReturnValue(heldWatch) }) + senderOne.send.mockClear() + emitProviderRegistered('conn-1') + + const joinedWatch = handlers['fs:watchWorktree']({ sender: senderTwo }, args) + const retryWatchMock = vi.fn().mockResolvedValue(vi.fn()) + failReinstall(new Error('relay not ready')) + getSshFilesystemProviderMock.mockReturnValue({ watch: retryWatchMock }) + await joinedWatch + await vi.advanceTimersByTimeAsync(1_000) + + // senderOne's watch really died with the old transport, so its resync must survive the merge. + expect(retryWatchMock).toHaveBeenCalledTimes(1) + expect(senderOne.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + }) + + warnSpy.mockRestore() + await closeAllWatchers() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts index 8ad05fc4ab1..d629d74dc02 100644 --- a/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts +++ b/src/main/ipc/filesystem-watcher-unwatchable-roots.test.ts @@ -23,7 +23,8 @@ vi.mock('./filesystem-watcher-wsl', () => ({ })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: vi.fn() + getSshFilesystemProvider: vi.fn(), + onSshFilesystemProviderRegistered: () => () => {} })) import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 8d658b25f12..4687a6b71e1 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -1,9 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - getSshFilesystemProviderMock: vi.fn() -})) +const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + providerRegistrationListeners: new Set<(connectionId: string) => void>() + }) +) + +/** Drive the provider-registration hook the way a relay establish/reconnect would. */ +function emitProviderRegistered(connectionId: string): void { + for (const listener of providerRegistrationListeners) { + listener(connectionId) + } +} vi.mock('electron', () => ({ ipcMain: { @@ -24,12 +34,17 @@ vi.mock('./filesystem-watcher-wsl', () => ({ })) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { + providerRegistrationListeners.add(listener) + return () => providerRegistrationListeners.delete(listener) + } })) import { closeAllWatchers, closeRemoteWatcherForWorktreePath, + forgetRemoteWatcherRemovalSnapshot, registerFilesystemWatcherHandlers, restoreRemoteWatcherAfterFailedRemoval } from './filesystem-watcher' @@ -327,6 +342,195 @@ describe('registerFilesystemWatcherHandlers', () => { vi.useRealTimers() }) + it('reinstalls an SSH worktree watch when the provider is re-registered after a reconnect', async () => { + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const staleUnwatch = vi.fn() + const watchMock = vi.fn().mockResolvedValue(staleUnwatch) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + expect(watchMock).toHaveBeenCalledTimes(1) + + // The reconnect replaces the provider; the watch made on the dead transport can never fire again. + emitProviderRegistered('conn-1') + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2)) + expect(staleUnwatch).toHaveBeenCalledTimes(1) + + // Events missed while the watch was down are unrecoverable, so consumers are told to resync. + await vi.waitFor(() => + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + }) + ) + + const reinstalledEvents = watchMock.mock.calls[1][1] as (events: unknown[]) => void + reinstalledEvents([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }] + }) + + await closeAllWatchers() + }) + + it('re-arms an SSH watch whose first install found no provider yet', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + // A connect slower than the retry window leaves the renderer subscribed with nothing installed. + getSshFilesystemProviderMock.mockReturnValue(undefined) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + emitProviderRegistered('conn-1') + + await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(1)) + warnSpy.mockRestore() + await closeAllWatchers() + }) + + it('resyncs after a reconnect whose reinstall only succeeded on a retry', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + getSshFilesystemProviderMock.mockReturnValue({ watch: vi.fn().mockResolvedValue(vi.fn()) }) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + // The relay is back, but its first fs.watch on the fresh transport still fails. + const retryWatchMock = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock + .mockReturnValueOnce({ watch: vi.fn().mockRejectedValue(new Error('relay not ready')) }) + .mockReturnValue({ watch: retryWatchMock }) + sender.send.mockClear() + emitProviderRegistered('conn-1') + await vi.advanceTimersByTimeAsync(1_000) + + expect(retryWatchMock).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + }) + + warnSpy.mockRestore() + await closeAllWatchers() + vi.useRealTimers() + }) + + it('does not resurrect an SSH watch the renderer already unwatched', async () => { + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + handlers['fs:unwatchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + emitProviderRegistered('conn-1') + await Promise.resolve() + await Promise.resolve() + + expect(watchMock).toHaveBeenCalledTimes(1) + await closeAllWatchers() + }) + + it('leaves watches on other connections untouched when one provider re-registers', async () => { + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + + emitProviderRegistered('conn-2') + await Promise.resolve() + await Promise.resolve() + + expect(watchMock).toHaveBeenCalledTimes(1) + await closeAllWatchers() + }) + + it('reinstalls one shared watch when several senders share a re-registered connection', async () => { + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']( + { sender: senderOne }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + await handlers['fs:watchWorktree']( + { sender: senderTwo }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + expect(watchMock).toHaveBeenCalledTimes(1) + + // Per-listener reinstall must still collapse onto one relay watch, and every listener resyncs. + emitProviderRegistered('conn-1') + await vi.waitFor(() => expect(senderTwo.send).toHaveBeenCalled()) + expect(watchMock).toHaveBeenCalledTimes(2) + for (const sender of [senderOne, senderTwo]) { + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/home/me/repo', + events: [{ kind: 'overflow', absolutePath: '/home/me/repo' }] + }) + } + + await closeAllWatchers() + }) + + it('does not reinstall an SSH watch for a renderer that was destroyed', async () => { + let destroyed = false + const destroyHandlers: (() => void)[] = [] + const sender = { + isDestroyed: () => destroyed, + send: vi.fn(), + once: vi.fn((_event: string, handler: () => void) => { + destroyHandlers.push(handler) + }), + id: 1 + } + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + expect(destroyHandlers).toHaveLength(1) + + destroyed = true + for (const handler of destroyHandlers) { + handler() + } + + emitProviderRegistered('conn-1') + await Promise.resolve() + await Promise.resolve() + + expect(watchMock).toHaveBeenCalledTimes(1) + await closeAllWatchers() + }) + it('shares SSH worktree watchers across renderer senders until the last unwatch', async () => { const sendOne = vi.fn() const sendTwo = vi.fn() @@ -423,6 +627,29 @@ describe('registerFilesystemWatcherHandlers', () => { }) }) + it('does not re-arm an SSH watch for a worktree that was successfully deleted', async () => { + const watchMock = vi.fn().mockResolvedValue(vi.fn()) + const closeWatch = vi.fn().mockResolvedValue(undefined) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock, closeWatch }) + const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + + await handlers['fs:watchWorktree']( + { sender }, + { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + ) + await closeRemoteWatcherForWorktreePath('conn-1', '/home/me/repo') + forgetRemoteWatcherRemovalSnapshot('conn-1', '/home/me/repo') + + // A reconnect can land before the renderer's unwatch; the path no longer exists on the host. + emitProviderRegistered('conn-1') + await Promise.resolve() + await Promise.resolve() + + expect(watchMock).toHaveBeenCalledTimes(1) + expect(sender.send).not.toHaveBeenCalled() + await closeAllWatchers() + }) + it('does not restore an SSH listener stopped while deletion is pending', async () => { const firstUnwatch = vi.fn() const watchMock = vi.fn().mockResolvedValue(firstUnwatch) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 2e2a27cb3f6..fa7c6031a6d 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -11,7 +11,10 @@ import { import { isWslPath } from '../wsl' import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' -import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' +import { + getSshFilesystemProvider, + onSshFilesystemProviderRegistered +} from '../providers/ssh-filesystem-dispatch' import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watcher-process' import { isWatcherProcessFailure } from './parcel-watcher-process-failure' @@ -889,11 +892,19 @@ const suspendedRemoteWatcherListeners = new Map< string, { connectionId: string; worktreePath: string; listeners: Map } >() +// Why: the renderer subscribes once per target and never re-issues, so the intent to watch has to +// outlive any single connection — an install that failed or died with a dropped transport is +// re-armed from here when a provider appears. Without it a reconnect (or a connect slower than the +// retry window) leaves the watch dead until the app restarts. +const desiredRemoteWatchers = new Map< + string, + { connectionId: string; worktreePath: string; listeners: Map } +>() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() const pendingRemoteWatcherRetryListeners = new Map< string, - { listeners: Map; startedAt: number } + { listeners: Map; startedAt: number; resyncOnInstall: boolean } >() // Why: last-listener cleanup aborts relay setup; late success is unwatched rather than installed after the renderer stopped watching. const inFlightRemoteInstalls = new Map() @@ -903,6 +914,7 @@ const pendingRemoteInstallPromises = new Map void) | null = null const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 @@ -979,7 +991,11 @@ export function forgetRemoteWatcherRemovalSnapshot( connectionId: string, worktreePath: string ): void { - suspendedRemoteWatcherListeners.delete(remoteWatcherKey(connectionId, worktreePath)) + const key = remoteWatcherKey(connectionId, worktreePath) + suspendedRemoteWatcherListeners.delete(key) + // Why: the worktree is gone — keeping the intent lets a reconnect landing before the renderer's + // unwatch re-watch a deleted path (60s of retries against the host, then a bogus overflow). + desiredRemoteWatchers.delete(key) } function addInFlightRemoteInstallListener( @@ -1050,6 +1066,9 @@ function releaseRemoteWatchListener(key: string, senderId: number): void { } function cleanupRemoteWatchersForSender(senderId: number): void { + for (const key of Array.from(desiredRemoteWatchers.keys())) { + forgetDesiredRemoteWatcher(key, senderId) + } for (const [key, suspended] of suspendedRemoteWatcherListeners) { suspended.listeners.delete(senderId) if (suspended.listeners.size === 0) { @@ -1249,7 +1268,10 @@ function scheduleRemoteWatcherRetry( sender: WebContents, connectionId: string, worktreePath: string, - startedAt = Date.now() + startedAt = Date.now(), + // Why: a retry that replaces a watch which was already live owes the renderer an overflow once it + // lands — the events lost while it was down are otherwise never signalled. + resyncOnInstall = false ): void { const key = remoteWatcherKey(connectionId, worktreePath) const existingRetry = pendingRemoteWatcherRetryListeners.get(key) @@ -1257,12 +1279,14 @@ function scheduleRemoteWatcherRetry( if (!sender.isDestroyed()) { existingRetry.listeners.set(sender.id, sender) } + existingRetry.resyncOnInstall ||= resyncOnInstall return } const retry = { listeners: new Map(sender.isDestroyed() ? [] : [[sender.id, sender]]), - startedAt + startedAt, + resyncOnInstall } pendingRemoteWatcherRetryListeners.set(key, retry) @@ -1296,10 +1320,26 @@ function scheduleRemoteWatcherRetry( listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath)) ) .then((results) => { + if (retry.resyncOnInstall) { + for (const [index, listener] of listeners.entries()) { + if (results[index] === 'installed' && !listener.isDestroyed()) { + listener.send('fs:changed', { + worktreePath, + events: [{ kind: 'overflow', absolutePath: worktreePath }] + } satisfies FsChangedPayload) + } + } + } // Why: don't re-arm on 'cancelled' (renderer stopped watching) — it would fire a stale overflow when the 60s window expires. if (results.some((result) => result === 'unavailable')) { for (const listener of listeners) { - scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, retry.startedAt) + scheduleRemoteWatcherRetry( + listener, + connectionId, + worktreePath, + retry.startedAt, + retry.resyncOnInstall + ) } } }) @@ -1308,7 +1348,13 @@ function scheduleRemoteWatcherRetry( return } for (const listener of listeners) { - scheduleRemoteWatcherRetry(listener, connectionId, worktreePath, retry.startedAt) + scheduleRemoteWatcherRetry( + listener, + connectionId, + worktreePath, + retry.startedAt, + retry.resyncOnInstall + ) } }) }, REMOTE_WATCH_RETRY_MS) @@ -1318,6 +1364,13 @@ function scheduleRemoteWatcherRetry( // ── Public API ─────────────────────────────────────────────────────── export function registerFilesystemWatcherHandlers(): void { + // Why: re-registration replaces the handler set, so drop the previous subscription instead of + // stacking a second re-arm on every provider registration. + unsubscribeFromProviderRegistrations?.() + unsubscribeFromProviderRegistrations = onSshFilesystemProviderRegistered( + reinstallRemoteWatchersForConnection + ) + ipcMain.handle( 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { @@ -1325,6 +1378,9 @@ export function registerFilesystemWatcherHandlers(): void { // Why: a real new watch reopens the subsystem after closeAllWatchers latched it shut (also resets tests between cases). remoteWatchersClosed = false const key = remoteWatcherKey(args.connectionId, args.worktreePath) + // Why: record intent before the install so a provider registering mid-flight (or long after + // this attempt gives up) can still re-arm this listener. + rememberDesiredRemoteWatcher(args.connectionId, args.worktreePath, event.sender) const result = await installRemoteWatcher( event.sender, args.connectionId, @@ -1353,6 +1409,9 @@ export function registerFilesystemWatcherHandlers(): void { (_event, args: { worktreePath: string; connectionId?: string }): void => { if (args.connectionId) { const key = remoteWatcherKey(args.connectionId, args.worktreePath) + // Why: the caller stopped watching on purpose — drop the intent or a later provider + // registration would resurrect a watch nobody asked for. + forgetDesiredRemoteWatcher(key, _event.sender.id) const suspended = suspendedRemoteWatcherListeners.get(key) suspended?.listeners.delete(_event.sender.id) if (suspended?.listeners.size === 0) { @@ -1386,8 +1445,133 @@ function remoteWatcherKey(connectionId: string, worktreePath: string): string { return JSON.stringify([connectionId, normalizeRuntimePathForComparison(worktreePath)]) } +function rememberDesiredRemoteWatcher( + connectionId: string, + worktreePath: string, + sender: WebContents +): void { + if (sender.isDestroyed()) { + return + } + const key = remoteWatcherKey(connectionId, worktreePath) + const desired = desiredRemoteWatchers.get(key) ?? { + connectionId, + worktreePath, + listeners: new Map() + } + desired.listeners.set(sender.id, sender) + desiredRemoteWatchers.set(key, desired) + registerSenderCleanup(sender) +} + +function forgetDesiredRemoteWatcher(key: string, senderId: number): void { + const desired = desiredRemoteWatchers.get(key) + if (!desired) { + return + } + desired.listeners.delete(senderId) + if (desired.listeners.size === 0) { + desiredRemoteWatchers.delete(key) + } +} + +/** + * Rebuild remote watches for a connection whose filesystem provider was just (re)registered. + * + * Why: the relay's watch registrations die with the transport they were made on, and the previous + * provider's unwatch handle is scoped to that dead transport. Reinstalling is the only way the + * subscription comes back, and consumers get an overflow so they resync whatever changed while the + * watch was down. + */ +function reinstallRemoteWatchersForConnection(connectionId: string): void { + if (remoteWatchersClosed) { + return + } + for (const [key, desired] of Array.from(desiredRemoteWatchers)) { + if (desired.connectionId !== connectionId) { + continue + } + for (const [senderId, sender] of Array.from(desired.listeners)) { + if (sender.isDestroyed()) { + desired.listeners.delete(senderId) + } + } + if (desired.listeners.size === 0) { + desiredRemoteWatchers.delete(key) + continue + } + + // Why: drop the entry the dead transport left behind first — installRemoteWatcher treats an + // existing entry as already-installed and would hand back a watcher that can never fire again. + const stale = remoteWatchers.get(key) + if (stale) { + remoteWatchers.delete(key) + try { + stale.unwatch() + } catch { + // Why: the handle belongs to the replaced transport; failing to close it is expected. + } + } + const retryTimer = pendingRemoteWatcherRetries.get(key) + if (retryTimer) { + clearTimeout(retryTimer) + pendingRemoteWatcherRetries.delete(key) + pendingRemoteWatcherRetryListeners.delete(key) + } + loggedUnavailableRemoteWatchers.delete(key) + + const listeners = Array.from(desired.listeners.values()) + void Promise.all( + listeners.map((listener) => + installRemoteWatcher(listener, desired.connectionId, desired.worktreePath) + ) + ) + .then((results) => { + for (const [index, listener] of listeners.entries()) { + if (results[index] !== 'installed' || listener.isDestroyed()) { + continue + } + // Why: events between the transport dropping and this reinstall are gone for good; + // overflow is the existing "resync, I can't tell you what changed" signal. + listener.send('fs:changed', { + worktreePath: desired.worktreePath, + events: [{ kind: 'overflow', absolutePath: desired.worktreePath }] + } satisfies FsChangedPayload) + } + if (results.some((result) => result === 'unavailable')) { + for (const listener of listeners) { + scheduleRemoteWatcherRetry( + listener, + desired.connectionId, + desired.worktreePath, + Date.now(), + true + ) + } + } + }) + .catch((error: unknown) => { + if (isWatcherRemovalInProgressError(error)) { + return + } + for (const listener of listeners) { + scheduleRemoteWatcherRetry( + listener, + desired.connectionId, + desired.worktreePath, + Date.now(), + true + ) + } + }) + } +} + /** Tear down all watchers on app shutdown. */ export async function closeAllWatchers(): Promise { + // Why: drop the intent with the rest of the state, but keep the provider-registration + // subscription — a new fs:watchWorktree reopens the subsystem and still needs the re-arm hook. + desiredRemoteWatchers.clear() senderCleanupRegistered.clear() unwatchableRoots.clear() suspendedLocalWatcherListeners.clear() diff --git a/src/main/providers/ssh-filesystem-dispatch.test.ts b/src/main/providers/ssh-filesystem-dispatch.test.ts new file mode 100644 index 00000000000..9b932be8c38 --- /dev/null +++ b/src/main/providers/ssh-filesystem-dispatch.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + getSshFilesystemProvider, + onSshFilesystemProviderRegistered, + registerSshFilesystemProvider, + unregisterSshFilesystemProvider +} from './ssh-filesystem-dispatch' +import type { IFilesystemProvider } from './types' + +const provider = {} as IFilesystemProvider + +describe('onSshFilesystemProviderRegistered', () => { + it('notifies subscribers on every registration, including a reconnect replacing the provider', () => { + const listener = vi.fn() + const unsubscribe = onSshFilesystemProviderRegistered(listener) + + registerSshFilesystemProvider('conn-1', provider) + registerSshFilesystemProvider('conn-1', {} as IFilesystemProvider) + + expect(listener).toHaveBeenCalledTimes(2) + expect(listener).toHaveBeenNthCalledWith(1, 'conn-1') + expect(listener).toHaveBeenNthCalledWith(2, 'conn-1') + + unsubscribe() + unregisterSshFilesystemProvider('conn-1') + }) + + it('exposes the new provider to subscribers while they are being notified', () => { + let seen: IFilesystemProvider | undefined + const unsubscribe = onSshFilesystemProviderRegistered((connectionId) => { + seen = getSshFilesystemProvider(connectionId) + }) + + registerSshFilesystemProvider('conn-2', provider) + + expect(seen).toBe(provider) + unsubscribe() + unregisterSshFilesystemProvider('conn-2') + }) + + it('stops notifying after unsubscribe', () => { + const listener = vi.fn() + onSshFilesystemProviderRegistered(listener)() + + registerSshFilesystemProvider('conn-3', provider) + + expect(listener).not.toHaveBeenCalled() + unregisterSshFilesystemProvider('conn-3') + }) + + it('keeps registration working when a subscriber throws', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const healthy = vi.fn() + const unsubscribeThrower = onSshFilesystemProviderRegistered(() => { + throw new Error('subscriber blew up') + }) + const unsubscribeHealthy = onSshFilesystemProviderRegistered(healthy) + + expect(() => registerSshFilesystemProvider('conn-4', provider)).not.toThrow() + expect(getSshFilesystemProvider('conn-4')).toBe(provider) + expect(healthy).toHaveBeenCalledWith('conn-4') + + unsubscribeThrower() + unsubscribeHealthy() + unregisterSshFilesystemProvider('conn-4') + warnSpy.mockRestore() + }) +}) diff --git a/src/main/providers/ssh-filesystem-dispatch.ts b/src/main/providers/ssh-filesystem-dispatch.ts index 6cadd3b7eca..04ef209bef1 100644 --- a/src/main/providers/ssh-filesystem-dispatch.ts +++ b/src/main/providers/ssh-filesystem-dispatch.ts @@ -5,11 +5,32 @@ const sshProviders = new Map() export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE = 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' +// Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old +// transport (file watches) needs a signal to rebuild it — nothing else marks that boundary. +const registrationListeners = new Set<(connectionId: string) => void>() + +export function onSshFilesystemProviderRegistered( + listener: (connectionId: string) => void +): () => void { + registrationListeners.add(listener) + return () => { + registrationListeners.delete(listener) + } +} + export function registerSshFilesystemProvider( connectionId: string, provider: IFilesystemProvider ): void { sshProviders.set(connectionId, provider) + for (const listener of registrationListeners) { + try { + listener(connectionId) + } catch (error) { + // Why: relay establish must not fail because a subscriber threw. + console.warn('[ssh-filesystem] provider registration listener failed:', error) + } + } } export function unregisterSshFilesystemProvider(connectionId: string): void { From b9a7c00df53d0afa8e563c584755b0946fda2580 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:38:03 -0700 Subject: [PATCH 002/150] fix(editor): read out-of-worktree SSH paths without a stamped target id (#9743) (#10455) * fix(editor): read out-of-worktree SSH paths without a stamped target id (#9743) Route external absolute-path tabs by their resolved SSH connection instead of the externalSshTargetId stamp, which only the terminal-link open path sets. The stamp keeps its fail-closed role when present. * fix(editor): keep client-local live-tail log tabs off the worktree SSH host AI Vault "View Log" tabs are opened client-local by construction (readOnly + liveTail, no runtime env), so inferring the external SSH owner from the worktree connection made them read the remote host instead of the granted client path. Restrict that inference to non-live-tail tabs; an explicit externalSshTargetId stamp still routes remotely. --- .../useEditorPanelContentState.test.tsx | 114 ++++++++++++++++++ .../editor/useEditorPanelContentState.ts | 36 +++--- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 5777fc7fbef..3920caa89a8 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -87,6 +87,10 @@ type ProbeProps = { gitStatusByWorktree?: Record } +const authorizeExternalPath = vi.fn() +// Why: opening any liveTail tab arms useLocalLogTail's change subscription. +const onLocalLogTailChanged = vi.fn(() => () => {}) +const fsApi = { authorizeExternalPath, onLocalLogTailChanged } let latestFileContents: Record = {} let latestDiffContents: Record = {} let latestReloadContent: (file: OpenFile) => void = () => {} @@ -130,6 +134,10 @@ describe('useEditorPanelContentState', () => { beforeEach(() => { latestFileContents = {} latestDiffContents = {} + authorizeExternalPath.mockReset() + authorizeExternalPath.mockResolvedValue(undefined) + onLocalLogTailChanged.mockClear() + ;(window as unknown as { api: unknown }).api = { fs: fsApi } mocks.readRuntimeFileContent.mockReset() mocks.getRuntimeGitDiff.mockReset() mocks.getRuntimeGitBranchDiff.mockReset() @@ -226,6 +234,112 @@ describe('useEditorPanelContentState', () => { ) }) + it('loads an unstamped external SSH-host tab through the resolved connection', async () => { + const activeFile = createOpenFile({ + id: '/work/reports/audit.md', + filePath: '/work/reports/audit.md', + relativePath: '/work/reports/audit.md', + worktreeId: 'repo-ssh::/work/demo-project' + }) + mocks.getConnectionIdForFile.mockReturnValue('ssh-1') + mocks.readRuntimeFileContent.mockResolvedValue({ content: '# remote', isBinary: false }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# remote')) + // Why: the client-local grant must not be requested for a remote-owned path. + expect(authorizeExternalPath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ + filePath: '/work/reports/audit.md', + connectionId: 'ssh-1', + expectedExternalSshTargetId: undefined + }) + ) + }) + + it('keeps a client-local live-tail log tab on the client inside an SSH workspace', async () => { + const logPath = '/Users/me/.codex/sessions/session.jsonl' + const worktreeId = 'repo-ssh::/work/demo-project' + const externalTab = { id: logPath, filePath: logPath, relativePath: logPath, worktreeId } + const activeFile = createOpenFile({ ...externalTab, readOnly: true, liveTail: true }) + mocks.getConnectionIdForFile.mockReturnValue('ssh-1') + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'log line', isBinary: false }) + + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('log line')) + // Why: AI Vault only surfaces client-local logs, so the worktree's SSH target must + // not capture this read — it stays a granted client-local path. + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: logPath }) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: undefined, includeLocalLogMetadata: true }) + ) + }) + + it('re-authorizes a client-local external tab before reading it', async () => { + const activeFile = createOpenFile({ + id: '/Users/me/notes/audit.md', + filePath: '/Users/me/notes/audit.md', + relativePath: '/Users/me/notes/audit.md', + worktreeId: 'repo-local::/Users/me/project' + }) + mocks.getConnectionIdForFile.mockReturnValue(undefined) + mocks.readRuntimeFileContent.mockResolvedValue({ content: '# local', isBinary: false }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('# local')) + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: '/Users/me/notes/audit.md' }) + }) + + it('rejects an unstamped external tab in a remote runtime workspace', async () => { + const activeFile = createOpenFile({ + id: '/work/reports/audit.md', + filePath: '/work/reports/audit.md', + relativePath: '/work/reports/audit.md', + worktreeId: 'repo-runtime::/work/demo-project' + }) + mocks.getConnectionIdForFile.mockReturnValue(undefined) + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openFiles: [], + setLastKnownDiskSignature: vi.fn() + }) + + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + + await act(async () => { + root?.render() + }) + + await vi.waitFor(() => + expect(latestFileContents[activeFile.id]?.loadError).toBe( + 'External local files are not available for remote workspaces.' + ) + ) + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + }) + it('rejects an external SSH-host tab after its target owner changes', async () => { const activeFile = createOpenFile({ id: '/tmp/ssh-preview.png', diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index 19887474cd7..26cc45e7c19 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -138,6 +138,11 @@ export function useEditorPanelContentState({ activeSettings, restoredOpenFile?.runtimeEnvironmentId ) + // Why: liveTail tabs are AI Vault logs discovered on this client, so the + // worktree's SSH owner must never be inferred for them (a stamp still routes). + const isLiveTailLogTab = + restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true + let readConnectionId = connectionId if ( resolvedConnectionId === undefined && !readSettings?.activeRuntimeEnvironmentId?.trim() && @@ -149,23 +154,27 @@ export function useEditorPanelContentState({ throw new Error(WORKTREE_OWNER_NOT_READY_ERROR) } if (restoredOpenFile?.filePath === filePath && restoredOpenFile.relativePath === filePath) { - const externalSshTargetId = restoredOpenFile.externalSshTargetId?.trim() - if ( - !externalSshTargetId && - (readSettings?.activeRuntimeEnvironmentId?.trim() || connectionId) - ) { - // Why: restored external-file tabs contain client-local absolute - // paths. Remote runtime and SSH workspaces cannot read those paths - // without an explicit upload/import flow. + // Why: an out-of-worktree absolute path in an SSH workspace belongs to the + // remote host, so the resolved connection owns it even when the tab predates + // (or was opened outside) the terminal-link path that stamps the target id. + const externalSshOwnerId = + restoredOpenFile.externalSshTargetId?.trim() || + (isLiveTailLogTab ? undefined : connectionId) + if (!externalSshOwnerId && readSettings?.activeRuntimeEnvironmentId?.trim()) { + // Why: runtime file RPCs are worktree-scoped, so a client-local absolute + // path has no route into a remote runtime workspace. throw new Error('External local files are not available for remote workspaces.') } - if (!externalSshTargetId) { - // Why: restored external-file tabs need their main-process path grant + if (!externalSshOwnerId) { + // Why: client-local external tabs need their main-process path grant // refreshed because that authorization is only held in memory. await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + // Why: that grant covers the client path, so this read must stay off the + // worktree's SSH host. + readConnectionId = undefined } } - const readScope = getRuntimeFileReadScope(readSettings, connectionId) + const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) const key = inFlightReadKey(readScope, filePath) if (options?.force) { // Why: forced reloads must not attach to a currently registered read @@ -179,10 +188,9 @@ export function useEditorPanelContentState({ filePath, relativePath: restoredOpenFile?.relativePath ?? relativePath, worktreeId, - connectionId, + connectionId: readConnectionId, expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, - includeLocalLogMetadata: - restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true + includeLocalLogMetadata: isLiveTailLogTab }) as Promise inFlightFileReads.set(key, pending) queueMicrotask(() => { From a903304ad47d73ee08e1714e777eb8e0b030dc93 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:55:11 +0000 Subject: [PATCH 003/150] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 420de3778a5..19298ff921c 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 9.6m + + downloads: 9.7m @@ -15,7 +15,7 @@ downloads downloads - 9.6m - 9.6m + 9.7m + 9.7m From 9acb68581678c73a66422fe0303ca57cbf82ef2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:18:40 -0700 Subject: [PATCH 004/150] fix(ssh): re-arm remote watches that die without a reconnect (#10470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ssh): re-arm remote file watches when the provider reconnects Remote file changes stopped being detected over SSH until the file was reopened. The watch pipeline itself was fine — nothing ever re-established the subscription after the transport it was made on went away. Two paths left an editor tab permanently stale: - A reconnect kills the relay's watch registrations, and the previous provider's unwatch handle belongs to the dead transport. - A connect slower than the 60s retry window made installRemoteWatcher give up for good; first deploy to a new host far exceeds that. Neither recovered, because installRemoteWatcher is only reachable from the fs:watchWorktree handler, the retry timer, and the removal-restore path, and the renderer only issues a watch for newly added targets. Reopening the file just re-read it — the watcher stayed dead. Give the layer that owns the transport the job of re-arming: registerSshFilesystemProvider now notifies subscribers, which covers both establish and reconnect since registerProviders runs on both. The watcher keeps the intent to watch in a registry that outlives any single connection, and on registration drops the stale entry (installRemoteWatcher treats an existing entry as installed and would otherwise hand back a watcher that can never fire), reinstalls, and emits overflow so consumers resync the gap. Intent is dropped on unwatch and sender destroy so a closed tab is never resurrected. Verified over SSH to a Rocky Linux 10 host: after the reconnect that previously killed it, a remote append lands in the editor in ~2s with a live remote watcher process, and a second edit in ~1.5s. * fix(ssh): drop watch intent when a remote worktree is removed A removed worktree kept its entry in the intent registry, so a reconnect landing before the renderer's unwatch would re-watch a deleted path — 60s of retries against the host and then a bogus overflow. Also covers two reinstall cases: several senders on one connection must collapse onto a single relay watch (and all of them resync), and a destroyed renderer must not be reinstalled. * fix(ssh): resync when a reconnect's watch only lands on a retry The reinstall emitted the overflow only for listeners whose first install returned 'installed'. If that attempt failed — relay-watcher.js still spawning on the fresh transport, a transient fs.watch rejection — the 1s retry restored the watch but never signalled the gap, so everything that changed while the transport was down stayed invisible: the STA-2525 symptom reappearing inside the fix for it. Thread the resync intent through the retry record so the overflow lands when the retry does. All pre-existing callers default to false, so the watch/terminal-error/restore paths are unchanged. * test(ssh): cover the resync merge when a fresh watch claims the retry slot The `resyncOnInstall ||=` merge was uncovered: removing it left every existing test green. It is load-bearing — if a second renderer joins the reinstall's failing install and reaches the retry slot first with resync=false, the whole chain stays false and the retry restores the watch without ever signalling the gap. New sibling file rather than an append: filesystem-watcher.test.ts is ~10 counted lines from the 800-line lint cap, and AGENTS.md forbids a max-lines disable. * fix(ssh): re-arm a remote watch that died without a reconnect The 60s fast-retry window gave up with a single overflow and no further trigger — provider registration is the only re-arm, and a watch killed by remote OOM/inotify exhaustion leaves the SSH link perfectly healthy. Back off from 1min to a 30min ceiling instead, and stand down entirely when the provider is gone so registration owns that case. * fix(ssh): re-arm the remote file-explorer watch on reconnect SshFilesystemProvider.dispose() stops each watch registration without invoking its terminal callbacks, so a dropped transport left the runtime file-explorer watch silently dead: the lease's restart only runs from the failed-removal path, and the renderer's runtime subscription stays open because it rides a different link. Reinstall on the connection's next provider registration and emit overflow so clients resync. --- .../filesystem-watcher-dormant-rearm.test.ts | 169 ++++++++++++++++++ src/main/ipc/filesystem-watcher.ts | 113 ++++++++++++ .../orca-runtime-files-ssh-rearm.test.ts | 145 +++++++++++++++ .../runtime/orca-runtime-files-watch.test.ts | 3 +- src/main/runtime/orca-runtime-files.test.ts | 1 + src/main/runtime/orca-runtime-files.ts | 110 +++++++++++- 6 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-dormant-rearm.test.ts create mode 100644 src/main/runtime/orca-runtime-files-ssh-rearm.test.ts diff --git a/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts new file mode 100644 index 00000000000..f557ebca379 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-dormant-rearm.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock, providerRegistrationListeners } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + providerRegistrationListeners: new Set<(connectionId: string) => void>() + }) +) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { + providerRegistrationListeners.add(listener) + return () => providerRegistrationListeners.delete(listener) + } +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +const WORKTREE_PATH = '/home/me/repo' +const ARGS = { worktreePath: WORKTREE_PATH, connectionId: 'conn-1' } +const OVERFLOW_PAYLOAD = { + worktreePath: WORKTREE_PATH, + events: [{ kind: 'overflow', absolutePath: WORKTREE_PATH }] +} +const RETRY_GIVE_UP_MS = 61_000 +const DORMANT_FIRST_MS = 60_000 + +function createSender(id: number): { + isDestroyed: () => boolean + send: ReturnType + once: ReturnType + id: number +} { + return { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id } +} + +describe('remote filesystem watcher dormant re-arm', () => { + const handlers: HandlerMap = {} + let warnSpy: ReturnType + + afterEach(() => { + warnSpy.mockRestore() + }) + + beforeEach(async () => { + vi.useRealTimers() + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + /** Install a live watch, kill it terminally, then burn the 1s/60s fast retry window out. */ + async function installThenGiveUp( + sender: ReturnType, + watchAfterDeath: ReturnType + ): Promise { + let onTerminalError: (error: Error) => void = () => {} + getSshFilesystemProviderMock.mockReturnValue({ + watch: vi.fn().mockImplementation((_path, _callback, options) => { + onTerminalError = options.onTerminalError + return Promise.resolve(vi.fn()) + }) + }) + await handlers['fs:watchWorktree']({ sender }, ARGS) + + // The SSH link stays up: only the remote watcher dies (inotify exhaustion, relay watcher killed). + getSshFilesystemProviderMock.mockReturnValue({ watch: watchAfterDeath }) + onTerminalError(new Error('remote watcher died')) + await vi.advanceTimersByTimeAsync(RETRY_GIVE_UP_MS) + expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) + sender.send.mockClear() + } + + it('re-arms after the fast retry window gives up, with no reconnect to trigger it', async () => { + vi.useFakeTimers() + const sender = createSender(1) + const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached')) + await installThenGiveUp(sender, failingWatch) + + const recoveredWatch = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: recoveredWatch }) + await vi.advanceTimersByTimeAsync(DORMANT_FIRST_MS) + + expect(recoveredWatch).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) + + await closeAllWatchers() + vi.useRealTimers() + }) + + it('backs the re-arm off instead of hammering a host that keeps refusing', async () => { + vi.useFakeTimers() + const sender = createSender(1) + const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached')) + await installThenGiveUp(sender, failingWatch) + failingWatch.mockClear() + + await vi.advanceTimersByTimeAsync(DORMANT_FIRST_MS) + expect(failingWatch).toHaveBeenCalledTimes(1) + + // Half an hour of a permanently broken remote watcher must stay in single digits, not 1s retries. + await vi.advanceTimersByTimeAsync(30 * 60_000) + expect(failingWatch.mock.calls.length).toBeLessThanOrEqual(6) + expect(failingWatch.mock.calls.length).toBeGreaterThan(1) + + await closeAllWatchers() + vi.useRealTimers() + }) + + it('stops re-arming once the renderer unwatches', async () => { + vi.useFakeTimers() + const sender = createSender(1) + const failingWatch = vi.fn().mockRejectedValue(new Error('inotify limit reached')) + await installThenGiveUp(sender, failingWatch) + failingWatch.mockClear() + + handlers['fs:unwatchWorktree']({ sender }, ARGS) + await vi.advanceTimersByTimeAsync(60 * 60_000) + + expect(failingWatch).not.toHaveBeenCalled() + + await closeAllWatchers() + vi.useRealTimers() + }) + + it('leaves a dropped connection to the provider-registration re-arm', async () => { + vi.useFakeTimers() + const sender = createSender(1) + const failingWatch = vi.fn().mockRejectedValue(new Error('relay gone')) + await installThenGiveUp(sender, failingWatch) + failingWatch.mockClear() + + // Provider gone == connection down; its registration is the cheaper trigger, so don't poll. + getSshFilesystemProviderMock.mockReturnValue(undefined) + await vi.advanceTimersByTimeAsync(60 * 60_000) + + const recoveredWatch = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch: recoveredWatch }) + await vi.advanceTimersByTimeAsync(60 * 60_000) + expect(recoveredWatch).not.toHaveBeenCalled() + + for (const listener of providerRegistrationListeners) { + listener('conn-1') + } + await vi.advanceTimersByTimeAsync(0) + + expect(recoveredWatch).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', OVERFLOW_PAYLOAD) + + await closeAllWatchers() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index fa7c6031a6d..b217f51e5b4 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -900,6 +900,13 @@ const desiredRemoteWatchers = new Map< string, { connectionId: string; worktreePath: string; listeners: Map } >() +// Why: provider registration only fires on reconnect, so a watch that dies while the SSH link stays +// healthy (remote OOM, inotify/fd exhaustion, relay watcher killed) has no re-arm trigger at all +// once the fast window gives up. Backoff keeps the recovery attempt without the 1s storm. +const dormantRemoteWatchers = new Map< + string, + { delayMs: number; timer: ReturnType } +>() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() const pendingRemoteWatcherRetryListeners = new Map< @@ -917,6 +924,10 @@ let remoteWatcherLifecycleGeneration = 0 let unsubscribeFromProviderRegistrations: (() => void) | null = null const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 +// Why: doubling from a minute to a half-hour ceiling costs a permanently broken remote ~7 fs.watch +// calls in the first hour and 2/hour after, which a flapping link can absorb. +const REMOTE_WATCH_DORMANT_RETRY_MS = 60_000 +const REMOTE_WATCH_DORMANT_RETRY_MAX_MS = 30 * 60_000 export async function closeRemoteWatcherForWorktreePath( connectionId: string, @@ -948,6 +959,8 @@ export async function closeRemoteWatcherForWorktreePath( pendingRemoteWatcherRetries.delete(key) pendingRemoteWatcherRetryListeners.delete(key) } + // Why: removal is deliberate — a backoff firing mid-removal would re-watch the path being deleted. + clearDormantRemoteWatcher(key) const inFlight = inFlightRemoteInstalls.get(key) if (inFlight) { inFlight.listeners.clear() @@ -996,6 +1009,7 @@ export function forgetRemoteWatcherRemovalSnapshot( // Why: the worktree is gone — keeping the intent lets a reconnect landing before the renderer's // unwatch re-watch a deleted path (60s of retries against the host, then a bogus overflow). desiredRemoteWatchers.delete(key) + clearDormantRemoteWatcher(key) } function addInFlightRemoteInstallListener( @@ -1307,6 +1321,8 @@ function scheduleRemoteWatcherRetry( events: [{ kind: 'overflow', absolutePath: worktreePath }] } satisfies FsChangedPayload) } + // Why: overflow only refreshes once — without this the watch stays dead until the app restarts. + scheduleDormantRemoteWatcherRearm(connectionId, worktreePath) return } @@ -1472,9 +1488,100 @@ function forgetDesiredRemoteWatcher(key: string, senderId: number): void { desired.listeners.delete(senderId) if (desired.listeners.size === 0) { desiredRemoteWatchers.delete(key) + clearDormantRemoteWatcher(key) } } +function clearDormantRemoteWatcher(key: string): void { + const dormant = dormantRemoteWatchers.get(key) + if (!dormant) { + return + } + clearTimeout(dormant.timer) + dormantRemoteWatchers.delete(key) +} + +function scheduleDormantRemoteWatcherRearm( + connectionId: string, + worktreePath: string, + delayMs = REMOTE_WATCH_DORMANT_RETRY_MS +): void { + const key = remoteWatcherKey(connectionId, worktreePath) + if (remoteWatchersClosed || !desiredRemoteWatchers.has(key) || dormantRemoteWatchers.has(key)) { + return + } + const timer = setTimeout(() => { + dormantRemoteWatchers.delete(key) + void rearmDormantRemoteWatcher(key, connectionId, worktreePath, delayMs) + }, delayMs) + // Why: a half-hour timer shouldn't be what keeps the process alive at quit. + timer.unref?.() + dormantRemoteWatchers.set(key, { delayMs, timer }) +} + +async function rearmDormantRemoteWatcher( + key: string, + connectionId: string, + worktreePath: string, + delayMs: number +): Promise { + const desired = desiredRemoteWatchers.get(key) + if (remoteWatchersClosed || !desired) { + return + } + for (const [senderId, sender] of Array.from(desired.listeners)) { + if (sender.isDestroyed()) { + desired.listeners.delete(senderId) + } + } + if (desired.listeners.size === 0) { + desiredRemoteWatchers.delete(key) + return + } + // Why: a live watch or an in-flight fast retry already owns this key; installing again would + // clobber the entry the running watch reads its listeners from. + if (remoteWatchers.has(key) || pendingRemoteWatcherRetries.has(key)) { + return + } + // Why: no provider means the connection itself is down, and its registration re-arms for free — + // polling would only add wire traffic to a link that is already being rebuilt. + if (!getSshFilesystemProvider(connectionId)) { + return + } + + const listeners = Array.from(desired.listeners.values()) + let results: RemoteWatcherInstallResult[] + try { + results = await Promise.all( + listeners.map((listener) => installRemoteWatcher(listener, connectionId, worktreePath)) + ) + } catch (error) { + if (isWatcherRemovalInProgressError(error)) { + // Why: removal owns the key now and either forgets the intent or restores the watch itself. + return + } + scheduleDormantRemoteWatcherRearm(connectionId, worktreePath, nextDormantDelayMs(delayMs)) + return + } + for (const [index, listener] of listeners.entries()) { + if (results[index] !== 'installed' || listener.isDestroyed()) { + continue + } + listener.send('fs:changed', { + worktreePath, + events: [{ kind: 'overflow', absolutePath: worktreePath }] + } satisfies FsChangedPayload) + } + // Why: 'cancelled' means shutdown or the last listener left, so only 'unavailable' stays dormant. + if (results.some((result) => result === 'unavailable')) { + scheduleDormantRemoteWatcherRearm(connectionId, worktreePath, nextDormantDelayMs(delayMs)) + } +} + +function nextDormantDelayMs(delayMs: number): number { + return Math.min(delayMs * 2, REMOTE_WATCH_DORMANT_RETRY_MAX_MS) +} + /** * Rebuild remote watches for a connection whose filesystem provider was just (re)registered. * @@ -1518,6 +1625,8 @@ function reinstallRemoteWatchersForConnection(connectionId: string): void { pendingRemoteWatcherRetries.delete(key) pendingRemoteWatcherRetryListeners.delete(key) } + // Why: this reinstall supersedes the pending backoff; leaving it armed double-installs the key. + clearDormantRemoteWatcher(key) loggedUnavailableRemoteWatchers.delete(key) const listeners = Array.from(desired.listeners.values()) @@ -1592,6 +1701,10 @@ export async function closeAllWatchers(): Promise { } pendingRemoteWatcherRetries.clear() pendingRemoteWatcherRetryListeners.clear() + for (const dormant of dormantRemoteWatchers.values()) { + clearTimeout(dormant.timer) + } + dormantRemoteWatchers.clear() loggedUnavailableRemoteWatchers.clear() // Why: latch both subsystems shut so late installs can't register; generation bumps reject older-lifecycle waiters. remoteWatchersClosed = true diff --git a/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts new file mode 100644 index 00000000000..f25f6c9051a --- /dev/null +++ b/src/main/runtime/orca-runtime-files-ssh-rearm.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../shared/types' + +const { + resolveAuthorizedPathMock, + statMock, + watchInWatcherProcessMock, + closeWatcherInWatcherProcessMock, + getSshFilesystemProviderMock, + providerRegistrationListeners +} = vi.hoisted(() => ({ + resolveAuthorizedPathMock: vi.fn(), + statMock: vi.fn(), + watchInWatcherProcessMock: vi.fn(), + closeWatcherInWatcherProcessMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn(), + providerRegistrationListeners: new Set<(connectionId: string) => void>() +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual>('fs/promises') + return { ...actual, stat: statMock } +}) +vi.mock('./file-watcher-host', () => ({ + closeFileExplorerWatcherInWatcherProcess: closeWatcherInWatcherProcessMock, + watchFileExplorerInWatcherProcess: watchInWatcherProcessMock +})) +vi.mock('../ipc/filesystem-auth', async () => { + const actual = await vi.importActual>('../ipc/filesystem-auth') + return { ...actual, resolveAuthorizedPath: resolveAuthorizedPathMock } +}) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.', + onSshFilesystemProviderRegistered: (listener: (connectionId: string) => void) => { + providerRegistrationListeners.add(listener) + return () => providerRegistrationListeners.delete(listener) + } +})) + +import { + _resetRuntimeFileWatcherLeasesForTests, + awaitRuntimeFileWatcherUnsubscribes, + RuntimeFileCommands +} from './orca-runtime-files' + +const ROOT_PATH = '/home/me/repo' +const CONNECTION_ID = 'conn-1' +const OVERFLOW_EVENTS: FsChangeEvent[] = [{ kind: 'overflow', absolutePath: ROOT_PATH }] + +/** Drive the provider-registration hook the way a relay reconnect would. */ +function emitProviderRegistered(connectionId: string): void { + for (const listener of providerRegistrationListeners) { + listener(connectionId) + } +} + +function createRuntimeFileCommands(): RuntimeFileCommands { + return new RuntimeFileCommands({ + getRuntimeId: () => 'runtime-1', + requireStore: () => ({ getRepo: vi.fn(() => undefined) }), + resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH })), + resolveRuntimeFileTarget: vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: ROOT_PATH }, + connectionId: CONNECTION_ID + })), + resolveRuntimeGitTarget: vi.fn(), + openFile: vi.fn() + } as never) +} + +describe('remote file-explorer watch re-arm', () => { + beforeEach(() => { + resolveAuthorizedPathMock.mockReset() + statMock.mockReset() + watchInWatcherProcessMock.mockReset() + closeWatcherInWatcherProcessMock.mockReset() + getSshFilesystemProviderMock.mockReset() + providerRegistrationListeners.clear() + }) + + afterEach(async () => { + await awaitRuntimeFileWatcherUnsubscribes() + _resetRuntimeFileWatcherLeasesForTests() + }) + + it('reinstalls and resyncs when the connection re-registers its provider', async () => { + // Why: dispose() on transport loss stops the registration without firing onTerminalError, so + // nothing else tells this watch it died. + const firstUnwatch = vi.fn() + const secondUnwatch = vi.fn() + const watch = vi.fn().mockResolvedValueOnce(firstUnwatch).mockResolvedValueOnce(secondUnwatch) + getSshFilesystemProviderMock.mockReturnValue({ watch }) + const commands = createRuntimeFileCommands() + const onEvents = vi.fn() + + await commands.watchFileExplorer('id:wt-1', onEvents) + expect(watch).toHaveBeenCalledTimes(1) + + emitProviderRegistered(CONNECTION_ID) + await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(2)) + + expect(onEvents).toHaveBeenCalledWith(OVERFLOW_EVENTS) + // The dead transport's handle must not be closed against the fresh registration. + expect(firstUnwatch).not.toHaveBeenCalled() + }) + + it('ignores registrations for other connections', async () => { + const watch = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch }) + const commands = createRuntimeFileCommands() + + await commands.watchFileExplorer('id:wt-1', vi.fn()) + emitProviderRegistered('conn-other') + await Promise.resolve() + + expect(watch).toHaveBeenCalledTimes(1) + }) + + it('stops re-arming after the watch is released', async () => { + const watch = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch }) + const commands = createRuntimeFileCommands() + + const unsubscribe = await commands.watchFileExplorer('id:wt-1', vi.fn()) + await unsubscribe() + emitProviderRegistered(CONNECTION_ID) + await Promise.resolve() + + expect(watch).toHaveBeenCalledTimes(1) + }) + + it('stops re-arming after the worktree is removed', async () => { + const watch = vi.fn().mockResolvedValue(vi.fn()) + getSshFilesystemProviderMock.mockReturnValue({ watch }) + const commands = createRuntimeFileCommands() + + await commands.watchFileExplorer('id:wt-1', vi.fn()) + commands.forgetFileExplorerWatchersAfterRemoval(ROOT_PATH, CONNECTION_ID) + emitProviderRegistered(CONNECTION_ID) + await Promise.resolve() + + expect(watch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/orca-runtime-files-watch.test.ts b/src/main/runtime/orca-runtime-files-watch.test.ts index 23d5739ecd2..41c57b5c44f 100644 --- a/src/main/runtime/orca-runtime-files-watch.test.ts +++ b/src/main/runtime/orca-runtime-files-watch.test.ts @@ -54,7 +54,8 @@ vi.mock('../ipc/filesystem-auth', async () => { }) vi.mock('../providers/ssh-filesystem-dispatch', () => ({ - getSshFilesystemProvider: getSshFilesystemProviderMock + getSshFilesystemProvider: getSshFilesystemProviderMock, + onSshFilesystemProviderRegistered: () => () => undefined })) import { diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index f92ebd6b656..379372dd0ac 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -93,6 +93,7 @@ vi.mock('../ipc/local-worktree-runtime-options', () => ({ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), + onSshFilesystemProviderRegistered: () => () => undefined, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.' })) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index c518a4ced3d..e37c7106747 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -72,6 +72,7 @@ import { import type { Store } from '../persistence' import { getSshFilesystemProvider, + onSshFilesystemProviderRegistered, SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE } from '../providers/ssh-filesystem-dispatch' import type { FileStat, IFilesystemProvider } from '../providers/types' @@ -126,6 +127,10 @@ type RuntimeFileWatcherLease = { forget(): void } const runtimeFileWatcherLeasesByOwnerAndRoot = new Map>() +// Why: the provider's dispose() stops each watch registration without firing its terminal callback, +// so a dropped SSH transport leaves this watch silently dead — a reconnect's fresh provider is the +// only signal it can be rebuilt from. Keyed like the leases so worktree removal can drop it. +const sshFileExplorerWatchRearms = new Map void>>() const MOBILE_BINARY_EXTENSIONS = new Set([ '.avif', '.bmp', @@ -225,6 +230,94 @@ function runtimeWatcherReleaseKey( return JSON.stringify([runtimeId, connectionId ?? null, normalizeRuntimeWatcherRoot(rootPath)]) } +/** + * Keep an SSH file-explorer watch alive across reconnects. + * + * Why: the previous provider's unwatch handle belongs to the dead transport, so reinstalling on the + * fresh provider is the only way the subscription comes back. Callers get an overflow because the + * events lost while the watch was down can't be replayed. + */ +function armSshFileExplorerWatchRearm(args: { + runtimeId: string + connectionId: string + rootPath: string + callback: (events: FsChangeEvent[]) => void + onTerminalError: (error: Error) => void + signal?: AbortSignal + initialUnwatch: () => void +}): { unsubscribe: () => Promise } { + const key = runtimeWatcherReleaseKey(args.runtimeId, args.connectionId, args.rootPath) + let currentUnwatch = args.initialUnwatch + let stopped = false + let reinstalling: Promise | null = null + + const reinstall = async (): Promise => { + const provider = getSshFilesystemProvider(args.connectionId) + if (stopped || !provider) { + return + } + // Why: the old handle is scoped to the dead transport; closing it here would only risk + // unwatching the root we just re-registered on the new one. + const nextUnwatch = await provider.watch(args.rootPath, args.callback, { + signal: args.signal, + onTerminalError: args.onTerminalError + }) + if (stopped) { + nextUnwatch() + return + } + currentUnwatch = nextUnwatch + args.callback([{ kind: 'overflow', absolutePath: args.rootPath }]) + } + + const unsubscribeRearm = onSshFilesystemProviderRegistered((registeredId) => { + if (registeredId !== args.connectionId || stopped) { + return + } + // Why: reconnect storms can register repeatedly; chain so a second one can't double-install. + const attempt = (reinstalling ?? Promise.resolve()) + .then(reinstall) + .catch((error: unknown) => { + args.onTerminalError(error instanceof Error ? error : new Error(String(error))) + }) + .finally(() => { + if (reinstalling === attempt) { + reinstalling = null + } + }) + reinstalling = attempt + }) + + const stop = (): void => { + stopped = true + unsubscribeRearm() + const rearms = sshFileExplorerWatchRearms.get(key) + rearms?.delete(stop) + if (rearms?.size === 0) { + sshFileExplorerWatchRearms.delete(key) + } + } + const rearms = sshFileExplorerWatchRearms.get(key) ?? new Set<() => void>() + rearms.add(stop) + sshFileExplorerWatchRearms.set(key, rearms) + + return { + unsubscribe: () => { + stop() + const close = async (): Promise => currentUnwatch() + // Why: awaiting an absent reinstall costs a microtask, and removal gating relies on the + // unwatch being issued on the same turn the lease releases it. + return reinstalling ? reinstalling.catch(() => undefined).then(close) : close() + } + } +} + +function stopSshFileExplorerWatchRearms(key: string): void { + for (const stop of Array.from(sshFileExplorerWatchRearms.get(key) ?? [])) { + stop() + } +} + function registerRuntimeFileWatcherRelease( runtimeId: string, connectionId: string | undefined, @@ -385,6 +478,9 @@ export function _resetRuntimeFileWatcherLeasesForTests(): void { for (const lease of leases) { lease.forget() } + for (const key of Array.from(sshFileExplorerWatchRearms.keys())) { + stopSshFileExplorerWatchRearms(key) + } runtimeFileWatcherLeasesByOwnerAndRoot.clear() } @@ -1205,7 +1301,16 @@ export class RuntimeFileCommands { } // Why: the RPC layer already threads AbortSignal for local watches; SSH must cancel the remote fs.watch, not wait it out. const close = await provider.watch(target.path, callback, { signal, onTerminalError }) - return { unsubscribe: async () => close(), rootPaths: [target.path] } + const rearm = armSshFileExplorerWatchRearm({ + runtimeId: this.host.getRuntimeId(), + connectionId: target.connectionId, + rootPath: target.path, + callback, + onTerminalError, + signal, + initialUnwatch: close + }) + return { unsubscribe: rearm.unsubscribe, rootPaths: [target.path] } } const rootPath = await resolveAuthorizedPath(target.path, this.host.requireStore()) @@ -1266,6 +1371,9 @@ export class RuntimeFileCommands { forgetFileExplorerWatchersAfterRemoval(rootPath: string, connectionId?: string): void { const key = runtimeWatcherReleaseKey(this.host.getRuntimeId(), connectionId, rootPath) + // Why: forget() never runs the lease's unsubscribe, so the re-arm would outlive a deleted + // worktree and re-watch it on the next reconnect. + stopSshFileExplorerWatchRearms(key) const leases = runtimeFileWatcherLeasesByOwnerAndRoot.get(key) if (leases) { for (const lease of Array.from(leases)) { From c3526cc19dcc5e0c6d0c6286d9ac817695016b7d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:30:54 -0700 Subject: [PATCH 005/150] feat(codex): surface a stalled config sync instead of failing silently (#10449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(codex): surface a stalled config sync instead of failing silently Why: the mirror keeps serving the last synced settings when ~/.codex/config.toml is missing, blank, or unreadable. That is the right call for data safety, but it is invisible — a downed WSL distro or an unhydrated cloud-synced home leaves "Orca ignores my config edits" with no log line and no UI to diagnose. Status is derived on demand from the same predicates the mirror uses, so the two cannot disagree. The stall is logged once per episode rather than on every launch and quota poll, and the Codex account section names the file and what to do. * fix(codex): latch an unreadable source and stop over-claiming recovery An unreadable source throws out of the mirror, so reporting only on the success path left that stall latch-less: it logged the raw failure on every launch and quota poll while its reason never reached the surfaced status. Report from the catch path too. The clear message also claimed the source was "readable again", which is false when the stall ended because the runtime config was removed rather than because the source came back. Restoring console.warn now happens in afterEach — an inline mockRestore is skipped by a failing assertion, and the leaked spy made every later case in the block fail spuriously. * fix(codex): latch the stall promotion hits first, and scope it to the host Review round 1 findings: - The unreadable-source latch still never fired in the steady state. Once a baseline exists, promotion reads the source before the mirror does, so it throws first and `!promotionPlan` returned before any reporting — logging a reasonless failure every launch and quota poll, which is exactly what the previous commit claimed to fix. Report from that branch too. The test only passed because its fixture had no baseline; it now seeds one first and fails without the fix. - The banner named the host's ~/.codex while a WSL or per-account runtime was selected, whose real source is a different file entirely. Gate it to the host scope, matching how the sign-in warning is already gated. - Three new translate keys were missing from the locale catalogs, failing the localization gate in `pnpm lint`. - The registrar mock was never asserted, so deleting the registration left the suite green. - `codexConfigSyncStatus` hung off the `agentHooks` namespace despite having nothing to do with agent hooks; moved to its own `codexConfigSync.status` while it is still a four-file change. * fix(codex): report sync health for the home the selection actually mirrors Review round 2: - The status resolved the shared runtime home, but the system default now runs Codex directly against ~/.codex and managed accounts get their own home. So a stalled per-account mirror showed no banner at all, while a stale shared home could warn about a config the active lane never reads. Resolve the mirrored home from the current selection, and report synced when the lane has no mirror to fall behind. - The round-1 report on the promotion failure path could clear the latch on a pass where no mirror ran, claiming a recovery that never happened and silencing every later pass. Only ever latch a stall there; leave clearing to the path that actually mirrored. * fix(codex): refetch sync status when the active Codex account changes Review round 3: - Resolving the status per selection made the fetch account-dependent, but the effect was not keyed on the active account. Switching accounts left the banner describing the previous one — and switching INTO a stalled account showed nothing at all, which is the silence this change exists to remove. - Pin the home resolution itself: it had no direct test, and its shared-home path was a hand-copied literal that could drift from the real helper and silence the banner with every other test still green. - Narrow the handler's dependency to the one method it calls, which also drops an `as unknown as` cast from its test. - Skip the chmod-based test on Windows, where a read-only directory does not block writes so the scenario cannot be constructed; matches the convention already used in config-settings-promotion.test.ts. * chore(codex): restore the handler docstring and isolate the resolver suite Round 4 returned clean; these are its two non-blocking nits. Narrowing the handler param left its JSDoc stranded above the new type, so the function had no hover doc. The resolver suite also read the developer's real CODEX_HOME and shell rc, so anyone exporting one would see it fail locally. --- config/tsconfig.cli.json | 1 + .../runtime-home-mirrored-status-home.test.ts | 104 ++++++++ .../codex-accounts/runtime-home-service.ts | 22 ++ src/main/codex-accounts/service.ts | 9 + src/main/codex/codex-config-mirror.ts | 20 +- src/main/codex/config-sync-stall.test.ts | 229 ++++++++++++++++++ src/main/codex/config-sync-stall.ts | 98 ++++++++ src/main/ipc/codex-config-sync.test.ts | 80 ++++++ src/main/ipc/codex-config-sync.ts | 30 +++ src/main/ipc/register-core-handlers.test.ts | 11 +- src/main/ipc/register-core-handlers.ts | 2 + src/preload/api-types.ts | 4 + src/preload/index.ts | 4 + .../src/components/settings/AccountsPane.tsx | 58 +++++ .../codex-config-sync-warning.test.ts | 21 ++ .../settings/codex-config-sync-warning.ts | 17 ++ src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- src/renderer/src/web/web-preload-api.ts | 6 + src/shared/codex-config-sync-types.ts | 8 + 23 files changed, 742 insertions(+), 7 deletions(-) create mode 100644 src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts create mode 100644 src/main/codex/config-sync-stall.test.ts create mode 100644 src/main/codex/config-sync-stall.ts create mode 100644 src/main/ipc/codex-config-sync.test.ts create mode 100644 src/main/ipc/codex-config-sync.ts create mode 100644 src/renderer/src/components/settings/codex-config-sync-warning.test.ts create mode 100644 src/renderer/src/components/settings/codex-config-sync-warning.ts create mode 100644 src/shared/codex-config-sync-types.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 5eaaf5c068b..5d1d949131e 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -41,6 +41,7 @@ "../src/main/codex/config-settings-baseline.ts", "../src/main/codex/config-settings-conflict-resolution.ts", "../src/main/codex/config-settings-promotion.ts", + "../src/main/codex/config-sync-stall.ts", "../src/main/codex/config-toml-deprecated-hook-flag.ts", "../src/main/codex/config-toml-key-path.ts", "../src/main/codex/config-toml-line-scan.ts", diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts new file mode 100644 index 00000000000..19bc71dac23 --- /dev/null +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as NodeOs from 'node:os' +import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' + +const testState = { userData: '', home: '' } +const previousEnv: Record = {} + +vi.mock('electron', () => ({ app: { getPath: () => testState.userData } })) +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') + return { ...actual, homedir: () => testState.home } +}) + +beforeEach(() => { + vi.resetModules() + testState.userData = mkdtempSync(join(tmpdir(), 'orca-codex-status-home-ud-')) + testState.home = mkdtempSync(join(tmpdir(), 'orca-codex-status-home-')) + // Why: the real-home check consults CODEX_HOME and the shell rc, so a + // developer who exports one would otherwise fail this suite locally. + for (const key of [ + 'ORCA_USER_DATA_PATH', + 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', + 'CODEX_HOME', + 'ORCA_CODEX_HOME' + ]) { + previousEnv[key] = process.env[key] + delete process.env[key] + } + process.env.ORCA_USER_DATA_PATH = testState.userData + mkdirSync(join(testState.home, '.codex'), { recursive: true }) +}) + +afterEach(() => { + rmSync(testState.userData, { recursive: true, force: true }) + rmSync(testState.home, { recursive: true, force: true }) + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } +}) + +function createStore(accounts: CodexManagedAccount[], activeId: string | null) { + const settings = { + codexManagedAccounts: accounts, + activeCodexManagedAccountId: activeId, + activeCodexManagedAccountIdsByRuntime: { host: activeId, wsl: {} } + } as GlobalSettings + return { + getSettings: () => settings, + updateSettings: (updates: Partial) => Object.assign(settings, updates) + } +} + +function createManagedAccount(id: string): CodexManagedAccount { + const home = join(testState.userData, 'codex-accounts', id, 'home') + mkdirSync(home, { recursive: true }) + writeFileSync(join(home, '.orca-managed-home'), `${id}\n`, 'utf-8') + writeFileSync(join(home, 'auth.json'), '{}', 'utf-8') + return { + id, + providerAccountId: id, + email: `${id}@example.com`, + managedHomePath: home, + runtime: 'host' + } as unknown as CodexManagedAccount +} + +// Why: the config-sync status is only correct if it names the home the current +// selection actually mirrors into. Nothing else pins that resolution, so a +// change to the lane rules or the shared-home layout would silence the banner +// with every other test still green. +describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { + it('returns null for the system default, which runs on the real home with no mirror', async () => { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(createStore([], null) as never) + + expect(service.getMirroredHostHomePathForStatus()).toBeNull() + }) + + it('returns the selected account own home, which is what its mirror targets', async () => { + const account = createManagedAccount('acct-1') + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(createStore([account], account.id) as never) + + expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath) + }) + + it('returns the shared runtime home when the real-home lane is off', async () => { + process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '0' + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths') + const service = new CodexRuntimeHomeService(createStore([], null) as never) + + // Why: compare against the real helper, not a repeated literal, so the + // status cannot silently drift if the managed home layout ever moves. + expect(service.getMirroredHostHomePathForStatus()).toBe(getOrcaManagedCodexHomePath()) + }) +}) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index dfa49c74cd1..9b1340b3e70 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -34,6 +34,7 @@ import { WSL_CODEX_RUNTIME_HOME_SEGMENTS } from '../pty/codex-home-wsl-env' import { writeFileAtomically } from './fs-utils' import { getOrcaManagedCodexHomePath, + getOrcaUserDataPath, getCodexSessionBackfillStateDirPath, getSystemCodexHomePath, syncCodexGlobalInstructionsIntoManagedHome, @@ -1150,6 +1151,27 @@ export class CodexRuntimeHomeService { return getOrcaManagedCodexHomePath() } + /** + * Resolves the managed home the config mirror actually targets for the + * current HOST selection, or null when no mirror runs for it. + * + * Read-only on purpose: unlike the launch and quota-fetch paths this prepares + * nothing and creates no directories, so surfacing sync health cannot alter + * the state it is reporting on. Returns null for the system default on the + * real-home lane, which runs Codex directly against ~/.codex — there is no + * mirror there, so there is nothing that can fall behind. + */ + getMirroredHostHomePathForStatus(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + if (selfContainedAccount) { + return this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + } + if (this.isHostSystemDefaultRealHome()) { + return null + } + return join(getOrcaUserDataPath(), 'codex-runtime-home', 'home') + } + private getRuntimeAuthPath(): string { return join(this.getRuntimeHomePath(), 'auth.json') } diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index b57c277e526..677d6d569db 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -261,6 +261,15 @@ export class CodexAccountService { this.safeSyncCanonicalConfigToManagedHomes() } + /** + * Read-only access for surfaces that report on the runtime home rather than + * prepare it — notably the config-sync status channel, which must resolve the + * home the current selection actually mirrors into without creating anything. + */ + get runtimeHomeService(): CodexRuntimeHomeService { + return this.runtimeHome + } + private serializeMutation(fn: () => Promise): Promise { const next = this.mutationQueue.then(fn, fn) this.mutationQueue = next.catch(() => {}) diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 803b5f1fda0..4b439e20372 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -13,6 +13,7 @@ import { type CodexSettingsPromotionPlan } from './config-settings-promotion' import { readCodexSettingsBaseline } from './config-settings-baseline' +import { getCodexConfigSyncStatus, reportCodexConfigSyncOutcome } from './config-sync-stall' import { preserveRuntimeConflictValues } from './codex-config-settings-preservation' import { deduplicateProjectTomlSections, @@ -39,15 +40,32 @@ export function syncSystemConfigIntoManagedCodexHome( if (!promotionPlan) { // Why: mirroring after a failed write-back would erase the runtime change; // leave both runtime and its old baseline intact so the next launch retries. + // Report first: once a baseline exists, an unreadable source throws inside + // promotion rather than the mirror, so reporting only later would leave the + // steady-state stall logging a reasonless failure on every pass forever. + // Only a stall, never a clear: promotion failing on a readable source still + // means no mirror ran, so clearing the latch here would claim a recovery + // that did not happen and silence every later pass. + const stalledStatus = getCodexConfigSyncStatus(homes) + if (stalledStatus.state === 'stalled') { + reportCodexConfigSyncOutcome(homes.runtimeHomePath, stalledStatus) + } return } let mirrorResult: CodexConfigMirrorResult try { mirrorResult = syncSystemConfigIntoManagedCodexHomeUnsafe(homes, promotionPlan) } catch (error) { - console.warn('[codex-config] Failed to mirror system Codex config:', error) + // Why: an unreadable source throws out of the mirror, so reporting only on + // the success path would leave that stall latch-less — logging the generic + // failure on every launch and quota poll while the surfaced reason never + // reaches the user. + reportCodexConfigSyncOutcome(homes.runtimeHomePath, getCodexConfigSyncStatus(homes), error) return } + // Why: report from the same pass that decided, so the surfaced status can + // never disagree with what the mirror actually did. + reportCodexConfigSyncOutcome(homes.runtimeHomePath, getCodexConfigSyncStatus(homes)) if (mirrorResult.status === 'skipped-missing-source') { // Why: advancing an existing baseline would mark the unmirrored runtime // change as promoted, so it could never retry once the source reappears. diff --git a/src/main/codex/config-sync-stall.test.ts b/src/main/codex/config-sync-stall.test.ts new file mode 100644 index 00000000000..6cdd25613b2 --- /dev/null +++ b/src/main/codex/config-sync-stall.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + getCodexConfigSyncStatus, + resetCodexConfigSyncStallLatchForTests +} from './config-sync-stall' +import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror' + +let root: string +let homes: { runtimeHomePath: string; systemHomePath: string } + +function systemConfigPath(): string { + return join(homes.systemHomePath, 'config.toml') +} + +function runtimeConfigPath(): string { + return join(homes.runtimeHomePath, 'config.toml') +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-codex-sync-stall-')) + homes = { runtimeHomePath: join(root, 'runtime'), systemHomePath: join(root, 'system') } + mkdirSync(homes.runtimeHomePath, { recursive: true }) + mkdirSync(homes.systemHomePath, { recursive: true }) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +describe('getCodexConfigSyncStatus', () => { + it('reports synced while the source config is usable', () => { + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n', 'utf-8') + + expect(getCodexConfigSyncStatus(homes)).toEqual({ + state: 'synced', + reason: null, + systemConfigPath: systemConfigPath() + }) + }) + + it('reports a stall when the source config is missing', () => { + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + + expect(getCodexConfigSyncStatus(homes)).toEqual({ + state: 'stalled', + reason: 'missing-source', + systemConfigPath: systemConfigPath() + }) + }) + + it('reports a stall when the source config is blank', () => { + writeFileSync(systemConfigPath(), '\n \n', 'utf-8') + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + + expect(getCodexConfigSyncStatus(homes).reason).toBe('blank-source') + }) + + it('reports a stall when the source config cannot be read', () => { + // Why: a directory at the config path survives existsSync but throws on + // read, standing in for a permission-denied or otherwise unreadable home. + mkdirSync(systemConfigPath(), { recursive: true }) + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + + expect(getCodexConfigSyncStatus(homes).reason).toBe('unreadable-source') + }) + + it('stays synced before the runtime config exists, since nothing can fall behind yet', () => { + expect(getCodexConfigSyncStatus(homes)).toEqual({ + state: 'synced', + reason: null, + systemConfigPath: systemConfigPath() + }) + }) + + // Why: the status must never claim a sync the mirror would decline, so pin it + // to the mirror's real behavior rather than to a duplicated predicate. + it('reports a stall exactly when the mirror preserves the runtime config', () => { + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + expect(getCodexConfigSyncStatus(homes).state).toBe('synced') + + rmSync(systemConfigPath()) + syncSystemConfigIntoManagedCodexHome(homes) + + expect(getCodexConfigSyncStatus(homes).state).toBe('stalled') + // The mirror keeps serving the last good settings — that is what makes the + // stall silent, and why it needs surfacing. + expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('model = "gpt-5"') + }) + + it('clears the stall once the source config returns', () => { + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + expect(getCodexConfigSyncStatus(homes).state).toBe('stalled') + + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + + expect(getCodexConfigSyncStatus(homes).state).toBe('synced') + }) +}) + +describe('reportCodexConfigSyncOutcome', () => { + beforeEach(() => { + resetCodexConfigSyncStallLatchForTests() + }) + + // Why: a failing assertion skips an inline mockRestore, and a leaked + // console.warn spy makes every later case in this block fail spuriously. + afterEach(() => { + vi.restoreAllMocks() + }) + + it('logs a stall once per episode rather than on every sync pass', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + + for (let pass = 0; pass < 3; pass += 1) { + syncSystemConfigIntoManagedCodexHome(homes) + } + + expect(warn.mock.calls.filter((call) => String(call[0]).includes('stalled'))).toHaveLength(1) + }) + + it('logs once when the stall clears after the source config returns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + syncSystemConfigIntoManagedCodexHome(homes) + + expect( + warn.mock.calls.filter((call) => String(call[0]).includes('stall cleared')) + ).toHaveLength(1) + }) + + it('latches an unreadable source instead of logging the raw failure every pass', () => { + // Why: an unreadable source throws out of the mirror, so before the catch + // path reported it this stall logged the generic failure on every launch + // and quota poll and never surfaced its reason. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + // Why: seed a baseline with one healthy pass first. Without it promotion + // no-ops before touching the source and the mirror is what throws — which + // is NOT the steady state every real install is in, where promotion reads + // the source first and throws there instead. + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + warn.mockClear() + + rmSync(systemConfigPath()) + mkdirSync(systemConfigPath(), { recursive: true }) + + for (let pass = 0; pass < 3; pass += 1) { + syncSystemConfigIntoManagedCodexHome(homes) + } + + const stallLogs = warn.mock.calls.filter((call) => String(call[0]).includes('stalled')) + expect(stallLogs).toHaveLength(1) + expect(String(stallLogs[0]?.[0])).toContain('unreadable-source') + }) + + it('clears a stall without claiming the source became readable', () => { + // Why: a removed runtime config also reads as synced, so "readable again" + // would be a false claim about a source that is still gone. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + + rmSync(runtimeConfigPath()) + syncSystemConfigIntoManagedCodexHome(homes) + + const cleared = warn.mock.calls.filter((call) => String(call[0]).includes('stall cleared')) + expect(cleared).toHaveLength(1) + expect(String(cleared[0]?.[0])).not.toContain('readable again') + }) + + // chmod on a directory does not block writes on Windows, so promotion would + // succeed there and the scenario could not be constructed. + it.skipIf(process.platform === 'win32')( + 'does not claim recovery on a pass where no mirror ran', + () => { + // Why: promotion throwing still means the mirror was skipped, so the runtime + // config is not tracking the source. Clearing the latch there would claim a + // recovery that never happened and silence every later pass. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + + // In-Codex change while the source is gone -> stall latches, and the change + // stays pending promotion. + rmSync(systemConfigPath()) + writeFileSync(runtimeConfigPath(), 'model = "o4"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + expect(warn.mock.calls.filter((c) => String(c[0]).includes('stalled'))).toHaveLength(1) + warn.mockClear() + + // Source returns readable, but promotion cannot write the pending change + // back into a read-only ~/.codex, so it throws and the mirror is skipped. + writeFileSync(systemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + chmodSync(homes.systemHomePath, 0o555) + try { + syncSystemConfigIntoManagedCodexHome(homes) + } finally { + chmodSync(homes.systemHomePath, 0o755) + } + + expect(warn.mock.calls.filter((c) => String(c[0]).includes('stall cleared'))).toHaveLength(0) + // The runtime never picked up the source, so the change must still be there. + expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('model = "o4"') + } + ) + + it('logs again when the stall reason changes', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + writeFileSync(runtimeConfigPath(), 'model = "runtime-model"\n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + + writeFileSync(systemConfigPath(), ' \n', 'utf-8') + syncSystemConfigIntoManagedCodexHome(homes) + + const stallLogs = warn.mock.calls.filter((call) => String(call[0]).includes('stalled')) + expect(stallLogs).toHaveLength(2) + expect(String(stallLogs[1]?.[0])).toContain('blank-source') + }) +}) diff --git a/src/main/codex/config-sync-stall.ts b/src/main/codex/config-sync-stall.ts new file mode 100644 index 00000000000..c03598d4a28 --- /dev/null +++ b/src/main/codex/config-sync-stall.ts @@ -0,0 +1,98 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { readAgentStateFileSync } from '../agent-state-file-reader' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths' +import type { CodexSettingsPromotionHomes } from './config-settings-promotion' +import type { + CodexConfigSyncStallReason, + CodexConfigSyncStatus +} from '../../shared/codex-config-sync-types' + +/** + * Reports whether the managed Codex runtime config is still tracking the user's + * real `~/.codex/config.toml`, and why it is not when it has fallen behind. + * + * The mirror preserves the managed runtime config when the source is missing or + * blank, which is silent by design — but the stall can persist for every launch + * (a downed WSL distro, an unhydrated cloud-synced home), leaving "Orca ignores + * my config edits" with nothing to diagnose. Derived on demand from the same + * predicates the mirror uses, so the two can never disagree. + */ +export function getCodexConfigSyncStatus( + homes: CodexSettingsPromotionHomes = { + runtimeHomePath: getOrcaManagedCodexHomePath(), + systemHomePath: getSystemCodexHomePath() + } +): CodexConfigSyncStatus { + const systemConfigPath = join(homes.systemHomePath, 'config.toml') + const runtimeConfigPath = join(homes.runtimeHomePath, 'config.toml') + // Why: a stall only withholds settings once a managed runtime config exists; + // without one the mirror seeds it and there is nothing yet to fall behind. + if (!existsSync(runtimeConfigPath)) { + return { state: 'synced', reason: null, systemConfigPath } + } + if (!existsSync(systemConfigPath)) { + return { state: 'stalled', reason: 'missing-source', systemConfigPath } + } + let rawSystemConfig: string + try { + rawSystemConfig = readAgentStateFileSync(systemConfigPath) + } catch { + // Why: the mirror aborts on an unreadable source too, so report the stall + // rather than claiming a sync that cannot happen. + return { state: 'stalled', reason: 'unreadable-source', systemConfigPath } + } + if (rawSystemConfig.trim() === '') { + return { state: 'stalled', reason: 'blank-source', systemConfigPath } + } + return { state: 'synced', reason: null, systemConfigPath } +} + +// Why: the mirror runs on every launch and on the quota poll, so logging each +// skip would bury the log. Latch per home and log the transition instead, so an +// ongoing stall stays one line and a recovery is visible. +const stalledHomes = new Map() + +/** + * Logs a config-sync stall once per episode instead of once per sync pass, and + * logs again when the stall clears or its reason changes. + * + * Pass `mirrorError` from the mirror's catch path — an unreadable source throws + * out of the mirror, and without it that stall would log the raw failure on + * every launch and quota poll while never latching. + */ +export function reportCodexConfigSyncOutcome( + runtimeHomePath: string, + status: CodexConfigSyncStatus, + mirrorError?: unknown +): void { + const previousReason = stalledHomes.get(runtimeHomePath) + if (status.state === 'synced') { + if (previousReason) { + stalledHomes.delete(runtimeHomePath) + // Why: a removed runtime config also reads as synced, so this cannot + // claim the source became readable — only that the stall no longer holds. + console.warn( + `[codex-config] Config sync stall cleared for ${runtimeHomePath} (was ${previousReason}).` + ) + } + if (mirrorError) { + // Why: the mirror still failed for some reason the stall check cannot + // name, so surface it rather than swallowing it behind a clean status. + console.warn('[codex-config] Failed to mirror system Codex config:', mirrorError) + } + return + } + if (previousReason === status.reason) { + return + } + stalledHomes.set(runtimeHomePath, status.reason) + console.warn( + `[codex-config] Config sync stalled (${status.reason}): ${status.systemConfigPath} is unusable, so ${runtimeHomePath} keeps its last synced settings. Edits to the source will not apply until it is readable.` + ) +} + +/** Clears the per-home episode latch; the latch is module state, so suites that assert on transitions need a clean slate between cases. */ +export function resetCodexConfigSyncStallLatchForTests(): void { + stalledHomes.clear() +} diff --git a/src/main/ipc/codex-config-sync.test.ts b/src/main/ipc/codex-config-sync.test.ts new file mode 100644 index 00000000000..e1219472c8d --- /dev/null +++ b/src/main/ipc/codex-config-sync.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as NodeOs from 'node:os' + +const { handleMock, removeHandlerMock, homedirMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + removeHandlerMock: vi.fn(), + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock, removeHandler: removeHandlerMock } +})) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: homedirMock } +}) + +import { registerCodexConfigSyncHandlers } from './codex-config-sync' +import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' + +let root: string + +function invokeHandler(mirroredHome: string | null): CodexConfigSyncStatus { + handleMock.mockClear() + registerCodexConfigSyncHandlers({ + getMirroredHostHomePathForStatus: () => mirroredHome + }) + const handler = handleMock.mock.calls.at(-1)?.[1] as () => CodexConfigSyncStatus + return handler() +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-config-sync-ipc-')) + homedirMock.mockReturnValue(root) + mkdirSync(join(root, '.codex'), { recursive: true }) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.clearAllMocks() +}) + +describe('codexConfigSync:status handler', () => { + it('reports the stall for the home the current selection actually mirrors into', () => { + // Why: a managed account mirrors into its own per-account home, not the + // shared one — reporting on the shared home would miss the stall entirely. + const perAccountHome = join(root, 'codex-accounts', 'acct-1', 'home') + mkdirSync(perAccountHome, { recursive: true }) + writeFileSync(join(perAccountHome, 'config.toml'), 'model = "runtime-model"\n', 'utf-8') + + expect(invokeHandler(perAccountHome)).toEqual({ + state: 'stalled', + reason: 'missing-source', + systemConfigPath: join(root, '.codex', 'config.toml') + }) + }) + + it('reports synced when the selection runs directly on the real home', () => { + // Why: the system default has no mirror, so a stale shared runtime home + // must not produce a warning about a config that lane never reads. + const staleSharedHome = join(root, 'codex-runtime-home', 'home') + mkdirSync(staleSharedHome, { recursive: true }) + writeFileSync(join(staleSharedHome, 'config.toml'), 'model = "stale"\n', 'utf-8') + + expect(invokeHandler(null)).toEqual({ + state: 'synced', + reason: null, + systemConfigPath: join(root, '.codex', 'config.toml') + }) + }) + + it('re-registers cleanly so a reload cannot leak a duplicate handler', () => { + invokeHandler(null) + expect(removeHandlerMock).toHaveBeenCalledWith('codexConfigSync:status') + }) +}) diff --git a/src/main/ipc/codex-config-sync.ts b/src/main/ipc/codex-config-sync.ts new file mode 100644 index 00000000000..c61ba571bae --- /dev/null +++ b/src/main/ipc/codex-config-sync.ts @@ -0,0 +1,30 @@ +import { ipcMain } from 'electron' +import { join } from 'node:path' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { getCodexConfigSyncStatus } from '../codex/config-sync-stall' +import type { CodexConfigSyncStatus } from '../../shared/codex-config-sync-types' + +/** The read-only slice of the runtime home service this channel needs. */ +type CodexMirroredHomeResolver = { + getMirroredHostHomePathForStatus: () => string | null +} + +/** Registers the read-only IPC channel the settings pane reads once per mount for Codex config sync health. */ +export function registerCodexConfigSyncHandlers(runtimeHome: CodexMirroredHomeResolver): void { + ipcMain.removeHandler('codexConfigSync:status') + ipcMain.handle('codexConfigSync:status', (): CodexConfigSyncStatus => { + const systemHomePath = getSystemCodexHomePath() + const runtimeHomePath = runtimeHome.getMirroredHostHomePathForStatus() + if (!runtimeHomePath) { + // Why: the system default runs Codex directly against ~/.codex, so there + // is no mirror that can fall behind. Reporting on the shared home here + // would warn about a config that lane never reads. + return { + state: 'synced', + reason: null, + systemConfigPath: join(systemHomePath, 'config.toml') + } + } + return getCodexConfigSyncStatus({ runtimeHomePath, systemHomePath }) + }) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 4bb5727354c..d2b36bdf08c 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -53,6 +53,7 @@ const { registerGitLabHandlersMock, registerHostedReviewHandlersMock, registerExportHandlersMock, + registerCodexConfigSyncHandlersMock, registerOnboardingHandlersMock, registerDashboardPopoutHandlersMock, registerTerminalPreviewHandlersMock, @@ -117,6 +118,7 @@ const { registerGitLabHandlersMock: vi.fn(), registerHostedReviewHandlersMock: vi.fn(), registerExportHandlersMock: vi.fn(), + registerCodexConfigSyncHandlersMock: vi.fn(), registerOnboardingHandlersMock: vi.fn(), registerDashboardPopoutHandlersMock: vi.fn(), registerTerminalPreviewHandlersMock: vi.fn(), @@ -144,6 +146,10 @@ vi.mock('./runtime-environment-transport-routing', () => ({ callRuntimeEnvironment: callRuntimeEnvironmentMock })) +vi.mock('./codex-config-sync', () => ({ + registerCodexConfigSyncHandlers: registerCodexConfigSyncHandlersMock +})) + vi.mock('./onboarding', () => ({ registerOnboardingHandlers: registerOnboardingHandlersMock })) @@ -448,7 +454,7 @@ describe('registerCoreHandlers', () => { const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } const openCodeUsage = { marker: 'openCodeUsage' } - const codexAccounts = { marker: 'codexAccounts' } + const codexAccounts = { marker: 'codexAccounts', runtimeHomeService: { marker: 'runtimeHome' } } const claudeAccounts = { marker: 'claudeAccounts' } const rateLimits = { marker: 'rateLimits' } const agentAwakeService = { marker: 'agentAwakeService' } @@ -490,6 +496,9 @@ describe('registerCoreHandlers', () => { expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime, { getPtyIdForPaneKey: expect.any(Function) }) + expect(registerCodexConfigSyncHandlersMock).toHaveBeenCalledWith( + codexAccounts.runtimeHomeService + ) expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerMiniMaxCredentialsHandlersMock).toHaveBeenCalledWith(rateLimits) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 7f2e82d1197..2c834255eab 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -56,6 +56,7 @@ import { registerTerminalRenderDesyncEvidenceHandler } from './terminal-render-d import { registerOrcaProfileHandlers } from './orca-profiles' import { registerCodexAccountHandlers } from './codex-accounts' import { registerAgentHookHandlers } from './agent-hooks' +import { registerCodexConfigSyncHandlers } from './codex-config-sync' import { getPtyIdForPaneKey } from './pty' import { registerAgentTrustHandlers } from './agent-trust' import { registerClaudeAccountHandlers } from './claude-accounts' @@ -137,6 +138,7 @@ export function registerCoreHandlers( registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) + registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerMiniMaxCredentialsHandlers(rateLimits) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 8912fcadcea..b2d78e851a3 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -313,6 +313,7 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../shared/browser-a import type { CliInstallStatus } from '../shared/cli-install-types' import type { E2EConfig } from '../shared/e2e-config' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' +import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' import type { AgentStatusClearIpcPayload, AgentStatusIpcPayload, @@ -2210,6 +2211,9 @@ export type PreloadApi = { installWsl: (args?: { distro?: string | null }) => Promise removeWsl: (args?: { distro?: string | null }) => Promise } + codexConfigSync: { + status: () => Promise + } agentHooks: { claudeStatus: () => Promise openClaudeStatus: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index acce2d1f309..009924197aa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -11,6 +11,7 @@ import type { } from '../shared/terminal-preview' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' +import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' import type { TerminalPaneSplitSource } from '../shared/feature-education-telemetry' import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime' import type { StartupCommandDelivery } from '../shared/codex-startup-delivery' @@ -1944,6 +1945,9 @@ const api = { ipcRenderer.invoke('cli:removeWsl', args) }, + codexConfigSync: { + status: (): Promise => ipcRenderer.invoke('codexConfigSync:status') + }, agentHooks: { claudeStatus: (): Promise => ipcRenderer.invoke('agentHooks:claudeStatus'), diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 396d0f92351..e9a1c0efc58 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -65,6 +65,8 @@ import { DialogTitle } from '../ui/dialog' import { getCodexAccountAuthWarning } from './codex-account-auth-warning' +import { getCodexConfigSyncWarning } from './codex-config-sync-warning' +import type { CodexConfigSyncStatus } from '../../../../shared/codex-config-sync-types' import { getProviderAccountActiveIdForView, getProviderAccountRuntime, @@ -430,6 +432,38 @@ export function AccountsPane({ authKind: activeCodexAccountId === null ? systemCodexIdentity?.authKind : undefined }) : null + // Why: the mirror keeps serving the last synced settings when ~/.codex is + // unusable, so without this the user only sees their edits being ignored. + const [codexConfigSync, setCodexConfigSync] = useState(null) + useEffect(() => { + // Why: the status resolves the host's own ~/.codex and shared runtime home. + // A WSL or remote scope mirrors different homes entirely, so showing it there + // would name a config file that has nothing to do with the selected runtime. + if (isRemoteAccountScope || accountRuntime.runtime !== 'host') { + setCodexConfigSync(null) + return + } + let cancelled = false + void window.api.codexConfigSync + .status() + .then((status) => { + if (!cancelled) { + setCodexConfigSync(status) + } + }) + .catch(() => { + if (!cancelled) { + setCodexConfigSync(null) + } + }) + return () => { + cancelled = true + } + // Why: the status resolves whichever home the ACTIVE selection mirrors into + // (per-account, shared, or none for the real-home lane), so switching + // accounts must refetch or the banner describes the previous account. + }, [isRemoteAccountScope, accountRuntime.runtime, activeCodexAccountId, codexAccountsLoaded]) + const codexConfigSyncWarning = getCodexConfigSyncWarning(codexConfigSync) const systemCodexMissingSignIn = activeCodexAuthWarning === 'missing-sign-in' const systemCodexNeedsSignIn = activeCodexAccountId === null && Boolean(activeCodexAuthWarning) const accountRuntimeUnavailable = @@ -1102,6 +1136,30 @@ export function AccountsPane({ ) : null} + {codexConfigSyncWarning ? ( +
+ + + {codexConfigSyncWarning === 'missing-source' + ? translate( + 'auto.components.settings.AccountsPane.codexConfigSyncMissingSource', + 'Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.', + { value0: codexConfigSync?.systemConfigPath ?? '' } + ) + : codexConfigSyncWarning === 'blank-source' + ? translate( + 'auto.components.settings.AccountsPane.codexConfigSyncBlankSource', + 'Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.', + { value0: codexConfigSync?.systemConfigPath ?? '' } + ) + : translate( + 'auto.components.settings.AccountsPane.codexConfigSyncUnreadableSource', + "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions.", + { value0: codexConfigSync?.systemConfigPath ?? '' } + )} + +
+ ) : null}
) diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 0550e9704a6..fb1395a8bce 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -167,6 +167,42 @@ async function renderAppearancePane( return container } +async function rerenderAppearancePane( + settings: GlobalSettings = getDefaultSettings('/tmp') +): Promise { + const root = mountedRoots.at(-1) + if (!root) { + throw new Error('expected a mounted AppearancePane root') + } + await act(async () => { + root.render( + + + + + + ) + }) +} + +function appearanceSectionToggle( + container: HTMLElement, + sectionId: 'interface' | 'terminal' | 'window' +): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll('button[aria-expanded]')).find( + (button) => button.getAttribute('aria-controls') === `appearance-section-${sectionId}` + ) +} + describe('AppearancePane', () => { afterEach(async () => { await act(async () => { @@ -182,6 +218,7 @@ describe('AppearancePane', () => { mocks.state.availableStatusBarToggles = [] mocks.state.appPlatform = 'linux' mocks.state.settingsSearchQuery = 'automations' + mocks.state.appearanceAccordionDeepLink = null mocks.state.usagePercentageDisplay = 'used' // UIZoomControl reads window.api.ui on mount; the inline-expansion pane can // render the full Interface section, so provide a minimal renderer bridge @@ -481,7 +518,7 @@ describe('AppearancePane', () => { expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('antigravity') }) - it('collapses sibling sections so only the Interface section is expanded by default', async () => { + it('expands Interface, Terminal, and Window & Sidebar by default', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) @@ -489,7 +526,80 @@ describe('AppearancePane', () => { container.querySelectorAll('button[aria-expanded="true"]') ).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-')) - expect(expanded).toHaveLength(1) - expect(expanded[0]?.textContent).toContain('Interface') + expect(expanded).toHaveLength(3) + expect(expanded.map((button) => button.textContent).join(' ')).toContain('Interface') + expect(expanded.map((button) => button.textContent).join(' ')).toContain('Terminal') + expect(expanded.map((button) => button.textContent).join(' ')).toContain('Window & Sidebar') + }) + + it('lets each appearance section collapse independently', async () => { + mocks.state.settingsSearchQuery = '' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + + const terminalToggle = appearanceSectionToggle(container, 'terminal') + + expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true') + + await act(async () => { + terminalToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(terminalToggle?.getAttribute('aria-expanded')).toBe('false') + + const stillExpanded = Array.from( + container.querySelectorAll('button[aria-expanded="true"]') + ).filter((button) => button.getAttribute('aria-controls')?.startsWith('appearance-section-')) + + expect(stillExpanded).toHaveLength(2) + expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain('Interface') + expect(stillExpanded.map((button) => button.textContent).join(' ')).toContain( + 'Window & Sidebar' + ) + }) + + it('re-opens a collapsed section for appearance deep links without collapsing siblings', async () => { + mocks.state.settingsSearchQuery = '' + mocks.state.appearanceAccordionDeepLink = null + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + + const windowToggle = appearanceSectionToggle(container, 'window') + const terminalToggle = appearanceSectionToggle(container, 'terminal') + expect(windowToggle?.getAttribute('aria-expanded')).toBe('true') + + await act(async () => { + windowToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(windowToggle?.getAttribute('aria-expanded')).toBe('false') + expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true') + + mocks.state.appearanceAccordionDeepLink = 'window' + await rerenderAppearancePane() + + expect(appearanceSectionToggle(container, 'window')?.getAttribute('aria-expanded')).toBe('true') + expect(appearanceSectionToggle(container, 'terminal')?.getAttribute('aria-expanded')).toBe( + 'true' + ) + expect(mocks.state.clearAppearanceAccordionDeepLink).toHaveBeenCalled() + }) + + it('disables section toggles while searching so clearing search does not surprise-collapse', async () => { + mocks.state.settingsSearchQuery = 'terminal' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + + const terminalToggle = appearanceSectionToggle(container, 'terminal') + expect(terminalToggle?.disabled).toBe(true) + expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true') + + await act(async () => { + terminalToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(terminalToggle?.getAttribute('aria-expanded')).toBe('true') + + mocks.state.settingsSearchQuery = '' + await rerenderAppearancePane() + + const afterClear = appearanceSectionToggle(container, 'terminal') + expect(afterClear?.disabled).toBe(false) + expect(afterClear?.getAttribute('aria-expanded')).toBe('true') }) }) diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 58954d32565..6e73d5aafdf 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -55,6 +55,12 @@ type AppearancePaneProps = { type AppearanceSectionKey = 'interface' | 'terminal' | 'window' +const ALL_APPEARANCE_SECTIONS = [ + 'interface', + 'terminal', + 'window' +] as const satisfies readonly AppearanceSectionKey[] + function resolveThemeSummary(theme: GlobalSettings['theme']): string { if (theme === 'system') { return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') @@ -88,20 +94,29 @@ export function AppearancePane({ const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient const isDesktopMac = getRendererAppPlatform() === 'darwin' && !isWebClient - const [manuallyOpenSection, setManuallyOpenSection] = useState( - 'interface' + // Why: Terminal / Window settings were too easy to miss when only Interface + // started open; keep sections independently collapsible but expanded by default. + const [openSections, setOpenSections] = useState>( + () => new Set(ALL_APPEARANCE_SECTIONS) ) // Why: nested deep links (e.g. Usage percentages) land under Window & Sidebar; - // expand that accordion before Settings scrolls so the row is actually visible. + // expand that section before Settings scrolls so the row is actually visible. useLayoutEffect(() => { if (!appearanceAccordionDeepLink) { return } - setManuallyOpenSection(appearanceAccordionDeepLink) + setOpenSections((current) => { + if (current.has(appearanceAccordionDeepLink)) { + return current + } + const next = new Set(current) + next.add(appearanceAccordionDeepLink) + return next + }) clearAppearanceAccordionDeepLink() - // Why: accordion expand is layout-synchronous; scroll on the next frame so - // the target has non-zero height when Settings (or this fallback) scrolls. + // Why: expand is layout-synchronous; scroll on the next frame so the target + // has non-zero height when Settings (or this fallback) scrolls. const frameId = requestAnimationFrame(() => { document .getElementById(USAGE_PERCENTAGE_DISPLAY_SETTING_ID) @@ -167,8 +182,8 @@ export function AppearancePane({ const appIconMatches = matchesSettingsSearch(searchQuery, getAppIconEntries()) // While searching, force-open every section that contains a match so its - // controls (including advanced ones) are revealed; otherwise the accordion - // shows exactly one manually-chosen section. + // controls (including advanced ones) are revealed; otherwise use the user's + // independent open/closed state (all expanded by default). function isSectionOpen(key: AppearanceSectionKey): boolean { if (isSearching) { return key === 'interface' @@ -177,11 +192,19 @@ export function AppearancePane({ ? terminalMatches : windowMatches } - return manuallyOpenSection === key + return openSections.has(key) } function toggleSection(key: AppearanceSectionKey): void { - setManuallyOpenSection((current) => (current === key ? null : key)) + setOpenSections((current) => { + const next = new Set(current) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) } const interfaceSummary = `${resolveThemeSummary(settings.theme)} · ${ @@ -203,6 +226,7 @@ export function AppearancePane({ summary={interfaceSummary} open={isSectionOpen('interface')} onToggle={() => toggleSection('interface')} + toggleDisabled={isSearching} > toggleSection('terminal')} + toggleDisabled={isSearching} > toggleSection('window')} + toggleDisabled={isSearching} > void + /** Why: search force-opens matching sections; disable collapse so toggles + * do not silently rewrite open-state that only applies after search clears. */ + toggleDisabled?: boolean children: React.ReactNode } -/** Compact summary row that expands its section inline. The parent owns the - * open state so opening one row can collapse the previously open one - * (accordion behavior) and search can force a section open. */ +/** Compact summary row that expands its section inline. The parent owns open + * state so sections can stay independently collapsible and search can force + * a section open. */ export function AppearanceSection({ id, icon, @@ -24,6 +27,7 @@ export function AppearanceSection({ summary, open, onToggle, + toggleDisabled = false, children }: AppearanceSectionProps): React.JSX.Element { const contentId = `appearance-section-${id}` @@ -39,7 +43,8 @@ export function AppearanceSection({ aria-expanded={open} aria-controls={contentId} onClick={onToggle} - className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + disabled={toggleDisabled} + className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-accent/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-default disabled:hover:bg-transparent" > {icon} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index ac0578fa38b..65b98097cca 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -645,7 +645,7 @@ function Settings(): React.JSX.Element { } pendingNavSectionRef.current = paneSectionId pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId - // Why: force Appearance's collapsed status-bar accordion open before scrolling so the row is visible. + // Why: ensure Appearance's nested status-bar section is open before scrolling so the row is visible. if (settingsNavigationTarget.pane === 'appearance') { const accordion = resolveAppearanceAccordionDeepLink(settingsNavigationTarget.sectionId) if (accordion) { @@ -974,7 +974,7 @@ function Settings(): React.JSX.Element { const scrollTargetId = pendingScrollTargetRef.current const pendingNavSectionId = pendingNavSectionRef.current - // Why: subsection deep links clear a stale filter that could hide the target row; pane-level links keep it to force-open the matching accordion. + // Why: subsection deep links clear a stale filter that could hide the target row; pane-level links keep it to force-open the matching section. if ( scrollTargetId && pendingNavSectionId && diff --git a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts index b7bbc49218b..5745bfa325b 100644 --- a/src/renderer/src/components/settings/appearance-usage-percentage-search.ts +++ b/src/renderer/src/components/settings/appearance-usage-percentage-search.ts @@ -5,12 +5,12 @@ import { translateSearchKeyword } from './settings-search-keywords' /** Stable Settings deep-link / scroll target for the Used/Remaining control. */ export const USAGE_PERCENTAGE_DISPLAY_SETTING_ID = 'usage-percentage-display' -/** Appearance accordion keys that can be force-opened for nested deep links. */ +/** Appearance section keys that can be force-opened for nested deep links. */ export type AppearanceAccordionSection = 'interface' | 'terminal' | 'window' /** - * Map a Settings subsection id to the Appearance accordion that must be open - * before the row is visible (collapsed accordion content is not user-visible). + * Map a Settings subsection id to the Appearance section that must be open + * before the row is visible (collapsed section content is not user-visible). */ export function resolveAppearanceAccordionDeepLink( sectionId: string | undefined diff --git a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts index e686b51d37c..a944ae12f65 100644 --- a/src/renderer/src/components/settings/terminal-advanced-platform-search.ts +++ b/src/renderer/src/components/settings/terminal-advanced-platform-search.ts @@ -62,7 +62,9 @@ export const getTerminalMacOptionSearchEntries = createLocalizedCatalog(() => [ 'international' ), ...translateSearchKeyword('auto.components.settings.terminal.search.fae142a354', 'readline'), - ...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty') + // Why: product name stays untranslated so search matches "Ghostty". + 'Ghostty', + 'ghostty' ] } ]) @@ -99,7 +101,9 @@ export const getTerminalGhosttyImportSearchEntries = createLocalizedCatalog(() = 'One-time import of supported Ghostty terminal settings.' ), keywords: [ - ...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty'), + // Why: product name stays untranslated so search matches "Ghostty". + 'Ghostty', + 'ghostty', ...translateSearchKeyword('auto.components.settings.terminal.search.fd752b3cac', 'import'), ...translateSearchKeyword('auto.components.settings.terminal.search.f66a7cf715', 'terminal'), ...translateSearchKeyword('auto.components.settings.terminal.search.2ade3ea490', 'config'), diff --git a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts index d77c5e68432..becfb796bee 100644 --- a/src/renderer/src/components/settings/terminal-pane-appearance-search.ts +++ b/src/renderer/src/components/settings/terminal-pane-appearance-search.ts @@ -85,7 +85,9 @@ export const getTerminalPaneInteractionSearchEntries = createLocalizedCatalog(() ...translateSearchKeyword('auto.components.settings.terminal.search.ea364ce6e4', 'mouse'), ...translateSearchKeyword('auto.components.settings.terminal.search.d1fa00a9cb', 'hover'), ...translateSearchKeyword('auto.components.settings.terminal.search.846a7a1204', 'pane'), - ...translateSearchKeyword('auto.components.settings.terminal.search.82b63d07fe', 'ghostty'), + // Why: product name stays untranslated so search matches "Ghostty". + 'Ghostty', + 'ghostty', ...translateSearchKeyword('auto.components.settings.terminal.search.f036794286', 'active') ] }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 71aef99a990..f73ec351319 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8642,7 +8642,6 @@ "10f9fb6fea": "settings", "2ade3ea490": "config", "fd752b3cac": "import", - "82b63d07fe": "ghostty", "73e9422f19": "One-time import of supported Ghostty terminal settings.", "a979df0083": "Import from Ghostty", "warp_import": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 38bd42096b2..7bd23476609 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8582,7 +8582,6 @@ "10f9fb6fea": "ajustes", "2ade3ea490": "configuración", "fd752b3cac": "importar", - "82b63d07fe": "ghostty", "73e9422f19": "Importación única de ajustes de terminal compatibles de Ghostty.", "a979df0083": "Importar desde Ghostty", "4cec42dbf7": "internacional", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7d4a14ba7a2..e45b172ace4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8604,7 +8604,6 @@ "10f9fb6fea": "設定", "2ade3ea490": "構成", "fd752b3cac": "インポート", - "82b63d07fe": "ghostty", "73e9422f19": "サポートされている Ghostty terminal 設定の 1 回限りのインポート。", "a979df0083": "Ghostty からインポート", "warp_import": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f4a063ce1a4..d032b5bdb5d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8567,7 +8567,6 @@ "10f9fb6fea": "설정", "2ade3ea490": "구성", "fd752b3cac": "가져오기", - "82b63d07fe": "ghostty", "73e9422f19": "지원되는 Ghostty terminal 설정을 한 번만 가져옵니다.", "a979df0083": "Ghostty에서 가져오기", "4cec42dbf7": "국제", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f8d65c5be1e..cd83fea80a0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -6870,7 +6870,7 @@ "4aae5db258": "字体粗细", "f04b17a50e": "新窗格和实时更新的默认终端字体系列。", "a408266e67": "字体家族", - "855a76343a": "从幽灵导入", + "855a76343a": "从 Ghostty 导入", "711e589f18": "新窗格和实时更新的默认终端排版。", "048aac8a64": "终端排版", "4415beb958": "已禁用", @@ -8567,9 +8567,8 @@ "10f9fb6fea": "设置", "2ade3ea490": "配置", "fd752b3cac": "导入", - "82b63d07fe": "Ghostty", "73e9422f19": "一次性导入受支持的 Ghostty 终端设置。", - "a979df0083": "从幽灵导入", + "a979df0083": "从 Ghostty 导入", "4cec42dbf7": "国际", "b495dc6a9f": "吉斯", "d8d6f7a3c5": "麦科斯", From c4b7aeddd75cda5c1c0620ce169c64b649aeca3c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:59:08 +0000 Subject: [PATCH 046/150] Update README downloads badge --- docs/assets/readme-downloads.svg | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index f76adc84560..a10c0826aab 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,21 +1,21 @@ - - downloads: 9.9m + + downloads: 10.0m - + - - + + downloads downloads - 9.9m - 9.9m + 10.0m + 10.0m From 17617b1a6cf38fa681a0ddec853c4b3ff839b06b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:09:23 -0700 Subject: [PATCH 047/150] fix(terminal-pane): resolve React #185 setState loop via state identity (#10632) Preserve object identity in pane-title overlay rect state to fix infinite re-renders. A fresh {} literal creates a new reference on every call, causing React to treat the state as changed even when logically equivalent, triggering the layout effect to continuously re-measure and re-set state (React #185). Add cold-park verdict flip telemetry to diagnose crash cluster C5: records whether parking state churns in the field for next crash bundle analysis. Document that AddressPicker crash (cluster C6) originates in radix-ui's SelectItem unmount cleanup, not in our component code. --- ...ash-reporting-renderer-breadcrumbs.test.ts | 19 ++ src/main/ipc/crash-reporting.ts | 22 ++- .../src/components/network/AddressPicker.tsx | 4 + .../components/terminal-pane/TerminalPane.tsx | 34 +--- .../pane-title-overlay-rects.test.ts | 60 ++++++ .../terminal-pane/pane-title-overlay-rects.ts | 33 ++++ ...rminal-park-verdict-flip-telemetry.test.ts | 173 ++++++++++++++++++ .../terminal-park-verdict-flip-telemetry.ts | 93 ++++++++++ .../use-terminal-tab-cold-parking.ts | 19 ++ 9 files changed, 423 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts diff --git a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts index 57b0076b08e..28bc5052848 100644 --- a/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts +++ b/src/main/ipc/crash-reporting-renderer-breadcrumbs.test.ts @@ -236,6 +236,25 @@ describe('renderer breadcrumb IPC routing', () => { }) }) + // Why: park-churn notices are per-tab but the ring is process-wide, so many + // tabs churning at once would otherwise evict the pre-crash trail. + it('coalesces park-verdict churn notices by name across tabs', () => { + emitRendererBreadcrumb({ + name: 'terminal_park_verdict_churn', + data: { tabId: 'tab-1', flips: 12, elapsedMs: 8 } + }) + emitRendererBreadcrumb({ + name: 'terminal_park_verdict_churn', + data: { tabId: 'tab-2', flips: 12, elapsedMs: 9 } + }) + + expect(recordCrashBreadcrumbMock).not.toHaveBeenCalled() + expect(recordCoalescedCrashBreadcrumbMock).toHaveBeenCalledTimes(2) + for (const call of recordCoalescedCrashBreadcrumbMock.mock.calls) { + expect(call[0]).toMatchObject({ coalesceKey: 'terminal_park_verdict_churn' }) + } + }) + it('records non-error renderer breadcrumbs without coalescing', () => { emitRendererBreadcrumb({ name: 'renderer_bootstrap_started', data: { dev: true } }) diff --git a/src/main/ipc/crash-reporting.ts b/src/main/ipc/crash-reporting.ts index 7ced0354ee4..ec061219049 100644 --- a/src/main/ipc/crash-reporting.ts +++ b/src/main/ipc/crash-reporting.ts @@ -325,16 +325,24 @@ function buildUncapturedCrashReportText( // storm, #8260) can flush the whole fixed-size breadcrumb ring in seconds, // erasing the pre-crash trail. Coalesce repeats into one entry that carries a // suppressed count instead. -const COALESCED_RENDERER_ERROR_BREADCRUMB_NAMES = new Set([ +const COALESCED_RENDERER_BREADCRUMB_NAMES = new Set([ 'renderer_error', - 'renderer_unhandled_rejection' + 'renderer_unhandled_rejection', + 'terminal_park_verdict_churn' ]) -const RENDERER_ERROR_BREADCRUMB_COALESCE_MS = 30_000 +const RENDERER_BREADCRUMB_COALESCE_MS = 30_000 +// Why: these carry no message identity — they are per-tab telemetry whose rate, +// not whose text, is the signal. Coalescing by name alone bounds a many-tab +// storm to one ring entry plus a suppressed count. +const NAME_ONLY_COALESCED_BREADCRUMB_NAMES = new Set(['terminal_park_verdict_churn']) -function rendererErrorBreadcrumbCoalesceKey( +function rendererBreadcrumbCoalesceKey( name: string, data: CrashReportBreadcrumbData | undefined ): string | undefined { + if (NAME_ONLY_COALESCED_BREADCRUMB_NAMES.has(name)) { + return name + } const primaryMessage = name === 'renderer_error' ? data?.message : data?.reasonMessage const fallbackMessage = name === 'renderer_error' ? data?.errorMessage : undefined const message = @@ -394,8 +402,8 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { return } const data = sanitizeRendererBreadcrumbData(args.data) - if (COALESCED_RENDERER_ERROR_BREADCRUMB_NAMES.has(args.name)) { - const coalesceKey = rendererErrorBreadcrumbCoalesceKey(args.name, data) + if (COALESCED_RENDERER_BREADCRUMB_NAMES.has(args.name)) { + const coalesceKey = rendererBreadcrumbCoalesceKey(args.name, data) if (!coalesceKey) { recordCrashBreadcrumb(args.name, data) recordRendererBreadcrumbTrace(args.name, data) @@ -405,7 +413,7 @@ export function registerCrashReportingHandlers(store: CrashReportStore): void { name: args.name, data, coalesceKey, - minIntervalMs: RENDERER_ERROR_BREADCRUMB_COALESCE_MS + minIntervalMs: RENDERER_BREADCRUMB_COALESCE_MS }) // Why: tracing every suppressed duplicate would preserve the same // serialization and disk churn that breadcrumb coalescing removes. diff --git a/src/renderer/src/components/network/AddressPicker.tsx b/src/renderer/src/components/network/AddressPicker.tsx index 076dd24decd..8d6603861b9 100644 --- a/src/renderer/src/components/network/AddressPicker.tsx +++ b/src/renderer/src/components/network/AddressPicker.tsx @@ -42,6 +42,10 @@ export type AddressPickerProps = { id?: string } +// Note (crash cluster C6, React #185 in settings): the throwing dispatch is in +// @radix-ui/react-select's SelectItem unmount cleanup, not in our code — this +// file holds no unmount-phase setState, so there is nothing here to guard. +// Item churn here is derived from props only; re-audit if that stops holding. export function AddressPicker({ options, value, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 50ea86eaca2..20bc410c681 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -59,7 +59,11 @@ import { MobileDriverOverlay } from './MobileDriverOverlay' import { stripSshReconnectOwnedErrorLines, TerminalErrorToast } from './TerminalErrorToast' import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog' import TerminalContextMenu from './TerminalContextMenu' -import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay' +import TerminalPaneHeaderOverlay, { type PaneTitleOverlayRect } from './TerminalPaneHeaderOverlay' +import { + arePaneTitleOverlayRectsEqual, + clearPaneTitleOverlayRects +} from './pane-title-overlay-rects' import NativeChatView from '../native-chat/NativeChatView' import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog' @@ -238,12 +242,6 @@ type TerminalPaneProps = { onCloseTab: () => void } -type PaneTitleOverlayRect = { - left: number - top: number - width: number -} - type TerminalQuickCommandEditorDialogProps = { command: TerminalQuickCommand onOpenChange: (open: boolean) => void @@ -274,24 +272,6 @@ function formatClipboardImagePasteError(error: unknown): string { return `Image paste failed: ${detail}` } -function arePaneTitleOverlayRectsEqual( - a: Record, - b: Record -): boolean { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) { - return false - } - return aKeys.every((key) => { - const paneId = Number(key) - const left = Math.abs((a[paneId]?.left ?? 0) - (b[paneId]?.left ?? 0)) - const top = Math.abs((a[paneId]?.top ?? 0) - (b[paneId]?.top ?? 0)) - const width = Math.abs((a[paneId]?.width ?? 0) - (b[paneId]?.width ?? 0)) - return left < 0.5 && top < 0.5 && width < 0.5 - }) -} - export default function TerminalPane({ tabId, worktreeId, @@ -2268,7 +2248,7 @@ export default function TerminalPane({ const manager = managerRef.current const container = containerRef.current if (!manager || !container) { - setPaneTitleOverlayRects({}) + setPaneTitleOverlayRects(clearPaneTitleOverlayRects) return } const containerRect = container.getBoundingClientRect() @@ -2293,7 +2273,7 @@ export default function TerminalPane({ const manager = managerRef.current const container = containerRef.current if (!manager || !container) { - setPaneTitleOverlayRects({}) + setPaneTitleOverlayRects(clearPaneTitleOverlayRects) return } diff --git a/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts new file mode 100644 index 00000000000..2484b853474 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + arePaneTitleOverlayRectsEqual, + clearPaneTitleOverlayRects +} from './pane-title-overlay-rects' + +describe('clearPaneTitleOverlayRects', () => { + // Why: this identity contract IS the React #185 guard — a fresh {} literal is + // never state-equal, so returning one here commits a render every pass. + it('returns the same reference when already empty', () => { + const prev = {} + expect(clearPaneTitleOverlayRects(prev)).toBe(prev) + }) + + it('clears a populated map to a new empty object', () => { + const prev = { 1: { left: 10, top: 20, width: 30 } } + const next = clearPaneTitleOverlayRects(prev) + + expect(next).not.toBe(prev) + expect(next).toEqual({}) + }) + + it('is stable across repeated calls once empty', () => { + const first = clearPaneTitleOverlayRects({ 1: { left: 1, top: 2, width: 3 } }) + expect(clearPaneTitleOverlayRects(first)).toBe(first) + }) +}) + +describe('arePaneTitleOverlayRectsEqual', () => { + it('treats sub-pixel drift as equal', () => { + expect( + arePaneTitleOverlayRectsEqual( + { 1: { left: 10, top: 20, width: 30 } }, + { 1: { left: 10.4, top: 20.4, width: 30.4 } } + ) + ).toBe(true) + }) + + it('treats a half-pixel-or-greater move as different', () => { + expect( + arePaneTitleOverlayRectsEqual( + { 1: { left: 10, top: 20, width: 30 } }, + { 1: { left: 10.5, top: 20, width: 30 } } + ) + ).toBe(false) + }) + + it('treats differing pane counts as different', () => { + expect( + arePaneTitleOverlayRectsEqual( + { 1: { left: 0, top: 0, width: 10 } }, + { 1: { left: 0, top: 0, width: 10 }, 2: { left: 0, top: 0, width: 10 } } + ) + ).toBe(false) + }) + + it('reports two empty maps as equal', () => { + expect(arePaneTitleOverlayRectsEqual({}, {})).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts new file mode 100644 index 00000000000..f03bc8f3503 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-title-overlay-rects.ts @@ -0,0 +1,33 @@ +/** + * Pane-title overlay rect state transitions for TerminalPane. + * + * Why: both helpers exist to hand React a referentially-equal value when + * nothing changed, so a measurement pass cannot commit a redundant render + * (React #185 amplification). Extracted so that identity contract is testable. + */ +import type { PaneTitleOverlayRect } from './TerminalPaneHeaderOverlay' + +export function arePaneTitleOverlayRectsEqual( + a: Record, + b: Record +): boolean { + const aKeys = Object.keys(a) + const bKeys = Object.keys(b) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every((key) => { + const paneId = Number(key) + const left = Math.abs((a[paneId]?.left ?? 0) - (b[paneId]?.left ?? 0)) + const top = Math.abs((a[paneId]?.top ?? 0) - (b[paneId]?.top ?? 0)) + const width = Math.abs((a[paneId]?.width ?? 0) - (b[paneId]?.width ?? 0)) + return left < 0.5 && top < 0.5 && width < 0.5 + }) +} + +/** Returns `prev` untouched when already empty — never a fresh {} literal. */ +export function clearPaneTitleOverlayRects( + prev: Record +): Record { + return Object.keys(prev).length === 0 ? prev : {} +} diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts new file mode 100644 index 00000000000..8f53bf39c5e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, + TERMINAL_TAB_PARK_FLIP_WINDOW_MS, + recordParkVerdictFlips, + type ParkVerdictFlipRecord +} from './terminal-park-verdict-flip-telemetry' + +const recordBreadcrumb = vi.fn() +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: (name: string, data?: unknown) => recordBreadcrumb(name, data) +})) + +const TAB = 'tab-1' + +function observe(args: { + records: Map + parked: boolean + nowMs: number + liveTabIds?: ReadonlySet +}): void { + recordParkVerdictFlips({ + records: args.records, + liveTabIds: args.liveTabIds ?? new Set([TAB]), + nextParkedTabIds: args.parked ? new Set([TAB]) : new Set(), + nowMs: args.nowMs + }) +} + +beforeEach(() => { + recordBreadcrumb.mockClear() +}) + +describe('recordParkVerdictFlips', () => { + it('stays silent for a stable verdict', () => { + const records = new Map() + for (let i = 0; i < 100; i += 1) { + observe({ records, parked: true, nowMs: 1_000 + i * 1_000 }) + } + + expect(recordBreadcrumb).not.toHaveBeenCalled() + expect(records.get(TAB)?.flips).toBe(0) + }) + + it('emits one breadcrumb per window once the verdict churns', () => { + const records = new Map() + for (let i = 0; i < 40; i += 1) { + observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ tabId: TAB, flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT }) + ) + }) + + // Why: flips saturates at the notice limit, so elapsedMs is the only field + // that tells a runaway loop apart from slow churn — assert both ends. + it('reports elapsedMs so a tight loop is distinguishable from slow churn', () => { + const tightRecords = new Map() + for (let i = 0; i < 40; i += 1) { + observe({ records: tightRecords, parked: i % 2 === 0, nowMs: 1_000 + i }) + } + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 12 }) + ) + + recordBreadcrumb.mockClear() + + const slowRecords = new Map() + for (let i = 0; i < 13; i += 1) { + observe({ records: slowRecords, parked: i % 2 === 0, nowMs: 1_000 + i * 4_000 }) + } + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ flips: TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT, elapsedMs: 48_000 }) + ) + }) + + it('re-arms after the window elapses', () => { + const records = new Map() + for (let i = 0; i < 40; i += 1) { + observe({ records, parked: i % 2 === 0, nowMs: 1_000 + i * 10 }) + } + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + + const laterMs = 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS * 2 + for (let i = 0; i < 40; i += 1) { + observe({ records, parked: i % 2 === 0, nowMs: laterMs + i * 10 }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(2) + }) + + // Why: an unclamped backwards jump would freeze the window and suppress the + // very signal this module exists to capture. + it('treats a backwards clock jump as a fresh window', () => { + const records = new Map() + observe({ records, parked: true, nowMs: 10_000_000 }) + observe({ records, parked: false, nowMs: 1_000 }) + + expect(records.get(TAB)?.windowStartMs).toBe(1_000) + expect(records.get(TAB)?.flips).toBe(1) + }) + + // Why: >= is the boundary operator; a > regression would silently stretch the + // window and delay every notice by one full period. + it('treats an exactly-elapsed window as expired', () => { + const records = new Map() + observe({ records, parked: true, nowMs: 1_000 }) + observe({ records, parked: false, nowMs: 1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS }) + + expect(records.get(TAB)?.windowStartMs).toBe(1_000 + TERMINAL_TAB_PARK_FLIP_WINDOW_MS) + expect(records.get(TAB)?.flips).toBe(1) + }) + + it('honours flipWindowMs and noticeLimit overrides', () => { + const records = new Map() + for (let i = 0; i < 10; i += 1) { + recordParkVerdictFlips({ + records, + liveTabIds: new Set([TAB]), + nextParkedTabIds: i % 2 === 0 ? new Set([TAB]) : new Set(), + nowMs: 1_000 + i, + flipWindowMs: 500, + noticeLimit: 3 + }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ flips: 3, windowMs: 500 }) + ) + }) + + it('keeps per-tab windows and notices independent', () => { + const records = new Map() + const other = 'tab-2' + for (let i = 0; i < 40; i += 1) { + recordParkVerdictFlips({ + records, + liveTabIds: new Set([TAB, other]), + // Why: TAB churns every call, other stays parked throughout. + nextParkedTabIds: i % 2 === 0 ? new Set([TAB, other]) : new Set([other]), + nowMs: 1_000 + i * 10 + }) + } + + expect(recordBreadcrumb).toHaveBeenCalledTimes(1) + expect(recordBreadcrumb).toHaveBeenCalledWith( + 'terminal_park_verdict_churn', + expect.objectContaining({ tabId: TAB }) + ) + expect(records.get(other)?.flips).toBe(0) + }) + + it('drops records for tabs that no longer exist', () => { + const records = new Map() + observe({ records, parked: true, nowMs: 1_000 }) + + recordParkVerdictFlips({ + records, + liveTabIds: new Set(), + nextParkedTabIds: new Set(), + nowMs: 2_000 + }) + + expect(records.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts new file mode 100644 index 00000000000..f54477c90f0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-park-verdict-flip-telemetry.ts @@ -0,0 +1,93 @@ +/** + * Cold-park verdict flip telemetry (observation only — changes no verdict). + * + * Why: crash cluster C5 (React #185 in TerminalPaneOverlayLayer) points at a + * setState loop in the cold-parking passive effect, but no park-flip loop has + * been reproduced, and the capture-coverage oscillator that looked like the + * cause provably self-terminates (the unmount capture is never cleared on + * remount, so coverage pins rather than alternates). This records whether the + * rendered park verdict actually churns in the field. Deliberately no damping: + * guarding a loop nobody has shown can run would add a park delay and a + * retention path for no demonstrated benefit. + * + * Reading the signal: breadcrumbs also emit a durable `renderer.breadcrumb` + * trace span, so this is queryable without waiting for a crash bundle. `flips` + * always equals the notice limit — `elapsedMs` is what separates a render loop + * from slow churn. Silence only rules out churn at effect cadence; a same-tick + * cascade can crash before the limit accumulates. If it fires, start with the + * candidate selection in terminal-hidden-view-parking.ts. + */ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +export const TERMINAL_TAB_PARK_FLIP_WINDOW_MS = 60_000 +/** Flips per window that no sane park policy should reach. */ +export const TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT = 12 + +export type ParkVerdictFlipRecord = { + parked: boolean + windowStartMs: number + flips: number + notified: boolean +} + +/** Records park-verdict churn per tab; emits one breadcrumb per window. */ +export function recordParkVerdictFlips(args: { + records: Map + liveTabIds: ReadonlySet + nextParkedTabIds: ReadonlySet + nowMs: number + flipWindowMs?: number + noticeLimit?: number +}): void { + const { + records, + liveTabIds, + nextParkedTabIds, + nowMs, + flipWindowMs = TERMINAL_TAB_PARK_FLIP_WINDOW_MS, + noticeLimit = TERMINAL_TAB_PARK_FLIP_NOTICE_LIMIT + } = args + + for (const tabId of Array.from(records.keys())) { + if (!liveTabIds.has(tabId)) { + records.delete(tabId) + } + } + + for (const tabId of liveTabIds) { + const parked = nextParkedTabIds.has(tabId) + const record = records.get(tabId) + + if (!record) { + records.set(tabId, { parked, windowStartMs: nowMs, flips: 0, notified: false }) + continue + } + if (parked === record.parked) { + continue + } + + // Why: Date.now() jumps backwards on NTP/sleep-wake; treat any out-of-range + // elapsed value as a fresh window rather than trusting the delta. + const elapsedMs = nowMs - record.windowStartMs + if (elapsedMs >= flipWindowMs || elapsedMs < 0) { + record.windowStartMs = nowMs + record.flips = 0 + record.notified = false + } + + record.parked = parked + record.flips += 1 + + if (!record.notified && record.flips >= noticeLimit) { + record.notified = true + // Why: flips is always exactly noticeLimit here, so elapsedMs is the only + // field that separates a render loop from slow benign churn. + recordRendererCrashBreadcrumb('terminal_park_verdict_churn', { + tabId, + flips: record.flips, + elapsedMs: nowMs - record.windowStartMs, + windowMs: flipWindowMs + }) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index fa7de03116e..3bd6b962ba8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -18,6 +18,10 @@ import { selectColdParkedTerminalTabs, type TerminalTabColdParkCandidate } from './terminal-hidden-view-parking' +import { + recordParkVerdictFlips, + type ParkVerdictFlipRecord +} from './terminal-park-verdict-flip-telemetry' import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' import { canWatcherCoverParkedTerminalTab, @@ -73,6 +77,7 @@ export function useTerminalTabColdParking(args: { ) const terminalTabHiddenSinceRef = useRef(new Map()) const terminalTabParkingTimersRef = useRef(new Map()) + const parkVerdictRecordsRef = useRef(new Map()) const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( () => new Set() @@ -240,6 +245,20 @@ export function useTerminalTabColdParking(args: { worktreeId ]) + // Why: observation only — records whether the *rendered* park verdict churns, + // so a crash bundle can confirm or refute a park-flip update loop. Watching + // the pre-gate cold set instead would miss loops driven by coldParkTerminalPanes + // or the portal/measuring gates. Changes no verdict; see + // terminal-park-verdict-flip-telemetry.ts. + useEffect(() => { + recordParkVerdictFlips({ + records: parkVerdictRecordsRef.current, + liveTabIds: new Set(terminalTabs.map((terminalTab) => terminalTab.id)), + nextParkedTabIds: parkedTerminalTabIds, + nowMs: Date.now() + }) + }, [parkedTerminalTabIds, terminalTabs]) + // Why: runs in the same effect flush as the commit that parked/revealed the // panes — watcher disposal therefore lands before any PTY data IPC can // reach a freshly remounted pane, and watcher start lands after the parked From 2e6467710a818c853bd82223a7262a63b3802924 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:31:40 -0700 Subject: [PATCH 048/150] fix(mac): disable unstable Skia Graphite renderer (#10643) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/main/startup/configure-process.test.ts | 23 ++++++++++++++++++++++ src/main/startup/configure-process.ts | 5 +++++ 2 files changed, 28 insertions(+) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7d582880f28..512467eb724 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -398,6 +398,29 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('max-active-webgl-contexts', '128') }) + it('disables Skia Graphite only on macOS without disabling hardware acceleration', async () => { + const { app } = await import('electron') + const { enableMainProcessGpuFeatures } = await import('./configure-process') + + delete process.env.ORCA_E2E_USER_DATA_DIR + vi.mocked(app.disableHardwareAcceleration).mockClear() + + for (const platform of ['darwin', 'linux', 'win32'] as const) { + setPlatform(platform) + vi.mocked(app.commandLine.appendSwitch).mockClear() + + enableMainProcessGpuFeatures() + + if (platform === 'darwin') { + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-skia-graphite') + } else { + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('disable-skia-graphite') + } + } + + expect(app.disableHardwareAcceleration).not.toHaveBeenCalled() + }) + it('disables the GPU sandbox on Linux Wayland without disabling acceleration', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 40ec5e2efbe..74eacdac187 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -262,6 +262,11 @@ export function enableMainProcessGpuFeatures(): void { return } + if (process.platform === 'darwin') { + // Why: Graphite can strand corrupt Metal tiles after idle; Ganesh preserves GPU compositing without the stale surface. + app.commandLine.appendSwitch('disable-skia-graphite') + } + // Why: Blink evicts the oldest WebGL context past 16/renderer and each terminal pane holds one, silently downgrading panes to DOM. // 128 raises the ceiling for real layouts while staying bounded so context leaks still surface. app.commandLine.appendSwitch('max-active-webgl-contexts', '128') From 16482623b3d818d59e5c5f097f1c02530b43fdaa Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:02:32 -0700 Subject: [PATCH 049/150] Move language setting to primary interface section (#10594) * Move language setting to primary interface section Language is a first-class presentation preference alongside Theme and Zoom, so it shouldn't be buried in the Advanced disclosure. Advanced now contains only platform-chrome controls. * Show selected language in Interface section summary Extracts the interface summary logic into a dedicated module that includes theme, language, and font selections. This makes the chosen language visible in the collapsed Interface section summary, addressing the discoverability issue where language was hidden until Advanced was expanded. --- .../settings/AppearanceInterfaceSection.tsx | 65 +++++++++---------- .../settings/AppearancePane.test.tsx | 57 ++++++++++++++++ .../components/settings/AppearancePane.tsx | 16 +---- .../appearance-interface-summary.test.ts | 27 ++++++++ .../settings/appearance-interface-summary.ts | 35 ++++++++++ 5 files changed, 152 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/components/settings/appearance-interface-summary.test.ts create mode 100644 src/renderer/src/components/settings/appearance-interface-summary.ts diff --git a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx index 9d5bb1cbbab..d8ddf4819c4 100644 --- a/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx +++ b/src/renderer/src/components/settings/AppearanceInterfaceSection.tsx @@ -67,12 +67,12 @@ export function AppearanceInterfaceSection({ const typographyEntry = getTypographyEntries()[0] const zoomEntry = getZoomEntries()[0] const advancedEntries = [ - ...(SHOW_UI_LANGUAGE_SETTING ? getLanguageEntries() : []), ...getTitlebarEntries(), ...getSystemTrayEntries({ showSystemTray: isDesktopWindows }), ...getMenuBarIconEntries({ showMenuBarIcon: isDesktopMac }) ] const showAdvanced = !isSearching || matchesSettingsSearch(searchQuery, advancedEntries) + const languageTitle = translate('settings.appearance.language.title', 'Language') return (
@@ -111,6 +111,36 @@ export function AppearanceInterfaceSection({ /> + {SHOW_UI_LANGUAGE_SETTING ? ( + + updateSettings({ uiLanguage: value as UiLanguage })} + > + + + + + {UI_LANGUAGE_CHOICES.map((choice) => ( + + {getUiLanguageChoiceLabel(choice, translate)} + + ))} + + + } + /> + + ) : null} +
- {SHOW_UI_LANGUAGE_SETTING ? ( - - updateSettings({ uiLanguage: value as UiLanguage })} - > - - - - - {UI_LANGUAGE_CHOICES.map((choice) => ( - - {getUiLanguageChoiceLabel(choice, translate)} - - ))} - - - } - /> - - ) : null} - { delete (window as unknown as { api?: unknown }).api }) + it('shows language as a primary interface control without opening Advanced', async () => { + mocks.state.settingsSearchQuery = '' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const languageTrigger = container.querySelector( + '[data-slot="select-trigger"][aria-label="Language"]' + ) + const advancedTrigger = Array.from( + container.querySelectorAll('button[aria-expanded]') + ).find((button) => button.textContent?.includes('Advanced')) + + expect(languageTrigger).not.toBeNull() + expect(advancedTrigger).toBeDefined() + expect(advancedTrigger?.getAttribute('aria-expanded')).toBe('false') + expect( + container.querySelector('button[role="switch"][aria-label="Titlebar App Name"]') + ).toBeNull() + }) + + it('keeps Advanced closed when searching for language', async () => { + mocks.state.settingsSearchQuery = 'language' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const languageTrigger = container.querySelector( + '[data-slot="select-trigger"][aria-label="Language"]' + ) + + expect(languageTrigger).not.toBeNull() + expect(container.textContent).not.toContain('Advanced') + expect( + container.querySelector('button[role="switch"][aria-label="Titlebar App Name"]') + ).toBeNull() + }) + it('renders the language dropdown with system, english, chinese, korean, japanese, and spanish options', async () => { mocks.state.settingsSearchQuery = 'language' const updateSettings = vi.fn() @@ -257,6 +289,7 @@ describe('AppearancePane', () => { expect(languageTrigger).not.toBeNull() expect(chineseOption).not.toBeNull() + expect(container.textContent).not.toContain('Advanced') expect(container.textContent).toContain('System') expect(container.textContent).toContain('English') expect(container.textContent).toContain('中文(简体)') @@ -271,6 +304,30 @@ describe('AppearancePane', () => { expect(updateSettings).toHaveBeenCalledWith({ uiLanguage: 'zh' }) }) + it('includes the selected language in the collapsed Interface summary', async () => { + mocks.state.settingsSearchQuery = '' + const settings = { + ...getDefaultSettings('/tmp'), + uiLanguage: 'zh' as const, + theme: 'dark' as const, + appFontFamily: 'Inter' + } + const container = await renderAppearancePane(settings) + const interfaceToggle = Array.from( + container.querySelectorAll( + 'button[aria-controls="appearance-section-interface"]' + ) + )[0] + expect(interfaceToggle).toBeDefined() + await act(async () => { + interfaceToggle?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(interfaceToggle?.getAttribute('aria-expanded')).toBe('false') + // Summary is only rendered on the collapsed toggle (children stay mounted but hidden). + expect(interfaceToggle?.textContent).toContain('Dark · 中文(简体) · Inter') + }) + it('updates the left sidebar appearance from sidebar settings', async () => { mocks.state.settingsSearchQuery = 'left sidebar' const updateSettings = vi.fn() diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index 6e73d5aafdf..f6c71840654 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -39,6 +39,7 @@ import { getLeftSidebarAppearanceEntry, getWorkspaceCardLayoutEntry } from './appearance-sidebar-search' +import { resolveInterfaceSectionSummary } from './appearance-interface-summary' export { getAppearancePaneSearchEntries } type AppearancePaneProps = { @@ -61,16 +62,6 @@ const ALL_APPEARANCE_SECTIONS = [ 'window' ] as const satisfies readonly AppearanceSectionKey[] -function resolveThemeSummary(theme: GlobalSettings['theme']): string { - if (theme === 'system') { - return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') - } - if (theme === 'light') { - return translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light') - } - return translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark') -} - export function AppearancePane({ settings, updateSettings, @@ -207,10 +198,7 @@ export function AppearancePane({ }) } - const interfaceSummary = `${resolveThemeSummary(settings.theme)} · ${ - settings.appFontFamily || - translate('auto.components.settings.AppearancePane.interfaceDefaultFont', 'Default font') - }` + const interfaceSummary = resolveInterfaceSectionSummary(settings) const terminalSummary = `${ settings.terminalFontFamily || translate('auto.components.settings.AppearancePane.terminalDefaultFont', 'Default font') diff --git a/src/renderer/src/components/settings/appearance-interface-summary.test.ts b/src/renderer/src/components/settings/appearance-interface-summary.test.ts new file mode 100644 index 00000000000..e60f1bcae04 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-interface-summary.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { resolveInterfaceSectionSummary } from './appearance-interface-summary' + +describe('resolveInterfaceSectionSummary', () => { + it('includes theme, language, and font when the language setting is shown', () => { + const settings = { + ...getDefaultSettings('/tmp'), + theme: 'dark' as const, + uiLanguage: 'zh' as const, + appFontFamily: 'Inter' + } + + expect(resolveInterfaceSectionSummary(settings)).toBe('Dark · 中文(简体) · Inter') + }) + + it('falls back to the default font label when app font is empty', () => { + const settings = { + ...getDefaultSettings('/tmp'), + theme: 'light' as const, + uiLanguage: 'system' as const, + appFontFamily: '' + } + + expect(resolveInterfaceSectionSummary(settings)).toBe('Light · System · Default font') + }) +}) diff --git a/src/renderer/src/components/settings/appearance-interface-summary.ts b/src/renderer/src/components/settings/appearance-interface-summary.ts new file mode 100644 index 00000000000..e37a4f2aa66 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-interface-summary.ts @@ -0,0 +1,35 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { + getUiLanguageChoiceLabel, + SHOW_UI_LANGUAGE_SETTING, + UI_LANGUAGE_CHOICES +} from '@/i18n/supported-languages' +import { translate } from '@/i18n/i18n' + +function resolveThemeSummary(theme: GlobalSettings['theme']): string { + if (theme === 'system') { + return translate('auto.components.settings.AppearancePane.fb0e0b4453', 'System') + } + if (theme === 'light') { + return translate('auto.components.settings.AppearancePane.fd89b5487c', 'Light') + } + return translate('auto.components.settings.AppearancePane.7d26ccabe8', 'Dark') +} + +function resolveLanguageSummary(uiLanguage: GlobalSettings['uiLanguage']): string { + const choice = UI_LANGUAGE_CHOICES.find((entry) => entry.value === uiLanguage) + if (choice == null) { + return translate('settings.appearance.language.system', 'System') + } + return getUiLanguageChoiceLabel(choice, translate) +} + +export function resolveInterfaceSectionSummary(settings: GlobalSettings): string { + const fontSummary = + settings.appFontFamily || + translate('auto.components.settings.AppearancePane.interfaceDefaultFont', 'Default font') + if (!SHOW_UI_LANGUAGE_SETTING) { + return `${resolveThemeSummary(settings.theme)} · ${fontSummary}` + } + return `${resolveThemeSummary(settings.theme)} · ${resolveLanguageSummary(settings.uiLanguage)} · ${fontSummary}` +} From 9f81f97a0ca6d30119660ed85b0169b9f0fb5bed Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:20:17 -0700 Subject: [PATCH 050/150] Fix SSH relay installs on split shell/SFTP namespaces (#10645) * Fix ssh-relay install on hosts with split shell/SFTP namespaces On Synology DSM and similar hosts, the SSH shell and SFTP subsystem expose different absolute paths for the same directory (e.g., /var/services/homes/alice vs /homes/alice). The relay installer silently picked the wrong path and failed discovery. This fix implements SFTP namespace detection: each install creates an unguessable ownership marker and probes both namespaces to detect divergence. When paths differ, SFTP writes redirect to the candidate namespace while shell commands keep the canonical path. Markers are random tokens redacted from logs. * Fix ssh-relay install on hosts with split shell/SFTP namespaces Strengthen path validation to catch traversal and empty segments in absolute POSIX paths, preventing security issues. Improve split-namespace handling with comprehensive wire tests for uploads and file writes. Ensure system SSH connections bypass namespace mapping entirely rather than attempting incorrect retargeting. --- .../ssh/sftp-namespace-resolution.test.ts | 361 +++++++++++ src/main/ssh/sftp-namespace-resolution.ts | 245 +++++++ src/main/ssh/sftp-upload.ts | 37 ++ .../ssh/ssh-connection-sftp-namespace.test.ts | 471 ++++++++++++++ src/main/ssh/ssh-connection-sftp-wire.test.ts | 361 +++++++++++ src/main/ssh/ssh-connection.ts | 62 +- .../ssh-relay-cross-version-isolation.test.ts | 41 +- src/main/ssh/ssh-relay-deploy-helpers.test.ts | 39 ++ src/main/ssh/ssh-relay-deploy.test.ts | 3 +- src/main/ssh/ssh-relay-deploy.ts | 179 ++++-- src/main/ssh/ssh-relay-exec-command.ts | 36 +- src/main/ssh/ssh-relay-gc-retry.test.ts | 5 +- src/main/ssh/ssh-relay-install-marker.ts | 29 + .../ssh/ssh-relay-install-namespace.test.ts | 151 +++++ src/main/ssh/ssh-relay-install-namespace.ts | 122 ++++ src/main/ssh/ssh-relay-install-transfers.ts | 101 +++ .../ssh/ssh-relay-native-deps-install.test.ts | 60 +- .../ssh-relay-sftp-namespace-install.test.ts | 599 ++++++++++++++++++ src/main/ssh/ssh-relay-versioned-install.ts | 4 +- 19 files changed, 2755 insertions(+), 151 deletions(-) create mode 100644 src/main/ssh/sftp-namespace-resolution.test.ts create mode 100644 src/main/ssh/sftp-namespace-resolution.ts create mode 100644 src/main/ssh/ssh-connection-sftp-namespace.test.ts create mode 100644 src/main/ssh/ssh-connection-sftp-wire.test.ts create mode 100644 src/main/ssh/ssh-relay-install-marker.ts create mode 100644 src/main/ssh/ssh-relay-install-namespace.test.ts create mode 100644 src/main/ssh/ssh-relay-install-namespace.ts create mode 100644 src/main/ssh/ssh-relay-install-transfers.ts create mode 100644 src/main/ssh/ssh-relay-sftp-namespace-install.test.ts diff --git a/src/main/ssh/sftp-namespace-resolution.test.ts b/src/main/ssh/sftp-namespace-resolution.test.ts new file mode 100644 index 00000000000..f404d27979d --- /dev/null +++ b/src/main/ssh/sftp-namespace-resolution.test.ts @@ -0,0 +1,361 @@ +// Why: picking the wrong SFTP path silently installs the relay somewhere the shell +// will never launch it, so every discovery outcome needs a pinned decision. + +import type { SFTPWrapper } from 'ssh2' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + resolveSftpTransferPath, + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SHELL_HOME = '/var/services/homes/alice' +const RELAY_DIR = '.orca-remote/relay-0.1.0+hash' +const MARKER = '.install-lock/.sftp-namespace-deadbeef' + +const mapping: SftpNamespacePathMapping = { + homeRelativePath: RELAY_DIR, + shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${MARKER}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER}` +} + +type LstatOutcome = 'present' | { code: number } | { message: string } + +function statusError(code: number): Error { + return Object.assign(new Error(`SFTP status ${code}`), { code }) +} + +// A marker probe must care only about "the call succeeded", never about the reported type or size. +const MARKER_STATS = { + isDirectory: () => false, + isSymbolicLink: () => true, + mode: 0o120_777, + size: 0 +} + +function makeSftp(options: { + startPath?: string | Error | unknown + lstat?: (path: string) => LstatOutcome +}): { + sftp: SFTPWrapper + realpathCalls: string[] + lstatCalls: string[] +} { + const realpathCalls: string[] = [] + const lstatCalls: string[] = [] + const sftp = { + realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: unknown) => void) => { + realpathCalls.push(path) + if (options.startPath instanceof Error) { + cb(options.startPath) + return + } + cb(null, options.startPath) + }), + lstat: vi.fn((path: string, cb: (err: Error | null, stats?: unknown) => void) => { + lstatCalls.push(path) + const outcome = options.lstat?.(path) ?? { code: 2 } + if (outcome === 'present') { + cb(null, MARKER_STATS) + return + } + cb('code' in outcome ? statusError(outcome.code) : new Error(outcome.message)) + }) + } + return { sftp: sftp as unknown as SFTPWrapper, realpathCalls, lstatCalls } +} + +// The marker lives under the SFTP start directory but not under the shell path. +function divergentLstat(startPath: string) { + return (path: string): LstatOutcome => + path === `${startPath}/${RELAY_DIR}/${MARKER}` ? 'present' : { code: 2 } +} + +describe('resolveSftpTransferPath', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + it('keeps the shell path and skips probing when both namespaces agree', async () => { + const { sftp, lstatCalls } = makeSftp({ startPath: SHELL_HOME }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([]) + }) + + it('redirects to the SFTP namespace when only that side carries our marker', async () => { + const { sftp, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`) + expect(lstatCalls).toEqual([ + `${SHELL_HOME}/${RELAY_DIR}/${MARKER}`, + `/homes/alice/${RELAY_DIR}/${MARKER}` + ]) + }) + + it('handles a start directory that is not a home directory at all', async () => { + const { sftp } = makeSftp({ + startPath: '/volume1/shared', + lstat: divergentLstat('/volume1/shared') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`/volume1/shared/${RELAY_DIR}`) + }) + + it('normalizes a trailing slash on the reported start directory', async () => { + const { sftp } = makeSftp({ + startPath: '/homes/alice/', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`) + }) + + it('resolves a nested file path, not just the relay directory', async () => { + const fileMapping: SftpNamespacePathMapping = { + ...mapping, + homeRelativePath: `${RELAY_DIR}/package.json` + } + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPath( + sftp, + `${SHELL_HOME}/${RELAY_DIR}/package.json`, + fileMapping + ) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}/package.json`) + }) + + it('refuses an unrelated same-version directory that lacks our marker', async () => { + const { sftp, lstatCalls } = makeSftp({ startPath: '/homes/alice' }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toHaveLength(2) + }) + + it('keeps the shell path when the marker is already visible there', async () => { + const { sftp, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: (path) => (path === mapping.shellProbePath ? 'present' : { code: 2 }) + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([mapping.shellProbePath]) + }) + + // Why: only SSH_FX_NO_SUCH_FILE proves absence; anything else must not license a redirect. + it.each([ + ['generic failure', { code: 4 } as LstatOutcome], + ['permission denied', { code: 3 } as LstatOutcome], + ['a code-less transport error', { message: 'socket hang up' } as LstatOutcome] + ])('never probes the candidate after %s on the shell marker', async (_label, outcome) => { + const { sftp, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: (path) => (path === mapping.shellProbePath ? outcome : 'present') + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([mapping.shellProbePath]) + }) + + it('keeps the shell path when the candidate probe is inconclusive', async () => { + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: (path) => (path === mapping.shellProbePath ? { code: 2 } : { code: 4 }) + }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retaining shell path')) + }) + + it.each([ + ['REALPATH fails', new Error('permission denied')], + ['REALPATH returns a relative path', 'homes/alice'], + ['REALPATH returns a non-string', 42], + ['REALPATH smuggles a line break', '/homes/alice\nrm -rf /'], + ['REALPATH contains an empty component', '/homes//alice'], + ['REALPATH contains a dot component', '/homes/./alice'], + ['REALPATH contains a traversal component', '/homes/archive/../alice'] + ])('keeps the shell path and skips LSTAT when %s', async (_label, startPath) => { + const { sftp, lstatCalls } = makeSftp({ startPath }) + + const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(lstatCalls).toEqual([]) + }) + + it.each([ + ['a relative shell path', { shell: `${SHELL_HOME}/${RELAY_DIR}`.slice(1) }, 'SFTP namespace'], + ['an absolute home-relative path', { homeRelativePath: `/${RELAY_DIR}` }, 'SFTP namespace'], + ['a traversal segment', { homeRelativePath: `${RELAY_DIR}/../escape` }, 'Unsafe remote path'], + [ + 'a traversal segment in the absolute shell path', + { shell: `${SHELL_HOME}/archive/../${RELAY_DIR}` }, + 'Unsafe remote path' + ], + [ + 'an empty component in the absolute shell path', + { shell: `${SHELL_HOME}//${RELAY_DIR}` }, + 'Unsafe remote path' + ], + [ + 'a dot component in the absolute marker path', + { + shellProbePath: `${SHELL_HOME}/./${RELAY_DIR}/${MARKER}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER}` + }, + 'Unsafe remote path' + ], + ['a line break in the marker path', { shellProbePath: `${SHELL_HOME}/a\nb` }, 'SFTP namespace'], + [ + 'a mismatched transfer suffix', + { shell: `${SHELL_HOME}/${RELAY_DIR}/other` }, + 'share one home-relative suffix' + ], + [ + 'a mismatched shell namespace prefix', + { shellProbePath: `/other/home/${RELAY_DIR}/${MARKER}` }, + 'share one shell namespace prefix' + ], + [ + 'a mismatched marker basename', + { homeRelativeProbePath: `${RELAY_DIR}/.install-lock/.sftp-namespace-other` }, + 'marker paths must share one marker basename' + ], + [ + 'a marker outside the install lock', + { + shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef`, + homeRelativeProbePath: `${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef` + }, + 'inside the transfer relay install lock' + ], + [ + 'a marker under another relay tree', + { + shellProbePath: `${SHELL_HOME}/other/.install-lock/.sftp-namespace-deadbeef`, + homeRelativeProbePath: 'other/.install-lock/.sftp-namespace-deadbeef' + }, + 'inside the transfer relay install lock' + ] + ])('rejects %s before issuing any SFTP request', async (_label, overrides, expected) => { + const { shell, ...mappingOverrides } = overrides as Record + const { sftp, realpathCalls, lstatCalls } = makeSftp({ startPath: SHELL_HOME }) + + await expect( + resolveSftpTransferPath(sftp, shell ?? `${SHELL_HOME}/${RELAY_DIR}`, { + ...mapping, + ...mappingOverrides + }) + ).rejects.toThrow(expected) + expect(realpathCalls).toEqual([]) + expect(lstatCalls).toEqual([]) + }) + + it('redacts marker tokens from discovery diagnostics', async () => { + const token = 'a'.repeat(32) + const secretMarker = `.install-lock/.sftp-namespace-${token}` + const secretMapping: SftpNamespacePathMapping = { + homeRelativePath: RELAY_DIR, + shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${secretMarker}`, + homeRelativeProbePath: `${RELAY_DIR}/${secretMarker}` + } + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: () => ({ message: `failure at ${secretMapping.shellProbePath}` }) + }) + + await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, secretMapping) + + const warnings = vi.mocked(console.warn).mock.calls.flat().join('\n') + expect(warnings).toContain('.sftp-namespace-[redacted]') + expect(warnings).not.toContain(token) + }) +}) + +describe('resolveSftpTransferPathIfMapped', () => { + it('issues no discovery requests when no mapping was supplied', async () => { + const { sftp, realpathCalls, lstatCalls } = makeSftp({ startPath: '/homes/alice' }) + + const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, { + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(realpathCalls).toEqual([]) + expect(lstatCalls).toEqual([]) + }) + + // Why: Windows SFTP reports drive paths like /C:/Users/alice, which break the POSIX prefix contract. + it('ignores a mapping on a Windows host', async () => { + const { sftp, realpathCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, { + hostPlatform: getRemoteHostPlatform('win32-x64'), + sftpNamespace: mapping + }) + + expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`) + expect(realpathCalls).toEqual([]) + }) + + it('conservatively ignores a Windows path when platform metadata is absent', async () => { + const { sftp, realpathCalls, lstatCalls } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + const windowsPath = 'C:\\Users\\alice\\relay\\.version' + + const resolved = await resolveSftpTransferPathIfMapped(sftp, windowsPath, { + sftpNamespace: mapping + }) + + expect(resolved).toBe(windowsPath) + expect(realpathCalls).toEqual([]) + expect(lstatCalls).toEqual([]) + }) + + it('resolves on a POSIX host with a mapping', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + const { sftp } = makeSftp({ + startPath: '/homes/alice', + lstat: divergentLstat('/homes/alice') + }) + + const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, { + hostPlatform: getRemoteHostPlatform('linux-x64'), + sftpNamespace: mapping + }) + + expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`) + }) +}) diff --git a/src/main/ssh/sftp-namespace-resolution.ts b/src/main/ssh/sftp-namespace-resolution.ts new file mode 100644 index 00000000000..1020d915127 --- /dev/null +++ b/src/main/ssh/sftp-namespace-resolution.ts @@ -0,0 +1,245 @@ +// Resolve an SFTP transfer path when the SSH shell and the SFTP subsystem expose +// different absolute namespaces for the same directory (e.g. Synology DSM's +// /var/services/homes/alice shell home vs. /homes/alice SFTP start directory). +// +// See: docs/ssh-relay-sftp-namespace.md + +import type { SFTPWrapper } from 'ssh2' +import { assertSafeRemotePathSegment, isWindowsRemoteHost } from './ssh-remote-platform' +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { redactRelayInstallMarkerTokens } from './ssh-relay-install-marker' + +export type SftpNamespacePathMapping = { + homeRelativePath: string + shellProbePath: string + homeRelativeProbePath: string +} + +// SSH_FX_NO_SUCH_FILE. The only status that definitively proves a path is absent; +// permission denied, generic failure, and code-less errors are inconclusive. +const SFTP_STATUS_NO_SUCH_FILE = 2 + +type MarkerProbe = + | { kind: 'present' } + | { kind: 'absent' } + | { kind: 'inconclusive'; detail: string } + +function hasNulOrLineBreak(value: string): boolean { + return value.includes('\0') || value.includes('\r') || value.includes('\n') +} + +function assertAbsolutePosixPath(label: string, value: string): void { + if (!value.startsWith('/') || hasNulOrLineBreak(value)) { + throw new Error( + `SFTP namespace ${label} must be an absolute POSIX path: ${JSON.stringify(redactRelayInstallMarkerTokens(value))}` + ) + } + // Same segment hygiene as REALPATH start paths — empty/`.`/`..` are programming errors. + if (value === '/') { + return + } + for (const segment of value.slice(1).split('/')) { + assertSafeRemotePathSegment(segment, 'posix') + } +} + +function assertHomeRelativePosixPath(label: string, value: string): void { + if (!value || value.startsWith('/') || hasNulOrLineBreak(value)) { + throw new Error( + `SFTP namespace ${label} must be a relative POSIX path: ${JSON.stringify(redactRelayInstallMarkerTokens(value))}` + ) + } + for (const segment of value.split('/')) { + assertSafeRemotePathSegment(segment, 'posix') + } +} + +function assertMappingIdentity(shellAbsolutePath: string, mapping: SftpNamespacePathMapping): void { + if (!shellAbsolutePath.endsWith(`/${mapping.homeRelativePath}`)) { + throw new Error('SFTP namespace transfer paths must share one home-relative suffix') + } + + const probeSegments = mapping.homeRelativeProbePath.split('/') + const markerFileName = probeSegments.at(-1) + const shellMarkerFileName = mapping.shellProbePath.slice( + mapping.shellProbePath.lastIndexOf('/') + 1 + ) + if (!markerFileName || shellMarkerFileName !== markerFileName) { + throw new Error('SFTP namespace marker paths must share one marker basename') + } + const shellNamespacePrefix = shellAbsolutePath.slice(0, -mapping.homeRelativePath.length) + if (mapping.shellProbePath !== `${shellNamespacePrefix}${mapping.homeRelativeProbePath}`) { + throw new Error('SFTP namespace transfer and marker must share one shell namespace prefix') + } + + const lockSegmentIndex = probeSegments.length - 2 + const relayDir = probeSegments.slice(0, lockSegmentIndex).join('/') + if ( + lockSegmentIndex < 1 || + probeSegments[lockSegmentIndex] !== '.install-lock' || + (mapping.homeRelativePath !== relayDir && !mapping.homeRelativePath.startsWith(`${relayDir}/`)) + ) { + throw new Error('SFTP namespace marker must be inside the transfer relay install lock') + } +} + +function normalizeSftpStartPath(value: unknown): string | null { + if (typeof value !== 'string' || !value.startsWith('/') || hasNulOrLineBreak(value)) { + return null + } + if (value === '/') { + return value + } + const normalized = value.replace(/\/+$/, '') + if (!normalized.startsWith('/')) { + return null + } + const segments = normalized.slice(1).split('/') + return segments.some((segment) => !segment || segment === '.' || segment === '..') + ? null + : normalized +} + +function joinSftpStartPath(startPath: string, homeRelativePath: string): string { + return `${startPath.replace(/\/+$/, '')}/${homeRelativePath}` +} + +function realpathSftp(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.realpath(remotePath, (err, resolved) => { + if (err) { + reject(err) + return + } + resolve(resolved) + }) + }) +} + +// Why: sftpPathExists collapses "absent" and "could not tell" into one boolean; +// namespace selection must never treat an inconclusive probe as absence. +function probeMarkerPath(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve) => { + sftp.lstat(remotePath, (err) => { + if (!err) { + resolve({ kind: 'present' }) + return + } + const code = (err as { code?: unknown }).code + if (code === SFTP_STATUS_NO_SUCH_FILE) { + resolve({ kind: 'absent' }) + return + } + resolve({ + kind: 'inconclusive', + detail: + typeof code === 'number' ? `status ${code}` : redactRelayInstallMarkerTokens(err.message) + }) + }) + }) +} + +function logRetainedShellPath(operation: string, detail: string, shellAbsolutePath: string): void { + console.warn( + `[ssh-relay] SFTP namespace discovery inconclusive (${operation}: ${redactRelayInstallMarkerTokens(detail)}); retaining shell path ${redactRelayInstallMarkerTokens(shellAbsolutePath)}` + ) +} + +/** + * Pick the path this SFTP session should transfer to. + * + * Returns `shellAbsolutePath` unless the session both fails to see the install + * owner's marker there and does see it under its own start directory. Discovery + * failures degrade to the shell path rather than inventing a namespace error. + */ +export async function resolveSftpTransferPath( + sftp: SFTPWrapper, + shellAbsolutePath: string, + mapping: SftpNamespacePathMapping +): Promise { + assertAbsolutePosixPath('transfer path', shellAbsolutePath) + assertAbsolutePosixPath('marker path', mapping.shellProbePath) + assertHomeRelativePosixPath('relative transfer path', mapping.homeRelativePath) + assertHomeRelativePosixPath('relative marker path', mapping.homeRelativeProbePath) + assertMappingIdentity(shellAbsolutePath, mapping) + + let reportedStartPath: unknown + try { + reportedStartPath = await realpathSftp(sftp, '.') + } catch (err) { + logRetainedShellPath( + 'REALPATH', + err instanceof Error ? err.message : String(err), + shellAbsolutePath + ) + return shellAbsolutePath + } + const startPath = normalizeSftpStartPath(reportedStartPath) + if (!startPath) { + logRetainedShellPath('REALPATH', 'unusable start directory', shellAbsolutePath) + return shellAbsolutePath + } + + const candidatePath = joinSftpStartPath(startPath, mapping.homeRelativePath) + if (candidatePath === shellAbsolutePath) { + return shellAbsolutePath + } + + const shellMarker = await probeMarkerPath(sftp, mapping.shellProbePath) + if (shellMarker.kind !== 'absent') { + if (shellMarker.kind === 'inconclusive') { + logRetainedShellPath('LSTAT', shellMarker.detail, shellAbsolutePath) + } + return shellAbsolutePath + } + + const candidateMarker = await probeMarkerPath( + sftp, + joinSftpStartPath(startPath, mapping.homeRelativeProbePath) + ) + if (candidateMarker.kind === 'present') { + console.log( + `[ssh-relay] SFTP namespace differs; transfer path: ${redactRelayInstallMarkerTokens(candidatePath)}` + ) + return candidatePath + } + if (candidateMarker.kind === 'inconclusive') { + logRetainedShellPath('LSTAT', candidateMarker.detail, shellAbsolutePath) + } + return shellAbsolutePath +} + +export type SftpTransferPathOptions = { + hostPlatform?: RemoteHostPlatform + sftpNamespace?: SftpNamespacePathMapping +} + +/** + * Namespace-resolve a transfer path only when a mapping was supplied and the host + * is not Windows, whose SFTP drive paths (`/C:/Users/...`) break the POSIX prefix + * contract. Callers without a mapping issue no REALPATH or LSTAT. + */ +export function resolveSftpTransferPathIfMapped( + sftp: SFTPWrapper, + shellAbsolutePath: string, + options?: SftpTransferPathOptions +): Promise { + const mapping = options?.sftpNamespace + if ( + !mapping || + (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) || + (!options?.hostPlatform && isRecognizableWindowsAbsolutePath(shellAbsolutePath)) + ) { + return Promise.resolve(shellAbsolutePath) + } + return resolveSftpTransferPath(sftp, shellAbsolutePath, mapping) +} + +function isRecognizableWindowsAbsolutePath(value: string): boolean { + return ( + /^[A-Za-z]:[\\/]/u.test(value) || + value.startsWith('\\\\') || + /^\/[A-Za-z]:\//u.test(value) || + /^\/\/[^/]/u.test(value) + ) +} diff --git a/src/main/ssh/sftp-upload.ts b/src/main/ssh/sftp-upload.ts index 9ece3e2aaa9..22b1331cf13 100644 --- a/src/main/ssh/sftp-upload.ts +++ b/src/main/ssh/sftp-upload.ts @@ -125,6 +125,43 @@ export function uploadBuffer( }) } +export function writeStringViaSftp( + sftp: SFTPWrapper, + remotePath: string, + contents: string +): Promise { + return new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(remotePath) + let settled = false + const cleanup = (): void => { + sftp.removeListener('error', onError) + ws.removeListener('close', onClose) + ws.removeListener('error', onError) + } + const onClose = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() + } + const onError = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(err) + } + // Why: prepend so a session error settles this write before a late-error swallower sees it. + sftp.prependOnceListener('error', onError) + ws.once('close', onClose) + ws.once('error', onError) + ws.end(contents) + }) +} + export async function uploadDirectory( sftp: SFTPWrapper, localDir: string, diff --git a/src/main/ssh/ssh-connection-sftp-namespace.test.ts b/src/main/ssh/ssh-connection-sftp-namespace.test.ts new file mode 100644 index 00000000000..dd5730aa210 --- /dev/null +++ b/src/main/ssh/ssh-connection-sftp-namespace.test.ts @@ -0,0 +1,471 @@ +// Why: namespace resolution has to happen on the very session that transfers, and +// only for the two relay-install writes — a leak into other transfers or into the +// system-SSH/Windows branches would silently retarget unrelated file operations. + +import { EventEmitter } from 'node:events' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('ssh2', () => ({ + BaseAgent: class {}, + Client: class {}, + createAgent: vi.fn(), + utils: { parseKey: vi.fn() } +})) + +vi.mock('./ssh-system-fallback', () => ({ + getOrcaControlSocketPath: vi.fn().mockReturnValue(null), + spawnSystemSsh: vi.fn(), + spawnSystemSshCommand: vi.fn(), + downloadFileViaSystemSsh: vi.fn().mockResolvedValue(undefined), + uploadDirectoryViaSystemSsh: vi.fn().mockResolvedValue(undefined), + uploadFileViaSystemSsh: vi.fn().mockResolvedValue(undefined), + writeBufferViaSystemSsh: vi.fn().mockResolvedValue(undefined), + writeFileViaSystemSsh: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-control-socket', () => ({ removeControlSocketPath: vi.fn() })) +vi.mock('./ssh-config-parser', () => ({ resolveWithSshG: vi.fn().mockResolvedValue(null) })) + +import { SshConnection } from './ssh-connection' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { isSshSessionLimitError } from './ssh-session-limit-error' +import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback' +import type { SshTarget } from '../../shared/ssh-types' + +const SHELL_HOME = '/var/services/homes/alice' +const SFTP_HOME = '/homes/alice' +const RELAY_DIR = '.orca-remote/relay-0.1.0+hash' +const MARKER = '.install-lock/.sftp-namespace-cafebabe' +const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_DIR}` + +const namespace: SftpNamespacePathMapping = { + homeRelativePath: RELAY_DIR, + shellProbePath: `${SHELL_RELAY_DIR}/${MARKER}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER}` +} + +function fileNamespace(fileName: string): SftpNamespacePathMapping { + return { ...namespace, homeRelativePath: `${RELAY_DIR}/${fileName}` } +} + +type FakeSftp = EventEmitter & { + realpathCalls: string[] + lstatCalls: string[] + writtenPaths: string[] + mkdirPaths: string[] + endCalls: number + emitCloseOnEnd: boolean + pendingRealpathCallbacks: RealpathCallback[] + pendingLstatCallbacks: LstatCallback[] + realpath: ReturnType + lstat: ReturnType + mkdir: ReturnType + createWriteStream: ReturnType + end: ReturnType +} + +type RealpathCallback = (err: Error | null, resolved?: string) => void +type LstatCallback = (err: Error | null) => void + +function createFakeSftp(options?: { pendingRealpath?: boolean; pendingLstat?: boolean }): FakeSftp { + const sftp = new EventEmitter() as FakeSftp + sftp.realpathCalls = [] + sftp.lstatCalls = [] + sftp.writtenPaths = [] + sftp.mkdirPaths = [] + sftp.endCalls = 0 + sftp.emitCloseOnEnd = true + sftp.pendingRealpathCallbacks = [] + sftp.pendingLstatCallbacks = [] + sftp.realpath = vi.fn((path: string, cb: RealpathCallback) => { + sftp.realpathCalls.push(path) + if (options?.pendingRealpath) { + sftp.pendingRealpathCallbacks.push(cb) + return + } + cb(null, SFTP_HOME) + }) + // The install-owner marker exists only under the SFTP start directory. + sftp.lstat = vi.fn((path: string, cb: LstatCallback) => { + sftp.lstatCalls.push(path) + if (options?.pendingLstat) { + sftp.pendingLstatCallbacks.push(cb) + return + } + if (path === `${SFTP_HOME}/${RELAY_DIR}/${MARKER}`) { + cb(null) + return + } + cb(Object.assign(new Error('No such file'), { code: 2 })) + }) + sftp.mkdir = vi.fn((path: string, cb: (err: Error | null) => void) => { + sftp.mkdirPaths.push(path) + cb(null) + }) + sftp.createWriteStream = vi.fn((path: string) => { + sftp.writtenPaths.push(path) + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)), + destroy: vi.fn(), + off: ws.removeListener.bind(ws), + write: vi.fn(), + on: ws.on.bind(ws) + }) + }) + sftp.end = vi.fn(() => { + sftp.endCalls += 1 + if (sftp.emitCloseOnEnd) { + setTimeout(() => sftp.emit('close'), 0) + } + }) + return sftp +} + +function createTarget(): SshTarget { + return { + id: 'target-1', + label: 'Synology', + host: 'nas.local', + port: 22, + username: 'alice', + authMethod: 'agent' + } as SshTarget +} + +function connectedTo( + sftpSessions: FakeSftp[], + options?: { sftpError?: Error; useSystemSsh?: boolean } +): SshConnection { + const conn = new SshConnection(createTarget(), { + onStateChange: vi.fn(), + onLog: vi.fn() + } as never) + let handed = 0 + const client = { + sftp: (cb: (err: Error | undefined, sftp: unknown) => void) => { + if (options?.sftpError) { + cb(options.sftpError, undefined) + return + } + cb(undefined, sftpSessions[handed++] ?? sftpSessions.at(-1)) + } + } + // Why: the transfer branches are the unit under test; skip the connect handshake. + Object.assign(conn as unknown as Record, { + client, + useSystemSshTransport: options?.useSystemSsh ?? false + }) + return conn +} + +describe('SshConnection SFTP namespace resolution', () => { + const linux = getRemoteHostPlatform('linux-x64') + const windows = getRemoteHostPlatform('win32-x64') + let localDir: string + + beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + // realpath: macOS /var is a symlink and uploadDirectory rejects a root that resolves elsewhere. + localDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-'))) + writeFileSync(join(localDir, 'relay.js'), 'console.log(1)') + }) + + afterEach(() => { + rmSync(localDir, { recursive: true, force: true }) + }) + + it('writes to the SFTP namespace path discovered on the same session', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, '0.1.0+hash', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + + expect(sftp.realpathCalls).toEqual(['.']) + expect(sftp.writtenPaths).toEqual([`${SFTP_HOME}/${RELAY_DIR}/.version`]) + expect(sftp.endCalls).toBe(1) + }) + + it('uploads the bundle into the SFTP namespace directory', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.uploadDirectory(localDir, SHELL_RELAY_DIR, { + hostPlatform: linux, + sftpNamespace: namespace + }) + + expect(sftp.writtenPaths).toEqual([`${SFTP_HOME}/${RELAY_DIR}/relay.js`]) + expect(sftp.endCalls).toBe(1) + }) + + it('issues no discovery requests when the caller supplies no mapping', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { hostPlatform: linux }) + + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.lstatCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/.version`]) + }) + + it('ignores a mapping on a Windows host', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile('C:\\Users\\alice\\relay\\.version', 'v', { + hostPlatform: windows, + sftpNamespace: fileNamespace('.version') + }) + + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual(['C:\\Users\\alice\\relay\\.version']) + }) + + it('never resolves on the system-SSH transport', async () => { + const conn = connectedTo([], { useSystemSsh: true }) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + await conn.uploadDirectory(localDir, SHELL_RELAY_DIR, { + hostPlatform: linux, + sftpNamespace: namespace + }) + + expect(vi.mocked(writeFileViaSystemSsh)).toHaveBeenCalledWith( + expect.anything(), + `${SHELL_RELAY_DIR}/.version`, + 'v', + expect.anything() + ) + expect(vi.mocked(uploadDirectoryViaSystemSsh)).toHaveBeenCalledWith( + expect.anything(), + localDir, + SHELL_RELAY_DIR, + expect.anything() + ) + }) + + // Why: only the relay-install writes carry a marker; other transfers have no owner to verify. + it('leaves writeBuffer and openFileUploadSession on the shell path', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp, sftp]) + + await conn.writeBuffer(`${SHELL_RELAY_DIR}/blob.bin`, Buffer.from('x'), { + hostPlatform: linux, + sftpNamespace: fileNamespace('blob.bin') + }) + const session = await conn.openFileUploadSession({ + hostPlatform: linux, + sftpNamespace: namespace + }) + session.close() + + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/blob.bin`]) + }) + + it('keeps the shell path when discovery is inconclusive', async () => { + const sftp = createFakeSftp() + sftp.lstat = vi.fn((path: string, cb: (err: Error | null) => void) => { + sftp.lstatCalls.push(path) + cb(Object.assign(new Error('permission denied'), { code: 3 })) + }) + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + + expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/.version`]) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retaining shell path')) + }) + + it('aborts a transfer stuck in discovery and reports a confirmed channel close', async () => { + const sftp = createFakeSftp({ pendingRealpath: true }) + const conn = connectedTo([sftp]) + const controller = new AbortController() + + const write = conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) + controller.abort() + + const error = (await write.catch((err: Error) => err)) as Error & { + sshChannelCloseConfirmed?: boolean + } + expect(error.name).toBe('AbortError') + expect(error.sshChannelCloseConfirmed).toBe(true) + expect(sftp.endCalls).toBe(1) + expect(sftp.writtenPaths).toEqual([]) + }) + + it('reports an unconfirmed close when the aborted session never closes', async () => { + vi.useFakeTimers() + try { + const sftp = createFakeSftp({ pendingRealpath: true }) + sftp.emitCloseOnEnd = false + const conn = connectedTo([sftp]) + const controller = new AbortController() + + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1)) + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + + const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } + expect(error.name).toBe('AbortError') + expect(error.sshChannelCloseConfirmed).toBe(false) + expect(sftp.endCalls).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + // Why: relay deploy classifies MaxSessions to back off; wrapping it would hide the retry signal. + it('preserves a session-limit channel-open error unchanged', async () => { + const original = Object.assign( + new Error('Channel open failure: open failed reason 4: MaxSessions'), + { reason: 4 } + ) + const sftp = createFakeSftp() + const conn = connectedTo([sftp], { + sftpError: original + }) + + const error = await conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + .catch((err: Error) => err) + + expect(error).toBe(original) + expect(isSshSessionLimitError(error)).toBe(true) + expect(sftp.realpathCalls).toEqual([]) + expect(sftp.writtenPaths).toEqual([]) + }) + + it.each([ + ['succeeds', (callback: RealpathCallback) => callback(null, SFTP_HOME)], + ['rejects', (callback: RealpathCallback) => callback(new Error('late REALPATH failure'))] + ])( + 'ignores a late REALPATH callback that %s after a confirmed abort', + async (_outcome, completeRealpath) => { + const sftp = createFakeSftp({ pendingRealpath: true }) + const conn = connectedTo([sftp]) + const controller = new AbortController() + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + try { + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.pendingRealpathCallbacks).toHaveLength(1)) + controller.abort() + + const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } + expect(error).toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: true + }) + + completeRealpath(sftp.pendingRealpathCallbacks[0]!) + await new Promise((resolve) => setImmediate(resolve)) + + expect(unhandledRejection).not.toHaveBeenCalled() + expect(sftp.writtenPaths).toEqual([]) + expect(sftp.endCalls).toBe(1) + } finally { + process.off('unhandledRejection', unhandledRejection) + } + } + ) + + it.each([ + ['succeeds', (callback: LstatCallback) => callback(null)], + [ + 'rejects', + (callback: LstatCallback) => + callback(Object.assign(new Error('late LSTAT failure'), { code: 3 })) + ] + ])( + 'ignores a late LSTAT callback that %s after an unconfirmed abort', + async (_outcome, completeLstat) => { + vi.useFakeTimers() + const unhandledRejection = vi.fn() + process.on('unhandledRejection', unhandledRejection) + try { + const sftp = createFakeSftp({ pendingLstat: true }) + sftp.emitCloseOnEnd = false + const conn = connectedTo([sftp]) + const controller = new AbortController() + const write = conn + .writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version'), + signal: controller.signal + }) + .catch((err: Error) => err) + await vi.waitFor(() => expect(sftp.pendingLstatCallbacks).toHaveLength(1)) + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + + const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean } + expect(error).toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + + completeLstat(sftp.pendingLstatCallbacks[0]!) + await Promise.resolve() + await Promise.resolve() + + expect(unhandledRejection).not.toHaveBeenCalled() + expect(sftp.writtenPaths).toEqual([]) + expect(sftp.endCalls).toBe(1) + } finally { + process.off('unhandledRejection', unhandledRejection) + vi.useRealTimers() + } + } + ) + + it('swallows a late session error after the transfer settled', async () => { + const sftp = createFakeSftp() + const conn = connectedTo([sftp]) + + await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { + hostPlatform: linux, + sftpNamespace: fileNamespace('.version') + }) + + expect(() => sftp.emit('error', new Error('late channel reset'))).not.toThrow() + }) +}) diff --git a/src/main/ssh/ssh-connection-sftp-wire.test.ts b/src/main/ssh/ssh-connection-sftp-wire.test.ts new file mode 100644 index 00000000000..9f99dc22233 --- /dev/null +++ b/src/main/ssh/ssh-connection-sftp-wire.test.ts @@ -0,0 +1,361 @@ +import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, posix } from 'node:path' +import { Client, Server as Ssh2Server, utils, type Connection, type SFTPWrapper } from 'ssh2' +import { expect, it, vi } from 'vitest' +import type { SshTarget } from '../../shared/ssh-types' +import { SshConnection } from './ssh-connection' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const SHELL_HOME = '/var/services/homes/alice' +const SFTP_HOME = '/homes/alice' +const RELAY_DIR = '.orca-remote/relay-0.1.0+wire' +const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_DIR}` +const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_DIR}` +const MARKER_FILE = `.sftp-namespace-${'a'.repeat(32)}` +const MARKER_PATH = `.install-lock/${MARKER_FILE}` +const SFTP_OPEN_WRITE = 2 +const SFTP_STATUS_OK = 0 +const SFTP_STATUS_NO_SUCH_FILE = 2 +const SFTP_STATUS_FAILURE = 4 + +type SftpWireServer = { + port: number + operations: string[] + close: () => Promise +} + +type OpenFile = Awaited> + +function backingPath(backingRoot: string, remotePath: string): string | null { + if (remotePath === SFTP_HOME) { + return backingRoot + } + if (!remotePath.startsWith(`${SFTP_HOME}/`)) { + return null + } + const relativePath = posix.relative(SFTP_HOME, remotePath) + if (!relativePath || relativePath.startsWith('../') || posix.isAbsolute(relativePath)) { + return null + } + return join(backingRoot, ...relativePath.split('/')) +} + +function sendFsError(sftp: SFTPWrapper, requestId: number, error: unknown): void { + const code = + error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT' + ? SFTP_STATUS_NO_SUCH_FILE + : SFTP_STATUS_FAILURE + sftp.status(requestId, code) +} + +function fileId(handle: Buffer, files: Map): number | null { + if (handle.length !== 4) { + return null + } + const id = handle.readUInt32BE(0) + return files.has(id) ? id : null +} + +function installSftpHandlers(sftp: SFTPWrapper, backingRoot: string, operations: string[]): void { + const files = new Map() + let nextFileId = 0 + sftp.on('error', () => {}) + sftp.on('REALPATH', (requestId, remotePath) => { + operations.push(`REALPATH:${remotePath}`) + if (remotePath !== '.') { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + sftp.name(requestId, [ + { + filename: SFTP_HOME, + longname: SFTP_HOME, + attrs: { mode: 0o40_755, uid: 0, gid: 0, size: 0, atime: 0, mtime: 0 } + } + ]) + }) + sftp.on('LSTAT', (requestId, remotePath) => { + operations.push(`LSTAT:${remotePath}`) + const localPath = backingPath(backingRoot, remotePath) + if (!localPath) { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + void lstat(localPath).then( + (stats) => + sftp.attrs(requestId, { + mode: stats.mode, + size: stats.size, + uid: stats.uid, + gid: stats.gid, + atime: Math.floor(stats.atimeMs / 1000), + mtime: Math.floor(stats.mtimeMs / 1000) + }), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('MKDIR', (requestId, remotePath) => { + operations.push(`MKDIR:${remotePath}`) + const localPath = backingPath(backingRoot, remotePath) + if (!localPath) { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + void mkdir(localPath).then( + () => sftp.status(requestId, SFTP_STATUS_OK), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('OPEN', (requestId, remotePath, flags) => { + operations.push(`OPEN:${remotePath}`) + const localPath = backingPath(backingRoot, remotePath) + if (!localPath || !(flags & SFTP_OPEN_WRITE)) { + sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE) + return + } + void open(localPath, 'w').then( + (file) => { + const id = nextFileId++ + files.set(id, file) + const handle = Buffer.alloc(4) + handle.writeUInt32BE(id) + sftp.handle(requestId, handle) + }, + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('WRITE', (requestId, handle, offset, data) => { + operations.push('WRITE') + const id = fileId(handle, files) + if (id === null) { + sftp.status(requestId, SFTP_STATUS_FAILURE) + return + } + void files + .get(id)! + .write(data, 0, data.length, offset) + .then( + () => sftp.status(requestId, SFTP_STATUS_OK), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) + sftp.on('CLOSE', (requestId, handle) => { + operations.push('CLOSE') + const id = fileId(handle, files) + if (id === null) { + sftp.status(requestId, SFTP_STATUS_FAILURE) + return + } + const file = files.get(id)! + files.delete(id) + void file.close().then( + () => sftp.status(requestId, SFTP_STATUS_OK), + (error: unknown) => sendFsError(sftp, requestId, error) + ) + }) +} + +async function startSftpWireServer(backingRoot: string): Promise { + const operations: string[] = [] + const connections = new Set() + const hostKey = utils.generateKeyPairSync('ed25519').private + const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => { + connections.add(connection) + connection.on('error', () => {}) + connection.on('close', () => connections.delete(connection)) + connection.on('authentication', (context) => { + if ( + context.method === 'password' && + context.username === 'fixture' && + context.password === 'secret' + ) { + context.accept() + } else { + context.reject() + } + }) + connection.on('ready', () => { + connection.on('session', (accept) => { + const session = accept() + session.on('sftp', (acceptSftp) => { + installSftpHandlers(acceptSftp(), backingRoot, operations) + }) + }) + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('SSH fixture did not bind a TCP port') + } + return { + port: address.port, + operations, + close: async () => { + for (const connection of connections) { + connection.end() + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + } +} + +function connectSshClient(port: number): Promise { + return new Promise((resolve, reject) => { + const client = new Client() + client.once('ready', () => resolve(client)) + client.once('error', reject) + client.connect({ + host: '127.0.0.1', + port, + username: 'fixture', + password: 'secret', + hostVerifier: () => true, + readyTimeout: 5_000 + }) + }) +} + +function connectionWithClient(client: Client): SshConnection { + const target = { + id: 'sftp-wire', + label: 'SFTP wire fixture', + host: '127.0.0.1', + port: 22, + username: 'fixture', + authMethod: 'password' + } as SshTarget + const connection = new SshConnection(target, { onStateChange: vi.fn() }) + Object.assign(connection as unknown as Record, { + client, + useSystemSshTransport: false + }) + return connection +} + +async function boundedTransfer( + operation: Promise, + operations: string[], + label: string +): Promise { + let timeout: ReturnType | undefined + try { + await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error(`SFTP wire ${label} timed out: ${operations.join(', ')}`)), + 5_000 + ) + }) + ]) + } finally { + clearTimeout(timeout) + } +} + +function splitNamespaceMapping(homeRelativePath: string): SftpNamespacePathMapping { + return { + homeRelativePath, + shellProbePath: `${SHELL_RELAY_DIR}/${MARKER_PATH}`, + homeRelativeProbePath: `${RELAY_DIR}/${MARKER_PATH}` + } +} + +async function withSplitNamespaceFixture( + run: (args: { + connection: SshConnection + fixture: SftpWireServer + backingRelayDir: string + }) => Promise +): Promise { + const backingRoot = await mkdtemp(join(tmpdir(), 'orca-sftp-wire-remote-')) + const backingRelayDir = join(backingRoot, ...RELAY_DIR.split('/')) + await mkdir(join(backingRelayDir, '.install-lock'), { recursive: true }) + await writeFile(join(backingRelayDir, '.install-lock', MARKER_FILE), '') + + let fixture: SftpWireServer | undefined + let client: Client | undefined + try { + fixture = await startSftpWireServer(backingRoot) + client = await connectSshClient(fixture.port) + await run({ + connection: connectionWithClient(client), + fixture, + backingRelayDir + }) + } finally { + client?.end() + await fixture?.close() + await rm(backingRoot, { recursive: true, force: true }) + } +} + +it('uploads through a verified split namespace over a real ssh2 SFTP session', async () => { + const localDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-sftp-wire-local-'))) + await mkdir(join(localDir, 'nested')) + await writeFile(join(localDir, 'nested', 'payload.bin'), Buffer.from([0, 1, 2, 255])) + + try { + await withSplitNamespaceFixture(async ({ connection, fixture, backingRelayDir }) => { + const mapping = splitNamespaceMapping(RELAY_DIR) + + await boundedTransfer( + connection.uploadDirectory(localDir, SHELL_RELAY_DIR, { + hostPlatform: getRemoteHostPlatform('linux-x64'), + sftpNamespace: mapping + }), + fixture.operations, + 'upload' + ) + + expect(await readFile(join(backingRelayDir, 'nested', 'payload.bin'))).toEqual( + Buffer.from([0, 1, 2, 255]) + ) + expect(fixture.operations).toContain(`REALPATH:.`) + expect(fixture.operations).toContain(`LSTAT:${mapping.shellProbePath}`) + expect(fixture.operations).toContain(`LSTAT:${SFTP_RELAY_DIR}/${MARKER_PATH}`) + expect(fixture.operations).toContain(`MKDIR:${SFTP_RELAY_DIR}/nested`) + expect(fixture.operations).toContain(`OPEN:${SFTP_RELAY_DIR}/nested/payload.bin`) + expect(fixture.operations).toEqual(expect.arrayContaining(['WRITE', 'CLOSE'])) + }) + } finally { + await rm(localDir, { recursive: true, force: true }) + } +}, 15_000) + +it('writes a mapped file through a verified split namespace over a real ssh2 SFTP session', async () => { + await withSplitNamespaceFixture(async ({ connection, fixture, backingRelayDir }) => { + const mapping = splitNamespaceMapping(`${RELAY_DIR}/package.json`) + const shellFilePath = `${SHELL_RELAY_DIR}/package.json` + const contents = '{"name":"orca-relay"}\n' + + await boundedTransfer( + connection.writeFile(shellFilePath, contents, { + hostPlatform: getRemoteHostPlatform('linux-x64'), + sftpNamespace: mapping + }), + fixture.operations, + 'writeFile' + ) + + expect(await readFile(join(backingRelayDir, 'package.json'), 'utf8')).toBe(contents) + expect(fixture.operations).toContain(`REALPATH:.`) + expect(fixture.operations).toContain(`LSTAT:${mapping.shellProbePath}`) + expect(fixture.operations).toContain(`LSTAT:${SFTP_RELAY_DIR}/${MARKER_PATH}`) + expect(fixture.operations).toContain(`OPEN:${SFTP_RELAY_DIR}/package.json`) + expect(fixture.operations).toEqual(expect.arrayContaining(['WRITE', 'CLOSE'])) + // Shell-namespace path must never be opened for a confirmed split write. + expect(fixture.operations).not.toContain(`OPEN:${shellFilePath}`) + }) +}, 15_000) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index b16cbce866d..899fa5b35f3 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -40,6 +40,10 @@ import { type SshConnectionCallbacks } from './ssh-connection-utils' import type { RemoteHostPlatform } from './ssh-remote-platform' +import { + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' import { @@ -50,6 +54,8 @@ export type { SshConnectionCallbacks } from './ssh-connection-utils' type SshRemoteFileOptions = { hostPlatform?: RemoteHostPlatform + // Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch. + sftpNamespace?: SftpNamespacePathMapping } // Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway. @@ -423,15 +429,17 @@ export class SshConnection { sftp.on('error', swallowLateSftpError) sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) try { - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await raceSftpFileTransferWithAbort( - uploadDirectory(sftp, localDir, remoteDir), - linkedSignal.signal, - (onClose) => { - sftp.once('close', onClose) - endSftp() - } - ) + // Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace. + const transfer = (async (): Promise => { + const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options) + linkedSignal.signal.throwIfAborted() + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await uploadDirectory(sftp, localDir, targetDir) + })() + await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + }) } finally { endSftp() } @@ -519,35 +527,13 @@ export class SshConnection { sftp.on('error', swallowLateSftpError) sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) try { - const write = new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(remotePath) - let settled = false - const cleanup = (): void => { - sftp.removeListener('error', onError) - ws.removeListener('close', onClose) - ws.removeListener('error', onError) - } - const onClose = (): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve() - } - const onError = (err: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(err) - } - sftp.prependOnceListener('error', onError) - ws.once('close', onClose) - ws.once('error', onError) - ws.end(contents) - }) + // Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace. + const write = (async (): Promise => { + const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options) + linkedSignal.signal.throwIfAborted() + const { writeStringViaSftp } = await import('./sftp-upload') + await writeStringViaSftp(sftp, targetPath, contents) + })() await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { sftp.once('close', onClose) endSftp() diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index 14f063e3bf1..d566a9b067d 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -7,6 +7,7 @@ // collapses to a shared dir passes every other unit test and re-introduces // the original "stale daemon serves new client" bug. +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -61,24 +62,28 @@ function makeMockConnection(): SshConnection { stdout: { on: vi.fn() }, close: vi.fn() }), - sftp: vi.fn().mockResolvedValue({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - on: vi.fn(), - once: vi.fn(), - createWriteStream: vi.fn().mockReturnValue({ - on: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - once: vi.fn((_event: string, cb: () => void) => { - if (_event === 'close') { - setTimeout(cb, 0) - } - }), - end: vi.fn() - }), - end: vi.fn() + // Why: production attaches and removes real SFTP/write-stream listeners, so the fake must be an emitter. + sftp: vi.fn().mockImplementation(() => { + const sftp = new EventEmitter() + return Promise.resolve( + Object.assign(sftp, { + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + // Shell home and SFTP start directory agree here, so no namespace redirect applies. + realpath: vi.fn((_p: string, cb: (err: Error | null, resolved: string) => void) => + cb(null, '/home/u') + ), + lstat: vi.fn((_p: string, cb: (err: Error | null) => void) => + cb(Object.assign(new Error('No such file'), { code: 2 })) + ), + createWriteStream: vi.fn().mockImplementation(() => { + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) + }) + }), + end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) + }) + ) }) } as unknown as SshConnection } diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index 4df9c8ad28a..30e436b979f 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -207,6 +207,9 @@ describe('waitForSentinel', () => { }) describe('execCommand', () => { + const installMarkerToken = 'a'.repeat(32) + const installMarkerPath = `/home/u/.install-lock/.sftp-namespace-${installMarkerToken}` + it('waits for channel close before rejecting a timed-out remote command', async () => { vi.useFakeTimers() try { @@ -271,6 +274,42 @@ describe('execCommand', () => { expect(channel.stderr.listenerCount('data')).toBe(0) }) + it('redacts install-owner marker tokens from command failures', async () => { + const channel = createMockChannel() + const conn = { + exec: vi.fn().mockResolvedValue(channel) + } + const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`) + + await Promise.resolve() + channel.emit('data', Buffer.from(`touch failed for ${installMarkerPath}\n`)) + channel.emit('close', 1) + + const error = await execCommandRejection(commandPromise) + expect(error.message).toContain('.sftp-namespace-[redacted]') + expect(error.message).not.toContain(installMarkerToken) + }) + + it('redacts install-owner marker tokens from timeout errors', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`, { + timeoutMs: 1_000 + }) + + await vi.advanceTimersByTimeAsync(1_000) + channel.emit('close', 0) + + const error = await execCommandRejection(commandPromise) + expect(error.message).toContain('.sftp-namespace-[redacted]') + expect(error.message).not.toContain(installMarkerToken) + } finally { + vi.useRealTimers() + } + }) + it('surfaces stdout alongside stderr on nonzero exit instead of masking it', async () => { const channel = createMockChannel() const conn = { diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 28fe2a3e49c..723cbbbf413 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -64,7 +64,8 @@ vi.mock('./ssh-relay-versioned-install', () => ({ })) vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined) + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' })) vi.mock('./ssh-relay-repair-lock', () => ({ diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index f2154d1eb13..6b488fdd87e 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -6,11 +6,19 @@ import type { SshConnection } from './ssh-connection' import type { RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { - uploadDirectory, waitForSentinel, execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-deploy-helpers' +import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers' +import { + createRelayInstallMarkerCommand, + createRelayInstallNamespace, + makeRelayInstallDirectoryCommand, + relayHomeRelativeDir, + relaySftpNamespaceMapping, + type RelayInstallNamespace +} from './ssh-relay-install-namespace' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { readLocalFullVersion, @@ -36,7 +44,6 @@ import { } from './ssh-relay-build-toolchain' import { commandWithNodePath, - makeRemoteDirectoryCommand, makeRemoteExecutableCommand, readRemoteHomeCommand, removeRemoteFileCommand @@ -311,6 +318,9 @@ async function deployAndLaunchRelayAttempt( console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) + // Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace. + const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion) + let ownsInstallLock = false let launchGcClaimToken: string | undefined if (alreadyInstalled) { @@ -320,6 +330,7 @@ async function deployAndLaunchRelayAttempt( platform, hostPlatform, nodePath, + homeRelativeRelayDir, deploySignal ) ownsInstallLock = launchFence.ownsInstallLock @@ -336,9 +347,23 @@ async function deployAndLaunchRelayAttempt( signal: deploySignal })) ) { + const installNamespace = createInstallNamespaceIfSupported( + conn, + hostPlatform, + homeRelativeRelayDir + ) + onProgress?.('Uploading relay...') console.log('[ssh-relay] Uploading relay...') - await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform, deploySignal) + await uploadRelay( + conn, + platform, + remoteRelayDir, + fullVersion, + hostPlatform, + deploySignal, + installNamespace + ) console.log('[ssh-relay] Upload complete') onProgress?.('Installing native dependencies...') @@ -349,7 +374,9 @@ async function deployAndLaunchRelayAttempt( platform, hostPlatform, nodePath, - deploySignal + deploySignal, + [], + installNamespace ) console.log('[ssh-relay] Native deps installed') @@ -421,7 +448,8 @@ async function uploadRelay( remoteDir: string, fullVersion: string, hostPlatform: RemoteHostPlatform, - signal?: AbortSignal + signal?: AbortSignal, + namespace?: RelayInstallNamespace ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -431,11 +459,20 @@ async function uploadRelay( ) } - await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir), { - signal - }) + // Why: the install-owner marker rides along with mkdir, so a standard install spends no extra exec channel on it. + await execHostCommand( + conn, + hostPlatform, + makeRelayInstallDirectoryCommand(hostPlatform, remoteDir, namespace), + { signal } + ) - await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform, signal) + await uploadRelayDirectory(conn, localRelayDir, remoteDir, hostPlatform, { + signal, + sftpNamespace: namespace + ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir) + : undefined + }) if (!isWindowsRemoteHost(hostPlatform)) { await execHostCommand( @@ -447,60 +484,36 @@ async function uploadRelay( } // Why: write .version via SFTP not shell to avoid quoting content-hashed versions; the daemon reads it to validate the wire handshake. - await writeRemoteFile( + await writeRelayFile( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, '.version'), fullVersion, - signal + { + signal, + sftpNamespace: namespace + ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, '.version') + : undefined + } ) } -async function uploadDirectoryForConnection( - conn: SshConnection, - localRelayDir: string, - remoteDir: string, - hostPlatform: RemoteHostPlatform, - signal?: AbortSignal -): Promise { - if (typeof conn.uploadDirectory === 'function') { - await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform, signal }) - return - } - - const sftp = await conn.sftp() - try { - await uploadDirectory(sftp, localRelayDir, remoteDir) - } finally { - sftp.end() - } -} - -async function writeRemoteFile( +/** + * A marker is only meaningful where a split namespace can occur and where Orca + * owns the SFTP session: POSIX hosts reached over the bundled ssh2 transport. + */ +function createInstallNamespaceIfSupported( conn: SshConnection, hostPlatform: RemoteHostPlatform, - remotePath: string, - contents: string, - signal?: AbortSignal -): Promise { - if (typeof conn.writeFile === 'function') { - await conn.writeFile(remotePath, contents, { hostPlatform, signal }) - return - } - - const sftp = await conn.sftp() - try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(remotePath) - // .once: a late session 'error' after resolve/reject would otherwise be unhandled and crash main. - sftp.once('error', reject) - ws.once('close', resolve) - ws.once('error', reject) - ws.end(contents) - }) - } finally { - sftp.end() + homeRelativeRelayDir: string +): RelayInstallNamespace | undefined { + if (isWindowsRemoteHost(hostPlatform)) { + return undefined } + // A connection double without the transport accessor is an ssh2 connection. + const usesSystemSsh = + typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false + return usesSystemSsh ? undefined : createRelayInstallNamespace(homeRelativeRelayDir) } const NODE_PTY_VERSION = '1.1.0' @@ -576,6 +589,7 @@ async function repairInstalledNativeDeps( platform: RelayPlatform, hostPlatform: RemoteHostPlatform, nodePath: string, + homeRelativeRelayDir: string, signal?: AbortSignal ): Promise<{ ownsInstallLock: boolean; gcClaimToken?: string }> { const initialProbe = await probeRequiredNativeDeps( @@ -627,6 +641,14 @@ async function repairInstalledNativeDeps( // Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir. const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) if (!probe.available) { + // Why: only stamp ownership once the locked recheck proves this connection is the one about to write. + const repairNamespace = await createRepairInstallMarker( + conn, + hostPlatform, + remoteDir, + homeRelativeRelayDir, + signal + ) await installNativeDeps( conn, remoteDir, @@ -634,7 +656,8 @@ async function repairInstalledNativeDeps( hostPlatform, nodePath, signal, - probe.missing + probe.missing, + repairNamespace ) await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) } @@ -652,6 +675,42 @@ async function repairInstalledNativeDeps( } } +/** + * Stamp this connection as the install owner during repair. Marker creation is + * best-effort: without it the writes simply keep using the shell path. + */ +async function createRepairInstallMarker( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + remoteDir: string, + homeRelativeRelayDir: string, + signal?: AbortSignal +): Promise { + const namespace = createInstallNamespaceIfSupported(conn, hostPlatform, homeRelativeRelayDir) + if (!namespace) { + return undefined + } + try { + await execHostCommand( + conn, + hostPlatform, + createRelayInstallMarkerCommand(namespace, hostPlatform, remoteDir), + { signal } + ) + return namespace + } catch (err) { + // Why: an unconfirmed termination still owes the caller its lock semantics; only a confirmed failure degrades to shell paths. + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() + console.warn( + `[ssh-relay] SFTP namespace marker unavailable at ${remoteDir}; retaining shell paths` + ) + return undefined + } +} + async function acquireRelayLaunchGcFence( conn: SshConnection, remoteDir: string, @@ -689,7 +748,8 @@ async function installNativeDeps( hostPlatform: RemoteHostPlatform, nodePath: string, signal?: AbortSignal, - resetDeps: RelayNativeDepName[] = [] + resetDeps: RelayNativeDepName[] = [], + namespace?: RelayInstallNamespace ): Promise { // Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127. // Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead. @@ -702,12 +762,17 @@ async function installNativeDeps( dependencies: RELAY_NATIVE_DEPS, allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST })}\n` - await writeRemoteFile( + await writeRelayFile( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'package.json'), pkgJson, - signal + { + signal, + sftpNamespace: namespace + ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json') + : undefined + } ) try { diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts index e4bfd8bc042..c631cd2d41a 100644 --- a/src/main/ssh/ssh-relay-exec-command.ts +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -1,5 +1,10 @@ +import type { ClientChannel } from 'ssh2' import type { SshConnection } from './ssh-connection' import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import { + redactRelayInstallMarkerError, + redactRelayInstallMarkerTokens +} from './ssh-relay-install-marker' import type { SystemSshCommandChannel } from './system-ssh-command' const EXEC_TIMEOUT_MS = 30_000 @@ -36,7 +41,14 @@ export async function execCommand( // Why: reconnect/disconnect can flip the connection back to ssh2 before a // killed local OpenSSH child emits close; the channel's transport is immutable. const openedWithSystemSsh = conn.usesSystemSshTransport?.() === true - const channel = await conn.exec(command, execOptions) + let channel: ClientChannel + try { + channel = await conn.exec(command, execOptions) + } catch (error) { + // Preserve identity/classifier fields while removing install-owner tokens. + redactRelayInstallMarkerError(error) + throw error + } return new Promise((resolve, reject) => { let stdout = '' let stderr = '' @@ -98,7 +110,10 @@ export async function execCommand( }, COMMAND_CLOSE_GRACE_MS) channel.close() } - const fail = (err: Error): void => requestTermination(err) + const fail = (err: Error): void => { + redactRelayInstallMarkerError(err) + requestTermination(err) + } const onAbort = (): void => requestTermination(createSshOperationAbortError()) const onStdoutData = (data: Buffer): void => { stdout = appendExecOutputTail(stdout, data.toString('utf-8')) @@ -126,14 +141,25 @@ export async function execCommand( } else if (code !== 0) { // Why: on the system-ssh transport channel.stderr carries local OpenSSH // client noise; preferring it masks the real failure in stdout (2>&1). - const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') - settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) + const output = redactRelayInstallMarkerTokens( + [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') + ) + settle( + reject, + new Error( + `Command "${redactRelayInstallMarkerTokens(command)}" failed (exit ${code}): ${output}` + ) + ) } else { settle(resolve, stdout) } } const timeout = setTimeout(() => { - requestTermination(new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)) + requestTermination( + new Error( + `Command "${redactRelayInstallMarkerTokens(command)}" timed out after ${timeoutMs / 1000}s` + ) + ) }, timeoutMs) // Why: remote reboot tears down exec channels with stream errors. Without diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts index fee4946d356..76a5cb18444 100644 --- a/src/main/ssh/ssh-relay-gc-retry.test.ts +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -33,7 +33,10 @@ vi.mock('./ssh-relay-versioned-install', () => ({ abandonInstall: vi.fn(), gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) })) -vi.mock('./ssh-relay-install-lock', () => ({ acquireInstallLock: vi.fn() })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn(), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) vi.mock('./ssh-relay-repair-lock', () => ({ tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') })) diff --git a/src/main/ssh/ssh-relay-install-marker.ts b/src/main/ssh/ssh-relay-install-marker.ts new file mode 100644 index 00000000000..a5ff95bd4e4 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-marker.ts @@ -0,0 +1,29 @@ +import { randomBytes } from 'node:crypto' + +const SFTP_NAMESPACE_MARKER_PREFIX = '.sftp-namespace-' +const SFTP_NAMESPACE_MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/giu +const MARKER_TOKEN_BYTES = 16 + +export function createRelayInstallMarkerFileName(): string { + return `${SFTP_NAMESPACE_MARKER_PREFIX}${randomBytes(MARKER_TOKEN_BYTES).toString('hex')}` +} + +export function redactRelayInstallMarkerTokens(value: string): string { + return value.replace(SFTP_NAMESPACE_MARKER_PATTERN, `${SFTP_NAMESPACE_MARKER_PREFIX}[redacted]`) +} + +export function redactRelayInstallMarkerError(error: unknown): void { + if (!(error instanceof Error)) { + return + } + const redactedMessage = redactRelayInstallMarkerTokens(error.message) + if (redactedMessage !== error.message) { + error.message = redactedMessage + } + if (error.stack) { + const redactedStack = redactRelayInstallMarkerTokens(error.stack) + if (redactedStack !== error.stack) { + error.stack = redactedStack + } + } +} diff --git a/src/main/ssh/ssh-relay-install-namespace.test.ts b/src/main/ssh/ssh-relay-install-namespace.test.ts new file mode 100644 index 00000000000..7d7dd0be5e0 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-namespace.test.ts @@ -0,0 +1,151 @@ +// Why: the shell path and the SFTP-relative path must be built from the same +// validated segments, and the install-owner marker is what proves a redirected +// directory belongs to this install rather than an unrelated same-version one. + +import { describe, expect, it } from 'vitest' +import { + createRelayInstallMarkerCommand, + createRelayInstallNamespace, + makeRelayInstallDirectoryCommand, + relayHomeRelativeDir, + relayInstallMarkerShellPath, + relayRemoteDirSegments, + relaySftpNamespaceMapping +} from './ssh-relay-install-namespace' +import { getRemoteHostPlatform } from './ssh-remote-platform' +import { computeRemoteRelayDir } from './ssh-relay-versioned-install' + +const LINUX = getRemoteHostPlatform('linux-x64') +const WINDOWS = getRemoteHostPlatform('win32-x64') +const VERSION = '0.1.0+abc123' +const SHELL_RELAY_DIR = `/var/services/homes/alice/.orca-remote/relay-${VERSION}` + +describe('relayRemoteDirSegments', () => { + it('builds the two segments every relay path shares', () => { + expect(relayRemoteDirSegments(VERSION, 'posix')).toEqual(['.orca-remote', `relay-${VERSION}`]) + }) + + it('produces a home-relative dir that matches the shell dir suffix', () => { + expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true) + }) + + it.each([ + ['a path separator', '1.0/../etc'], + ['a NUL byte', '1.0\0'], + ['a carriage return', '1.0\rmalicious'], + ['a line feed', '1.0\nmalicious'] + ])('rejects %s in the version segment', (_label, version) => { + expect(() => relayRemoteDirSegments(version, 'posix')).toThrow('Unsafe remote path segment') + }) + + it('applies Windows-specific segment rules on the windows flavor', () => { + expect(() => relayRemoteDirSegments('1.0 ', 'windows')).toThrow('Unsafe remote path segment') + expect(() => relayRemoteDirSegments('1.0 ', 'posix')).not.toThrow() + }) +}) + +describe('computeRemoteRelayDir agreement', () => { + it('ends with the suffix the SFTP-relative builder produces', () => { + // Why: a split namespace rebuilds the path from the home-relative suffix, so the two must agree. + expect(computeRemoteRelayDir('/var/services/homes/alice', VERSION)).toBe(SHELL_RELAY_DIR) + expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true) + }) + + it.each([ + ['a path separator', '0.1.0/../etc'], + ['a NUL byte', '0.1.0\0'], + ['a line feed', '0.1.0\nrm -rf /'] + ])('rejects %s in the version rather than building a path', (_label, version) => { + expect(() => computeRemoteRelayDir('/home/u', version)).toThrow('Unsafe remote path segment') + }) + + it('applies Windows segment rules on the windows flavor', () => { + expect(computeRemoteRelayDir('C:\\Users\\u', VERSION, 'windows')).toBe( + `C:/Users/u/.orca-remote/relay-${VERSION}` + ) + expect(() => computeRemoteRelayDir('C:\\Users\\u', '0.1.0 ', 'windows')).toThrow( + 'Unsafe remote path segment' + ) + }) +}) + +describe('createRelayInstallNamespace', () => { + it('mints an unguessable 128-bit marker name per install', () => { + const first = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + const second = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + + expect(first.markerFileName).toMatch(/^\.sftp-namespace-[0-9a-f]{32}$/) + expect(first.markerFileName).not.toBe(second.markerFileName) + expect(first.homeRelativeRelayDir).toBe(`.orca-remote/relay-${VERSION}`) + }) +}) + +describe('relaySftpNamespaceMapping', () => { + const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + + it('maps the bundle directory itself when no file name is given', () => { + const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR) + + expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}`) + expect(mapping.homeRelativeProbePath).toBe( + `.orca-remote/relay-${VERSION}/.install-lock/${namespace.markerFileName}` + ) + expect(mapping.shellProbePath).toBe( + `${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}` + ) + }) + + it('maps a file inside the bundle directory', () => { + const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, 'package.json') + + expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}/package.json`) + }) + + it('shares one marker across every write of an install', () => { + const bundle = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR) + const version = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, '.version') + + expect(version.shellProbePath).toBe(bundle.shellProbePath) + expect(version.homeRelativeProbePath).toBe(bundle.homeRelativeProbePath) + }) + + it.each(['nested/file', '..', '', 'line\nbreak'])( + 'rejects an unsafe relative file name %j at mapping construction', + (relativeFileName) => { + expect(() => + relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, relativeFileName) + ).toThrow('Unsafe remote path segment') + } + ) +}) + +describe('install directory command', () => { + const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION)) + + it('folds marker creation into the first-install mkdir', () => { + const command = makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR, namespace) + + expect(command).toContain(SHELL_RELAY_DIR) + expect(command).toContain(`${SHELL_RELAY_DIR}/.install-lock`) + expect(command).toContain('umask 077') + expect(command).toContain(`touch `) + expect(command).toContain(namespace.markerFileName) + }) + + it('is the plain directory command when no namespace applies', () => { + expect(makeRelayInstallDirectoryCommand(WINDOWS, 'C:\\Users\\alice\\relay')).not.toContain( + '.sftp-namespace-' + ) + expect(makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR)).not.toContain('touch ') + }) + + it('creates the lock directory before touching the marker inside it', () => { + const command = createRelayInstallMarkerCommand(namespace, LINUX, SHELL_RELAY_DIR) + const markerPath = relayInstallMarkerShellPath(namespace, LINUX, SHELL_RELAY_DIR) + + expect(command.indexOf('.install-lock')).toBeLessThan(command.indexOf('touch ')) + expect(command.indexOf('umask 077')).toBeLessThan(command.indexOf('touch ')) + expect(markerPath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}`) + expect(command).toContain(markerPath) + }) +}) diff --git a/src/main/ssh/ssh-relay-install-namespace.ts b/src/main/ssh/ssh-relay-install-namespace.ts new file mode 100644 index 00000000000..b46f22dda29 --- /dev/null +++ b/src/main/ssh/ssh-relay-install-namespace.ts @@ -0,0 +1,122 @@ +// Install-owner identity for relay uploads that cross a split shell/SFTP namespace. +// +// The shell and SFTP paths share one validated home-relative suffix +// (`.orca-remote/relay-`); a random marker inside the install lock +// lets each SFTP session prove it is looking at THIS install's directory. +// +// See: docs/ssh-relay-sftp-namespace.md + +import { RELAY_REMOTE_DIR } from './relay-protocol' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' +import { shellEscape } from './ssh-connection-utils' +import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker' +import { makeRemoteDirectoryCommand } from './ssh-remote-commands' +import { + assertSafeRemotePathSegment, + joinRemotePath, + type RemoteHostPlatform, + type RemotePathFlavor +} from './ssh-remote-platform' + +export type RelayInstallNamespace = { + homeRelativeRelayDir: string + markerFileName: string +} + +/** + * The two validated segments every relay path is built from. Both the shell + * builder and the SFTP-relative builder go through here so they cannot drift. + */ +export function relayRemoteDirSegments( + fullVersion: string, + pathFlavor: RemotePathFlavor +): string[] { + const segments = [RELAY_REMOTE_DIR, `relay-${fullVersion}`] + for (const segment of segments) { + assertSafeRemotePathSegment(segment, pathFlavor) + // Why: the version reaches logs and diagnostics, where an embedded CR/LF can forge lines. + if (segment.includes('\r') || segment.includes('\n')) { + throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`) + } + } + return segments +} + +export function relayHomeRelativeDir(fullVersion: string): string { + return relayRemoteDirSegments(fullVersion, 'posix').join('/') +} + +export function createRelayInstallNamespace(homeRelativeRelayDir: string): RelayInstallNamespace { + // Why: an unguessable token, not just a unique suffix — a same-version directory + // under an unrelated SFTP start directory must not qualify as ours. + return { + homeRelativeRelayDir, + markerFileName: createRelayInstallMarkerFileName() + } +} + +/** + * Describe one relay transfer to the SFTP namespace resolver. `relativeFileName` + * is omitted for the bundle directory upload itself. + */ +export function relaySftpNamespaceMapping( + namespace: RelayInstallNamespace, + host: RemoteHostPlatform, + shellRelayDir: string, + relativeFileName?: string +): SftpNamespacePathMapping { + if (relativeFileName !== undefined) { + assertSafeRemotePathSegment(relativeFileName, 'posix') + if (relativeFileName.includes('\r') || relativeFileName.includes('\n')) { + throw new Error('Unsafe remote path segment in relay SFTP mapping') + } + } + const homeRelativeLockDir = `${namespace.homeRelativeRelayDir}/${RELAY_INSTALL_LOCK_NAME}` + return { + homeRelativePath: + relativeFileName !== undefined + ? `${namespace.homeRelativeRelayDir}/${relativeFileName}` + : namespace.homeRelativeRelayDir, + shellProbePath: relayInstallMarkerShellPath(namespace, host, shellRelayDir), + homeRelativeProbePath: `${homeRelativeLockDir}/${namespace.markerFileName}` + } +} + +export function relayInstallMarkerShellPath( + namespace: RelayInstallNamespace, + host: RemoteHostPlatform, + shellRelayDir: string +): string { + return joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME, namespace.markerFileName) +} + +/** + * Create the install-owner marker inside the lock this caller already holds. + * POSIX-only: no marker is created for Windows or the system-SSH transport. + */ +export function createRelayInstallMarkerCommand( + namespace: RelayInstallNamespace, + host: RemoteHostPlatform, + shellRelayDir: string +): string { + const lockDir = joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME) + const markerPath = relayInstallMarkerShellPath(namespace, host, shellRelayDir) + return `${makeRemoteDirectoryCommand(host, lockDir)} && umask 077 && touch ${shellEscape(markerPath)}` +} + +/** + * First-install directory creation, folded together with marker creation so a + * standard install spends no extra exec channel on namespace discovery. + */ +export function makeRelayInstallDirectoryCommand( + host: RemoteHostPlatform, + shellRelayDir: string, + namespace?: RelayInstallNamespace +): string { + const makeDir = makeRemoteDirectoryCommand(host, shellRelayDir) + if (!namespace) { + return makeDir + } + return `${makeDir} && ${createRelayInstallMarkerCommand(namespace, host, shellRelayDir)}` +} diff --git a/src/main/ssh/ssh-relay-install-transfers.ts b/src/main/ssh/ssh-relay-install-transfers.ts new file mode 100644 index 00000000000..decbcd103ee --- /dev/null +++ b/src/main/ssh/ssh-relay-install-transfers.ts @@ -0,0 +1,101 @@ +// Relay-install SFTP writes. Each helper prefers the SshConnection transfer +// method and otherwise drives one SFTP session itself, because deploy and +// native-dependency tests pass partial connection doubles. Both routes share the +// same namespace resolution, abort race, and one-shot session teardown. + +import type { SFTPWrapper } from 'ssh2' +import type { SshConnection } from './ssh-connection' +import { writeStringViaSftp } from './sftp-upload' +import { uploadDirectory } from './ssh-relay-deploy-helpers' +import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' +import { + resolveSftpTransferPathIfMapped, + type SftpNamespacePathMapping +} from './sftp-namespace-resolution' +import type { RemoteHostPlatform } from './ssh-remote-platform' + +export type RelayTransferOptions = { + signal?: AbortSignal + sftpNamespace?: SftpNamespacePathMapping +} + +export async function uploadRelayDirectory( + conn: SshConnection, + localRelayDir: string, + shellRemoteDir: string, + hostPlatform: RemoteHostPlatform, + options?: RelayTransferOptions +): Promise { + if (typeof conn.uploadDirectory === 'function') { + await conn.uploadDirectory(localRelayDir, shellRemoteDir, { + hostPlatform, + signal: options?.signal, + sftpNamespace: options?.sftpNamespace + }) + return + } + await runSftpFallbackTransfer(conn, options, async (sftp) => { + const targetDir = await resolveSftpTransferPathIfMapped(sftp, shellRemoteDir, { + hostPlatform, + sftpNamespace: options?.sftpNamespace + }) + options?.signal?.throwIfAborted() + await uploadDirectory(sftp, localRelayDir, targetDir) + }) +} + +export async function writeRelayFile( + conn: SshConnection, + hostPlatform: RemoteHostPlatform, + shellRemotePath: string, + contents: string, + options?: RelayTransferOptions +): Promise { + if (typeof conn.writeFile === 'function') { + await conn.writeFile(shellRemotePath, contents, { + hostPlatform, + signal: options?.signal, + sftpNamespace: options?.sftpNamespace + }) + return + } + await runSftpFallbackTransfer(conn, options, async (sftp) => { + const targetPath = await resolveSftpTransferPathIfMapped(sftp, shellRemotePath, { + hostPlatform, + sftpNamespace: options?.sftpNamespace + }) + options?.signal?.throwIfAborted() + await writeStringViaSftp(sftp, targetPath, contents) + }) +} + +async function runSftpFallbackTransfer( + conn: SshConnection, + options: RelayTransferOptions | undefined, + transfer: (sftp: SFTPWrapper) => Promise +): Promise { + const sftp = await conn.sftp(options?.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + // A late session 'error' after settle would otherwise be unhandled and crash main. + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + await raceSftpFileTransferWithAbort( + transfer(sftp), + options?.signal ?? new AbortController().signal, + (onClose) => { + sftp.once('close', onClose) + endSftp() + } + ) + } finally { + endSftp() + } +} diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index fdc7ff3e73e..ceab584dfed 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,5 +1,6 @@ // Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -46,7 +47,8 @@ vi.mock('./ssh-relay-versioned-install', () => ({ })) vi.mock('./ssh-relay-install-lock', () => ({ - acquireInstallLock: vi.fn().mockResolvedValue(undefined) + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' })) vi.mock('./ssh-relay-repair-lock', () => ({ @@ -84,37 +86,32 @@ type SftpWriteCapture = { execCallCountAtWrite: Record } +type SftpCallback = (err: Error | null, resolved?: string) => void +const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 }) + function makeMockConnection(capture: SftpWriteCapture): SshConnection { - const sftpCreate = (): unknown => ({ - mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), - on: vi.fn(), - once: vi.fn(), - createWriteStream: vi.fn().mockImplementation((path: string) => { - capture.paths.push(path) - let buf = '' - let closeCb: (() => void) | undefined - const stub = { - on: vi.fn((event: string, cb: () => void) => { - if (event === 'close') { - closeCb = cb - } - }), - end: vi.fn((data?: string) => { - if (typeof data === 'string') { - buf += data - } - capture.contents[path] = buf - capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length - if (closeCb) { - setTimeout(closeCb, 0) - } + // Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter. + const sftpCreate = (): unknown => { + const sftp = new EventEmitter() + return Object.assign(sftp, { + mkdir: vi.fn((_p: string, cb: SftpCallback) => cb(null)), + // This host's shell home and SFTP start directory agree, so no namespace redirect is possible. + realpath: vi.fn((_p: string, cb: SftpCallback) => cb(null, '/home/u')), + lstat: vi.fn((_p: string, cb: SftpCallback) => cb(NO_SUCH_SFTP_FILE)), + createWriteStream: vi.fn().mockImplementation((path: string) => { + capture.paths.push(path) + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn((data?: string) => { + capture.contents[path] = `${capture.contents[path] ?? ''}${data ?? ''}` + capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length + setTimeout(() => ws.emit('close'), 0) + }) }) - } - // Why: production uses ws.once('close'); the mock delegates 'once' to the same handler table as 'on'. - return Object.assign(stub, { once: stub.on }) - }), - end: vi.fn() - }) + }), + end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) + }) + } return { canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), exec: vi.fn().mockResolvedValue({ @@ -694,6 +691,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '/home/u', 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock + '', // SFTP-namespace install-owner marker (repair) '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', @@ -730,6 +728,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '/home/u', 'MISSING', // health probe: require() fails 'MISSING', // re-probe after lock + '', // SFTP-namespace install-owner marker (repair) { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) 'DEAD', 'READY' @@ -752,6 +751,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { .mockResolvedValueOnce('/home/u') .mockResolvedValueOnce('MISSING') .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('') // SFTP-namespace install-owner marker (repair) .mockRejectedValueOnce( Object.assign(new Error('npm termination was not confirmed'), { sshChannelCloseConfirmed: false diff --git a/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts new file mode 100644 index 00000000000..a9e33e62ca1 --- /dev/null +++ b/src/main/ssh/ssh-relay-sftp-namespace-install.test.ts @@ -0,0 +1,599 @@ +// Why: on a split-namespace host (Synology DSM) the shell path and the SFTP path +// name the same directory differently, so the deploy must keep issuing shell +// commands against the canonical path while every SFTP write is redirected — and +// only when this connection's own install marker proves the candidate is ours. + +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/mock/app' } +})) + +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+testhash') +})) + +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn().mockResolvedValue(undefined), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, + execCommand: vi.fn() +})) + +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) + +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), + computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), + finalizeInstall: vi.fn().mockResolvedValue(undefined), + abandonInstall: vi.fn().mockResolvedValue(undefined), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined), + RELAY_INSTALL_LOCK_NAME: '.install-lock' +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { parseUnameToRelayPlatform } from './relay-protocol' +import { + abandonInstall, + finalizeInstall, + isRelayAlreadyInstalled +} from './ssh-relay-versioned-install' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import type { SshConnection } from './ssh-connection' +import type { SftpNamespacePathMapping } from './sftp-namespace-resolution' + +// The transfer-option slice these tests inspect; SshConnection keeps its own options type internal. +type TransferOptions = { sftpNamespace?: SftpNamespacePathMapping } + +const SHELL_HOME = '/home/u' +const SFTP_HOME = '/homes/u' +const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash' +const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}` +const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}` +const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/ + +type ConnectionOptions = { + // '/homes/u' models a DSM host whose SFTP subsystem starts outside the shell home. + sftpStartPath?: string + lstatPresent?: (path: string) => boolean + hangRealpath?: boolean + // Models an SFTP session that never confirms close, so teardown stays unconfirmed. + neverCloses?: boolean + systemSsh?: boolean + // Present only on the shipping path; absent doubles exercise the deploy's own SFTP fallback. + transferMethods?: boolean +} + +type Capture = { + writePaths: string[] + uploadTargets: string[] + realpathCalls: string[] + lstatCalls: string[] + sftpEndCalls: number + uploadOptions: (TransferOptions | undefined)[] + writeOptions: (TransferOptions | undefined)[] +} + +function newCapture(): Capture { + return { + writePaths: [], + uploadTargets: [], + realpathCalls: [], + lstatCalls: [], + sftpEndCalls: 0, + uploadOptions: [], + writeOptions: [] + } +} + +// The marker this run created, read back from the shell command that made it. +function issuedMarkerName(): string | undefined { + for (const [, command] of vi.mocked(execCommand).mock.calls) { + const match = decodeCommand(command).match(MARKER_PATTERN) + if (match) { + return match[0] + } + } + return undefined +} + +function decodeCommand(command: string): string { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : command +} + +function execCommands(): string[] { + return vi.mocked(execCommand).mock.calls.map(([, command]) => decodeCommand(command)) +} + +function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshConnection { + const startPath = options.sftpStartPath ?? SFTP_HOME + // Default: the marker is visible only through the SFTP namespace, and only under this install's token. + const lstatPresent = + options.lstatPresent ?? + ((path: string) => + path.startsWith(`${SFTP_HOME}/`) && path.includes(issuedMarkerName() ?? '\0')) + + const makeSftp = (): unknown => { + const sftp = new EventEmitter() + return Object.assign(sftp, { + mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)), + realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: string) => void) => { + capture.realpathCalls.push(path) + if (options.hangRealpath) { + return + } + cb(null, startPath) + }), + lstat: vi.fn((path: string, cb: (err: Error | null) => void) => { + capture.lstatCalls.push(path) + cb(lstatPresent(path) ? null : Object.assign(new Error('No such file'), { code: 2 })) + }), + createWriteStream: vi.fn().mockImplementation((path: string) => { + capture.writePaths.push(path) + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)) + }) + }), + end: vi.fn(() => { + capture.sftpEndCalls += 1 + if (!options.neverCloses) { + setTimeout(() => sftp.emit('close'), 0) + } + }) + }) + } + + const conn: Record = { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), + exec: vi.fn().mockResolvedValue({ + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }), + sftp: vi.fn().mockImplementation(() => Promise.resolve(makeSftp())) + } + if (options.systemSsh) { + conn.usesSystemSshTransport = vi.fn().mockReturnValue(true) + } + if (options.transferMethods) { + conn.uploadDirectory = vi + .fn() + .mockImplementation((_local: string, remote: string, opts?: TransferOptions) => { + capture.uploadTargets.push(remote) + capture.uploadOptions.push(opts) + return Promise.resolve() + }) + conn.writeFile = vi + .fn() + .mockImplementation((remote: string, _contents: string, opts?: TransferOptions) => { + capture.writePaths.push(remote) + capture.writeOptions.push(opts) + return Promise.resolve() + }) + } + return conn as unknown as SshConnection +} + +function feed(responses: string[]): void { + for (const response of responses) { + vi.mocked(execCommand).mockResolvedValueOnce(response) + } +} + +// POSIX first install, healthy npm install and node-pty probe. +const POSIX_FIRST_INSTALL = [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + '', // mkdir remoteDir (+ install-owner marker) + '', // chmod +x node + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' +] + +// POSIX repair of an installed dir whose native deps are missing. +const POSIX_REPAIR = [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + 'MISSING', // probe before the repair lock + 'MISSING', // re-probe under the lock + '', // install-owner marker + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' +] + +describe('relay install writes on a split SFTP namespace', () => { + let capture: Capture + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset() + vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => { + capture.uploadTargets.push(remote) + return Promise.resolve() + }) + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') + capture = newCapture() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.restoreAllMocks() + }) + + it('redirects every first-install write while shell commands keep the canonical path', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([SFTP_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SFTP_RELAY_DIR}/.version`, + `${SFTP_RELAY_DIR}/package.json` + ]) + // Every shell command — mkdir, chmod, npm, launch — still names the shell path. + for (const command of execCommands()) { + expect(command).not.toContain(SFTP_RELAY_DIR) + } + expect(execCommands().some((command) => command.includes(SHELL_RELAY_DIR))).toBe(true) + }) + + it('folds the install-owner marker into the first-install mkdir', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + const markerCommands = execCommands().filter((command) => MARKER_PATTERN.test(command)) + expect(markerCommands).toHaveLength(1) + expect(markerCommands[0]).toContain('mkdir') + expect(markerCommands[0]).toContain(`${SHELL_RELAY_DIR}/.install-lock`) + }) + + it('probes one shared marker for every write of an install', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + const marker = issuedMarkerName() + expect(marker).toMatch(MARKER_PATTERN) + expect(capture.lstatCalls).toEqual([ + `${SHELL_RELAY_DIR}/.install-lock/${marker}`, + `${SFTP_RELAY_DIR}/.install-lock/${marker}`, + `${SHELL_RELAY_DIR}/.install-lock/${marker}`, + `${SFTP_RELAY_DIR}/.install-lock/${marker}`, + `${SHELL_RELAY_DIR}/.install-lock/${marker}`, + `${SFTP_RELAY_DIR}/.install-lock/${marker}` + ]) + }) + + it('refuses a same-version candidate dir that carries another install marker', async () => { + const foreign = `.sftp-namespace-${'f'.repeat(32)}` + const conn = makeConnection(capture, { + lstatPresent: (path) => path.startsWith(`${SFTP_HOME}/`) && path.includes(foreign) + }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SHELL_RELAY_DIR}/.version`, + `${SHELL_RELAY_DIR}/package.json` + ]) + }) + + it('keeps the shell path and skips probing when both namespaces agree', async () => { + const conn = makeConnection(capture, { sftpStartPath: SHELL_HOME }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.lstatCalls).toEqual([]) + }) + + it('resolves against a start directory outside any home', async () => { + const conn = makeConnection(capture, { + sftpStartPath: '/volume1/shared', + lstatPresent: (path) => path.startsWith('/volume1/shared/') + }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(capture.uploadTargets).toEqual([`/volume1/shared/${RELAY_SUFFIX}`]) + }) + + it('passes the same mapping to a connection that owns its own transfer methods', async () => { + const conn = makeConnection(capture, { transferMethods: true }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + // The shipping path hands over shell paths plus a mapping; resolution happens on the write session. + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SHELL_RELAY_DIR}/.version`, + `${SHELL_RELAY_DIR}/package.json` + ]) + const marker = issuedMarkerName() + const mappings = [...capture.uploadOptions, ...capture.writeOptions].map( + (options) => options?.sftpNamespace + ) + expect(mappings).toHaveLength(3) + for (const mapping of mappings) { + expect(mapping?.shellProbePath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${marker}`) + expect(mapping?.homeRelativeProbePath).toBe(`${RELAY_SUFFIX}/.install-lock/${marker}`) + } + expect(mappings.map((mapping) => mapping?.homeRelativePath)).toEqual([ + RELAY_SUFFIX, + `${RELAY_SUFFIX}/.version`, + `${RELAY_SUFFIX}/package.json` + ]) + expect(capture.realpathCalls).toEqual([]) + }) + + it('leaves system-SSH connections unmapped and unprobed', async () => { + // transferMethods models the shipping SshConnection path (uploadDirectory/writeFile → system SSH helpers). + const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) + feed(POSIX_FIRST_INSTALL) + + await deployAndLaunchRelay(conn) + + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(capture.realpathCalls).toEqual([]) + expect(capture.lstatCalls).toEqual([]) + expect(conn.sftp).not.toHaveBeenCalled() + // System SSH never retargets: shell absolute paths, no mapping, no SFTP session. + expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR]) + expect(capture.writePaths).toEqual([ + `${SHELL_RELAY_DIR}/.version`, + `${SHELL_RELAY_DIR}/package.json` + ]) + const transferOptions = [...capture.uploadOptions, ...capture.writeOptions] + expect(transferOptions).toHaveLength(3) + for (const options of transferOptions) { + expect(options?.sftpNamespace).toBeUndefined() + } + }) + + it('leaves Windows hosts unmapped and unprobed', async () => { + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('win32-x64') + const conn = makeConnection(capture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Windows AMD64', + 'C:\\Users\\u', + '' // mkdir remoteDir + ]) + // Fail the install right after the package.json write; the launch path is not what this asserts. + vi.mocked(execCommand).mockRejectedValueOnce(new Error('npm install failed')) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('npm install failed') + + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(capture.realpathCalls).toEqual([]) + expect(capture.writePaths).toEqual([ + 'C:/Users/u/.orca-remote/relay-0.1.0+testhash/.version', + 'C:/Users/u/.orca-remote/relay-0.1.0+testhash/package.json' + ]) + }) + + it('releases the first-install lock when a redirected upload fails', async () => { + const conn = makeConnection(capture) + feed(POSIX_FIRST_INSTALL) + vi.mocked(uploadDirectory).mockRejectedValueOnce(new Error('sftp write failed')) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('sftp write failed') + + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + + it('ends the SFTP session once when a deploy abort strands namespace discovery', async () => { + vi.useFakeTimers() + try { + const conn = makeConnection(capture, { hangRealpath: true }) + feed(POSIX_FIRST_INSTALL) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + expect(capture.sftpEndCalls).toBe(1) + // A confirmed close releases the first-install lock and leaves the dir incomplete. + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('retains the first-install lock when the aborted SFTP session never closes', async () => { + vi.useFakeTimers() + try { + const conn = makeConnection(capture, { hangRealpath: true, neverCloses: true }) + feed(POSIX_FIRST_INSTALL) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1)) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + await deploy + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) + +describe('relay repair writes on a split SFTP namespace', () => { + let capture: Capture + let warnSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(execCommand).mockReset() + vi.mocked(uploadDirectory).mockResolvedValue(undefined) + vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired') + capture = newCapture() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.restoreAllMocks() + }) + + it('stamps the marker only after the locked recheck, then redirects package.json', async () => { + const conn = makeConnection(capture) + let execCountAtLock = -1 + vi.mocked(tryAcquireRelayRepairLock).mockImplementation(() => { + execCountAtLock = vi.mocked(execCommand).mock.calls.length + return Promise.resolve('acquired') + }) + feed(POSIX_REPAIR) + + await deployAndLaunchRelay(conn) + + const commands = execCommands() + const markerIndex = commands.findIndex((command) => MARKER_PATTERN.test(command)) + expect(markerIndex).toBeGreaterThan(execCountAtLock) + // The re-probe under the lock is the last exec before the marker. + expect(commands[markerIndex - 1]).toContain('loadNativeModule') + expect(capture.writePaths).toEqual([`${SFTP_RELAY_DIR}/package.json`]) + }) + + it('does not stamp a marker when repairing over system SSH', async () => { + const conn = makeConnection(capture, { systemSsh: true, transferMethods: true }) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + SHELL_HOME, + 'MISSING', + 'MISSING', + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false) + expect(conn.sftp).not.toHaveBeenCalled() + expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) + expect(capture.writeOptions).toEqual([expect.objectContaining({ sftpNamespace: undefined })]) + }) + + it('degrades to shell paths when marker creation fails outright', async () => { + const conn = makeConnection(capture) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system')) + feed([ + '', // npm install native deps + '', // chmod prebuilds + 'ORCA-NPTY-PROBE-OK\n', + '', // rm probe stderr + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`]) + expect(capture.realpathCalls).toEqual([]) + expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( + expect.stringContaining('SFTP namespace marker unavailable') + ) + }) + + it('keeps the repair lock when marker creation has unconfirmed termination', async () => { + const conn = makeConnection(capture) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING']) + vi.mocked(execCommand).mockRejectedValueOnce( + Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false }) + ) + feed(['DEAD', 'READY']) + + await deployAndLaunchRelay(conn) + + // Repair is best-effort: the relay still launches, but nothing was written and no lock was released. + expect(capture.writePaths).toEqual([]) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual( + expect.stringContaining('launching degraded') + ) + }) +}) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 9945c997ef2..062444a9d01 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -11,6 +11,7 @@ import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { relayRemoteDirSegments } from './ssh-relay-install-namespace' import { isRelayGcClaimOwned, releaseRelayGcClaimWithRetry, @@ -100,7 +101,8 @@ export function computeRemoteRelayDir( pathFlavor === 'windows' ? getRemoteHostPlatform('win32-x64') : getRemoteHostPlatform('linux-x64') - return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, `relay-${fullVersion}`) + // Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge. + return joinRemotePath(host, remoteHome, ...relayRemoteDirSegments(fullVersion, pathFlavor)) } /** From ca70be831861c4697da67b747fffe4c5efa824a0 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:31:44 -0700 Subject: [PATCH 051/150] Add {linkedIssue} template variable for commit and PR generation (#10640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(source-control-ai): add {linkedIssue} recipe variable for commit and PR prompts Custom commit-message and pull-request recipes can now reference the GitHub issue linked to the workspace, so a template like "Fixes #{linkedIssue}" lands the closing trailer without the user retyping the number. - register `linkedIssue` on the commitMessage and pullRequest actions only, with the VARIABLE_INFO entry the chip hover card requires - substitute unconditionally via `formatLinkedIssueTemplateValue` (empty string when nothing resolves) so the token never survives into a prompt; enrich the draft context conditionally via `withLinkedIssueDraftContext` so unlinked workspaces keep their existing context shape - attach at the 7 call boundaries (runtime commit x2, runtime PR shared, IPC commit x2, IPC PR x2); the pure git gather stays pure - validate the renderer-supplied worktreeId against the request path and repoId before any meta read, comparing SSH paths as raw strings so a Windows host cannot rewrite a remote POSIX path - built-in prompts are unchanged; no GitLab dual-read and no default trailer * fix(source-control-ai): resolve {linkedIssue} adversarial review findings Addresses 13 of the 14 findings from the {linkedIssue} code review (6 minor, 8 nit, 0 critical, 0 major); Issue 5 (GitLab provider naming) is deferred to design Open Question 3 as product expansion. Behavior: - Dialog previews the workspace's real linked issue instead of the synthetic 123, in both the chip hover card and the plan preview, so an unlinked workspace previews the `Fixes #` it will actually generate. Settings dry-runs stay fully synthetic. - Reject non-positive, fractional and unsafe-integer issue numbers at the IPC resolver via a shared isLinkedIssueNumber predicate, so corrupt meta never reaches a draft context (previously -7 rendered `Fixes #-7` and 1e21 rendered `Fixes #1e+21`). - Fail closed on an empty-string repoId instead of skipping the cross-check. Structure: - Split the variable registry into source-control-ai-action-variables.ts and re-export it, restoring max-lines headroom with no consumer churn and no lint disable. - Constrain withLinkedIssueDraftContext to contexts declaring linkedIssue. - Move the misplaced shared imports into their import group. Docs and tests: - Document that the IPC id/path validator guards relay/CLI/future callers, not the renderer (whose path is id-derived), and rename the three tests that read as proof of a protection that cannot fire. - Add PR-side coverage that was missing: three git:generatePullRequestFields handler tests, a built-in PR prompt no-leak guard, and the runtime PR unlinked case. - Replace the coincidental '42' assertion with a fixture-unique sentinel. - Type the runtime worktree fixture with satisfies, which surfaced and fixed pre-existing drift in its git sub-object. - Add an e2e case covering the preload -> main -> meta -> template chain. Co-authored-by: Orca * fix(source-control-ai): resolve {linkedIssue} adversarial re-review findings Addresses all 8 findings from the {linkedIssue} code re-review (2 minor, 6 nit, 0 critical, 0 major); none deferred. Behavior: - Revert the variableOverrides parameter on planSourceControlTextGeneration. Its result is a Save/Generate gate, not a preview, and the recipe it validates is saved repo- or globally scoped -- so rendering it against the active workspace disabled both buttons with "Command input is empty." for a {linkedIssue}-only template on any unlinked workspace, blocking a global settings write. Validation is synthetic again; chip previews are unchanged. - Make the chip hover card additive instead of either/or. A supplied preview now appends a "This workspace" sample below the description and Example rather than replacing them, so the GitLab-empty and dangling `Fixes #` warning survives on the two dialogs where recipes are actually authored. basePrompt keeps its preview-only shape, where the preview is the content. Structure: - Drop the registry re-export from source-control-ai-actions.ts and move the last two consumers onto source-control-ai-action-variables, so one import path per symbol keeps a grep of the registry's consumers complete. - Split the registry/helper suites into source-control-ai-action-variables.test.ts so each test file mirrors its module. Tests: - Cover the Save/Generate gate at the canRunGeneration level for a bare {linkedIssue} recipe on linked and unlinked workspaces, with a negative control proving the buttons can still be disabled. - Cover the chip hover card directly; the dialog tests mock it away. - Guard the PR mismatched-id test with toHaveLength(1) so it cannot pass vacuously on an unrelated early return. - Add an unlinked-workspace e2e case (saw-issue:empty), which is what distinguishes a real resolver from one that always returns a number. Spec now runs green: 3 passed. - Rename the dialog test that claimed a synthetic-fallback assertion it did not make, and route its renders through one shared helper. Docs are worktree-local (.gitignore:84 ignores docs/**): the design doc's plan-preview and chip-surface claims, the manual QA rows, and both reviews' statements about pre-existing PR-handler tests are corrected there. * fix(source-control-ai): make the {linkedIssue} e2e guard and dialog test falsifiable The e2e unlinked case extracted the echoed issue with `ORCA_E2E_ISSUE=(\d*)`, which matches zero digits in front of an unexpanded `{linkedIssue}` and reported it as `empty` — so the case that exists to catch a literal token surviving into a prompt passed on exactly that regression. Capture the whole line instead: a literal now arrives as `saw-issue:{linkedIssue}` and fails, verified by dropping the substitution key for unlinked contexts and watching the case go red. Also drop the inert `not.toContain('Command input is empty.')` assertion — that copy is click-driven `generationError` state and this suite renders statically, so it could never fail; the claim it reached for is carried by the plan test. Rename two plan tests off the "plan preview" framing the design now rejects. Local review artifacts (design doc, implementation notes, final review) were swept to match the tree in the same pass; they are gitignored here. * Resolve {linkedIssue} from live metadata, not cache Resolved worktrees are cached for a second, causing commit and PR generation to use stale linked-issue state. Hosts now implement getWorktreeLinkedIssue to provide fresh issue metadata by worktree id, with proper fallback for unlinked workspaces. Updates both commit message and PR field generation paths; includes integration and e2e coverage. * Keep cached linkedIssue when metadata is unavailable Return undefined from getWorktreeLinkedIssue when live metadata cannot be read (store not ready), distinguishing it from null (unlinked). The caller now falls back to the cached worktree value instead of treating unavailable as unlinked. Also extract the linked-issue echo generator as a shared e2e test helper. --------- Co-authored-by: Orca --- src/main/ipc/filesystem.test.ts | 230 +++++++++++++++ src/main/ipc/filesystem.ts | 22 ++ .../source-control-ai-linked-issue.test.ts | 173 ++++++++++++ .../ipc/source-control-ai-linked-issue.ts | 83 ++++++ src/main/runtime/orca-runtime-git.test.ts | 262 +++++++++++++++++- src/main/runtime/orca-runtime-git.ts | 20 ++ ...linked-issue-live-meta.integration.test.ts | 141 ++++++++++ src/main/runtime/orca-runtime.ts | 13 +- .../commit-message-text-generation.test.ts | 162 +++++++++++ .../commit-message-text-generation.ts | 9 +- src/preload/api-types.ts | 4 + src/preload/index.ts | 2 + .../right-sidebar/SourceControl.tsx | 2 + .../SourceControlTextGenerationDialog.test.ts | 107 +++++++ .../SourceControlTextGenerationDialog.tsx | 4 + .../SourceControlTextGenerationDialogForm.tsx | 18 +- .../SourceControlActionVariableChips.test.tsx | 37 +++ .../SourceControlActionVariableChips.tsx | 89 +++--- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- .../source-control-generation-plan.test.ts | 38 +++ .../src/lib/source-control-generation-plan.ts | 12 +- .../src/runtime/runtime-git-client.test.ts | 43 +++ .../src/runtime/runtime-git-client.ts | 4 + src/shared/commit-message-generation.ts | 2 + src/shared/pull-request-generation.ts | 2 + ...source-control-ai-action-variables.test.ts | 94 +++++++ .../source-control-ai-action-variables.ts | 129 +++++++++ src/shared/source-control-ai-actions.test.ts | 2 +- src/shared/source-control-ai-actions.ts | 84 +----- .../helpers/source-control-ai-generators.ts | 24 ++ .../source-control-commit-message-ai.spec.ts | 69 +++++ .../source-control-pr-linked-issue-ai.spec.ts | 96 +++++++ 36 files changed, 1859 insertions(+), 133 deletions(-) create mode 100644 src/main/ipc/source-control-ai-linked-issue.test.ts create mode 100644 src/main/ipc/source-control-ai-linked-issue.ts create mode 100644 src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts create mode 100644 src/shared/source-control-ai-action-variables.test.ts create mode 100644 src/shared/source-control-ai-action-variables.ts create mode 100644 tests/e2e/source-control-pr-linked-issue-ai.spec.ts diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index 2f76c80791b..b3465ac1617 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -42,6 +42,8 @@ const { discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, cancelGeneratePullRequestFieldsLocalMock, + getPullRequestDraftContextMock, + resolveHostedReviewBodyForGenerationMock, getSshFilesystemProviderMock, getSshGitProviderMock, tryDeleteWslUncPathMock, @@ -85,6 +87,8 @@ const { discoverCommitMessageModelsRemoteMock: vi.fn(), cancelGenerateCommitMessageLocalMock: vi.fn(), cancelGeneratePullRequestFieldsLocalMock: vi.fn(), + getPullRequestDraftContextMock: vi.fn(), + resolveHostedReviewBodyForGenerationMock: vi.fn(), getSshFilesystemProviderMock: vi.fn(), getSshGitProviderMock: vi.fn(), tryDeleteWslUncPathMock: vi.fn(), @@ -189,6 +193,16 @@ vi.mock('../text-generation/commit-message-text-generation', () => ({ cancelGeneratePullRequestFieldsLocal: cancelGeneratePullRequestFieldsLocalMock })) +vi.mock('../text-generation/pull-request-context', () => ({ + getPullRequestDraftContext: getPullRequestDraftContextMock +})) + +vi.mock('../source-control/pull-request-template', () => ({ + readHostedPullRequestTemplate: vi.fn(), + readHostedReviewTemplate: vi.fn(), + resolveHostedReviewBodyForGeneration: resolveHostedReviewBodyForGenerationMock +})) + import { registerFilesystemHandlers } from './filesystem' import { invalidateAuthorizedRootsCache, registerWorktreeRootsForRepo } from './filesystem-auth' @@ -289,6 +303,8 @@ describe('registerFilesystemHandlers', () => { resolveCommitMessageSettingsMock, generateCommitMessageFromContextMock, generatePullRequestFieldsFromContextMock, + getPullRequestDraftContextMock, + resolveHostedReviewBodyForGenerationMock, discoverCommitMessageModelsLocalMock, discoverCommitMessageModelsRemoteMock, cancelGenerateCommitMessageLocalMock, @@ -2056,6 +2072,220 @@ describe('registerFilesystemHandlers', () => { }) }) + it('enriches the local commit context with a validated worktree linked issue', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId + }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + { ...context, linkedIssue: 123 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + // Why: folder-repo instances keep `::workspace:` on the meta key while the + // request path is the stripped cwd. A strip-before-lookup "cleanup" would still + // pass plain-id tests and silently lose enrichment on second workspaces. + it('enriches local commit context when the worktree id carries a folder-repo workspace suffix', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + const instanceId = `repo-1::${WORKTREE_FEATURE_PATH}::workspace:${'0'.repeat(8)}-0000-0000-0000-${'0'.repeat(12)}` + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' }) + const getWorktreeMeta = vi.fn((id: string) => + id === instanceId ? { linkedIssue: 9 } : undefined + ) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: instanceId + }) + + expect(getWorktreeMeta).toHaveBeenCalledWith(instanceId) + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + { ...context, linkedIssue: 9 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + // Why: the renderer derives worktreePath from worktreeId, so a mismatched pair + // models an independent caller (relay/CLI/future), not a stale renderer context. + it('ignores an independently supplied id that does not own the requested worktree path', async () => { + const context = { + branch: 'feature/ai', + stagedSummary: 'M\tREADME.md', + stagedPatch: '+hello' + } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getStagedCommitContextMock.mockResolvedValue(context) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Update' }) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` + }) + + expect(getWorktreeMeta).not.toHaveBeenCalled() + // Why: without this the assertion below passes vacuously on an early return. + expect(generateCommitMessageFromContextMock.mock.calls).toHaveLength(1) + expect(generateCommitMessageFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + + it('enriches the SSH commit context from host meta using the remote path', async () => { + const context = { branch: 'main', stagedSummary: 'A\tremote.txt', stagedPatch: '+remote' } + const params = { agentId: 'custom', model: '', customAgentCommand: 'agent' } + const worktreeId = 'repo-1::/remote/repo' + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + getSshGitProviderMock.mockReturnValue({ + getStagedCommitContext: vi.fn().mockResolvedValue(context), + executeCommitMessagePlan: vi.fn() + }) + generateCommitMessageFromContextMock.mockResolvedValue({ success: true, message: 'Add file' }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generateCommitMessage')!(null, { + worktreePath: '/remote/repo', + worktreeId, + connectionId: 'conn-1' + }) + + expect(generateCommitMessageFromContextMock).toHaveBeenCalledWith( + { ...context, linkedIssue: 77 }, + params, + expect.objectContaining({ kind: 'remote' }) + ) + }) + + describe('git:generatePullRequestFields linked issue', () => { + const PULL_REQUEST_CONTEXT = { + base: 'main', + branch: 'feature/ai', + branchChangedByPreparation: false, + commitSummary: 'a1b2c3d Add generation', + changeSummary: 'README.md | 2 +-', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + const PULL_REQUEST_ARGS = { base: 'main', title: '', body: '', draft: false } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + + beforeEach(() => { + resolveCommitMessageSettingsMock.mockReturnValue({ ok: true, params }) + resolveHostedReviewBodyForGenerationMock.mockResolvedValue('') + getPullRequestDraftContextMock.mockResolvedValue(PULL_REQUEST_CONTEXT) + generatePullRequestFieldsFromContextMock.mockResolvedValue({ success: true, fields: {} }) + }) + + it('enriches the local pull-request context with a validated worktree linked issue', async () => { + const worktreeId = `repo-1::${WORKTREE_FEATURE_PATH}` + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 123 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 123 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + it('enriches the SSH pull-request context from host meta using the remote path', async () => { + const worktreeId = 'repo-1::/remote/repo' + getSshGitProviderMock.mockReturnValue({ + exec: vi.fn(), + executeCommitMessagePlan: vi.fn() + }) + const linkedStore = { + ...store, + getWorktreeMeta: (id: string) => (id === worktreeId ? { linkedIssue: 77 } : undefined) + } + + registerFilesystemHandlers(linkedStore as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: '/remote/repo', + worktreeId, + connectionId: 'conn-1' + }) + + expect(generatePullRequestFieldsFromContextMock).toHaveBeenCalledWith( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 77 }, + params, + expect.objectContaining({ kind: 'remote' }) + ) + }) + + it('ignores a pull-request worktree id that does not own the requested path', async () => { + const getWorktreeMeta = vi.fn(() => ({ linkedIssue: 123 })) + + registerFilesystemHandlers({ ...store, getWorktreeMeta } as never) + + await handlers.get('git:generatePullRequestFields')!(null, { + ...PULL_REQUEST_ARGS, + worktreePath: WORKTREE_FEATURE_PATH, + worktreeId: `repo-1::${path.resolve('/workspace/repo-other')}` + }) + + expect(getWorktreeMeta).not.toHaveBeenCalled() + // Why: without the length guard the property assertion passes vacuously on `undefined`, + // so an unrelated early return would read as "enrichment correctly suppressed". + expect(generatePullRequestFieldsFromContextMock.mock.calls).toHaveLength(1) + expect(generatePullRequestFieldsFromContextMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + }) + it('returns a sanitized error when local agent account preparation fails', async () => { const context = { branch: 'feature/ai', diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 414b67516f1..726d5b0b89d 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -87,6 +87,7 @@ import { assertGitPushTargetShape } from '../../shared/git-push-target-validatio import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import type { HostedReviewProvider } from '../../shared/hosted-review' import type { ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' +import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' import { validateGitPushTarget } from '../git/push-target-validation' import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' import { @@ -100,6 +101,7 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { registerFilesystemMutationHandlers } from './filesystem-mutations' import { searchWithGitGrep } from './filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from './local-worktree-runtime-options' +import { resolveSourceControlAiLinkedIssue } from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' import { @@ -1360,6 +1362,8 @@ export function registerFilesystemHandlers( _event, args: { worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string repoId?: string connectionId?: string sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams @@ -1408,6 +1412,10 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No staged changes to summarize.' } } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args) + ) return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, @@ -1435,6 +1443,10 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No staged changes to summarize.' } } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args, worktreePath) + ) const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, commitMessageAgentEnv, @@ -1532,6 +1544,8 @@ export function registerFilesystemHandlers( _event, args: { worktreePath: string + // Raw (unstripped) meta key; validated against worktreePath before any meta read. + worktreeId?: string repoId?: string base: string title: string @@ -1601,6 +1615,10 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No branch changes to summarize.' } } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args) + ) return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: args.worktreePath, @@ -1644,6 +1662,10 @@ export function registerFilesystemHandlers( if (!context) { return { success: false, error: 'No branch changes to summarize.' } } + context = withLinkedIssueDraftContext( + context, + resolveSourceControlAiLinkedIssue(store, args, worktreePath) + ) const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, commitMessageAgentEnv, diff --git a/src/main/ipc/source-control-ai-linked-issue.test.ts b/src/main/ipc/source-control-ai-linked-issue.test.ts new file mode 100644 index 00000000000..1a5291fc6d7 --- /dev/null +++ b/src/main/ipc/source-control-ai-linked-issue.test.ts @@ -0,0 +1,173 @@ +import path from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import { resolveSourceControlAiLinkedIssue } from './source-control-ai-linked-issue' + +const LOCAL_PATH = path.resolve('/workspace/repo-feature') +const LOCAL_ID = `repo-1::${LOCAL_PATH}` +const REMOTE_PATH = '/home/tester/wt' +const REMOTE_ID = `repo-1::${REMOTE_PATH}` + +function makeStore(meta: Record): Store { + return { + getWorktreeMeta: vi.fn((worktreeId: string) => meta[worktreeId]) + } as unknown as Store +} + +describe('resolveSourceControlAiLinkedIssue', () => { + it('reads meta with the raw id when the id matches the request path', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH, + repoId: 'repo-1' + }) + ).toBe(123) + expect(store.getWorktreeMeta).toHaveBeenCalledWith(LOCAL_ID) + }) + + it('keeps the folder-repo instance suffix in the meta key while validating the stripped path', () => { + const instanceId = `${LOCAL_ID}::workspace:${'0'.repeat(8)}-0000-0000-0000-${'0'.repeat(12)}` + const store = makeStore({ [instanceId]: { linkedIssue: 9 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: instanceId, + worktreePath: LOCAL_PATH + }) + ).toBe(9) + expect(store.getWorktreeMeta).toHaveBeenCalledWith(instanceId) + }) + + // Why: the desktop renderer derives `worktreePath` from `worktreeId`, so it can + // never send a mismatched pair — these cases model an independent caller (relay, + // CLI, future in-process caller) and assert the guard fails closed for them. + // They are not evidence that a stale renderer context is rejected; it is not. + it('rejects an independently supplied id whose path does not match the request', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: path.resolve('/workspace/repo-other') + }) + ).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('accepts the resolved worktree path as an alternate local candidate', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 5 } }) + + expect( + resolveSourceControlAiLinkedIssue( + store, + { worktreeId: LOCAL_ID, worktreePath: path.resolve('/workspace/symlinked') }, + LOCAL_PATH + ) + ).toBe(5) + }) + + it('rejects an independently supplied id whose repoId contradicts the request repoId', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH, + repoId: 'repo-2' + }) + ).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('fails closed on an empty-string repoId instead of skipping the cross-check', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH, + repoId: '' + }) + ).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('compares SSH remote paths as raw strings', () => { + const store = makeStore({ [REMOTE_ID]: { linkedIssue: 77 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: REMOTE_ID, + worktreePath: `${REMOTE_PATH}/`, + connectionId: 'conn-1' + }) + ).toBe(77) + }) + + it('matches SSH remote paths from a Windows host without path rewriting', () => { + const original = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + const store = makeStore({ [REMOTE_ID]: { linkedIssue: 77 } }) + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: REMOTE_ID, + worktreePath: REMOTE_PATH, + connectionId: 'conn-1' + }) + ).toBe(77) + } finally { + Object.defineProperty(process, 'platform', original) + } + }) + + it('returns null without touching the store when no id is supplied', () => { + const store = makeStore({ [LOCAL_ID]: { linkedIssue: 123 } }) + + expect(resolveSourceControlAiLinkedIssue(store, { worktreePath: LOCAL_PATH })).toBeNull() + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + }) + + it('tolerates a store without a meta accessor', () => { + expect( + resolveSourceControlAiLinkedIssue({} as Store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH + }) + ).toBeNull() + }) + + it('returns null for unparsable ids and unlinked or unusable meta', () => { + expect( + resolveSourceControlAiLinkedIssue(makeStore({}), { + worktreeId: 'no-separator', + worktreePath: LOCAL_PATH + }) + ).toBeNull() + for (const linkedIssue of [null, undefined, Number.NaN, 0, -7, 12.9]) { + expect( + resolveSourceControlAiLinkedIssue(makeStore({ [LOCAL_ID]: { linkedIssue } }), { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH + }) + ).toBeNull() + } + }) + + it('does not fall back to a GitLab-linked issue', () => { + const store = { + getWorktreeMeta: vi.fn(() => ({ linkedIssue: null, linkedGitLabIssue: 456 })) + } as unknown as Store + + expect( + resolveSourceControlAiLinkedIssue(store, { + worktreeId: LOCAL_ID, + worktreePath: LOCAL_PATH + }) + ).toBeNull() + }) +}) diff --git a/src/main/ipc/source-control-ai-linked-issue.ts b/src/main/ipc/source-control-ai-linked-issue.ts new file mode 100644 index 00000000000..559d39c7399 --- /dev/null +++ b/src/main/ipc/source-control-ai-linked-issue.ts @@ -0,0 +1,83 @@ +import { resolve } from 'node:path' +import type { Store } from '../persistence' +import { isLinkedIssueNumber } from '../../shared/source-control-ai-action-variables' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' + +export type LinkedIssueLookupArgs = { + worktreeId?: string + worktreePath: string + repoId?: string + connectionId?: string +} + +function trimTrailingSeparators(value: string): string { + return value.replace(/[\\/]+$/g, '') +} + +function comparableLocalPath(value: string): string { + const normalized = resolve(value) + return process.platform === 'win32' ? normalized.toLowerCase() : normalized +} + +function matchesRequestPath( + idWorktreePath: string, + args: LinkedIssueLookupArgs, + resolvedWorktreePath: string | undefined +): boolean { + if (args.connectionId) { + // Why: the SSH branch never resolves the path, so it is a remote POSIX string. + // resolve()/case-folding it from a Windows host would rewrite it and never match. + return trimTrailingSeparators(idWorktreePath) === trimTrailingSeparators(args.worktreePath) + } + const candidates = new Set( + [args.worktreePath, resolvedWorktreePath ?? args.worktreePath].map(comparableLocalPath) + ) + return candidates.has(comparableLocalPath(idWorktreePath)) +} + +/** + * Resolve the workspace's linked GitHub issue for Source Control AI generation. + * + * The renderer-supplied `worktreeId` is advisory — the same trust model as + * `getRepoForSourceControlAi` — so it is validated against the request's path + * (and `repoId`) before the meta read. + * + * Scope of that guarantee: today's desktop renderer derives `worktreePath` from + * `worktreeId` (`resolveLocalWorktreePath`), so its two operands always agree and + * this check cannot reject a renderer call — including a stale id after a + * workspace switch, which produces a matching stale *pair* (git then runs in that + * same stale worktree, so the number still belongs to the tree being committed). + * The validation exists for callers that supply id and path independently — a + * relay, the CLI, or a future in-process caller — where a mismatched pair really + * would read another workspace's meta. Keep it: it is cheap and fails closed. + * + * Meta is keyed by the raw id: the `::workspace:` suffix of folder-repo + * workspace instances is part of the key, while validation uses the stripped path. + */ +export function resolveSourceControlAiLinkedIssue( + store: Store, + args: LinkedIssueLookupArgs, + resolvedWorktreePath?: string +): number | null { + if (typeof args.worktreeId !== 'string' || !args.worktreeId) { + return null + } + if (typeof store.getWorktreeMeta !== 'function') { + return null + } + const parsed = splitWorktreeIdForFilesystem(args.worktreeId) + if (!parsed) { + return null + } + // Why: `typeof` rather than truthiness, so an empty-string repoId fails closed + // instead of silently disabling the cross-check. + if (typeof args.repoId === 'string' && parsed.repoId !== args.repoId) { + return null + } + if (!matchesRequestPath(parsed.worktreePath, args, resolvedWorktreePath)) { + return null + } + const linkedIssue = store.getWorktreeMeta(args.worktreeId)?.linkedIssue + // Why: GitHub only in v1 — no `linkedGitLabIssue` dual-read. + return isLinkedIssueNumber(linkedIssue) ? linkedIssue : null +} diff --git a/src/main/runtime/orca-runtime-git.test.ts b/src/main/runtime/orca-runtime-git.test.ts index 07f2cb61db4..1103852ed8a 100644 --- a/src/main/runtime/orca-runtime-git.test.ts +++ b/src/main/runtime/orca-runtime-git.test.ts @@ -60,19 +60,25 @@ vi.mock('../source-control/pull-request-template', () => ({ const tempDirs: string[] = [] -function makeWorktree(path: string): ResolvedRuntimeGitWorktree { - return { +function makeWorktree(path: string, linkedIssue: number | null = null): ResolvedRuntimeGitWorktree { + // Why: `satisfies Partial<…>` keeps every field name and type checked against the + // real worktree shape (the widening cast alone would let these tests keep passing + // against a `linkedIssue` key production no longer has) while still allowing the + // fixture to omit the fields these tests never read. + const worktree = { id: 'wt-1', repoId: 'repo-1', path, + linkedIssue, git: { path, branch: 'main', - bare: false, - detached: false, + isBare: false, + isMainWorktree: false, head: 'a'.repeat(40) } - } as unknown as ResolvedRuntimeGitWorktree + } satisfies Partial + return worktree as unknown as ResolvedRuntimeGitWorktree } function makeCommands(worktreePath: string): RuntimeGitCommands { @@ -588,4 +594,250 @@ describe('RuntimeGitCommands', () => { }) ) }) + + it('enriches the local commit context with the workspace linked issue', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.getStagedCommitContext.mockResolvedValue(context) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.generateRuntimeCommitMessage('id:wt-1') + + expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( + { ...context, linkedIssue: 123 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + it('enriches the SSH commit context with the workspace linked issue', async () => { + const worktreePath = '/home/tester/wt' + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'cursor', model: 'remote-model' } + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + mocks.getSshGitProvider.mockReturnValue({ + getStagedCommitContext: vi.fn().mockResolvedValue(context), + executeCommitMessagePlan: vi.fn() + }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree(worktreePath, 77), + connectionId: 'conn-1' + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.generateRuntimeCommitMessage('id:wt-1') + + expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( + { ...context, linkedIssue: 77 }, + params, + expect.objectContaining({ kind: 'remote' }) + ) + }) + + it('prefers live meta over the linked issue projected onto the resolved worktree', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.getStagedCommitContext.mockResolvedValue(context) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + const getWorktreeLinkedIssue = vi.fn(() => 321) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }), + getRuntimeSettings: () => ({}) as GlobalSettings, + getWorktreeLinkedIssue + }) + + await commands.generateRuntimeCommitMessage('id:wt-1') + + expect(getWorktreeLinkedIssue).toHaveBeenCalledWith('wt-1') + expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( + { ...context, linkedIssue: 321 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + it('drops a stale worktree issue number when live meta reports the workspace unlinked', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + mocks.resolveCommitMessageSettings.mockReturnValue({ + ok: true, + params: { agentId: 'codex', model: 'gpt-5.4-mini' } + }) + mocks.getStagedCommitContext.mockResolvedValue(context) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }), + getRuntimeSettings: () => ({}) as GlobalSettings, + getWorktreeLinkedIssue: () => null + }) + + await commands.generateRuntimeCommitMessage('id:wt-1') + + expect(mocks.generateCommitMessageFromContext.mock.calls[0][0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + + it('keeps the cached issue number when live meta is unavailable rather than unlinked', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.getStagedCommitContext.mockResolvedValue(context) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }), + getRuntimeSettings: () => ({}) as GlobalSettings, + // Why: what the host reports when its store is not initialized yet. + getWorktreeLinkedIssue: () => undefined + }) + + await commands.generateRuntimeCommitMessage('id:wt-1') + + expect(mocks.generateCommitMessageFromContext).toHaveBeenCalledWith( + { ...context, linkedIssue: 123 }, + params, + expect.objectContaining({ kind: 'local' }) + ) + }) + + it('reads pull-request linked issues from live meta too', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { + base: 'main', + branch: 'feature/login', + commitSummary: 'abc123 feat: test', + changeSummary: 'M README.md', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + mocks.getPullRequestDraftContext.mockResolvedValue(context) + mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} }) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ worktree: makeWorktree(worktreePath, 123) }), + getRuntimeSettings: () => ({}) as GlobalSettings, + getWorktreeLinkedIssue: () => 321 + }) + + await commands.generateRuntimePullRequestFields( + 'id:wt-1', + { base: 'main', title: '', body: '', draft: false }, + { sourceControlAiResolvedParams: { agentId: 'codex' as const, model: 'gpt-5.5' } } + ) + + expect(mocks.generatePullRequestFieldsFromContext.mock.calls[0][0]).toEqual({ + ...context, + linkedIssue: 321 + }) + }) + + it('leaves the commit context untouched when no issue is linked', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } + const params = { agentId: 'codex', model: 'gpt-5.4-mini' } + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params }) + mocks.getStagedCommitContext.mockResolvedValue(context) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + + await makeCommands(worktreePath).generateRuntimeCommitMessage('id:wt-1') + + expect(mocks.generateCommitMessageFromContext.mock.calls[0][0]).not.toHaveProperty( + 'linkedIssue' + ) + }) + + it('shares one linked-issue attach across both pull-request branches', async () => { + const context = { + base: 'main', + branch: 'feature/login', + commitSummary: 'abc123 feat: test', + changeSummary: 'M README.md', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + const params = { agentId: 'codex' as const, model: 'gpt-5.5' } + mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} }) + mocks.getSshGitProvider.mockReturnValue({ + exec: vi.fn(), + executeCommitMessagePlan: vi.fn() + }) + + for (const connectionId of [undefined, 'conn-1']) { + const worktreePath = connectionId + ? '/home/tester/wt' + : mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + if (!connectionId) { + tempDirs.push(worktreePath) + } + mocks.getPullRequestDraftContext.mockResolvedValue(context) + const commands = new RuntimeGitCommands({ + resolveRuntimeGitTarget: async () => ({ + worktree: makeWorktree(worktreePath, 55), + ...(connectionId ? { connectionId } : {}) + }), + getRuntimeSettings: () => ({}) as GlobalSettings + }) + + await commands.generateRuntimePullRequestFields( + 'id:wt-1', + { base: 'main', title: '', body: '', draft: false }, + { sourceControlAiResolvedParams: params } + ) + } + + expect(mocks.generatePullRequestFieldsFromContext.mock.calls).toHaveLength(2) + for (const call of mocks.generatePullRequestFieldsFromContext.mock.calls) { + expect(call[0]).toEqual({ ...context, linkedIssue: 55 }) + } + }) + + it('leaves the pull-request context untouched when no issue is linked', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-runtime-git-')) + tempDirs.push(worktreePath) + const context = { + base: 'main', + branch: 'feature/login', + commitSummary: 'abc123 feat: test', + changeSummary: 'M README.md', + patch: '+hello', + currentTitle: '', + currentBody: '', + currentDraft: false + } + const params = { agentId: 'codex' as const, model: 'gpt-5.5' } + mocks.getPullRequestDraftContext.mockResolvedValue(context) + mocks.generatePullRequestFieldsFromContext.mockResolvedValue({ success: true, fields: {} }) + + await makeCommands(worktreePath).generateRuntimePullRequestFields( + 'id:wt-1', + { base: 'main', title: '', body: '', draft: false }, + { sourceControlAiResolvedParams: params } + ) + + expect(mocks.generatePullRequestFieldsFromContext.mock.calls).toHaveLength(1) + expect(mocks.generatePullRequestFieldsFromContext.mock.calls[0][0]).not.toHaveProperty( + 'linkedIssue' + ) + }) }) diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index 7aaa8ede56a..a2dd698444f 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -23,6 +23,7 @@ import { mergeLegacyCommitMessageAiIntoSourceControlAi, type ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' +import { withLinkedIssueDraftContext } from '../../shared/source-control-ai-action-variables' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' import type { GitProviderStatusOptions } from '../providers/types' import { getRemoteCommitUrl, getRemoteFileUrl } from '../git/repo' @@ -160,11 +161,26 @@ export type RuntimeGitCommandHost = { resolveRuntimeGitTarget(selector: string): Promise getRuntimeSettings(): GlobalSettings getCommitMessageAgentEnvironment?(): CommitMessageAgentEnvironmentResolvers | undefined + /** + * Live linked-issue read by worktree id. Resolved worktrees come from a + * short-TTL cache, so link/unlink would otherwise lag generation; hosts that + * implement this are authoritative, including the `null` unlinked answer. + * Return `undefined` when metadata is unavailable (store not ready) so the + * caller keeps the resolved worktree's cached value instead of reading it as + * unlinked. + */ + getWorktreeLinkedIssue?(worktreeId: string): number | null | undefined } export class RuntimeGitCommands { constructor(private readonly host: RuntimeGitCommandHost) {} + private linkedIssueForTarget(target: RuntimeGitTarget): number | null | undefined { + const live = this.host.getWorktreeLinkedIssue?.(target.worktree.id) + // Why: `undefined` means the host could not answer, not "unlinked". + return live === undefined ? target.worktree.linkedIssue : live + } + async getRuntimeGitStatus( worktreeSelector: string, options?: GitProviderStatusOptions @@ -615,6 +631,7 @@ export class RuntimeGitCommands { if (!context) { return { success: false, error: 'No staged changes to summarize.' } } + context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target)) return generateCommitMessageFromContext(context, resolvedSettings.params, { kind: 'remote', cwd: target.worktree.path, @@ -634,6 +651,7 @@ export class RuntimeGitCommands { if (!context) { return { success: false, error: 'No staged changes to summarize.' } } + context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target)) const localEnv = await prepareLocalCommitMessageAgentEnv( resolvedSettings.params.agentId, this.host.getCommitMessageAgentEnvironment?.(), @@ -738,6 +756,8 @@ export class RuntimeGitCommands { if (!context) { return { success: false, error: 'No branch changes to summarize.' } } + // Why: both SSH and local branches share this context, so one attach covers each. + context = withLinkedIssueDraftContext(context, this.linkedIssueForTarget(target)) if (target.connectionId) { return generatePullRequestFieldsFromContext(context, resolvedSettings.params, { diff --git a/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts new file mode 100644 index 00000000000..fea4e93a34b --- /dev/null +++ b/src/main/runtime/orca-runtime-linked-issue-live-meta.integration.test.ts @@ -0,0 +1,141 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeMeta } from '../../shared/types' +import type * as GitStatusModule from '../git/status' +import type * as CommitMessageTextGenerationModule from '../text-generation/commit-message-text-generation' +import type * as WorktreeModule from '../git/worktree' +import { OrcaRuntimeService } from './orca-runtime' + +const mocks = vi.hoisted(() => ({ + listWorktrees: vi.fn(), + getStagedCommitContext: vi.fn(), + generateCommitMessageFromContext: vi.fn(), + resolveCommitMessageSettings: vi.fn() +})) + +vi.mock('../git/worktree', async () => ({ + ...(await vi.importActual('../git/worktree')), + listWorktrees: mocks.listWorktrees +})) + +vi.mock('../git/status', async () => ({ + ...(await vi.importActual('../git/status')), + getStagedCommitContext: mocks.getStagedCommitContext +})) + +vi.mock('../text-generation/commit-message-text-generation', async () => ({ + ...(await vi.importActual( + '../text-generation/commit-message-text-generation' + )), + generateCommitMessageFromContext: mocks.generateCommitMessageFromContext, + resolveCommitMessageSettings: mocks.resolveCommitMessageSettings +})) + +const REPO_ID = 'repo-1' +const STAGED_CONTEXT = { branch: 'main', stagedSummary: 'M\tREADME.md', stagedPatch: '+hello' } +const PARAMS = { agentId: 'codex', model: 'gpt-5.4-mini' } + +const tempDirs: string[] = [] + +/** + * Store double narrow enough to drive worktree resolution, so the runtime runs + * its real hydration (`listResolvedWorktrees` → `mergeWorktree` → cache) instead + * of a hand-built worktree fixture carrying `linkedIssue`. + */ +function makeStore(worktreePath: string) { + const worktreeId = `${REPO_ID}::${worktreePath}` + const worktreeMeta: Record = { + [worktreeId]: { + instanceId: worktreeId, + displayName: 'wt', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0 + } + } + return { + worktreeId, + updateLinkedIssue: (linkedIssue: number | null): void => { + // Why: mirrors `worktrees:updateMeta`, which persists the link without + // invalidating the runtime's resolved-worktree cache. + worktreeMeta[worktreeId] = { ...worktreeMeta[worktreeId], linkedIssue } + }, + store: { + getRepos: () => [ + { id: REPO_ID, path: worktreePath, displayName: 'repo', badgeColor: 'blue', addedAt: 1 } + ], + getRepo: (id: string) => + id === REPO_ID + ? { id: REPO_ID, path: worktreePath, displayName: 'repo', badgeColor: 'blue', addedAt: 1 } + : undefined, + getAllWorktreeMeta: () => worktreeMeta, + getWorktreeMeta: (id: string) => worktreeMeta[id], + setWorktreeMeta: (id: string, updates: Partial) => { + worktreeMeta[id] = { ...worktreeMeta[id], ...updates } + return worktreeMeta[id] + }, + getSettings: () => ({}) + } + } +} + +async function generatedCommitContext( + runtime: OrcaRuntimeService, + worktreeId: string +): Promise> { + mocks.generateCommitMessageFromContext.mockClear() + await runtime.generateRuntimeCommitMessage(`id:${worktreeId}`) + return mocks.generateCommitMessageFromContext.mock.calls[0][0] +} + +describe('runtime commit-message generation linked-issue freshness', () => { + beforeEach(() => { + mocks.listWorktrees.mockReset() + mocks.getStagedCommitContext.mockReset() + mocks.generateCommitMessageFromContext.mockReset() + mocks.resolveCommitMessageSettings.mockReset() + mocks.getStagedCommitContext.mockResolvedValue(STAGED_CONTEXT) + mocks.generateCommitMessageFromContext.mockResolvedValue({ success: true, message: 'docs' }) + mocks.resolveCommitMessageSettings.mockReturnValue({ ok: true, params: PARAMS }) + }) + + afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }) + } + }) + + it('substitutes the linked issue persisted since the last worktree resolution', async () => { + const worktreePath = mkdtempSync(join(tmpdir(), 'orca-linked-issue-')) + tempDirs.push(worktreePath) + const { store, worktreeId, updateLinkedIssue } = makeStore(worktreePath) + mocks.listWorktrees.mockResolvedValue([ + { + path: worktreePath, + head: 'a'.repeat(40), + branch: 'main', + isBare: false, + isMainWorktree: true + } + ]) + const runtime = new OrcaRuntimeService(store as never) + + // Why: the first generation warms the resolved-worktree cache with the + // unlinked projection, so a stale read would still answer `unlinked` below. + expect(await generatedCommitContext(runtime, worktreeId)).not.toHaveProperty('linkedIssue') + + updateLinkedIssue(321) + expect(await generatedCommitContext(runtime, worktreeId)).toMatchObject({ linkedIssue: 321 }) + + updateLinkedIssue(null) + expect(await generatedCommitContext(runtime, worktreeId)).not.toHaveProperty('linkedIssue') + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b24f59498f1..d3a9cb25785 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -7062,7 +7062,18 @@ export class OrcaRuntimeService { private readonly gitCommands = new RuntimeGitCommands({ resolveRuntimeGitTarget: (selector) => this.resolveRuntimeGitTarget(selector), getRuntimeSettings: () => this.requireStore().getSettings() as GlobalSettings, - getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined + getCommitMessageAgentEnvironment: () => this.commitMessageAgentEnv ?? undefined, + // Why: resolved worktrees are cached for a second, so link/unlink would lag + // generation; meta is keyed by the same id the resolver returns. + getWorktreeLinkedIssue: (worktreeId) => { + const store = this.store + // Why: an unreadable store is "unknown", not "unlinked" — undefined keeps + // the resolver's cached linkedIssue instead of suppressing {linkedIssue}. + if (!store?.getWorktreeMeta) { + return undefined + } + return store.getWorktreeMeta(worktreeId)?.linkedIssue ?? null + } }) getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 3b921daf65b..13feda4006c 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -1888,6 +1888,168 @@ describe('generateBranchNameFromContext', () => { }) }) +describe('linkedIssue template substitution', () => { + const COMMIT_CONTEXT = { + branch: 'feature/login', + stagedSummary: 'M src/login.ts', + stagedPatch: 'diff --git a/src/login.ts b/src/login.ts' + } + const PULL_REQUEST_CONTEXT = { + branch: 'feature/login', + base: 'main', + branchChangedByPreparation: false, + currentTitle: 'Fix login', + currentBody: '', + currentDraft: false, + commitSummary: 'a1b2c3d Fix login', + changeSummary: 'src/login.ts | 4 ++--', + patch: 'diff --git a/src/login.ts b/src/login.ts' + } + + function capturingTarget(capture: (prompt: string) => void): { + kind: 'remote' + cwd: string + missingBinaryLocation: string + execute: (plan: { stdinPayload: string | null }) => Promise<{ + stdout: string + stderr: string + exitCode: number + timedOut: boolean + }> + } { + return { + kind: 'remote', + cwd: '/repo', + missingBinaryLocation: 'remote PATH', + execute: async (plan) => { + capture(plan.stdinPayload ?? '') + return { + stdout: '{"base":"main","title":"Fix login","body":"body","draft":false}', + stderr: '', + exitCode: 0, + timedOut: false + } + } + } + } + + const templateParams = { + agentId: 'custom' as const, + model: '', + customAgentCommand: 'agent', + commandInputTemplate: '{basePrompt}\n\nFixes #{linkedIssue}' + } + + it('substitutes the linked issue into the commit-message prompt', async () => { + let prompt = '' + await generateCommitMessageFromContext( + { ...COMMIT_CONTEXT, linkedIssue: 42 }, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #42') + expect(prompt).not.toContain('{linkedIssue}') + }) + + it('renders an empty commit-message issue for null and omitted fields', async () => { + for (const context of [{ ...COMMIT_CONTEXT, linkedIssue: null }, COMMIT_CONTEXT]) { + let prompt = '' + await generateCommitMessageFromContext( + context, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #') + expect(prompt).not.toContain('{linkedIssue}') + } + }) + + // Why: a fixture-unique sentinel — a short number like 42 also appears in the + // character counts that truncateDiffForPrompt/limitSection emit, so growing any + // fixture past its limit would fail these guards for reasons unrelated to leakage. + const BUILT_IN_PROMPT_SENTINEL_ISSUE = 987654 + const builtInPromptParams = { + agentId: 'custom' as const, + model: '', + customAgentCommand: 'agent' + } + + it('leaves the built-in commit prompt free of issue guidance', async () => { + let prompt = '' + await generateCommitMessageFromContext( + { ...COMMIT_CONTEXT, linkedIssue: BUILT_IN_PROMPT_SENTINEL_ISSUE }, + builtInPromptParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).not.toContain(String(BUILT_IN_PROMPT_SENTINEL_ISSUE)) + expect(prompt).not.toContain('linkedIssue') + }) + + it('leaves the built-in pull-request prompt free of issue guidance', async () => { + let prompt = '' + await generatePullRequestFieldsFromContext( + { ...PULL_REQUEST_CONTEXT, linkedIssue: BUILT_IN_PROMPT_SENTINEL_ISSUE }, + builtInPromptParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).not.toContain(String(BUILT_IN_PROMPT_SENTINEL_ISSUE)) + expect(prompt).not.toContain('linkedIssue') + }) + + it('substitutes the linked issue into the pull-request prompt', async () => { + let prompt = '' + await generatePullRequestFieldsFromContext( + { ...PULL_REQUEST_CONTEXT, linkedIssue: 7 }, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #7') + expect(prompt).not.toContain('{linkedIssue}') + }) + + it('renders an empty pull-request issue when none resolves', async () => { + let prompt = '' + await generatePullRequestFieldsFromContext( + PULL_REQUEST_CONTEXT, + templateParams, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Fixes #') + expect(prompt).not.toContain('{linkedIssue}') + }) + + it('leaves a hand-typed linkedIssue literal in branch-name templates', async () => { + let prompt = '' + await generateBranchNameFromContext( + { firstPrompt: 'Fix login flow' }, + { ...templateParams, commandInputTemplate: '{basePrompt}\n\nIssue {linkedIssue}' }, + capturingTarget((value) => { + prompt = value + }) + ) + + expect(prompt).toContain('Issue {linkedIssue}') + }) +}) + describe('trimGeneratedCommitMessage', () => { it('removes trailing whitespace from generated messages', () => { const message = trimGeneratedCommitMessage('Update docs\n\n') diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index ff0c9e5fc24..fde91c1f746 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -44,6 +44,7 @@ import { type ResolvedSourceControlAiGenerationParams } from '../../shared/source-control-ai' import type { SourceControlAiOperation } from '../../shared/source-control-ai-types' +import { formatLinkedIssueTemplateValue } from '../../shared/source-control-ai-action-variables' import { renderSourceControlActionCommandTemplate } from '../../shared/source-control-ai-actions' import { resolveCliCommand } from '../codex-cli/command' import { @@ -876,7 +877,9 @@ export async function generateCommitMessageFromContext( basePrompt, branch: context.branch ?? '(detached)', stagedFiles: context.stagedSummary, - stagedPatch: context.stagedPatch + stagedPatch: context.stagedPatch, + // Why: always pass the key so `{linkedIssue}` never survives as a literal token. + linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildCommitMessagePrompt(context, params.customPrompt ?? '') const planned = planCommitMessageGeneration(params, prompt) @@ -947,7 +950,9 @@ export async function generatePullRequestFieldsFromContext( currentBody: context.currentBody, commitSummary: context.commitSummary, changedFiles: context.changeSummary, - patch: context.patch + patch: context.patch, + // Why: always pass the key so `{linkedIssue}` never survives as a literal token. + linkedIssue: formatLinkedIssueTemplateValue(context.linkedIssue) }) : buildPullRequestFieldsPrompt(context, params.customPrompt ?? '') const planned = planCommitMessageGeneration(params, prompt) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b2d78e851a3..b72739e9854 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2744,6 +2744,8 @@ export type PreloadApi = { }) => Promise<{ success: boolean; error?: string }> generateCommitMessage: (args: { worktreePath: string + /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ + worktreeId?: string repoId?: string connectionId?: string sourceControlAiResolvedParams?: ResolvedSourceControlAiGenerationParams @@ -2772,6 +2774,8 @@ export type PreloadApi = { }) => Promise generatePullRequestFields: (args: { worktreePath: string + /** Raw (unstripped) worktree meta key; validated against worktreePath in main. */ + worktreeId?: string repoId?: string base: string title: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 009924197aa..7de16fc46f0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3137,6 +3137,7 @@ const api = { }): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args), generateCommitMessage: (args: { worktreePath: string + worktreeId?: string repoId?: string connectionId?: string sourceControlAiResolvedParams?: unknown @@ -3154,6 +3155,7 @@ const api = { }): Promise => ipcRenderer.invoke('git:cancelGenerateCommitMessage', args), generatePullRequestFields: (args: { worktreePath: string + worktreeId?: string repoId?: string base: string title: string diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 6fea1c659cc..9887b10dac7 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -6323,6 +6323,7 @@ function SourceControlInner(): React.JSX.Element { settings={settings} repo={activeRepo ?? null} discoveryHostKey={sourceControlAiDiscoveryHostKey} + linkedIssue={activeWorktree?.linkedIssue ?? null} onGenerate={(params) => { void handleGenerate({ sourceControlAiResolvedParams: params }) }} @@ -6344,6 +6345,7 @@ function SourceControlInner(): React.JSX.Element { settings={settings} repo={activeRepo ?? null} discoveryHostKey={sourceControlAiDiscoveryHostKey} + linkedIssue={activeWorktree?.linkedIssue ?? null} onGenerate={(params) => { void handleGeneratePullRequestFields({ sourceControlAiResolvedParams: params }) }} diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts index f17eaea0845..1af9d5d8bc9 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.test.ts @@ -38,6 +38,37 @@ vi.mock('@/components/ui/select', () => ({ SelectValue: () => React.createElement('span') })) +/** The mocked chips serialize their previews into an attribute, so quotes arrive escaped. */ +function escapeHtml(value: string): string { + return value.replace(/"/g, '"') +} + +function renderTextGenerationForm(input: { + commandInputTemplate: string + linkedIssue: number | null +}): string { + return renderToStaticMarkup( + React.createElement(SourceControlTextGenerationDialogForm, { + actionId: 'commitMessage', + generateLabel: 'Generate commit message', + settings: null, + repo: null, + baseParams: { + agentId: 'codex', + model: 'gpt-5.4-mini', + commandInputTemplate: input.commandInputTemplate + }, + linkedIssue: input.linkedIssue, + saveTargets: [ + { target: { type: 'global' }, label: 'Save as global default', successMessage: '' } + ], + onGenerate: () => {}, + onOpenChange: () => {}, + onSaveDefaults: () => {} + }) + ) +} + describe('buildCommitMessageGenerationParams', () => { it('defaults saved text-generation recipes to the global target when repo and global are available', () => { expect( @@ -79,6 +110,82 @@ describe('buildCommitMessageGenerationParams', () => { expect(markup).toContain('You are generating a single git commit message.') }) + // Why: with no preview the chips fall back to the synthetic `123`, promising an unlinked + // workspace `Fixes #123` where it renders `Fixes #`. + it.each([ + { label: 'the linked issue', linkedIssue: 42, expected: '42' }, + { label: 'an empty value when unlinked', linkedIssue: null, expected: '' } + ])('previews $label for a workspace-scoped dialog', ({ linkedIssue, expected }) => { + const markup = renderToStaticMarkup( + React.createElement(SourceControlTextGenerationDialogForm, { + actionId: 'commitMessage', + generateLabel: 'Generate', + settings: null, + repo: null, + baseParams: { + agentId: 'codex', + model: 'gpt-5.4-mini', + commandInputTemplate: '{basePrompt}' + }, + linkedIssue, + saveTargets: [], + onGenerate: () => {}, + onOpenChange: () => {}, + onSaveDefaults: () => {} + }) + ) + + expect(markup).toContain(escapeHtml(JSON.stringify({ linkedIssue: expected }))) + }) + + it('omits the issue preview when no workspace is in scope', () => { + const markup = renderToStaticMarkup( + React.createElement(SourceControlTextGenerationDialogForm, { + actionId: 'commitMessage', + generateLabel: 'Generate', + settings: null, + repo: null, + baseParams: { + agentId: 'codex', + model: 'gpt-5.4-mini', + commandInputTemplate: '{basePrompt}' + }, + saveTargets: [], + onGenerate: () => {}, + onOpenChange: () => {}, + onSaveDefaults: () => {} + }) + ) + + expect(markup).not.toContain('linkedIssue') + }) + + // Why: the recipe saves repo- or globally scoped, so gating on the active workspace's + // empty `{linkedIssue}` would block a global write. + it.each([ + { label: 'a linked workspace', linkedIssue: 42 }, + { label: 'an unlinked workspace', linkedIssue: null } + ])('keeps a bare {linkedIssue} recipe runnable from $label', ({ linkedIssue }) => { + const markup = renderTextGenerationForm({ + commandInputTemplate: '{linkedIssue}', + linkedIssue + }) + + // Why: the "Command input is empty." copy is click-driven state, so `disabled=""` is the + // only gate static markup can observe. + expect(markup).toContain('Save defaults') + expect(markup).toContain('Generate commit message') + expect(markup).not.toContain('disabled=""') + }) + + it('still disables both actions for a template that renders empty for everyone', () => { + // Why: negative control — without it the two cases above pass even if the buttons could + // never disable at all. + const markup = renderTextGenerationForm({ commandInputTemplate: ' ', linkedIssue: 42 }) + + expect(markup).toContain('disabled=""') + }) + it('preserves the resolved model and thinking level for the selected agent', () => { expect( buildCommitMessageGenerationParams({ diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx index f3bb32767ab..56c3e52f8b9 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialog.tsx @@ -31,6 +31,8 @@ type SourceControlTextGenerationBaseDialogProps = { settings: GlobalSettings | null repo?: Pick | null discoveryHostKey: string + /** Omitted by workspace-less callers (Settings dry-run); `null` means "linked to nothing". */ + linkedIssue?: number | null onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void onSaveDefaults: ( target: SourceControlAiWriteTarget, @@ -89,6 +91,7 @@ export function SourceControlTextGenerationDialog({ settings, repo, discoveryHostKey, + linkedIssue, onGenerate, onSaveDefaults }: SourceControlTextGenerationDialogProps): React.JSX.Element { @@ -179,6 +182,7 @@ export function SourceControlTextGenerationDialog({ repo={repo ?? null} baseParams={baseParams} basePromptPreview={buildBasePromptPreview(actionId)} + linkedIssue={linkedIssue} saveTargets={saveTargets} onGenerate={onGenerate} onOpenChange={onOpenChange} diff --git a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx index 7315ba66207..dc59ac3da85 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlTextGenerationDialogForm.tsx @@ -19,6 +19,7 @@ import { listCommitMessageAgentCapabilities } from '../../../../shared/commit-message-agent-spec' import type { ResolvedSourceControlAiGenerationParams } from '../../../../shared/source-control-ai' +import { formatLinkedIssueTemplateValue } from '../../../../shared/source-control-ai-action-variables' import type { SourceControlTextActionId } from '../../../../shared/source-control-ai-actions' import type { SourceControlAiWriteTarget } from '../../../../shared/source-control-ai-recipe-save' import type { GlobalSettings, Repo, TuiAgent } from '../../../../shared/types' @@ -46,6 +47,8 @@ type SourceControlTextGenerationDialogFormProps = { repo: Pick | null baseParams: ResolvedSourceControlAiGenerationParams | null basePromptPreview?: string + /** Omitted by workspace-less callers (Settings dry-run); `null` means "linked to nothing". */ + linkedIssue?: number | null saveTargets: SourceControlTextGenerationSaveTarget[] onGenerate: (params: ResolvedSourceControlAiGenerationParams) => void onOpenChange: (open: boolean) => void @@ -80,6 +83,7 @@ export function SourceControlTextGenerationDialogForm({ repo, baseParams, basePromptPreview, + linkedIssue, saveTargets, onGenerate, onOpenChange, @@ -114,6 +118,18 @@ export function SourceControlTextGenerationDialogForm({ settings, customAgentCommand: baseParams?.customAgentCommand }) + // Why: chip previews only. The plan below stays synthetic so a workspace-empty + // `{linkedIssue}` cannot disable Save/Generate for a repo- or global-scoped recipe. + const variablePreviews = useMemo(() => { + const previews: Record = {} + if (basePromptPreview) { + previews.basePrompt = basePromptPreview + } + if (linkedIssue !== undefined) { + previews.linkedIssue = formatLinkedIssueTemplateValue(linkedIssue) + } + return Object.keys(previews).length > 0 ? previews : undefined + }, [basePromptPreview, linkedIssue]) const paramsPlanResult = params ? planSourceControlTextGeneration(actionId, params) : null const canRunGeneration = Boolean(params && paramsPlanResult?.ok) const saving = savingTargetKey !== null @@ -287,7 +303,7 @@ export function SourceControlTextGenerationDialogForm({ /> { const separator = commandTemplate.endsWith('\n') || commandTemplate.length === 0 ? '' : ' ' diff --git a/src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx b/src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx index 13e13eb50bf..8a3681925c6 100644 --- a/src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx +++ b/src/renderer/src/components/source-control/SourceControlActionVariableChips.test.tsx @@ -32,4 +32,41 @@ describe('SourceControlActionVariableChips', () => { expect(markup).toContain('overflow-y-auto') expect(markup).toContain('Generate a commit message.') }) + + // Why: the description is the only in-product warning that a bare `Fixes #{linkedIssue}` + // degrades to `Fixes #`, and the only place a GitLab user learns why it is always empty. + // A workspace preview must add to it, never replace it. + it.each([ + { label: 'a linked workspace', preview: '4242', expected: '4242' }, + { label: 'an unlinked workspace', preview: '', expected: '(empty)' } + ])( + 'keeps the linkedIssue description and example alongside $label preview', + ({ preview, expected }) => { + const markup = renderToStaticMarkup( + {}} + /> + ) + + expect(markup).toContain('Empty when no GitHub issue is linked') + expect(markup).toContain('GitLab-linked') + expect(markup).toContain('Example') + expect(markup).toContain('This workspace') + expect(markup).toContain(expected) + } + ) + + it('shows only the rendered prompt for a basePrompt preview', () => { + const markup = renderToStaticMarkup( + {}} + /> + ) + + expect(markup).not.toContain('built-in prompt for this action') + }) }) diff --git a/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx b/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx index b6ee822ed37..7a99c800eb1 100644 --- a/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx +++ b/src/renderer/src/components/source-control/SourceControlActionVariableChips.tsx @@ -3,8 +3,9 @@ import { Braces } from 'lucide-react' import { SOURCE_CONTROL_ACTION_VARIABLE_INFO, SOURCE_CONTROL_ACTION_VARIABLES, - type SourceControlActionId -} from '../../../../shared/source-control-ai-actions' + type SourceControlActionVariable +} from '../../../../shared/source-control-ai-action-variables' +import type { SourceControlActionId } from '../../../../shared/source-control-ai-actions' import { Button } from '../ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card' import { translate } from '@/i18n/i18n' @@ -28,37 +29,46 @@ function hasVariablePreview( ) } +function SourceControlVariableSample({ + label, + value +}: { + label: string + value: string +}): React.JSX.Element { + return ( +
+
+ {label} +
+
+        {value ||
+          translate(
+            'auto.components.source.control.SourceControlActionVariableChips.4bf6d88039',
+            '(empty)'
+          )}
+      
+
+ ) +} + function SourceControlVariableDetails({ variable, preview }: { - variable: string + variable: SourceControlActionVariable preview?: string }): React.JSX.Element { - if (preview !== undefined) { - if (variable === 'basePrompt') { - return ( -
-          {preview ||
-            translate(
-              'auto.components.source.control.SourceControlActionVariableChips.4bf6d88039',
-              '(empty)'
-            )}
-        
- ) - } - + // Why: for `basePrompt` the preview *is* the content — the static card only restates it. + if (preview !== undefined && variable === 'basePrompt') { return ( -
-
{`{${variable}}`}
-
-          {preview ||
-            translate(
-              'auto.components.source.control.SourceControlActionVariableChips.4bf6d88039',
-              '(empty)'
-            )}
-        
-
+
+        {preview ||
+          translate(
+            'auto.components.source.control.SourceControlActionVariableChips.4bf6d88039',
+            '(empty)'
+          )}
+      
) } @@ -69,17 +79,24 @@ function SourceControlVariableDetails({
{`{${variable}}`}
{info.description}
-
-
- {translate( - 'auto.components.source.control.SourceControlActionVariableChips.6b921a0ac2', - 'Example' + + {/* Why: a preview never replaces the description — it is the only in-product warning + that a bare `Fixes #{linkedIssue}` degrades to `Fixes #` on an unlinked workspace. */} + {preview !== undefined ? ( + -
-          {info.example}
-        
-
+ value={preview} + /> + ) : null}
) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f73ec351319..2b60a1d4e84 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3680,7 +3680,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "Variables", "6b921a0ac2": "Example", - "4bf6d88039": "(empty)" + "4bf6d88039": "(empty)", + "7377483644": "This workspace" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7bd23476609..73082fa62aa 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3657,7 +3657,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "Variables", "6b921a0ac2": "Ejemplo", - "4bf6d88039": "(vacío)" + "4bf6d88039": "(vacío)", + "7377483644": "Este espacio de trabajo" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e45b172ace4..7834b7fc892 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3657,7 +3657,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "変数", "6b921a0ac2": "例", - "4bf6d88039": "(空の)" + "4bf6d88039": "(空の)", + "7377483644": "このワークスペース" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d032b5bdb5d..349a383ba68 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3657,7 +3657,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "변수", "6b921a0ac2": "예", - "4bf6d88039": "(비어 있음)" + "4bf6d88039": "(비어 있음)", + "7377483644": "이 워크스페이스" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index cd83fea80a0..c33b1dd2147 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3657,7 +3657,8 @@ "SourceControlActionVariableChips": { "1b77798d5f": "变量", "6b921a0ac2": "例子", - "4bf6d88039": "(空的)" + "4bf6d88039": "(空的)", + "7377483644": "此工作区" } } }, diff --git a/src/renderer/src/lib/source-control-generation-plan.test.ts b/src/renderer/src/lib/source-control-generation-plan.test.ts index a51e12a1db4..e769c3f3ef0 100644 --- a/src/renderer/src/lib/source-control-generation-plan.test.ts +++ b/src/renderer/src/lib/source-control-generation-plan.test.ts @@ -50,6 +50,44 @@ describe('planSourceControlCommitMessageGeneration', () => { expect(result.ok && result.commandLabel).toContain('codex exec') }) + it('expands linkedIssue when validating commit and pull-request recipes', () => { + for (const actionId of ['commitMessage', 'pullRequest'] as const) { + // Why: `{prompt}` puts the rendered template in argv, so commandLabel shows it. + const result = planSourceControlTextGeneration(actionId, { + agentId: 'custom', + model: '', + customAgentCommand: 'echo {prompt}', + commandInputTemplate: 'Fixes #{linkedIssue}' + }) + + expect(result.ok && result.commandLabel).toBe('echo Fixes #123') + } + }) + + it('accepts a bare {linkedIssue} recipe, which is only empty per workspace', () => { + // Why: the recipe is saved repo- or globally scoped, so validation must not depend on + // whichever workspace happens to be active — an unlinked one must not fail the plan. + const result = planSourceControlTextGeneration('commitMessage', { + agentId: 'custom', + model: '', + customAgentCommand: 'echo {prompt}', + commandInputTemplate: '{linkedIssue}' + }) + + expect(result).toEqual(expect.objectContaining({ ok: true, commandLabel: 'echo 123' })) + }) + + it('leaves linkedIssue literal when validating branch-name recipes', () => { + const result = planSourceControlTextGeneration('branchName', { + agentId: 'custom', + model: '', + customAgentCommand: 'echo {prompt}', + commandInputTemplate: 'issue {linkedIssue}' + }) + + expect(result.ok && result.commandLabel).toBe('echo issue {linkedIssue}') + }) + it('shows per-action CLI arguments in dry-run command labels', () => { const result = planSourceControlTextGeneration('pullRequest', { agentId: 'codex', diff --git a/src/renderer/src/lib/source-control-generation-plan.ts b/src/renderer/src/lib/source-control-generation-plan.ts index 07b992b6824..a813129e5d6 100644 --- a/src/renderer/src/lib/source-control-generation-plan.ts +++ b/src/renderer/src/lib/source-control-generation-plan.ts @@ -23,7 +23,8 @@ const SYNTHETIC_TEXT_GENERATION_CONTEXT: Record< basePrompt: SYNTHETIC_COMMIT_PROMPT, branch: 'feature/example', stagedFiles: 'M src/example.ts', - stagedPatch: 'diff --git a/src/example.ts b/src/example.ts' + stagedPatch: 'diff --git a/src/example.ts b/src/example.ts', + linkedIssue: '123' }, pullRequest: { basePrompt: SYNTHETIC_PULL_REQUEST_PROMPT, @@ -33,7 +34,8 @@ const SYNTHETIC_TEXT_GENERATION_CONTEXT: Record< currentBody: 'Draft description', commitSummary: 'a1b2c3d Add source-control AI recipes', changedFiles: 'src/example.ts | 12 ++++++++++--', - patch: 'diff --git a/src/example.ts b/src/example.ts' + patch: 'diff --git a/src/example.ts b/src/example.ts', + linkedIssue: '123' }, branchName: { basePrompt: 'Generate a git branch name for a synthetic task.', @@ -48,6 +50,12 @@ const SYNTHETIC_BASE_PROMPTS: Record = { branchName: 'Generate a git branch name for a synthetic task.' } +/** + * Validates a recipe, so it always renders against the synthetic context above. + * Workspace values must not leak in here: the recipe is saved repo- or globally + * scoped, and gating it on the active workspace's `{linkedIssue}` would disable + * Save/Generate for a template that is not actually empty. + */ export function planSourceControlTextGeneration( actionId: SourceControlTextActionId, params: ResolvedSourceControlAiGenerationParams diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 78ed0853a48..36bd5d1aeb6 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -8,6 +8,7 @@ import { fastForwardRuntimeGit, fetchRuntimeGit, generateRuntimeCommitMessage, + generateRuntimePullRequestFields, getRuntimeGitDiff, getRuntimeGitHistory, getRuntimeGitIgnoredPaths, @@ -36,6 +37,7 @@ const gitFastForward = vi.fn() const gitPush = vi.fn() const gitRebaseFromBase = vi.fn() const gitGenerateCommitMessage = vi.fn() +const gitGeneratePullRequestFields = vi.fn() const gitDiscoverCommitMessageModels = vi.fn() const gitCancelGenerateCommitMessage = vi.fn() const runtimeEnvironmentCall = vi.fn() @@ -59,6 +61,7 @@ beforeEach(() => { gitPush.mockReset() gitRebaseFromBase.mockReset() gitGenerateCommitMessage.mockReset() + gitGeneratePullRequestFields.mockReset() gitDiscoverCommitMessageModels.mockReset() gitCancelGenerateCommitMessage.mockReset() runtimeEnvironmentCall.mockReset() @@ -84,6 +87,7 @@ beforeEach(() => { push: gitPush, rebaseFromBase: gitRebaseFromBase, generateCommitMessage: gitGenerateCommitMessage, + generatePullRequestFields: gitGeneratePullRequestFields, discoverCommitMessageModels: gitDiscoverCommitMessageModels, cancelGenerateCommitMessage: gitCancelGenerateCommitMessage }, @@ -691,6 +695,7 @@ describe('runtime git client', () => { expect(gitGenerateCommitMessage).toHaveBeenCalledWith({ worktreePath: '/repo', + worktreeId: 'repo-1::/repo', repoId: 'repo-1', connectionId: undefined, sourceControlAiResolvedParams @@ -733,4 +738,42 @@ describe('runtime git client', () => { }) expect(gitDiscoverCommitMessageModels).not.toHaveBeenCalled() }) + + it('passes the raw worktree id to local generation IPC', async () => { + // Why: the meta key keeps the `::workspace:` suffix that the cwd path strips. + const workspaceId = '123e4567-e89b-12d3-a456-426614174000' + const worktreeId = `folder-repo::/home/user::workspace:${workspaceId}` + const context = { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId, + worktreePath: `/home/user::workspace:${workspaceId}` + } + + await generateRuntimeCommitMessage(context) + await generateRuntimePullRequestFields(context, { + base: 'main', + title: '', + body: '', + draft: false + }) + + expect(gitGenerateCommitMessage).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId, worktreePath: '/home/user' }) + ) + expect(gitGeneratePullRequestFields).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId, worktreePath: '/home/user' }) + ) + }) + + it('omits worktreeId from local generation IPC when the context has none', async () => { + const context = { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: null, + worktreePath: '/repo' + } + + await generateRuntimeCommitMessage(context) + + expect(gitGenerateCommitMessage.mock.calls[0][0]).not.toHaveProperty('worktreeId') + }) }) diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 019eaef5039..2398252afb6 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -648,6 +648,8 @@ export async function generateRuntimeCommitMessage( if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generateCommitMessage({ worktreePath: resolveLocalWorktreePath(context), + // Why: raw id — the `::workspace:` suffix is part of the worktree meta key. + ...(context.worktreeId ? { worktreeId: context.worktreeId } : {}), repoId: context.worktreeId ? getRepoIdFromWorktreeId(context.worktreeId) : undefined, connectionId: context.connectionId, ...(overrides?.sourceControlAiResolvedParams @@ -727,6 +729,8 @@ export async function generateRuntimePullRequestFields( if (target.kind === 'local' || !context.worktreeId) { return window.api.git.generatePullRequestFields({ worktreePath: resolveLocalWorktreePath(context), + // Why: raw id — the `::workspace:` suffix is part of the worktree meta key. + ...(context.worktreeId ? { worktreeId: context.worktreeId } : {}), repoId: context.worktreeId ? getRepoIdFromWorktreeId(context.worktreeId) : undefined, connectionId: context.connectionId, ...input, diff --git a/src/shared/commit-message-generation.ts b/src/shared/commit-message-generation.ts index 7d08242bc81..cd3f8d72a62 100644 --- a/src/shared/commit-message-generation.ts +++ b/src/shared/commit-message-generation.ts @@ -7,6 +7,8 @@ export type CommitMessageDraftContext = { branch: string | null stagedSummary: string stagedPatch: string + /** Workspace-linked GitHub issue number. Omitted entirely when none resolves. */ + linkedIssue?: number | null } export type CommitMessageDraftOptions = { diff --git a/src/shared/pull-request-generation.ts b/src/shared/pull-request-generation.ts index fd927b82ae7..073b7f4333f 100644 --- a/src/shared/pull-request-generation.ts +++ b/src/shared/pull-request-generation.ts @@ -16,6 +16,8 @@ export type PullRequestDraftContext = { commitSummary: string changeSummary: string patch: string + /** Workspace-linked GitHub issue number. Omitted entirely when none resolves. */ + linkedIssue?: number | null } export type GeneratedPullRequestFields = { diff --git a/src/shared/source-control-ai-action-variables.test.ts b/src/shared/source-control-ai-action-variables.test.ts new file mode 100644 index 00000000000..94cf5be427c --- /dev/null +++ b/src/shared/source-control-ai-action-variables.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import type { CommitMessageDraftContext } from './commit-message-generation' +import { + formatLinkedIssueTemplateValue, + SOURCE_CONTROL_ACTION_VARIABLE_INFO, + SOURCE_CONTROL_ACTION_VARIABLES, + withLinkedIssueDraftContext +} from './source-control-ai-action-variables' +import { + renderSourceControlActionCommandTemplate, + SOURCE_CONTROL_LAUNCH_ACTION_IDS +} from './source-control-ai-actions' + +describe('source-control AI variable registry', () => { + it('documents every registered variable so chip hover cards cannot crash', () => { + // Why: the registry type already makes an undocumented chip a compile error; + // this keeps the guarantee falsifiable at runtime if that type ever loosens. + const documented = new Set(Object.keys(SOURCE_CONTROL_ACTION_VARIABLE_INFO)) + const undocumented = [...new Set(Object.values(SOURCE_CONTROL_ACTION_VARIABLES).flat())].filter( + (variable) => !documented.has(variable) + ) + + expect(undocumented).toEqual([]) + }) + + it('offers linkedIssue on commit message and pull request only', () => { + expect(SOURCE_CONTROL_ACTION_VARIABLES.commitMessage).toContain('linkedIssue') + expect(SOURCE_CONTROL_ACTION_VARIABLES.pullRequest).toContain('linkedIssue') + expect(SOURCE_CONTROL_ACTION_VARIABLES.branchName).not.toContain('linkedIssue') + for (const actionId of SOURCE_CONTROL_LAUNCH_ACTION_IDS) { + expect(SOURCE_CONTROL_ACTION_VARIABLES[actionId]).not.toContain('linkedIssue') + } + }) + + it('names GitHub and the empty case in the linkedIssue description', () => { + const info = SOURCE_CONTROL_ACTION_VARIABLE_INFO.linkedIssue + expect(info.description).toContain('GitHub') + expect(info.description).toContain('Empty') + expect(info.example).toBe('123') + }) +}) + +describe('formatLinkedIssueTemplateValue', () => { + it('renders positive integers as decimal strings', () => { + expect(formatLinkedIssueTemplateValue(123)).toBe('123') + expect(formatLinkedIssueTemplateValue(1)).toBe('1') + }) + + it('renders anything that is not a positive integer as an empty string', () => { + // Why: `Fixes #-7` / `Fixes #1e+21` are worse output than `Fixes #`, so corrupt + // metadata degrades to the unlinked rendering instead of a nonsense reference. + for (const value of [ + 0, + -7, + 12.9, + 1e21, + null, + undefined, + Number.NaN, + Number.POSITIVE_INFINITY + ]) { + expect(formatLinkedIssueTemplateValue(value)).toBe('') + } + }) + + it('expands both brace forms and never leaves the token literal', () => { + const template = 'Fixes #{linkedIssue} / {{ linkedIssue }}' + expect( + renderSourceControlActionCommandTemplate(template, { + linkedIssue: formatLinkedIssueTemplateValue(42) + }) + ).toBe('Fixes #42 / 42') + expect( + renderSourceControlActionCommandTemplate(template, { + linkedIssue: formatLinkedIssueTemplateValue(null) + }) + ).toBe('Fixes # / ') + }) +}) + +describe('withLinkedIssueDraftContext', () => { + it('attaches only positive integers and leaves the context untouched otherwise', () => { + const context: CommitMessageDraftContext = { + branch: 'main', + stagedSummary: 'M a.ts', + stagedPatch: 'diff' + } + + expect(withLinkedIssueDraftContext(context, 42)).toEqual({ ...context, linkedIssue: 42 }) + for (const value of [null, undefined, Number.NaN, 0, -7, 12.9]) { + expect(withLinkedIssueDraftContext(context, value)).toBe(context) + } + }) +}) diff --git a/src/shared/source-control-ai-action-variables.ts b/src/shared/source-control-ai-action-variables.ts new file mode 100644 index 00000000000..0aab5cceb20 --- /dev/null +++ b/src/shared/source-control-ai-action-variables.ts @@ -0,0 +1,129 @@ +import type { SourceControlActionId } from './source-control-ai-actions' + +/** + * Registering a variable a hover card cannot describe is a compile error: the + * element type is keyed off `SOURCE_CONTROL_ACTION_VARIABLE_INFO`, so chips can + * never index a missing entry. + */ +export const SOURCE_CONTROL_ACTION_VARIABLES: Record< + SourceControlActionId, + SourceControlActionVariable[] +> = { + commitMessage: ['basePrompt', 'branch', 'stagedFiles', 'stagedPatch', 'linkedIssue'], + pullRequest: [ + 'basePrompt', + 'branch', + 'baseBranch', + 'currentTitle', + 'currentBody', + 'commitSummary', + 'changedFiles', + 'patch', + 'linkedIssue' + ], + branchName: ['basePrompt', 'firstPrompt', 'assistantMessage'], + fixCommitFailure: ['basePrompt'], + fixPushFailure: ['basePrompt'], + fixChecks: ['basePrompt'], + resolveConflicts: ['basePrompt'], + resolveComments: ['basePrompt'] +} + +export type SourceControlActionVariableInfo = { + description: string + example: string +} + +export const SOURCE_CONTROL_ACTION_VARIABLE_INFO = { + basePrompt: { + description: + 'Orca’s built-in prompt for this action, including the context Orca knows how to gather safely.', + example: + 'Commit messages include staged diff guidance; PR details include branch comparison guidance; fix actions include the failure summary.' + }, + branch: { + description: 'The current source-control branch name.', + example: 'feature/source-control-ai-recipes' + }, + stagedFiles: { + description: 'A newline-separated list of staged files for commit-message generation.', + example: 'M src/shared/source-control-ai.ts\nA src/shared/source-control-ai-actions.ts' + }, + stagedPatch: { + description: 'The staged git patch used for commit-message generation.', + example: 'diff --git a/src/app.ts b/src/app.ts\n+addActionRecipeDefaults()' + }, + baseBranch: { + description: 'The target branch selected in the Create PR composer.', + example: 'main' + }, + currentTitle: { + description: 'The PR title currently typed in the composer before generation starts.', + example: 'Improve Source Control AI customization' + }, + currentBody: { + description: 'The PR description currently typed in the composer before generation starts.', + example: 'Adds configurable agents and command templates for Source Control actions.' + }, + commitSummary: { + description: 'A newline-separated list of commits on the branch compared to the base.', + example: 'a1b2c3d Add action recipe defaults\nd4e5f6a Render command templates' + }, + changedFiles: { + description: 'A summary of files changed between the branch and the base branch.', + example: + 'src/shared/source-control-ai-actions.ts | 24 +++++\nsrc/main/text-generation.ts | 8 +-' + }, + patch: { + description: 'The branch diff against the base branch used for PR-details generation.', + example: 'diff --git a/src/app.ts b/src/app.ts\n+renderSourceControlActionCommandTemplate()' + }, + firstPrompt: { + description: 'The first user request that created the Orca workspace.', + example: 'Fix CI and commit the result' + }, + assistantMessage: { + description: 'The initial agent response, when Orca has one available.', + example: 'I will inspect the failing check, patch the issue, and run tests.' + }, + linkedIssue: { + description: + 'The GitHub issue number linked to this workspace. Empty when no GitHub issue is linked (including GitLab-linked workspaces). Prefer instructional templates: a bare "Fixes #{linkedIssue}" becomes "Fixes #" when unlinked.', + example: '123' + } +} satisfies Record + +export type SourceControlActionVariable = keyof typeof SOURCE_CONTROL_ACTION_VARIABLE_INFO + +/** + * Issue numbers are positive integers on every supported provider, so anything + * else (negative, zero, fractional, non-finite) is corrupt metadata rather than + * a renderable issue reference — `Fixes #-7` is worse output than `Fixes #`. + * The safe-integer bound also keeps the rendering in decimal notation: `String` + * switches to exponent form (`1e+21`) above it. + */ +export function isLinkedIssueNumber(linkedIssue: unknown): linkedIssue is number { + return typeof linkedIssue === 'number' && Number.isSafeInteger(linkedIssue) && linkedIssue > 0 +} + +/** + * Render the workspace-linked GitHub issue for template substitution. Anything + * that is not a positive integer becomes `''` so the token expands to nothing + * instead of leaking into the prompt. + */ +export function formatLinkedIssueTemplateValue(linkedIssue: number | null | undefined): string { + return isLinkedIssueNumber(linkedIssue) ? String(linkedIssue) : '' +} + +/** + * Attach a resolved issue number to a draft context. Returns the context untouched + * when nothing resolves, so unlinked workspaces keep their existing context shape. + * The `linkedIssue`-bearing constraint keeps the attach off contexts that do not + * declare the field (branch-name generation), where it would be silently unread. + */ +export function withLinkedIssueDraftContext( + context: T, + linkedIssue: number | null | undefined +): T { + return isLinkedIssueNumber(linkedIssue) ? { ...context, linkedIssue } : context +} diff --git a/src/shared/source-control-ai-actions.test.ts b/src/shared/source-control-ai-actions.test.ts index db1af7c9b37..bb9cf45bf76 100644 --- a/src/shared/source-control-ai-actions.test.ts +++ b/src/shared/source-control-ai-actions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' +import { SOURCE_CONTROL_ACTION_VARIABLES } from './source-control-ai-action-variables' import { normalizeSourceControlAiActionDefaults, - SOURCE_CONTROL_ACTION_VARIABLES, SOURCE_CONTROL_LAUNCH_ACTION_IDS, SOURCE_CONTROL_LAUNCH_ACTION_LABELS, DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES, diff --git a/src/shared/source-control-ai-actions.ts b/src/shared/source-control-ai-actions.ts index 96edcf881d2..8b1a8acb636 100644 --- a/src/shared/source-control-ai-actions.ts +++ b/src/shared/source-control-ai-actions.ts @@ -2,6 +2,10 @@ import { isCustomAgentId, type CustomAgentId } from './commit-message-agent-spec import { isTuiAgent } from './tui-agent-config' import type { TuiAgent } from './types' +// Why: the variable registry lives in `./source-control-ai-action-variables` for max-lines +// headroom. It is deliberately not re-exported here — one import path per symbol keeps a +// grep of that module's consumers complete (the chip row is the one that must not diverge). + export type SourceControlTextActionId = 'commitMessage' | 'pullRequest' | 'branchName' export type SourceControlLaunchActionId = @@ -75,86 +79,6 @@ export const DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES: Record< resolveComments: '{basePrompt}' } -export const SOURCE_CONTROL_ACTION_VARIABLES: Record = { - commitMessage: ['basePrompt', 'branch', 'stagedFiles', 'stagedPatch'], - pullRequest: [ - 'basePrompt', - 'branch', - 'baseBranch', - 'currentTitle', - 'currentBody', - 'commitSummary', - 'changedFiles', - 'patch' - ], - branchName: ['basePrompt', 'firstPrompt', 'assistantMessage'], - fixCommitFailure: ['basePrompt'], - fixPushFailure: ['basePrompt'], - fixChecks: ['basePrompt'], - resolveConflicts: ['basePrompt'], - resolveComments: ['basePrompt'] -} - -export type SourceControlActionVariableInfo = { - description: string - example: string -} - -export const SOURCE_CONTROL_ACTION_VARIABLE_INFO: Record = - { - basePrompt: { - description: - 'Orca’s built-in prompt for this action, including the context Orca knows how to gather safely.', - example: - 'Commit messages include staged diff guidance; PR details include branch comparison guidance; fix actions include the failure summary.' - }, - branch: { - description: 'The current source-control branch name.', - example: 'feature/source-control-ai-recipes' - }, - stagedFiles: { - description: 'A newline-separated list of staged files for commit-message generation.', - example: 'M src/shared/source-control-ai.ts\nA src/shared/source-control-ai-actions.ts' - }, - stagedPatch: { - description: 'The staged git patch used for commit-message generation.', - example: 'diff --git a/src/app.ts b/src/app.ts\n+addActionRecipeDefaults()' - }, - baseBranch: { - description: 'The target branch selected in the Create PR composer.', - example: 'main' - }, - currentTitle: { - description: 'The PR title currently typed in the composer before generation starts.', - example: 'Improve Source Control AI customization' - }, - currentBody: { - description: 'The PR description currently typed in the composer before generation starts.', - example: 'Adds configurable agents and command templates for Source Control actions.' - }, - commitSummary: { - description: 'A newline-separated list of commits on the branch compared to the base.', - example: 'a1b2c3d Add action recipe defaults\nd4e5f6a Render command templates' - }, - changedFiles: { - description: 'A summary of files changed between the branch and the base branch.', - example: - 'src/shared/source-control-ai-actions.ts | 24 +++++\nsrc/main/text-generation.ts | 8 +-' - }, - patch: { - description: 'The branch diff against the base branch used for PR-details generation.', - example: 'diff --git a/src/app.ts b/src/app.ts\n+renderSourceControlActionCommandTemplate()' - }, - firstPrompt: { - description: 'The first user request that created the Orca workspace.', - example: 'Fix CI and commit the result' - }, - assistantMessage: { - description: 'The initial agent response, when Orca has one available.', - example: 'I will inspect the failing check, patch the issue, and run tests.' - } - } - const ACTION_ID_SET = new Set(SOURCE_CONTROL_ACTION_IDS) function isRecord(value: unknown): value is Record { diff --git a/tests/e2e/helpers/source-control-ai-generators.ts b/tests/e2e/helpers/source-control-ai-generators.ts index 67d2133cac0..be3f1b43247 100644 --- a/tests/e2e/helpers/source-control-ai-generators.ts +++ b/tests/e2e/helpers/source-control-ai-generators.ts @@ -27,6 +27,30 @@ async function setCustomGenerator(page: Page, scriptPath: string): Promise }, scriptPath) } +/** + * Writes a generator that echoes back whichever issue number reached the prompt, so the + * assertion covers the whole chain (renderer → IPC → worktree meta → template render → + * agent stdin) rather than any single hop. `emitPayload` lines run with a captured `issue` + * const in scope and must write the payload the caller's generation path expects. + */ +export function writeLinkedIssueEchoGenerator(scriptPath: string, emitPayload: string[]): void { + writeFileSync( + scriptPath, + [ + 'const chunks = []', + "process.stdin.on('data', (chunk) => chunks.push(chunk))", + "process.stdin.on('end', () => {", + " const prompt = Buffer.concat(chunks).toString('utf8')", + // Why: capture the whole line, not `\d*` — a `\d*` capture matches zero digits before + // an unexpanded `{linkedIssue}` and reports it as `empty`, hiding a literal token. + ' const match = prompt.match(/ORCA_E2E_ISSUE=([^\\r\\n]*)/)', + " const issue = match ? match[1] || 'empty' : 'missing'", + ...emitPayload, + '})' + ].join('\n') + ) +} + export async function installDelayedPrGenerator( page: Page, generatorScriptPath: string, diff --git a/tests/e2e/source-control-commit-message-ai.spec.ts b/tests/e2e/source-control-commit-message-ai.spec.ts index 60fd8c49dc5..f6201bc6a75 100644 --- a/tests/e2e/source-control-commit-message-ai.spec.ts +++ b/tests/e2e/source-control-commit-message-ai.spec.ts @@ -3,6 +3,7 @@ import { rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { test, expect } from './helpers/orca-app' +import { writeLinkedIssueEchoGenerator } from './helpers/source-control-ai-generators' import { waitForSessionReady } from './helpers/store' import { openSourceControlForWorktree } from './helpers/worktree-registration' @@ -41,6 +42,74 @@ function cleanupWorktree(repoPath: string, worktreePath: string, branchName: str } test.describe('Source Control AI commit messages', () => { + // Why: the unlinked case separates a real resolver from one that always returns a number, + // and — because the generator echoes the whole line — a literal `{linkedIssue}` reaches the + // assertion as `saw-issue:{linkedIssue}` instead of masquerading as the empty expansion. + for (const { label, linkedIssue, expected } of [ + { label: 'substitutes the workspace-linked issue into', linkedIssue: 4242, expected: '4242' }, + { + label: 'expands the issue token to nothing for an unlinked workspace in', + linkedIssue: null, + expected: 'empty' + } + ]) { + test(`${label} the commit-message recipe`, async ({ orcaPage, testRepoPath }) => { + const { branchName, worktreePath } = createWorktreeWithStagedChange(testRepoPath) + const generatorPath = path.join(os.tmpdir(), `${branchName}-linked-issue-generator.cjs`) + writeLinkedIssueEchoGenerator(generatorPath, [' process.stdout.write(`saw-issue:${issue}`)']) + + try { + await waitForSessionReady(orcaPage) + await openSourceControlForWorktree(orcaPage, testRepoPath, worktreePath) + + await orcaPage.evaluate( + async ({ generatorPath, linkedIssue }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const worktreeId = store.getState().activeWorktreeId + if (!worktreeId) { + throw new Error('No worktree was active after opening Source Control') + } + await window.api.worktrees.updateMeta({ worktreeId, updates: { linkedIssue } }) + const customAgentCommand = `node ${JSON.stringify(generatorPath)}` + await store.getState().updateSettings({ + activeRuntimeEnvironmentId: null, + sourceControlAi: { + enabled: true, + agentId: 'custom' as const, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand, + instructionsByOperation: {}, + actions: { + commitMessage: { + agentId: 'custom' as const, + commandInputTemplate: 'ORCA_E2E_ISSUE={linkedIssue}\n\n{basePrompt}' + } + } + } + }) + }, + { generatorPath, linkedIssue } + ) + + const textarea = orcaPage.getByRole('textbox', { name: 'Commit message' }) + await expect(textarea).toBeVisible({ timeout: 10_000 }) + + const generate = orcaPage.getByRole('button', { name: 'Generate commit message with AI' }) + await expect(generate).toBeEnabled() + await generate.click() + + await expect(textarea).toHaveValue(`saw-issue:${expected}`, { timeout: 15_000 }) + } finally { + rmSync(generatorPath, { force: true }) + cleanupWorktree(testRepoPath, worktreePath, branchName) + } + }) + } + test('generates a commit message from staged changes through the Source Control UI', async ({ orcaPage, testRepoPath diff --git a/tests/e2e/source-control-pr-linked-issue-ai.spec.ts b/tests/e2e/source-control-pr-linked-issue-ai.spec.ts new file mode 100644 index 00000000000..625d19d58b8 --- /dev/null +++ b/tests/e2e/source-control-pr-linked-issue-ai.spec.ts @@ -0,0 +1,96 @@ +import { rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + createBranchCommit, + openSourceControl, + seedCreatePrComposer +} from './helpers/source-control-ai-generation' +import { writeLinkedIssueEchoGenerator } from './helpers/source-control-ai-generators' +import { waitForSessionReady } from './helpers/store' + +// Why: the PR path reads the echoed issue from the generated title. +function writeLinkedIssuePrEchoGenerator(scriptPath: string, base: string): void { + writeLinkedIssueEchoGenerator(scriptPath, [ + ' process.stdout.write(JSON.stringify({', + ` base: ${JSON.stringify(base)},`, + ' title: `saw-issue:${issue}`,', + " body: 'linked-issue e2e body',", + ' draft: false', + ' }))' + ]) +} + +test.describe('Source Control AI pull request linkedIssue', () => { + // Why: the unlinked case separates a real resolver from one that always returns a number, + // and — because the generator echoes the whole line into the title — a literal + // `{linkedIssue}` reaches the assertion as `saw-issue:{linkedIssue}` instead of + // masquerading as the empty expansion. + for (const { label, linkedIssue, expected } of [ + { label: 'substitutes the workspace-linked issue into', linkedIssue: 4242, expected: '4242' }, + { + label: 'expands the issue token to nothing for an unlinked workspace in', + linkedIssue: null, + expected: 'empty' + } + ]) { + test(`${label} the pull-request recipe`, async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const { prWorktreeId, prWorktreePath, primaryBranch } = await seedCreatePrComposer(orcaPage) + createBranchCommit(prWorktreePath) + + const generatorPath = path.join( + os.tmpdir(), + `e2e-pr-linked-issue-${Date.now()}-${Math.random().toString(16).slice(2)}.cjs` + ) + writeLinkedIssuePrEchoGenerator(generatorPath, primaryBranch) + + try { + await orcaPage.evaluate( + async ({ generatorPath, linkedIssue, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await window.api.worktrees.updateMeta({ worktreeId, updates: { linkedIssue } }) + const customAgentCommand = `node ${JSON.stringify(generatorPath)}` + await store.getState().updateSettings({ + activeRuntimeEnvironmentId: null, + sourceControlAi: { + enabled: true, + agentId: 'custom' as const, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand, + instructionsByOperation: {}, + actions: { + pullRequest: { + agentId: 'custom' as const, + commandInputTemplate: 'ORCA_E2E_ISSUE={linkedIssue}\n\n{basePrompt}' + } + } + } + }) + }, + { generatorPath, linkedIssue, worktreeId: prWorktreeId } + ) + + await openSourceControl(orcaPage, prWorktreeId) + + const title = orcaPage.getByRole('textbox', { name: 'Pull request title' }) + await expect(title).toBeVisible({ timeout: 10_000 }) + + const generate = orcaPage.getByRole('button', { + name: 'Generate pull request details with AI' + }) + await expect(generate).toBeEnabled() + await generate.click() + + await expect(title).toHaveValue(`saw-issue:${expected}`, { timeout: 15_000 }) + } finally { + rmSync(generatorPath, { force: true }) + } + }) + } +}) From daba81e8cf81e2be9b4ddcd6c8c599499610b48c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:33:09 -0700 Subject: [PATCH 052/150] fix(terminal): recover wedged panes after input (#10617) * fix(terminal): recover wedged panes after input * refactor(terminal): make wedge probes explicit * fix(terminal): require quiet parser before recovery --- .../terminal-pane/pty-connection.test.ts | 50 ++++++++++++ .../terminal-pane/pty-connection.ts | 9 ++- .../terminal-write-pipeline-health.test.ts | 44 +++++++++++ .../terminal-write-pipeline-health.ts | 77 +++++++++++++++---- 4 files changed, 160 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index cd03a7669cb..ab40d22466b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4777,6 +4777,56 @@ describe('connectPanePty', () => { expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled() }) + it('recovers a wedged write pipeline after accepted input without renderer output', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { settleTerminalWriteStallWatch, WRITE_PIPELINE_STALL_CHECK_MS } = + await import('@/lib/pane-manager/terminal-write-pipeline-health') + const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState + const transport = createMockTransport('pty-wedged') + transportFactoryQueue.push(transport) + const pane = createPane(1) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + pane.terminal.write.mockClear() + pane.terminal.write.mockImplementation(() => {}) + + sendTerminalInputThroughPane(pane, 'x') + settleTerminalWriteStallWatch(pane.terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + await flushAsyncTicks() + + expect(transport.sendInput).toHaveBeenCalledWith('x') + expect(pane.terminal.write).toHaveBeenCalledWith('', expect.any(Function)) + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1') + binding.dispose() + }) + + it('does not arm the wedge probe when acknowledged input is rejected', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const { WRITE_PIPELINE_STALL_CHECK_MS } = + await import('@/lib/pane-manager/terminal-write-pipeline-health') + const transport = createMockTransport('pty-rejected') + transport.sendInputAccepted = vi.fn().mockResolvedValue(false) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + pane.terminal.write.mockClear() + pane.terminal.write.mockImplementation(() => {}) + + sendTerminalInputThroughPane(pane, '\x03') + await flushAsyncTicks() + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + await flushAsyncTicks() + + expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03') + expect(pane.terminal.write).not.toHaveBeenCalledWith('', expect.any(Function)) + binding.dispose() + }) + it('blocks input when tab-level ptyId is stale even if panePtyId is null', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 90c7aa09781..df1e39e6ebb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -76,7 +76,8 @@ import { } from './replay-guard' import { isTerminalWritePipelineCertifiedDead, - registerUndeliverableWriteHandler + registerUndeliverableWriteHandler, + requestTerminalWritePipelineProbe } from '@/lib/pane-manager/terminal-write-pipeline-health' import { captureTerminalPaneRecoveryGeneration, @@ -3357,6 +3358,8 @@ export function connectPanePty( const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { lastTerminalInputAt = performance.now() + // Why: input must probe a wedged xterm even when the PTY produces no renderer output. + requestTerminalWritePipelineProbe(pane.terminal) } const recordTerminalInputForHibernation = (): void => { useAppStore.getState().recordTerminalInput(cacheKey) @@ -3728,12 +3731,12 @@ export function connectPanePty( cancelSuspendedShellCommandInference() } clearPendingTerminalInputIntent() - markTerminalInputSent() const writePromise = transport .sendInputAccepted(data) .then((accepted) => { if (accepted) { - recordTerminalInputForHibernationFallback() + // Why: rejected writes use transport recovery and must not arm a parser probe. + markAcceptedTerminalInputSent() observeAcceptedShellCommandInput(data) observeAcceptedTerminalInput(data, acknowledgedIntent) interruptInference.observeInputIntent(acknowledgedIntent) diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts index 960c0c541a6..f88cbe02fde 100644 --- a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.test.ts @@ -9,6 +9,7 @@ import { isTerminalWritePipelineCertifiedDead, notifyUndeliverableWrite, registerUndeliverableWriteHandler, + requestTerminalWritePipelineProbe, settleTerminalWriteStallWatch, WRITE_PIPELINE_STALL_CHECK_MS } from './terminal-write-pipeline-health' @@ -113,6 +114,23 @@ describe('terminal write pipeline health', () => { _resetWritePipelineHealthForTests(terminal) }) + it('keeps a requested probe armed through unrelated parse progress', () => { + vi.useFakeTimers() + const terminal = makeTerminal() + terminal.dropCallbacks = true + const handler = vi.fn() + registerUndeliverableWriteHandler(terminal, handler) + + armTerminalWriteStallWatch(terminal) + requestTerminalWritePipelineProbe(terminal) + settleTerminalWriteStallWatch(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS * 2) + + expect(handler).toHaveBeenCalledWith('write-stalled') + expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true) + _resetWritePipelineHealthForTests(terminal) + }) + it('a slow-but-alive pipeline disarms when the probe parses', () => { vi.useFakeTimers() const terminal = makeTerminal() @@ -218,6 +236,32 @@ describe('terminal write pipeline health', () => { _resetWritePipelineHealthForTests(terminal) }) + it('keeps a requested probe alive while the pipeline makes parse progress', () => { + vi.useFakeTimers() + const terminal = makeTerminal() + terminal.dropCallbacks = true + const handler = vi.fn() + registerUndeliverableWriteHandler(terminal, handler) + + requestTerminalWritePipelineProbe(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + settleTerminalWriteStallWatch(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + settleTerminalWriteStallWatch(terminal) + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS / 2) + + expect(handler).not.toHaveBeenCalled() + expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(false) + + vi.advanceTimersByTime(WRITE_PIPELINE_STALL_CHECK_MS) + + expect(handler).toHaveBeenCalledWith('write-stalled') + expect(isTerminalWritePipelineCertifiedDead(terminal)).toBe(true) + _resetWritePipelineHealthForTests(terminal) + }) + it('notifies at most once per terminal instance', () => { const terminal = makeTerminal() const handler = vi.fn() diff --git a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts index 39e0b72c239..5c67e7138ea 100644 --- a/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts +++ b/src/renderer/src/lib/pane-manager/terminal-write-pipeline-health.ts @@ -6,10 +6,11 @@ // delivery ack credits leak, and the pane becomes a fossil the user can only // cure by reloading the window. Detection here is probe-certified (mirroring // replay-guard.ts): a stalled completion triggers an empty probe write; xterm -// parses in FIFO order, so a probe that also never completes proves the -// pipeline is dead rather than slow. Certification notifies a per-terminal -// handler (registered by the pane's PTY connection) that requests pane -// recovery — a remount that rebuilds the xterm and reattaches the live PTY. +// parses in FIFO order, so a completing probe proves preceding work drained. A +// silent probe is only certified dead after a full interval without any parse +// progress. Certification notifies a per-terminal handler (registered by the +// pane's PTY connection) that requests pane recovery — a remount that rebuilds +// the xterm and reattaches the live PTY. type WriteTarget = { write(data: string, callback?: () => void): void @@ -42,9 +43,12 @@ export function hasTerminalParseProgressSince(terminal: object, generation: numb return captureTerminalParseProgressGeneration(terminal) !== generation } +type StallWatchMode = 'output-completion' | 'fifo-probe' + type StallWatch = { timer: ReturnType onCertifiedDead?: () => void + mode: StallWatchMode } const stallWatchByTerminal = new WeakMap() @@ -106,31 +110,54 @@ export function isTerminalWritePipelineCertifiedDead(terminal: object): boolean * Arm (or keep armed) the stall watch for a terminal that just had a write * issued. Cleared by settleTerminalWriteStallWatch from the write-completion * callback. If the completion never arrives, an empty probe write certifies - * dead-vs-slow exactly like replay-guard.ts: probe completes → pipeline is - * alive (slow parse), re-arm and keep waiting; probe silent for another - * interval → dead, notify. + * dead-vs-slow conservatively like replay-guard.ts: probe completes → pipeline + * is alive (slow parse), re-arm and keep waiting; probe silent for another + * interval without any parse progress → dead, notify. */ -export function armTerminalWriteStallWatch( +function armTerminalWritePipelineWatch( terminal: WriteTarget, - options: { onCertifiedDead?: () => void; stallCheckMs?: number } = {} + options: { + mode: StallWatchMode + onCertifiedDead?: () => void + stallCheckMs?: number + } ): void { - if (stallWatchByTerminal.has(terminal) || certifiedDeadTerminals.has(terminal)) { + if (certifiedDeadTerminals.has(terminal)) { + return + } + const existingWatch = stallWatchByTerminal.get(terminal) + if (existingWatch) { + if (options.mode === 'fifo-probe') { + existingWatch.mode = 'fifo-probe' + } return } const stallCheckMs = options.stallCheckMs ?? WRITE_PIPELINE_STALL_CHECK_MS const watch: StallWatch = { + mode: options.mode, onCertifiedDead: options.onCertifiedDead, timer: setTimeout(probeForStall, stallCheckMs) } const certifyDead = (): void => certifyTerminalWritePipelineDead(terminal, watch) + function armProbeQuietWindow(quietSinceGeneration: number): void { + watch.timer = setTimeout(() => { + if (stallWatchByTerminal.get(terminal) !== watch) { + return + } + if (hasTerminalParseProgressSince(terminal, quietSinceGeneration)) { + armProbeQuietWindow(captureTerminalParseProgressGeneration(terminal)) + return + } + certifyDead() + }, stallCheckMs) + } function probeForStall(): void { if (stallWatchByTerminal.get(terminal) !== watch) { return } - let probeParsed = false + const probeQueuedAtGeneration = captureTerminalParseProgressGeneration(terminal) try { terminal.write('', () => { - probeParsed = true // Why: replay guards share this terminal-scoped generation; even an // auxiliary FIFO probe proves the parser is alive and making progress. recordTerminalParseProgress(terminal) @@ -146,15 +173,28 @@ export function armTerminalWriteStallWatch( certifyDead() return } - watch.timer = setTimeout(() => { - if (!probeParsed) { - certifyDead() - } - }, stallCheckMs) + if (stallWatchByTerminal.get(terminal) === watch) { + armProbeQuietWindow(probeQueuedAtGeneration) + } } stallWatchByTerminal.set(terminal, watch) } +export function armTerminalWriteStallWatch( + terminal: WriteTarget, + options: { onCertifiedDead?: () => void; stallCheckMs?: number } = {} +): void { + armTerminalWritePipelineWatch(terminal, { ...options, mode: 'output-completion' }) +} + +/** Require the active watch to reach its FIFO probe before it may settle. */ +export function requestTerminalWritePipelineProbe( + terminal: WriteTarget, + options: { stallCheckMs?: number } = {} +): void { + armTerminalWritePipelineWatch(terminal, { ...options, mode: 'fifo-probe' }) +} + /** Cancel a pending watch without claiming that any bytes parsed. */ export function cancelTerminalWriteStallWatch(terminal: object): void { const watch = stallWatchByTerminal.get(terminal) @@ -168,6 +208,9 @@ export function cancelTerminalWriteStallWatch(terminal: object): void { /** Write completed normally — the pipeline is healthy; drop any pending watch. */ export function settleTerminalWriteStallWatch(terminal: object): void { recordTerminalParseProgress(terminal) + if (stallWatchByTerminal.get(terminal)?.mode === 'fifo-probe') { + return + } cancelTerminalWriteStallWatch(terminal) } From 9042ef97926137fa5ea0af0b458f3b15a57d47d0 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:43:29 +0900 Subject: [PATCH 053/150] fix(terminal): make Zellij/TUI OSC 52 clipboard copy work by default (#10588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): make Zellij/TUI OSC 52 clipboard copy work by default Zellij and other multiplexers copy via OSC 52. Empty Pc is a valid XTerm default for clipboard, but we rejected it, and the feature defaulted off so copy silently failed inside Zellij. Accept empty Pc as clipboard, default the setting on (query still blocked; size capped), and surface Zellij in settings. Closes #10567 * fix(review): make the OSC 52 default actually reach existing installs Review fixes for #10588: - Persistence: profiles saved under the old off default persisted `false`, which is indistinguishable from a real opt-out, so the default flip never reached #10567's reporter. Added the repo's one-shot stamp (terminalAllowOsc52ClipboardDefaultedOnForAllUsers) so unmigrated profiles flip once and a later opt-out sticks. - Replay: reattach/cold-restore re-writes recorded PTY bytes through the same parser, so a stale `\e]52;c;...` silently clobbered the clipboard on every restart. Gated behind isPaneReplaying via a new resolveOsc52ClipboardGate. - Blocked toast latches once per renderer session and could be burned by a pre-hydration read; it now fires only for a real opt-out. - An empty Pd decoded to '' and, with the gate default-on, silently blanked the clipboard. Now rejected as invalid. - Localization: en.json is bundled and the catalog beats the code fallback, so all three copy changes were inert. Resynced across five locales. - Corrected the empty-Pc rationale: tmux (not Zellij) emits `\e]52;;`. * test(terminal): cover the OSC 52 gate wiring and settings copy Extracts createOsc52OscHandler so the replay/hydration gate wiring is covered, not just the pure gate — dropping the isReplaying getter now fails a test instead of passing silently. Adds catalog assertions for the two OSC 52 settings strings. Only the toast key was pinned, so the same inert-copy regression (code fallback edited, bundled en.json not) could still ship for the settings pane. * docs(settings): note that the OSC 52 default only covers new profiles Co-authored-by: Orca * fix(terminal): migrate the web settings store to the OSC 52 default-on flip The default-on flip only reached the Electron store. The web/remote client keeps its own settings in localStorage, so a profile that persisted the old `false` there stayed opted out — the same bug the Electron migration fixed, in the second store. Extract the migration into shared/osc52-clipboard-settings.ts and call it from both stores. Also coalesce OSC 52 writes onto a microtask so a hostile chunk of ~15-byte sequences cannot fan out into a million clipboard writes, and latch the blocked-write toast after it renders rather than before. * feat(terminal): tell users when the OSC 52 flip overrides their opt-out The default-on migration cannot distinguish a deliberate opt-out from a profile that simply never touched the setting — both persisted `false` under the old default. Flipping everyone is the only way to fix #10567 for existing installs, but doing it silently reverses a security choice the user made. Arm a one-shot notice at load when the migration overrides a persisted `false`, on both settings stores, and show it once the renderer hydrates. Profiles that never opted out are never notified. * fix(terminal): clear the OSC 52 notice after it renders, not before Co-authored-by: Orca * fix(terminal): keep the web OSC 52 notice armed against an unmigrated host The host store always projects osc52ClipboardDefaultOnNoticePending, so the plain spread in the web client's runtime UI merge overwrote an arm raised by its own localStorage settings migration — flipping the opt-out in silence. Co-authored-by: Orca * fix(terminal): stop the OSC 52 notice overclaiming, and cover it Round-3 review fixes: - Rename the arming predicate to osc52ClipboardDefaultOnOverridesPersistedOff. Both stores rewrite the whole settings object on every save, so every profile saved under the old off default holds `false` — the deliberate-opt-out cohort is not distinguishable on disk. Name, docs and test names now say so. - Read settings before the UI snapshot in readLocalWebUIState: getStoredSettings() arms the notice, so reading first snapshotted a pre-arm state that callers wrote back, erasing an arm the stamp can never raise again. - Give the notice toast a stable id; StrictMode re-runs the effect against the same closure, so the early return cannot catch the second pass. - Restore guardParserHandler parity in the coalescer microtask. - Drop the unverified Zellij claim justifying all-selections routing; that routing predates this branch and PRIMARY routing stays an open question. - Cover the notice hook (order, single-fire, deep-link), the armed flag reaching disk and surviving a clear, and pin the notice catalog to its code fallbacks. Co-authored-by: Orca * test(terminal): pin OSC 52 setting discovery by product name The migration notice says to turn it off in Terminal settings, so searching Zellij/Grok/tmux has to find it. Also note why the OSC 52 write-back clauses stay despite an unrelated always-true clause in the same condition. Co-authored-by: Orca * fix(terminal): consume the OSC 52 notice on close, and cover the guards it relies on The notice was cleared the moment the toast was enqueued, so a quit inside its 15s window spent the profile's only warning on a launch where nothing was ever seen — and the settings stamp means it can never re-arm. Clear on onAutoClose/onDismiss instead, plus explicitly in the action handler, because sonner's action path deletes the toast without firing onDismiss. Also closes three coverage gaps a review found: - ui.set must accept osc52ClipboardDefaultOnNoticePending. The update schema is strict, so dropping the key rejects the whole call rather than stripping it, and the renderer only logs that failure — every paired client would re-toast forever with nothing red. - the coalescer's try/catch and .catch had no test; the rejection case needs a plain function because vi.fn tracks settled results and hides the leak. - pin that every selection kind (including bare `p`) lands in the system clipboard, so routing PRIMARY separately later is a deliberate break. Co-authored-by: Orca * test(web): pin that ui.get arms the OSC 52 notice when it runs the migration readLocalWebUIState reads settings before the UI blob so the migration's arm is in place before the snapshot every caller writes back. Seeding localStorage after install is what makes ui.get the first settings read, and therefore what makes swapping those two lines fail. Co-authored-by: Orca * test(store): cover the OSC 52 notice clear and its hydration The clear sets local state before persisting so a rejected ui.set cannot leave the toast re-firing for the rest of the session; losing the persist only re-arms the notice next launch. Co-authored-by: Orca * docs(terminal): state the real residual risk of default-on OSC 52 Three comment corrections from review: - the safety note claimed exfil was the risk; queries are blocked, so it isn't. The actual accepted risk is execute-on-paste: decoded text goes to the clipboard verbatim, newlines included. Filtering here would break multi-line TUI copies, which is the feature; bracketed paste is where that is handled, and kitty/Ghostty take the same posture. - the coalescer bounds a flood per parse yield, not overall. - the replay gate reads at parse time while queued live bytes are drained before the guard engages, so a copy racing a reattach is dropped silently. Co-authored-by: Orca * test(terminal): close the four OSC 52 gaps a full revert walked through Mutation testing found four assertions that stayed green against the very change they were written to pin. The notice suite passed 8/9 against a complete revert to clear-at-enqueue: `calls[0][1][callback]?.()` is a silent no-op when the option is absent, and the call count was already satisfied by the enqueue-clear, so nothing separated "cleared by this callback" from "cleared earlier". Assert the option exists and the notice is unspent before invoking it. The stable toast id was deletable with all 9 green despite the adjacent comment calling it load-bearing for StrictMode. Pin it. The blocked toast's latch-after-throw fix was unproven: both orderings pass when `toast.info` succeeds. Only a throwing first call tells them apart. Deleting the hook call in App.tsx silenced the desktop notice with every suite green. Pin it alongside the static Toaster import, since sonner drops a toast enqueued before any Toaster subscribes and never replays it. Also retone the coalescer-latch comment, which claimed the reset ordering was load-bearing on its own; the try/catch reaches the same end, so the test binds the pair. All four verified green->red by mutation, then restored. * test(terminal): cover the OSC 52 notice and its guards Add tests pinning the static Toaster mount required to prevent notice dropout (#10567), the stable toast ID deduping StrictMode double-invokes, that the notice stays unspent on toast throws, and that flush-latch guards prevent silent consumption across error boundaries. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- config/scripts/locale-ko-key-overrides.json | 2 +- src/main/persistence.test.ts | 145 ++++++++++ src/main/persistence.ts | 18 ++ .../runtime/rpc/methods/client-ui-schemas.ts | 1 + .../runtime/rpc/methods/client-ui.test.ts | 24 ++ src/renderer/src/App.tsx | 2 + src/renderer/src/app-startup-routing.test.ts | 14 + .../settings/TerminalInteractionSection.tsx | 5 +- .../settings/osc52-clipboard-copy.test.ts | 45 +++ .../settings/terminal-clipboard-search.ts | 9 + .../settings/terminal-search.test.ts | 15 + .../osc52-clipboard-blocked-toast.test.ts | 24 +- .../osc52-clipboard-blocked-toast.ts | 6 +- .../osc52-clipboard-default-on-notice.test.ts | 166 +++++++++++ .../osc52-clipboard-default-on-notice.ts | 68 +++++ .../terminal-pane/osc52-clipboard.test.ts | 273 +++++++++++++++++- .../terminal-pane/osc52-clipboard.ts | 137 +++++++-- .../use-terminal-pane-lifecycle.ts | 16 +- src/renderer/src/i18n/locales/en.json | 15 +- src/renderer/src/i18n/locales/es.json | 15 +- src/renderer/src/i18n/locales/ja.json | 15 +- src/renderer/src/i18n/locales/ko.json | 15 +- src/renderer/src/i18n/locales/zh.json | 15 +- src/renderer/src/store/slices/ui.test.ts | 30 ++ src/renderer/src/store/slices/ui.ts | 11 + src/renderer/src/web/web-preload-api.test.ts | 154 ++++++++++ src/renderer/src/web/web-preload-api.ts | 38 ++- src/shared/constants.test.ts | 5 + src/shared/constants.ts | 11 +- src/shared/osc52-clipboard-settings.test.ts | 78 +++++ src/shared/osc52-clipboard-settings.ts | 37 +++ src/shared/types.ts | 6 +- 32 files changed, 1355 insertions(+), 60 deletions(-) create mode 100644 src/renderer/src/components/settings/osc52-clipboard-copy.test.ts create mode 100644 src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts create mode 100644 src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts create mode 100644 src/shared/osc52-clipboard-settings.test.ts create mode 100644 src/shared/osc52-clipboard-settings.ts diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 1a8b7d34154..aeda4eb6de7 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -2814,7 +2814,7 @@ "ko": "{{value0}} 메가바이트" }, "auto.components.settings.TerminalPane.6e6480a7df": { - "ko": "terminal의 프로그램(tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다." + "ko": "terminal의 프로그램(Zellij, tmux, Neovim, fzf, Grok, SSH)이 시스템 클립보드에 복사할 수 있게 합니다." }, "auto.components.settings.TerminalPane.8eefeaa3da": { "ko": "마우스를 따라 포커스 이동" diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 017114eaa5e..1894a42835a 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2872,6 +2872,151 @@ describe('Store', () => { expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true) }) + it('migrates the legacy OSC 52 clipboard disabled default to enabled', async () => { + // Why this migration exists: the old off default was persisted for every + // profile, so without the one-shot flip #10567 would only fix new installs. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalAllowOsc52Clipboard: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().terminalAllowOsc52Clipboard).toBe(true) + expect(store.getSettings().terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('persists the OSC 52 clipboard migration stamp back to disk', async () => { + // Why round-trip a store first: on a bare legacy profile ~30 other migrations also + // set loadNeedsSave, so the save happens regardless and this migration's own dirty + // flag goes untested. Re-loading a profile the new build already wrote leaves OSC 52 + // as the only unmigrated key — which is exactly the upgrade case that matters. + // Why no flush(): flush() writes unconditionally, so only the debounced load-path + // save proves this migration marked the state dirty by itself. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const migrated = await createStore() + migrated.flush() + + const settled = readDataFile() as { settings: Record } + settled.settings.terminalAllowOsc52Clipboard = false + delete settled.settings.terminalAllowOsc52ClipboardDefaultedOnForAllUsers + writeDataFile(settled) + + vi.useFakeTimers() + try { + const store = await createStore() + // Why over-advance: the debounce is exactly 1000ms, so an exact-fit advance turns a + // future debounce raise into a confusing no-write instead of a loud failure. + vi.advanceTimersByTime(5000) + await store.waitForPendingWrite() + } finally { + vi.useRealTimers() + } + + const persisted = readDataFile() as { + settings?: { + terminalAllowOsc52Clipboard?: boolean + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean + } + } + + expect(persisted.settings?.terminalAllowOsc52Clipboard).toBe(true) + expect(persisted.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('preserves a post-migration OSC 52 clipboard opt-out', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getSettings().terminalAllowOsc52Clipboard).toBe(false) + expect(store.getSettings().terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('arms the one-shot notice when the OSC 52 flip overrides a persisted off', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalAllowOsc52Clipboard: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('leaves the OSC 52 notice disarmed for a profile with no persisted value', async () => { + // Why: the notice explains an overridden choice; a fresh profile made no choice. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const store = await createStore() + expect(store.getUI().osc52ClipboardDefaultOnNoticePending).toBe(false) + }) + + it('keeps the OSC 52 notice armed on disk until the renderer clears it', async () => { + // Why the disk assertion: the flip happens during load, before any window exists, and + // once the settings stamp lands the arming predicate is false forever — so the on-disk + // ui flag is the only thing that survives a crash before the toast renders. + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalAllowOsc52Clipboard: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + + const armed = await createStore() + armed.flush() + const armedOnDisk = readDataFile() as { + ui?: { osc52ClipboardDefaultOnNoticePending?: boolean } + } + expect(armedOnDisk.ui?.osc52ClipboardDefaultOnNoticePending).toBe(true) + + const reloaded = await createStore() + expect(reloaded.getUI().osc52ClipboardDefaultOnNoticePending).toBe(true) + + reloaded.updateUI({ osc52ClipboardDefaultOnNoticePending: false }) + reloaded.flush() + const cleared = await createStore() + // Why re-check after the stamp: a cleared notice must not be resurrected by a later load. + expect(cleared.getUI().osc52ClipboardDefaultOnNoticePending).toBe(false) + }) + it('migrates the legacy Linux primary-selection default to enabled', async () => { await withPlatform('linux', async () => { writeDataFile({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 75aac705750..b26c68a9af5 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -223,6 +223,10 @@ import { normalizeTuiAgentEnvRecord } from '../shared/tui-agent-launch-defaults' import { normalizeTerminalCursorStyleDefault } from '../shared/terminal-cursor-style-settings' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../shared/osc52-clipboard-settings' import { normalizeTerminalLineHeight } from '../shared/terminal-line-height-settings' import { normalizeUiLanguage } from '../shared/ui-language' import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom' @@ -2909,6 +2913,14 @@ export class Store { const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers ? (parsed.settings?.floatingTerminalEnabled ?? true) : true + // Why: the old off default persisted `false` for every profile, indistinguishable from a real opt-out — flip unmigrated profiles once (#10567). + const migratedOsc52Clipboard = normalizeOsc52ClipboardDefaultOn(parsed.settings) + const osc52ClipboardNoticePending = + osc52ClipboardDefaultOnOverridesPersistedOff(parsed.settings) || + parsed.ui?.osc52ClipboardDefaultOnNoticePending === true + if (parsed.settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true) { + this.loadNeedsSave = true + } const floatingTerminalCwdMigrated = parsed.settings?.floatingTerminalCwdMigratedToAppWorkspace === true // Why: an earlier migration wrote '' for the notes dir; floating terminals still open at home, notes use a separate IPC. @@ -3141,6 +3153,7 @@ export class Store { localWindowsRuntimeDefault: migratedWindowsRuntimeDefault, localAccountRuntime: migratedLocalAccountRuntime, localAccountRuntimeDefaultedToAutoForAllUsers: true, + ...migratedOsc52Clipboard, floatingTerminalEnabled: migratedFloatingTerminalEnabled, floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: migratedFloatingTerminalCwd, @@ -3325,6 +3338,9 @@ export class Store { : false, setupGuideBrowserMilestoneLegacyComplete: parsed.ui?.setupGuideBrowserMilestoneLegacyComplete === true, + // Why persist rather than notify inline: the flip lands during load, before any + // window exists, and it must survive a crash before the user ever sees the notice. + osc52ClipboardDefaultOnNoticePending: osc52ClipboardNoticePending, sortBy: migrate ? ('smart' as const) : sort, showDotfilesByWorktree: normalizeShowDotfilesByWorktree( parsed.ui?.showDotfilesByWorktree @@ -5471,6 +5487,8 @@ export class Store { statusBarUsageMode: normalizeStatusBarUsageMode(this.state.ui?.statusBarUsageMode), // Why: strict boolean coercion so a missing/legacy value reads as false (first-run notice still fires). trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, + osc52ClipboardDefaultOnNoticePending: + this.state.ui?.osc52ClipboardDefaultOnNoticePending === true, markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( this.state.ui?.visibleWorkspaceHostIds diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 74efa071a5f..64b0443260f 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -227,6 +227,7 @@ export const UiUpdate = z dismissedUpdateNudgeId: NullableString.optional(), notificationPermissionRequested: z.boolean().optional(), updateReassuranceSeen: z.boolean().optional(), + osc52ClipboardDefaultOnNoticePending: z.boolean().optional(), acknowledgedAgentsByPaneKey: z.record(z.string(), z.number().finite()).optional(), browserDefaultUrl: NullableString.optional(), browserDefaultSearchEngine: z diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 1cf3be1337b..4accec2b0e3 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -397,6 +397,30 @@ describe('client UI RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ui: updated } }) }) + it('lets a paired client clear the OSC 52 default-on notice', async () => { + // Why pin this key: the update schema is strict, so an omitted field does not get + // stripped — it rejects the whole call. The renderer only logs that failure, so the + // one-shot notice would re-toast on every launch of every web/SSH/relay client. + const updated: PersistedUIState = { + ...getDefaultUIState(), + osc52ClipboardDefaultOnNoticePending: false + } + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => updated) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('ui.set', { osc52ClipboardDefaultOnNoticePending: false }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith({ + osc52ClipboardDefaultOnNoticePending: false + }) + }) + it('accepts persisted literal UI arrays and nested UI state', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0e3ad1e61eb..b0a6f6f539c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -198,6 +198,7 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut import { resolveMountedLazyModalIds, type LazyModalId } from './lazy-modal-mount-state' import { translate } from '@/i18n/i18n' import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog' +import { useOsc52ClipboardDefaultOnNotice } from './components/terminal-pane/osc52-clipboard-default-on-notice' import { hasRequestedBackgroundTerminalWorktreeMount, subscribeBackgroundTerminalWorktreeMountRequests @@ -642,6 +643,7 @@ function App(): React.JSX.Element { const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) const persistedUIReady = useAppStore((s) => s.persistedUIReady) const shouldMountContextualTourOverlay = activeContextualTourId !== null + useOsc52ClipboardDefaultOnNotice(persistedUIReady) const shouldMountSetupGuideTelemetryObserver = persistedUIReady const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 05f5837e7c8..31015a530a9 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -365,6 +365,20 @@ describe('renderer startup runtime routing', () => { expect(source).not.toContain("window.addEventListener('blur', handleBlur)") }) + it('arms the OSC 52 default-on notice behind a statically mounted Toaster (#10567)', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + + // Why pin the call site: the hook is the only caller, so deleting this line silences + // the migration notice on desktop with every unit suite still green. + expect(source).toContain('useOsc52ClipboardDefaultOnNotice(persistedUIReady)') + // Why pin the static import and the unconditional mount: sonner drops a toast enqueued + // before any Toaster subscribes, and never replays it — a lazy Toaster would burn the + // profile's one notice with its callbacks never firing, so it could never re-arm. + expect(source).toContain("import { Toaster } from '@/components/ui/sonner'") + expect(source).not.toContain("import('@/components/ui/sonner')") + expect(source).toContain(' { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const checkpointStart = source.indexOf( diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index 6126c197051..4ad9d67e762 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -340,12 +340,13 @@ export function TerminalInteractionSection({ )} description={translate( 'auto.components.settings.TerminalPane.69c64a479c', - 'Let Grok, tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).' + 'Let Zellij, tmux, Neovim, fzf, and Grok copy to the system clipboard over the PTY (including over SSH).' )} keywords={[ 'osc 52', 'osc52', 'clipboard', + 'zellij', 'tmux', 'neovim', 'nvim', @@ -364,7 +365,7 @@ export function TerminalInteractionSection({ )} description={translate( 'auto.components.settings.TerminalPane.6e6480a7df', - 'Let programs in the terminal (Grok, tmux, Neovim, fzf, SSH) copy to your system clipboard.' + 'Let programs in the terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) copy to your system clipboard.' )} checked={settings.terminalAllowOsc52Clipboard} onChange={() => diff --git a/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts b/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts new file mode 100644 index 00000000000..2940148b50e --- /dev/null +++ b/src/renderer/src/components/settings/osc52-clipboard-copy.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import en from '@/i18n/locales/en.json' +import es from '@/i18n/locales/es.json' +import ja from '@/i18n/locales/ja.json' +import ko from '@/i18n/locales/ko.json' +import zh from '@/i18n/locales/zh.json' + +// Why assert the catalog rather than the component: en.json is bundled as the `en` +// resource, so a catalog value wins over translate()'s code fallback. Editing a fallback +// alone renders nothing, and nothing else in CI compares the two. +describe('OSC 52 setting copy', () => { + const locales = { en, es, ja, ko, zh } + + for (const [name, locale] of Object.entries(locales)) { + it(`names Zellij and Grok in both ${name} OSC 52 setting descriptions`, () => { + const pane = locale.auto.components.settings.TerminalPane + for (const copy of [pane['69c64a479c'], pane['6e6480a7df']]) { + expect(copy).toContain('Zellij') + expect(copy).toContain('Grok') + } + }) + } + + // The notice is a one-shot the user sees exactly once, so a stale catalog value would + // ship unnoticed. Pin en.json to the code fallbacks verbatim. + it('keeps the migration notice catalog identical to its code fallbacks', () => { + const notice = en.auto.components.terminal.pane.osc52.clipboard.default.on.notice + expect(notice.title).toBe('TUI clipboard writes are now on by default') + expect(notice.description).toBe( + 'Zellij, tmux, Neovim and other terminal programs can now copy to your clipboard. Turn it off in Terminal settings.' + ) + expect(notice.action).toBe('Open Setting') + }) + + for (const [name, locale] of Object.entries({ es, ja, ko, zh })) { + it(`translates the ${name} migration notice rather than leaving English placeholders`, () => { + const notice = locale.auto.components.terminal.pane.osc52.clipboard.default.on.notice + const english = en.auto.components.terminal.pane.osc52.clipboard.default.on.notice + expect(notice.title).not.toBe(english.title) + expect(notice.description).not.toBe(english.description) + // Product names must survive translation — they are how the user recognizes the change. + expect(notice.description).toContain('Zellij') + }) + } +}) diff --git a/src/renderer/src/components/settings/terminal-clipboard-search.ts b/src/renderer/src/components/settings/terminal-clipboard-search.ts index 3b378e211b5..8cc00935d98 100644 --- a/src/renderer/src/components/settings/terminal-clipboard-search.ts +++ b/src/renderer/src/components/settings/terminal-clipboard-search.ts @@ -77,6 +77,15 @@ export const getTerminalClipboardSearchEntries = createLocalizedCatalog(() => [ 'auto.components.settings.terminal.clipboard.search.10d73e22d3', 'clipboard' ), + // englishOnly: a product name; localizing it would index a word nobody types. + ...translateSearchKeyword( + 'auto.components.settings.terminal.clipboard.search.zellij', + 'zellij', + { englishOnly: true } + ), + ...translateSearchKeyword('auto.components.settings.terminal.clipboard.search.grok', 'grok', { + englishOnly: true + }), ...translateSearchKeyword( 'auto.components.settings.terminal.clipboard.search.5ffcd13c90', 'tmux' diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index cc82051b774..b9d1ac76969 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -218,6 +218,21 @@ describe('getTerminalPaneSearchEntries', () => { expect(matchesSettingsSearch('compact', getAppearancePaneSearchEntries())).toBe(true) }) + // The notice tells users to "turn it off in Terminal settings", so the product names in + // the copy have to be the ones that find it. + it.each(['zellij', 'grok', 'tmux', 'osc 52'])( + 'finds the OSC 52 clipboard setting by searching %s', + (query) => { + const entries = getTerminalPaneSearchEntries({ isWindows: false, isMac: true }) + const osc52 = entries.filter((entry) => + entry.title.includes('Allow TUI Clipboard Writes (OSC 52)') + ) + + expect(osc52).toHaveLength(1) + expect(matchesSettingsSearch(query, osc52)).toBe(true) + } + ) + it('includes pinned worktree duplicate display in sidebar and Appearance search', () => { const entry = getShowPinnedWorktreesInGroupsEntry() diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts index 185cc6460e6..4fa8229bcf9 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts @@ -74,13 +74,31 @@ describe('showOsc52ClipboardBlockedToast', () => { expect(toastInfoMock).toHaveBeenCalledTimes(1) }) - it('mentions Grok in every supported locale', () => { + it('keeps the session notice unspent when the toast throws', async () => { + // Why: with the latch above toast.info, a throw burns the opted-out user's one + // hint and every later blocked write fails silently. Only a throwing first call + // separates the two orderings — the passing case satisfies both. + const { showOsc52ClipboardBlockedToast } = await importToastModule() + toastInfoMock.mockImplementationOnce(() => { + throw new Error('toast unavailable') + }) + + expect(() => showOsc52ClipboardBlockedToast()).toThrow('toast unavailable') + showOsc52ClipboardBlockedToast() + + expect(toastInfoMock).toHaveBeenCalledTimes(2) + }) + + it('mentions Grok and Zellij in every supported locale', () => { + // Why assert the catalog, not the code fallback: en.json is bundled as the + // `en` resource, so a catalog value silently wins over translate()'s fallback. const locales = [en, es, ja, ko, zh] for (const locale of locales) { - expect( + const description = locale.auto.components.terminal.pane.osc52.clipboard.blocked.toast['7cf51f74fd'] - ).toContain('Grok') + expect(description).toContain('Grok') + expect(description).toContain('Zellij') } }) }) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts index fb0b5d73a60..fad6a58a6fd 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts @@ -9,7 +9,6 @@ export function showOsc52ClipboardBlockedToast(): void { if (hasShownOsc52ClipboardBlockedToast) { return } - hasShownOsc52ClipboardBlockedToast = true toast.info( translate( @@ -19,7 +18,7 @@ export function showOsc52ClipboardBlockedToast(): void { { description: translate( 'auto.components.terminal.pane.osc52.clipboard.blocked.toast.7cf51f74fd', - 'Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.' + 'Enable TUI clipboard writes in Terminal settings to copy from SSH, Zellij, tmux, Neovim, fzf, or Grok.' ), duration: 12_000, action: { @@ -42,4 +41,7 @@ export function showOsc52ClipboardBlockedToast(): void { } } ) + // Why latch after: a throw above would otherwise burn the session's one notice + // without ever showing it, leaving the opted-out user with silent failures. + hasShownOsc52ClipboardBlockedToast = true } diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts new file mode 100644 index 00000000000..67bdc0d0099 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.test.ts @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' +import { + shouldShowOsc52ClipboardDefaultOnNotice, + useOsc52ClipboardDefaultOnNotice +} from './osc52-clipboard-default-on-notice' + +const { toastInfoMock, storeState } = vi.hoisted(() => ({ + toastInfoMock: vi.fn(), + storeState: { + osc52ClipboardDefaultOnNoticePending: true, + clearOsc52ClipboardDefaultOnNotice: vi.fn(), + setSettingsSearchQuery: vi.fn(), + openSettingsTarget: vi.fn(), + openSettingsPage: vi.fn() + } +})) + +vi.mock('sonner', () => ({ toast: { info: toastInfoMock } })) + +vi.mock('@/store', () => { + const useAppStore = (selector: (state: typeof storeState) => T): T => selector(storeState) + useAppStore.getState = (): typeof storeState => storeState + return { useAppStore } +}) + +const mountedRoots: Root[] = [] + +function HookProbe({ persistedUIReady }: { persistedUIReady: boolean }): null { + useOsc52ClipboardDefaultOnNotice(persistedUIReady) + return null +} + +async function mountProbe(persistedUIReady: boolean): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { persistedUIReady })) + }) +} + +describe('shouldShowOsc52ClipboardDefaultOnNotice', () => { + it('shows once the migrating profile has hydrated', () => { + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: true, noticePending: true }) + ).toBe(true) + }) + + it('stays quiet before hydration, when the flag still reads its default', () => { + // Why: pre-hydration the store holds `false` for everyone, so firing on that + // value would nag every profile — including the ones never opted out. + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: false, noticePending: true }) + ).toBe(false) + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: false, noticePending: false }) + ).toBe(false) + }) + + it('stays quiet for a profile the migration did not override', () => { + expect( + shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady: true, noticePending: false }) + ).toBe(false) + }) +}) + +describe('useOsc52ClipboardDefaultOnNotice', () => { + beforeEach(() => { + toastInfoMock.mockReset() + storeState.osc52ClipboardDefaultOnNoticePending = true + storeState.clearOsc52ClipboardDefaultOnNotice.mockReset() + storeState.setSettingsSearchQuery.mockReset() + storeState.openSettingsTarget.mockReset() + storeState.openSettingsPage.mockReset() + }) + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => root.unmount()) + } + document.body.innerHTML = '' + }) + + it('toasts the armed profile without spending the notice up front', async () => { + // Why not consume on enqueue: quitting inside the toast's 15s would burn the profile's + // only notice on a launch where the posture change was never actually communicated. + await mountProbe(true) + + expect(toastInfoMock).toHaveBeenCalledTimes(1) + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + }) + + it.each(['onAutoClose', 'onDismiss'] as const)('consumes the notice on %s', async (callback) => { + await mountProbe(true) + + // Why assert the option exists before invoking: optional chaining makes a missing + // callback a silent no-op, so a revert to clear-at-enqueue would satisfy the count + // below without ever wiring this close path. + const close = toastInfoMock.mock.calls[0]?.[1]?.[callback] + expect(close).toBeTypeOf('function') + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + + close() + + expect(storeState.clearOsc52ClipboardDefaultOnNotice).toHaveBeenCalledTimes(1) + }) + + it('dedupes the StrictMode double-invoke with a stable toast id', async () => { + // Why pin the id: the effect re-runs against the same closure, so the early return + // cannot catch the second pass — sonner collapses it only because the id matches. + await mountProbe(true) + + expect(toastInfoMock.mock.calls[0]?.[1]?.id).toBe('osc52-clipboard-default-on-notice') + }) + + it('keeps the notice armed when the toast throws, so it is not burned unshown', async () => { + toastInfoMock.mockImplementationOnce(() => { + throw new Error('toast unavailable') + }) + + await expect(mountProbe(true)).rejects.toThrow('toast unavailable') + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + }) + + it('stays silent for a disarmed profile and before hydration', async () => { + storeState.osc52ClipboardDefaultOnNoticePending = false + await mountProbe(true) + expect(toastInfoMock).not.toHaveBeenCalled() + + storeState.osc52ClipboardDefaultOnNoticePending = true + await mountProbe(false) + expect(toastInfoMock).not.toHaveBeenCalled() + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + }) + + it('deep-links to the OSC 52 terminal setting', async () => { + await mountProbe(true) + + const options = toastInfoMock.mock.calls[0]?.[1] + expect(options.description).toContain('Zellij') + expect(options.action.label).toBe('Open Setting') + + // Why assert unspent first: without it, a revert to clear-at-enqueue satisfies the + // count below and the action's own clear becomes deletable with nothing going red. + expect(storeState.clearOsc52ClipboardDefaultOnNotice).not.toHaveBeenCalled() + + options.action.onClick() + + // Why assert the clear here: sonner's action path deletes the toast without firing + // onDismiss, so acting on the notice would otherwise leave it to re-toast next launch. + expect(storeState.clearOsc52ClipboardDefaultOnNotice).toHaveBeenCalledTimes(1) + expect(storeState.setSettingsSearchQuery).toHaveBeenCalledWith('') + expect(storeState.openSettingsTarget).toHaveBeenCalledWith({ + pane: 'terminal', + repoId: null, + sectionId: OSC52_CLIPBOARD_SETTING_ID + }) + expect(storeState.openSettingsPage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts new file mode 100644 index 00000000000..901e67b62c2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-default-on-notice.ts @@ -0,0 +1,68 @@ +import { useEffect } from 'react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' + +/** Whether the migrating launch should tell the user their OSC 52 opt-out was overridden. */ +export function shouldShowOsc52ClipboardDefaultOnNotice(input: { + persistedUIReady: boolean + noticePending: boolean +}): boolean { + // Why gate on hydration: before ui.get() lands the flag reads false, and firing + // on the default would nag every profile that was never opted out. + return input.persistedUIReady && input.noticePending +} + +export function useOsc52ClipboardDefaultOnNotice(persistedUIReady: boolean): void { + const noticePending = useAppStore((s) => s.osc52ClipboardDefaultOnNoticePending) + const clearNotice = useAppStore((s) => s.clearOsc52ClipboardDefaultOnNotice) + + useEffect(() => { + if (!shouldShowOsc52ClipboardDefaultOnNotice({ persistedUIReady, noticePending })) { + return + } + toast.info( + translate( + 'auto.components.terminal.pane.osc52.clipboard.default.on.notice.title', + 'TUI clipboard writes are now on by default' + ), + { + // Why a stable id: StrictMode re-runs this effect against the same closure, so + // the early return can't catch the second pass — sonner dedupes it instead. + id: 'osc52-clipboard-default-on-notice', + description: translate( + 'auto.components.terminal.pane.osc52.clipboard.default.on.notice.description', + 'Zellij, tmux, Neovim and other terminal programs can now copy to your clipboard. Turn it off in Terminal settings.' + ), + duration: 15_000, + // Why consume on close rather than on enqueue: this is the profile's one notice that + // a security posture changed under it, and the flag can never re-arm once the settings + // stamp lands. Clearing at enqueue spends it on launches where nothing was ever seen — + // a quit inside the 15s, a crash in the gap. Clearing on close costs at most a repeat + // next launch, which the user ends by dismissing it. + onAutoClose: clearNotice, + onDismiss: clearNotice, + action: { + label: translate( + 'auto.components.terminal.pane.osc52.clipboard.default.on.notice.action', + 'Open Setting' + ), + onClick: () => { + // Why clear here too: sonner's action path deletes the toast without firing + // onDismiss, so acting on the notice would otherwise leave it armed. + clearNotice() + const store = useAppStore.getState() + store.setSettingsSearchQuery('') + store.openSettingsTarget({ + pane: 'terminal', + repoId: null, + sectionId: OSC52_CLIPBOARD_SETTING_ID + }) + store.openSettingsPage() + } + } + } + ) + }, [clearNotice, noticePending, persistedUIReady]) +} diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts index c714dc61f40..8c1dada1643 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { handleOsc52ClipboardRequest, parseOsc52 } from './osc52-clipboard' +import { + createOsc52OscHandler, + handleOsc52ClipboardRequest, + parseOsc52, + resolveOsc52ClipboardGate +} from './osc52-clipboard' function b64(s: string): string { return Buffer.from(s, 'utf-8').toString('base64') @@ -51,11 +56,26 @@ describe('parseOsc52', () => { expect(parseOsc52(b64('no-semicolon'))).toMatchObject({ kind: 'invalid' }) }) - it('rejects empty selection list', () => { - // Why: the spec defaults to "s0" on empty, but treating malformed - // payloads as clipboard writes would let buggy/malicious emitters - // mutate the clipboard unintentionally. - expect(parseOsc52(`;${b64('x')}`)).toMatchObject({ kind: 'invalid' }) + it('treats an empty selection list as clipboard, the way tmux emits it', () => { + // Why: tmux copies via `\e]52;;` with no selection letter, so + // rejecting empty Pc broke tmux copy. Zellij always sends an explicit 'c'/'p'. + expect(parseOsc52(`;${b64('from tmux')}`)).toEqual({ + kind: 'write', + selections: 'c', + text: 'from tmux' + }) + }) + + it('still refuses to answer a query when Pc is empty', () => { + expect(parseOsc52(';?')).toEqual({ kind: 'query' }) + }) + + it('rejects an empty payload instead of blanking the clipboard', () => { + // Why: this is XTerm's "clear the selection", which we decline to honor — with + // the gate default-on, any PTY could otherwise blank the clipboard for free. + expect(parseOsc52(';')).toMatchObject({ kind: 'invalid' }) + expect(parseOsc52('c;')).toMatchObject({ kind: 'invalid' }) + expect(parseOsc52('c; ')).toMatchObject({ kind: 'invalid' }) }) it('rejects unknown selection letters', () => { @@ -102,6 +122,41 @@ describe('handleOsc52ClipboardRequest', () => { expect(onBlockedWrite).toHaveBeenCalledTimes(1) }) + it('never answers a clipboard query even with writes enabled', () => { + // A guard, not coverage of this change: queries were already refused. It matters + // more now only because default-on makes `allowClipboardWrite: true` the norm. + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + + for (const query of ['c;?', ';?']) { + expect( + handleOsc52ClipboardRequest(query, { allowClipboardWrite: true, writeClipboardText }) + ).toBe(true) + } + + expect(writeClipboardText).not.toHaveBeenCalled() + }) + + it('routes every selection kind to the system clipboard, including bare PRIMARY', () => { + // Pins today's behavior rather than endorsing it: `selections` is parsed but never + // consulted, so a `p`-only write lands in CLIPBOARD. Routing it to the PRIMARY sink + // on Linux is a live question — this test is what makes that a deliberate break. + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + + for (const selections of ['p', 'q', 's0', 'pc']) { + handleOsc52ClipboardRequest(`${selections};${b64(`via ${selections}`)}`, { + allowClipboardWrite: true, + writeClipboardText + }) + } + + expect(writeClipboardText.mock.calls.map(([text]) => text)).toEqual([ + 'via p', + 'via q', + 'via s0', + 'via pc' + ]) + }) + it('does not surface blocked queries because Orca must not answer them', () => { const onBlockedWrite = vi.fn() @@ -114,3 +169,209 @@ describe('handleOsc52ClipboardRequest', () => { expect(onBlockedWrite).not.toHaveBeenCalled() }) }) + +describe('createOsc52OscHandler', () => { + function setup( + overrides: { + settingEnabled?: boolean | null + replaying?: boolean + writeClipboardText?: ReturnType Promise>> + } = {} + ) { + const settingEnabled = 'settingEnabled' in overrides ? overrides.settingEnabled : true + const writeClipboardText = + overrides.writeClipboardText ?? + vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + const showBlockedWriteToast = vi.fn() + const handler = createOsc52OscHandler({ + getSettingEnabled: () => settingEnabled, + getReplaying: () => overrides.replaying ?? false, + writeClipboardText, + showBlockedWriteToast + }) + return { handler, writeClipboardText, showBlockedWriteToast } + } + + it('writes through to the clipboard for a live pane', async () => { + const { handler, writeClipboardText } = setup() + expect(handler(`c;${b64('live copy')}`)).toBe(true) + await Promise.resolve() + expect(writeClipboardText).toHaveBeenCalledWith('live copy') + }) + + it('coalesces a flood of writes into one clipboard call', async () => { + // Why: a 15-byte sequence repeated across one hostile chunk would otherwise fire + // a million IPC round-trips and native clipboard writes. Last write still wins. + const { handler, writeClipboardText } = setup() + for (let i = 0; i < 1000; i++) { + handler(`c;${b64(`copy ${i}`)}`) + } + await Promise.resolve() + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('copy 999') + }) + + it('does not coalesce across separate turns', async () => { + const { handler, writeClipboardText } = setup() + handler(`c;${b64('first')}`) + await Promise.resolve() + handler(`c;${b64('second')}`) + await Promise.resolve() + expect(writeClipboardText).toHaveBeenNthCalledWith(1, 'first') + expect(writeClipboardText).toHaveBeenNthCalledWith(2, 'second') + }) + + it('reads the gate inputs at fire time so a mid-session toggle applies', async () => { + // Why getters, not values: settings hydrate and toggle after the handler is registered. + let enabled = false + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue(undefined) + const handler = createOsc52OscHandler({ + getSettingEnabled: () => enabled, + getReplaying: () => false, + writeClipboardText, + showBlockedWriteToast: vi.fn() + }) + + handler(`c;${b64('before')}`) + await Promise.resolve() + expect(writeClipboardText).not.toHaveBeenCalled() + + enabled = true + handler(`c;${b64('after')}`) + await Promise.resolve() + expect(writeClipboardText).toHaveBeenCalledExactlyOnceWith('after') + }) + + // The coalesced write runs in a microtask, outside the parser handler xterm guards, so + // a bridge failure there escapes the pane as an uncaught error instead of a failed copy. + it.each([ + [ + // Dropping the try/catch turns this into an uncaught exception that fails the run. + 'a clipboard bridge that throws synchronously', + vi.fn<(text: string) => Promise>(() => { + throw new Error('clipboard unavailable') + }) + ], + [ + // Not revert-proof on its own — dropping the `?.` turns this into a TypeError the + // try/catch also swallows. It pins the behavior: a bad bridge must not crash the pane. + 'a preload whose bridge returns nothing', + vi.fn<(text: string) => Promise>(() => undefined as unknown as Promise) + ] + ])('survives %s', async (_label, writeClipboardText) => { + const { handler } = setup({ writeClipboardText }) + + expect(handler(`c;${b64('copy me')}`)).toBe(true) + await Promise.resolve() + await Promise.resolve() + + expect(writeClipboardText).toHaveBeenCalledWith('copy me') + }) + + it('swallows a rejected clipboard write rather than leaking an unhandled rejection', async () => { + // Why the listener: an unhandled rejection here does not fail this suite on its own, + // so without it the `.catch` on the coalesced write is deletable with nothing going red. + const unhandled: unknown[] = [] + const record = (reason: unknown): void => { + unhandled.push(reason) + } + process.on('unhandledRejection', record) + const written: string[] = [] + try { + // Why not vi.fn here: the spy tracks settled results, which marks the rejection + // handled and hides exactly the leak this test exists to catch. + const handler = createOsc52OscHandler({ + getSettingEnabled: () => true, + getReplaying: () => false, + writeClipboardText: (text) => { + written.push(text) + return Promise.reject(new Error('denied by OS')) + }, + showBlockedWriteToast: vi.fn() + }) + + expect(handler(`c;${b64('copy me')}`)).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 10)) + } finally { + process.off('unhandledRejection', record) + } + + expect(written).toEqual(['copy me']) + expect(unhandled).toEqual([]) + }) + + it('keeps coalescing after a failed write instead of wedging the pane', async () => { + // Why: a stranded flush latch silently kills clipboard copy for the rest of the session. + // Today two things prevent it — the latch resets before the write, and the try/catch means + // the microtask reaches its end either way — so this binds the pair, not the ordering alone. + const writeClipboardText = vi + .fn<(text: string) => Promise>() + .mockImplementationOnce(() => { + throw new Error('clipboard unavailable') + }) + .mockResolvedValue(undefined) + const { handler } = setup({ writeClipboardText }) + + handler(`c;${b64('first')}`) + await Promise.resolve() + handler(`c;${b64('second')}`) + await Promise.resolve() + + expect(writeClipboardText).toHaveBeenNthCalledWith(2, 'second') + }) + + it('drops a replayed write and stays silent about it', async () => { + const { handler, writeClipboardText, showBlockedWriteToast } = setup({ replaying: true }) + expect(handler(`c;${b64('stale scrollback copy')}`)).toBe(true) + await Promise.resolve() + expect(writeClipboardText).not.toHaveBeenCalled() + expect(showBlockedWriteToast).not.toHaveBeenCalled() + }) + + it('toasts only for a real opt-out, never for unhydrated settings', async () => { + const optedOut = setup({ settingEnabled: false }) + optedOut.handler(`c;${b64('blocked')}`) + expect(optedOut.showBlockedWriteToast).toHaveBeenCalledTimes(1) + + const unhydrated = setup({ settingEnabled: null }) + unhydrated.handler(`c;${b64('blocked')}`) + await Promise.resolve() + expect(unhydrated.writeClipboardText).not.toHaveBeenCalled() + expect(unhydrated.showBlockedWriteToast).not.toHaveBeenCalled() + }) +}) + +describe('resolveOsc52ClipboardGate', () => { + it('allows a live write once the setting is on', () => { + expect(resolveOsc52ClipboardGate({ settingEnabled: true, replaying: false })).toEqual({ + allowClipboardWrite: true, + shouldSurfaceBlockedWrite: false + }) + }) + + it('drops replayed writes so restore cannot clobber the clipboard', () => { + // Why: reattach re-writes recorded PTY bytes through the same parser, so an old + // copy would silently overwrite what the user has copied since (#10588). + expect(resolveOsc52ClipboardGate({ settingEnabled: true, replaying: true })).toEqual({ + allowClipboardWrite: false, + shouldSurfaceBlockedWrite: false + }) + }) + + it('surfaces the blocked toast only for a real opt-out', () => { + expect(resolveOsc52ClipboardGate({ settingEnabled: false, replaying: false })).toEqual({ + allowClipboardWrite: false, + shouldSurfaceBlockedWrite: true + }) + }) + + it('stays quiet when a write races settings hydration', () => { + // Why: the toast latches once per renderer session; an unhydrated read looks + // blocked even though the default is on, so burning it there hides the real one. + for (const settingEnabled of [null, undefined]) { + expect(resolveOsc52ClipboardGate({ settingEnabled, replaying: false })).toEqual({ + allowClipboardWrite: false, + shouldSurfaceBlockedWrite: false + }) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts index bcde6890212..a2b074f2e84 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts @@ -1,23 +1,37 @@ // OSC 52 — "Manipulate Selection Data". xterm.js does not implement this -// handler itself; applications register it to let TUIs (tmux, neovim, fzf, -// ripgrep) copy to the host clipboard over SSH or through the PTY. +// handler itself; applications register it to let TUIs (Zellij, tmux, Neovim, +// fzf, Grok) copy to the host clipboard over SSH or through the PTY. // // Wire format (xterm.js strips the leading `\x1b]52;` and trailing BEL/ST // before handing us the payload string): // // Pc ; Pd // -// Pc is one or more selection-kind letters ("c"=clipboard, "p"=primary, -// "q"=secondary, "s"=select); Pd is base64-encoded UTF-8. If Pd is "?" the -// TUI is *querying* the clipboard — we deliberately ignore that case to -// avoid leaking clipboard contents to any process writing to the PTY. +// Pc is zero or more selection-kind letters ("c"=clipboard, "p"=primary, +// "q"=secondary, "s"=select). Every kind lands in the system clipboard and +// `selections` does not route the write — longstanding behavior this flip +// only makes reachable by default. A PRIMARY sink exists +// (writeSelectionClipboardText), so routing `p` there on Linux is an open +// question, deliberately left out of a change that is about the gate. +// Pd is base64-encoded UTF-8. If Pd is "?" the TUI is *querying* the +// clipboard — we deliberately ignore that case to avoid leaking clipboard +// contents to any process writing to the PTY. // -// Safety: OSC 52 is a classic data-exfil / overwrite vector — piping an -// attacker-controlled log into the terminal could silently replace the -// user's clipboard. Callers must gate on the user-opt-in setting -// `terminalAllowOsc52Clipboard` before invoking the handler. +// Safety: OSC 52 is a classic clipboard-overwrite vector — piping an +// attacker-controlled log into the terminal could silently replace the user's +// clipboard. Callers gate on `terminalAllowOsc52Clipboard` (default on; query +// stays blocked, so nothing is exfiltrated; payload size is capped). +// +// Accepted residual risk of default-on: the decoded text is written verbatim, +// newlines and all, so a hostile PTY can stage an execute-on-paste payload. +// We do not filter here — a multi-line copy out of a TUI is the feature — and +// the mitigation belongs at paste time, where bracketed paste keeps a pasted +// newline out of the shell's input (see terminal-bracketed-paste.ts). This +// matches kitty and Ghostty, which both allow OSC 52 writes unprompted by +// default while still gating reads. export type Osc52ParseResult = + /** `selections` is normalized: an empty Pc is reported as 'c'. */ | { kind: 'write'; selections: string; text: string } | { kind: 'query' } | { kind: 'invalid'; reason: string } @@ -28,7 +42,88 @@ export type Osc52ClipboardRequestOptions = { onBlockedWrite?: () => void } -const MAX_OSC52_BYTES = 128 * 1024 +const MAX_OSC52_BASE64_CHARS = 128 * 1024 + +/** Resolves whether an incoming OSC 52 write may touch the clipboard, and whether a + * refusal is worth telling the user about. */ +export function resolveOsc52ClipboardGate(input: { + /** Null/undefined until settings hydrate. */ + settingEnabled: boolean | null | undefined + /** True while recorded PTY bytes are being written back into this pane. */ + replaying: boolean +}): { allowClipboardWrite: boolean; shouldSurfaceBlockedWrite: boolean } { + // Why drop during replay: reattach and cold-restore re-write recorded PTY bytes through the same + // parser, so a stale `\e]52;c;…` would overwrite whatever the user has copied since. No fresh intent. + // + // Known over-suppression: the flag is read when xterm parses, not when the bytes were queued, and + // the replay path drains queued live bytes before engaging the guard (pty-connection.ts, "drain any + // queued background bytes BEFORE the replay paint"). A copy issued in the same tick as a reattach + // is therefore dropped silently. Fixing it means tagging chunks at queue time; a lost copy the user + // can repeat is the cheaper side of that trade. + const allowClipboardWrite = !input.replaying && input.settingEnabled === true + return { + allowClipboardWrite, + // Why not toast on replay or pre-hydration: the toast latches once per renderer session, and neither + // case is a real opt-out — unhydrated settings read as blocked even though the default is on. + shouldSurfaceBlockedWrite: + !allowClipboardWrite && + !input.replaying && + input.settingEnabled !== null && + input.settingEnabled !== undefined + } +} + +/** Composes the gate with the request handler into an xterm OSC handler. + * Extracted so the wiring is covered too, not just the gate in isolation. */ +export function createOsc52OscHandler(deps: { + getSettingEnabled: () => boolean | null | undefined + getReplaying: () => boolean + writeClipboardText: (text: string) => Promise + showBlockedWriteToast: () => void +}): (data: string) => boolean { + // Why coalesce: each sequence is only ~15 bytes, so one hostile chunk can fire a + // million parser callbacks — each a main-process clipboard write. Only the last of + // a microtask's worth is observable, so keep that and drop the rest. This bounds a + // flood to roughly one write per xterm parse yield, not to one write overall. + let pendingText: string | null = null + let flushScheduled = false + const writeCoalesced = (text: string): Promise => { + pendingText = text + if (!flushScheduled) { + flushScheduled = true + queueMicrotask(() => { + flushScheduled = false + const next = pendingText + pendingText = null + if (next !== null) { + // Why try/catch and not just .catch(): the write moved out of the guarded + // parser handler into a microtask, where a sync throw (or a preload that + // never installed writeClipboardText) would surface as an uncaught error. + try { + void deps.writeClipboardText(next)?.catch(() => { + /* ignore clipboard write failures */ + }) + } catch { + /* ignore clipboard write failures */ + } + } + }) + } + return Promise.resolve() + } + + return (data) => { + const gate = resolveOsc52ClipboardGate({ + settingEnabled: deps.getSettingEnabled(), + replaying: deps.getReplaying() + }) + return handleOsc52ClipboardRequest(data, { + allowClipboardWrite: gate.allowClipboardWrite, + writeClipboardText: writeCoalesced, + onBlockedWrite: gate.shouldSurfaceBlockedWrite ? deps.showBlockedWriteToast : undefined + }) + } +} export function handleOsc52ClipboardRequest( data: string, @@ -55,16 +150,12 @@ export function parseOsc52(data: string): Osc52ParseResult { if (semi === -1) { return { kind: 'invalid', reason: 'missing selection/data separator' } } - const selections = data.slice(0, semi) + // Why accept empty Pc: tmux copies via `\e]52;;` (window-copy.c passes an + // empty clip through the `Ms` capability). XTerm would read that as `s0`; we merge + // every kind into the clipboard regardless (see header). Zellij always sends `c`/`p`. + const selections = data.slice(0, semi) || 'c' const payload = data.slice(semi + 1) - // Why reject empty selections: the spec allows it (defaults to "s0"), but - // every TUI we care about emits at least one letter, and treating empty - // as "apply to clipboard" would let malformed payloads mutate the - // clipboard by accident. - if (selections.length === 0) { - return { kind: 'invalid', reason: 'empty selection list' } - } if (!/^[cpqs0-7]+$/.test(selections)) { return { kind: 'invalid', reason: 'unknown selection kind' } } @@ -76,7 +167,7 @@ export function parseOsc52(data: string): Osc52ParseResult { // Why guard size: xterm's own parser caps OSC payloads at ~10 MB; we cap // tighter because a legitimate clipboard write is rarely more than a // screenful and any multi-MB payload is almost certainly a bug or abuse. - if (payload.length > MAX_OSC52_BYTES) { + if (payload.length > MAX_OSC52_BASE64_CHARS) { return { kind: 'invalid', reason: 'payload exceeds size limit' } } @@ -84,6 +175,12 @@ export function parseOsc52(data: string): Osc52ParseResult { if (decoded === null) { return { kind: 'invalid', reason: 'payload is not valid base64' } } + // Why reject empty: this is XTerm's "clear the selection", which we decline to + // honor — with the gate default-on, any PTY could blank the clipboard for free. + // (A truncated sequence never lands here; xterm only calls us on parse success.) + if (decoded === '') { + return { kind: 'invalid', reason: 'empty payload' } + } return { kind: 'write', selections, text: decoded } } 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 4c71bf66a54..7650e870b09 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 @@ -67,7 +67,7 @@ import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' import { applyTerminalAppearance, installMode2031Handlers } from './terminal-appearance' import { pushMode2031SeedReply } from './terminal-mode-2031-replies' -import { handleOsc52ClipboardRequest } from './osc52-clipboard' +import { createOsc52OscHandler } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' import { guardParserHandler } from './terminal-parser-handler-guard' @@ -802,15 +802,17 @@ export function useTerminalPaneLifecycle({ }) mode2031DisposablesRef.current.set(pane.id, mode2031Disposables) - // OSC 52 — TUI-initiated clipboard writes (tmux/nvim/fzf/ssh). + // OSC 52 — TUI-initiated clipboard writes (Zellij/tmux/nvim/fzf/ssh). // Why: read settingsRef at fire time so mid-session gate toggles apply; return true in both paths so xterm doesn't fall through. const osc52Disposable = pane.terminal.parser.registerOscHandler( 52, - guardParserHandler('osc-52-clipboard', (data) => - handleOsc52ClipboardRequest(data, { - allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, - writeClipboardText: window.api.ui.writeClipboardText, - onBlockedWrite: showOsc52ClipboardBlockedToast + guardParserHandler( + 'osc-52-clipboard', + createOsc52OscHandler({ + getSettingEnabled: () => settingsRef.current?.terminalAllowOsc52Clipboard, + getReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), + writeClipboardText: (text) => window.api.ui.writeClipboardText(text), + showBlockedWriteToast: showOsc52ClipboardBlockedToast }) ) ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2b60a1d4e84..015e5f37fa5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2669,9 +2669,18 @@ "blocked": { "toast": { "97c98f1afe": "Open Setting", - "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, fzf, or Grok.", + "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, Zellij, tmux, Neovim, fzf, or Grok.", "89eaa3e80b": "Terminal clipboard write blocked" } + }, + "default": { + "on": { + "notice": { + "title": "TUI clipboard writes are now on by default", + "description": "Zellij, tmux, Neovim and other terminal programs can now copy to your clipboard. Turn it off in Terminal settings.", + "action": "Open Setting" + } + } } } }, @@ -7003,9 +7012,9 @@ "d23b43c5be": "Setup Script Location", "34a0dfa06e": "Where the repository setup script runs when a new workspace is created.", "21f8da2078": "Workspace Setup Script", - "6e6480a7df": "Let programs in the terminal (Grok, tmux, Neovim, fzf, SSH) copy to your system clipboard.", + "6e6480a7df": "Let programs in the terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) copy to your system clipboard.", "3338dcf8c1": "Allow TUI Clipboard Writes (OSC 52)", - "69c64a479c": "Let Grok, tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).", + "69c64a479c": "Let Zellij, tmux, Neovim, fzf, and Grok copy to the system clipboard over the PTY (including over SSH).", "4729c645fc": "Automatically copy terminal selections to the clipboard.", "902f5dee1f": "Copy on Select", "9129b7e805": "Hovering a terminal pane activates it without needing to click.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 73082fa62aa..643c084310f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "Abrir ajustes", - "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, tmux, Neovim, fzf o Grok.", + "7cf51f74fd": "Habilita las escrituras al portapapeles TUI en los ajustes de Terminal para copiar desde SSH, Zellij, tmux, Neovim, fzf o Grok.", "89eaa3e80b": "Escritura al portapapeles del terminal bloqueada" } + }, + "default": { + "on": { + "notice": { + "title": "La escritura del portapapeles desde TUI ahora está activada de forma predeterminada", + "description": "Zellij, tmux, Neovim y otros programas de terminal ya pueden copiar a tu portapapeles. Desactívalo en la configuración de Terminal.", + "action": "Abrir ajuste" + } + } } } }, @@ -6943,9 +6952,9 @@ "d23b43c5be": "Ubicación del script de configuración", "34a0dfa06e": "Dónde se ejecuta el script de configuración del repositorio cuando se crea un nuevo espacio de trabajo.", "21f8da2078": "Script de configuración del espacio de trabajo", - "6e6480a7df": "Permite que los programas del terminal (Grok, tmux, Neovim, fzf, SSH) copien al portapapeles del sistema.", + "6e6480a7df": "Permite que los programas del terminal (Zellij, tmux, Neovim, fzf, Grok, SSH) copien al portapapeles del sistema.", "3338dcf8c1": "Permitir escrituras en el portapapeles TUI (OSC 52)", - "69c64a479c": "Permite que Grok, tmux, Neovim y fzf copien al portapapeles del sistema a través de PTY (incluso por SSH).", + "69c64a479c": "Permite que Zellij, tmux, Neovim, fzf y Grok copien al portapapeles del sistema a través de PTY (incluso por SSH).", "4729c645fc": "Copia automáticamente las selecciones del terminal al portapapeles.", "902f5dee1f": "Copiar al seleccionar", "9129b7e805": "Al pasar el cursor sobre un panel de terminal, se activa sin necesidad de hacer clic.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 7834b7fc892..24b9d9a871b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "設定を開く", - "7cf51f74fd": "SSH、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", + "7cf51f74fd": "SSH、Zellij、tmux、Neovim、fzf、または Grok からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", "89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました" } + }, + "default": { + "on": { + "notice": { + "title": "TUI からのクリップボードへの書き込みがデフォルトで有効になりました", + "description": "Zellij、tmux、Neovim などのターミナルプログラムがクリップボードにコピーできるようになりました。Terminal 設定で無効にできます。", + "action": "設定を開く" + } + } } } }, @@ -6965,9 +6974,9 @@ "d23b43c5be": "セットアップスクリプトの場所", "34a0dfa06e": "新規ワークスペースの作成時に repos セットアップ スクリプトが実行される場所。", "21f8da2078": "ワークスペースセットアップスクリプト", - "6e6480a7df": "terminal 内のプログラム (Grok、tmux、Neovim、fzf、SSH) をシステムのクリップボードにコピーします。", + "6e6480a7df": "terminal 内のプログラム (Zellij、tmux、Neovim、fzf、Grok、SSH) をシステムのクリップボードにコピーします。", "3338dcf8c1": "TUI クリップボードへの書き込みを許可する (OSC 52)", - "69c64a479c": "Grok、tmux、Neovim、および fzf が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", + "69c64a479c": "Zellij、tmux、Neovim、fzf、および Grok が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", "4729c645fc": "terminal の選択内容をクリップボードに自動的にコピーします。", "902f5dee1f": "選択時にコピー", "9129b7e805": "terminal ペインにマウスを移動すると、クリックしなくても terminal ペインがアクティブになります。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 349a383ba68..d77d2f79d9f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "설정 열기", - "7cf51f74fd": "SSH, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", + "7cf51f74fd": "SSH, Zellij, tmux, Neovim, fzf 또는 Grok에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", "89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다." } + }, + "default": { + "on": { + "notice": { + "title": "TUI 클립보드 쓰기가 이제 기본으로 켜져 있습니다", + "description": "Zellij, tmux, Neovim 등 터미널 프로그램이 클립보드에 복사할 수 있습니다. Terminal 설정에서 끌 수 있습니다.", + "action": "설정 열기" + } + } } } }, @@ -6928,9 +6937,9 @@ "d23b43c5be": "설정 스크립트 위치", "34a0dfa06e": "새 워크스페이스를 만들 때 repos 설정 스크립트를 실행할 위치입니다.", "21f8da2078": "워크스페이스 설정 스크립트", - "6e6480a7df": "terminal의 프로그램(Grok, tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", + "6e6480a7df": "terminal의 프로그램(Zellij, tmux, Neovim, fzf, Grok, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", "3338dcf8c1": "TUI 클립보드 쓰기 허용(OSC 52)", - "69c64a479c": "Grok, tmux, Neovim 및 fzf가 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", + "69c64a479c": "Zellij, tmux, Neovim, fzf 및 Grok이 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", "4729c645fc": "terminal 선택 사항을 클립보드에 자동으로 복사합니다.", "902f5dee1f": "선택 시 복사", "9129b7e805": "terminal 패널에 마우스를 가리키면 클릭하지 않고도 활성화됩니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index c33b1dd2147..28e8c153460 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2646,9 +2646,18 @@ "blocked": { "toast": { "97c98f1afe": "打开设置", - "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim、fzf 或 Grok 进行复制。", + "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、Zellij、tmux、Neovim、fzf 或 Grok 进行复制。", "89eaa3e80b": "终端剪贴板写入被阻止" } + }, + "default": { + "on": { + "notice": { + "title": "TUI 剪贴板写入现已默认开启", + "description": "Zellij、tmux、Neovim 等终端程序现在可以复制到你的剪贴板。可在 Terminal 设置中关闭。", + "action": "打开设置" + } + } } } }, @@ -6928,9 +6937,9 @@ "d23b43c5be": "安装脚本位置", "34a0dfa06e": "创建新工作区时运行存储库设置脚本的位置。", "21f8da2078": "工作区设置脚本", - "6e6480a7df": "让终端中的程序(Grok、tmux、Neovim、fzf、SSH)复制到系统剪贴板。", + "6e6480a7df": "让终端中的程序(Zellij、tmux、Neovim、fzf、Grok、SSH)复制到系统剪贴板。", "3338dcf8c1": "允许 TUI 剪贴板写入 (OSC 52)", - "69c64a479c": "让 Grok、tmux、Neovim 和 fzf 通过 PTY(包括通过 SSH)复制到系统剪贴板。", + "69c64a479c": "让 Zellij、tmux、Neovim、fzf 和 Grok 通过 PTY(包括通过 SSH)复制到系统剪贴板。", "4729c645fc": "自动将终端选择复制到剪贴板。", "902f5dee1f": "选择时复制", "9129b7e805": "将鼠标悬停在终端窗格上即可将其激活,无需单击。", diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 66c9a1dbba3..4c0cfd08b29 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -3395,3 +3395,33 @@ describe('openDiffNotesSendMenuForActiveWorktree', () => { expect(store.getState().diffNotesSendMenuOpenRequest).toBeNull() }) }) + +describe('createUISlice clearOsc52ClipboardDefaultOnNotice', () => { + it('restores the armed notice from persisted UI', () => { + const store = createUIStore() + + expect(store.getState().osc52ClipboardDefaultOnNoticePending).toBe(false) + store + .getState() + .hydratePersistedUI(makePersistedUI({ osc52ClipboardDefaultOnNoticePending: true })) + + expect(store.getState().osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('stops the toast this session even when the persist fails', () => { + // Why local-first: the flag is the only thing keeping the toast off screen, and a + // rejected ui.set must not leave it re-firing on every render of this session. Losing + // the persist just re-arms the notice next launch, which is the safe direction. + const setUI = vi.fn(() => Promise.reject(new Error('runtime offline'))) + vi.stubGlobal('window', { api: { ui: { set: setUI } } }) + const store = createUIStore() + store + .getState() + .hydratePersistedUI(makePersistedUI({ osc52ClipboardDefaultOnNoticePending: true })) + + store.getState().clearOsc52ClipboardDefaultOnNotice() + + expect(store.getState().osc52ClipboardDefaultOnNoticePending).toBe(false) + expect(setUI).toHaveBeenCalledWith({ osc52ClipboardDefaultOnNoticePending: false }) + }) +}) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index f85e90e2c41..47def908ed7 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -966,6 +966,9 @@ export type UISlice = { setUpdateCardCollapsed: (collapsed: boolean) => void updateReassuranceSeen: boolean markUpdateReassuranceSeen: () => void + /** True on the launch where the OSC 52 default-on migration overrode a persisted `false`. */ + osc52ClipboardDefaultOnNoticePending: boolean + clearOsc52ClipboardDefaultOnNotice: () => void isFullScreen: boolean setIsFullScreen: (v: boolean) => void /** URL opened when a new browser tab is created. Null = blank tab (default). */ @@ -2464,6 +2467,7 @@ export const createUISlice: StateCreator = (set, get) })(), dismissedUpdateVersion: ui.dismissedUpdateVersion ?? null, updateReassuranceSeen: ui.updateReassuranceSeen ?? false, + osc52ClipboardDefaultOnNoticePending: ui.osc52ClipboardDefaultOnNoticePending === true, browserDefaultUrl: ui.browserDefaultUrl ?? null, browserDefaultSearchEngine: ui.browserDefaultSearchEngine ?? null, browserDefaultZoomLevel: normalizeBrowserPageZoomLevel(ui.browserDefaultZoomLevel), @@ -2578,6 +2582,13 @@ export const createUISlice: StateCreator = (set, get) void window.api.ui.set({ updateReassuranceSeen: true }).catch(console.error) set({ updateReassuranceSeen: true }) }, + osc52ClipboardDefaultOnNoticePending: false, + clearOsc52ClipboardDefaultOnNotice: () => { + // Why clear locally first: a failed persist must not re-toast this session. It will + // re-arm on the next launch, which is the safe direction for a one-shot notice. + set({ osc52ClipboardDefaultOnNoticePending: false }) + void window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }).catch(console.error) + }, isFullScreen: false, setIsFullScreen: (v) => set({ isFullScreen: v }), browserDefaultUrl: null, diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 60f2f636f97..e346618bfc6 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -506,6 +506,96 @@ describe('web settings preload API', () => { expect(settings.terminalCursorStyleDefaultedToBlock).toBe(true) }) + it('migrates OSC 52 clipboard writes on for stored web settings once', async () => { + // Why: the web store is a second, independent settings store — the constants-level + // default flip only reaches profiles that never persisted the old `false` (#10567). + const globals = installBrowserGlobals('Linux') + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ terminalAllowOsc52Clipboard: false }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + const stored = JSON.parse(globals.storage.getItem('orca.web.settings.v1') ?? '{}') as { + terminalAllowOsc52Clipboard?: boolean + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean + } + + expect(settings.terminalAllowOsc52Clipboard).toBe(true) + expect(settings.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + expect(stored.terminalAllowOsc52Clipboard).toBe(true) + expect(stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + + it('arms the OSC 52 notice in the web UI store when the flip overrides a persisted off', async () => { + const globals = installBrowserGlobals('Linux') + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ terminalAllowOsc52Clipboard: false }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.settings.get() + const storedUi = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as { + osc52ClipboardDefaultOnNoticePending?: boolean + } + + expect(storedUi.osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('does not arm the OSC 52 notice for a web profile with no persisted value', async () => { + const globals = installBrowserGlobals('Linux') + globals.storage.setItem('orca.web.settings.v1', JSON.stringify({ terminalFontSize: 15 })) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await globals.window.api.settings.get() + const storedUi = JSON.parse(globals.storage.getItem('orca.web.ui.v1') ?? '{}') as { + osc52ClipboardDefaultOnNoticePending?: boolean + } + + expect(storedUi.osc52ClipboardDefaultOnNoticePending).not.toBe(true) + }) + + it('arms the OSC 52 notice when ui.get is the read that runs the migration', async () => { + // Why seed after install: readLocalWebUIState must run the settings migration before it + // snapshots the UI blob, and only a ui.get that is itself the first settings read can + // show that. Reading first returns a pre-arm state every caller then writes back — and + // the stamp means nothing can raise the arm again. Another tab populating localStorage + // after this one loaded is the shape that reaches it. + const globals = installBrowserGlobals('Linux') + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ terminalAllowOsc52Clipboard: false }) + ) + + const ui = await globals.window.api.ui.get() + + expect(ui.osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + + it('preserves OSC 52 clipboard web opt-outs after migration', async () => { + const globals = installBrowserGlobals('Linux') + globals.storage.setItem( + 'orca.web.settings.v1', + JSON.stringify({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const settings = await globals.window.api.settings.get() + expect(settings.terminalAllowOsc52Clipboard).toBe(false) + expect(settings.terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + it('preserves first-work branch auto-rename web opt-outs after migration', async () => { const globals = installBrowserGlobals('Linux') globals.storage.setItem( @@ -1850,6 +1940,70 @@ describe('web UI preload API', () => { expect(stored.contextualToursSeenIds).toEqual(['tasks', 'browser']) }) + it('keeps the local OSC 52 notice armed when ui.get returns an unmigrated host', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: true, + // Why false: the host store always projects this key, so a plain spread + // would overwrite the arm the web client's own settings migration raised. + result: { ui: { osc52ClipboardDefaultOnNoticePending: false } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem( + 'orca.web.ui.v1', + JSON.stringify({ osc52ClipboardDefaultOnNoticePending: true }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const ui = await globals.window.api.ui.get() + expect(ui.osc52ClipboardDefaultOnNoticePending).toBe(true) + + await globals.window.api.ui.set({ osc52ClipboardDefaultOnNoticePending: false }) + const cleared = await globals.window.api.ui.get() + expect(cleared.osc52ClipboardDefaultOnNoticePending).toBe(false) + }) + + it('keeps the local OSC 52 notice armed when recordFeatureInteraction returns an unmigrated host', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + return Promise.resolve({ + id: method, + ok: true, + result: { ui: { osc52ClipboardDefaultOnNoticePending: false } }, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + globals.storage.setItem( + 'orca.web.ui.v1', + JSON.stringify({ osc52ClipboardDefaultOnNoticePending: true }) + ) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + const ui = await globals.window.api.ui.recordFeatureInteraction('tasks') + expect(ui.osc52ClipboardDefaultOnNoticePending).toBe(true) + }) + it('does not keep a local shadow copy of main-owned feature telemetry markers', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a1ed5406ced..e36c0249da2 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -77,6 +77,10 @@ import { } from '../../../shared/tui-agent-launch-defaults' import { normalizeAutoRenameBranchFromWorkDefaultOn } from '../../../shared/auto-rename-branch-from-work-settings' import { normalizeTerminalCursorStyleDefault } from '../../../shared/terminal-cursor-style-settings' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from '../../../shared/osc52-clipboard-settings' import { normalizeTerminalCustomThemes } from '../../../shared/terminal-custom-themes' import { normalizeUiLanguage } from '../../../shared/ui-language' import { normalizeUsagePercentageDisplay } from '../../../shared/usage-percentage-display' @@ -2395,6 +2399,7 @@ function createWebUiApi(): NonNullable['ui']> { const local = readLocalWebUIState() const next = { ...mergeWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), featureInteractions: mergeFeatureInteractionState( local.featureInteractions, result.ui.featureInteractions @@ -2444,6 +2449,7 @@ function createWebUiApi(): NonNullable['ui']> { const local = readLocalWebUIState() const next = { ...mergeWebUIState(local, result.ui), + osc52ClipboardDefaultOnNoticePending: mergeOsc52ClipboardNoticePending(local, result.ui), featureInteractions: mergeFeatureInteractionState( local.featureInteractions, result.ui.featureInteractions @@ -3344,6 +3350,7 @@ function getStoredSettings(): GlobalSettings { ...stored, ...normalizeAutoRenameBranchFromWorkDefaultOn(stored), ...normalizeTerminalCursorStyleDefault(stored), + ...normalizeOsc52ClipboardDefaultOn(stored), terminalCustomThemes: normalizeTerminalCustomThemes(stored.terminalCustomThemes), uiLanguage: normalizeUiLanguage(stored.uiLanguage) } @@ -3355,6 +3362,11 @@ function getStoredSettings(): GlobalSettings { stored.terminalCursorStyle !== migratedStored.terminalCursorStyle || stored.terminalCursorStyleDefaultedToBlock !== migratedStored.terminalCursorStyleDefaultedToBlock || + // Kept even though the terminalCustomThemes reference compare below already forces + // this branch for every stored blob: no migration should rely on that accident. + stored.terminalAllowOsc52Clipboard !== migratedStored.terminalAllowOsc52Clipboard || + stored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== + migratedStored.terminalAllowOsc52ClipboardDefaultedOnForAllUsers || stored.terminalCustomThemes !== migratedStored.terminalCustomThemes || stored.uiLanguage !== migratedStored.uiLanguage) ) { @@ -3362,6 +3374,14 @@ function getStoredSettings(): GlobalSettings { const parsed = JSON.parse(rawStoredSettings) as unknown if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { writeJson(SETTINGS_STORAGE_KEY, migratedStored) + if (osc52ClipboardDefaultOnOverridesPersistedOff(stored)) { + // Why a raw merge, not readLocalWebUIState(): that path calls back into + // getStoredSettings(), and writing through it here would recurse. + writeJson(UI_STORAGE_KEY, { + ...readJson>(UI_STORAGE_KEY, {}), + osc52ClipboardDefaultOnNoticePending: true + }) + } } } catch { // Keep readJson's invalid-JSON fallback non-destructive. @@ -3572,8 +3592,11 @@ function closeWebOnboarding(base: OnboardingState): OnboardingState { function readLocalWebUIState(): PersistedUIState { const defaults = getDefaultUIState() - const stored = readJson>(UI_STORAGE_KEY, {}) + // Why settings first: getStoredSettings() runs the OSC 52 migration, which writes the + // notice arm into UI_STORAGE_KEY. Reading before it would snapshot a pre-arm state that + // every caller then writes back, erasing the arm the stamp can never raise again. const storedSettings = getStoredSettings() + const stored = readJson>(UI_STORAGE_KEY, {}) const base = { ...defaults, // Why: mirror the main-process missing-property seed from legacy card layout mode when runtime ui.get is unavailable. @@ -3657,6 +3680,19 @@ function mergeContextualTourSeenIds( return [...merged] } +/** Why OR rather than let the host win: the web client migrates its own localStorage + * settings, so an arm raised here has no counterpart on the host, and the plain spread + * would drop it — flipping the opt-out in silence, which is what the notice exists to stop. */ +function mergeOsc52ClipboardNoticePending( + current: PersistedUIState, + incoming: PersistedUIState +): boolean { + return ( + current.osc52ClipboardDefaultOnNoticePending === true || + incoming.osc52ClipboardDefaultOnNoticePending === true + ) +} + function mergeSettings( base: GlobalSettings, updates: Partial, diff --git a/src/shared/constants.test.ts b/src/shared/constants.test.ts index 13b8565027a..b45717f9acb 100644 --- a/src/shared/constants.test.ts +++ b/src/shared/constants.test.ts @@ -36,6 +36,11 @@ describe('getDefaultSettings', () => { expect(getDefaultSettings('/tmp').terminalCursorStyleDefaultedToBlock).toBe(true) }) + it('allows OSC 52 clipboard writes by default for new settings', () => { + expect(getDefaultSettings('/tmp').terminalAllowOsc52Clipboard).toBe(true) + expect(getDefaultSettings('/tmp').terminalAllowOsc52ClipboardDefaultedOnForAllUsers).toBe(true) + }) + it('enables separate light terminal theme by default', () => { expect(getDefaultSettings('/tmp').terminalUseSeparateLightTheme).toBe(true) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 13c94faa9c5..e19bbebf1af 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -247,8 +247,13 @@ export function getDefaultSettings(homedir: string): GlobalSettings { // Why: default-on everywhere so it round-trips across platforms; only darwin acts on it. showMenuBarIcon: true, terminalClipboardOnSelect: false, - // Why: OSC 52 is a clipboard data-exfiltration vector; default off (query stays disabled separately). - terminalAllowOsc52Clipboard: false, + // Why: default on so Zellij/tmux/nvim copy works out of the box. Query + // replies stay disabled and payload size is capped in the OSC 52 handler. + // This default only covers new profiles; existing ones persisted `false` + // and are flipped once by the stamp below (shared/osc52-clipboard-settings.ts, + // applied by both the Electron store and the web client's localStorage store). + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true, claudeAgentTeamsMode: 'off', setupScriptLaunchMode: 'new-tab', terminalScrollbackRows: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, @@ -484,6 +489,8 @@ export function getDefaultUIState(): PersistedUIState { setupGuideBrowserMilestoneLegacyComplete: false, browserImportHintHidden: false, trayMinimizeNoticeShown: false, + // Why: fresh profiles start on the new default, so nothing was overridden to report. + osc52ClipboardDefaultOnNoticePending: false, mobileEmulatorTabIntroDismissed: false, mobileEmulatorAgentSetupDismissed: false, // Why: only upgraded profiles saw the old ordering, so only they get the one-time notice. diff --git a/src/shared/osc52-clipboard-settings.test.ts b/src/shared/osc52-clipboard-settings.test.ts new file mode 100644 index 00000000000..e4a79df5a6b --- /dev/null +++ b/src/shared/osc52-clipboard-settings.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeOsc52ClipboardDefaultOn, + osc52ClipboardDefaultOnOverridesPersistedOff +} from './osc52-clipboard-settings' + +describe('normalizeOsc52ClipboardDefaultOn', () => { + it('flips an unstamped profile on, because its `false` came from the old default', () => { + // Why: every profile saved before #10567 persisted `false`, which the settings + // merge then wins with — indistinguishable on disk from a deliberate opt-out. + expect(normalizeOsc52ClipboardDefaultOn({ terminalAllowOsc52Clipboard: false })).toEqual({ + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + }) + + it('leaves a stamped opt-out off', () => { + expect( + normalizeOsc52ClipboardDefaultOn({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + ).toEqual({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + }) + + it('defaults a fresh or absent profile on', () => { + for (const settings of [ + undefined, + {}, + { terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true } + ]) { + expect(normalizeOsc52ClipboardDefaultOn(settings)).toEqual({ + terminalAllowOsc52Clipboard: true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + } + }) + + it('is idempotent, so a crash before the write-back cannot re-flip an opt-out', () => { + const once = normalizeOsc52ClipboardDefaultOn({ terminalAllowOsc52Clipboard: false }) + expect(normalizeOsc52ClipboardDefaultOn(once)).toEqual(once) + + const optedOut = normalizeOsc52ClipboardDefaultOn({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + expect(normalizeOsc52ClipboardDefaultOn(optedOut)).toEqual(optedOut) + }) +}) + +describe('osc52ClipboardDefaultOnOverridesPersistedOff', () => { + it('is true only when the migration overrides a persisted off', () => { + expect( + osc52ClipboardDefaultOnOverridesPersistedOff({ terminalAllowOsc52Clipboard: false }) + ).toBe(true) + }) + + it('is false for a fresh profile, which has no persisted value to override', () => { + // Why: a notice here would nag every new install about a setting they never touched. + expect(osc52ClipboardDefaultOnOverridesPersistedOff(undefined)).toBe(false) + expect(osc52ClipboardDefaultOnOverridesPersistedOff({})).toBe(false) + expect( + osc52ClipboardDefaultOnOverridesPersistedOff({ terminalAllowOsc52Clipboard: true }) + ).toBe(false) + }) + + it('is false once stamped, because a stamped value is honored rather than overridden', () => { + expect( + osc52ClipboardDefaultOnOverridesPersistedOff({ + terminalAllowOsc52Clipboard: false, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + }) + ).toBe(false) + }) +}) diff --git a/src/shared/osc52-clipboard-settings.ts b/src/shared/osc52-clipboard-settings.ts new file mode 100644 index 00000000000..2beb06dda16 --- /dev/null +++ b/src/shared/osc52-clipboard-settings.ts @@ -0,0 +1,37 @@ +import type { GlobalSettings } from './types' + +type Osc52ClipboardSettings = Pick< + GlobalSettings, + 'terminalAllowOsc52Clipboard' | 'terminalAllowOsc52ClipboardDefaultedOnForAllUsers' +> + +export function normalizeOsc52ClipboardDefaultOn( + settings: Partial | undefined +): Osc52ClipboardSettings { + const defaultedOn = settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers === true + + return { + // Why: profiles saved under the old off default persisted `false`, which is + // indistinguishable from a real opt-out; only stamped profiles can express one. + terminalAllowOsc52Clipboard: defaultedOn + ? (settings?.terminalAllowOsc52Clipboard ?? true) + : true, + terminalAllowOsc52ClipboardDefaultedOnForAllUsers: true + } +} + +/** True when the migration is about to turn OSC 52 on over a persisted `false`. + * Why surface it: flipping a security posture back on without telling anyone is worse + * than the copy bug. Scope is deliberately wide — both stores rewrite the whole settings + * object on every save, so every profile saved under the old off default holds `false`, + * opt-out or not. This is an upgrade notice, not an opt-out notice; the cohort that + * genuinely chose `false` is not distinguishable on disk. Fresh profiles have no + * persisted value and are correctly excluded. */ +export function osc52ClipboardDefaultOnOverridesPersistedOff( + settings: Partial | undefined +): boolean { + return ( + settings?.terminalAllowOsc52ClipboardDefaultedOnForAllUsers !== true && + settings?.terminalAllowOsc52Clipboard === false + ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 96612d90977..a76e2429904 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2727,8 +2727,10 @@ export type GlobalSettings = { terminalFocusFollowsMouse: boolean /** X11/gnome-terminal "copy on select": selecting text auto-copies to the clipboard; default off. */ terminalClipboardOnSelect: boolean - /** Enables OSC 52 clipboard writes for TUIs (SSH clipboard bridge); default off since OSC 52 is a clipboard-exfiltration vector. */ + /** Enables OSC 52 clipboard writes for TUIs (tmux/Zellij/nvim, incl. over SSH); default on. Clipboard *queries* stay blocked and payload size is capped, so this is write-only exposure. */ terminalAllowOsc52Clipboard: boolean + /** One-shot stamp: profiles saved under the old off default get flipped on once, after which an explicit opt-out sticks. */ + terminalAllowOsc52ClipboardDefaultedOnForAllUsers?: boolean /** Experimental Claude Agent Teams; native panes use a tmux-compatible shim so teammate output stays on the normal PTY path. */ claudeAgentTeamsMode?: ClaudeAgentTeamsMode /** Where the repo setup script runs on workspace create; defaults to a background "Setup" tab to keep the main terminal usable. */ @@ -3301,6 +3303,8 @@ export type PersistedUIState = { browserImportHintHidden?: boolean /** Why: Windows-only. Set once on first hide to tray so the "Orca is still running" notice shows only once. */ trayMinimizeNoticeShown?: boolean + /** Set by the OSC 52 default-on migration when it overrode a persisted `false`; the renderer shows one notice and clears it. */ + osc52ClipboardDefaultOnNoticePending?: boolean /** User dismissed the first-run Mobile Emulator intro; reversible only by re-enabling the feature in Settings. */ mobileEmulatorTabIntroDismissed?: boolean /** User deferred the in-pane Mobile Emulator CLI + skill setup guide. */ From bc2bdfc52ef1d66a2ba0008348b52fb8b19ca9d2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:49:29 -0700 Subject: [PATCH 054/150] test(gpu): pin the win32-only fallback invariant the macOS Graphite fix relies on (#10646) Co-authored-by: Orca --- src/main/startup/configure-process.ts | 2 ++ src/main/startup/gpu-fallback-marker.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 74eacdac187..292f6195258 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -264,6 +264,8 @@ export function enableMainProcessGpuFeatures(): void { if (process.platform === 'darwin') { // Why: Graphite can strand corrupt Metal tiles after idle; Ganesh preserves GPU compositing without the stale surface. + // Reached on every macOS launch only because GPU fallback skips this function and is win32-only; if fallback ever + // reaches macOS this must move out of this path or Macs silently lose the fix. app.commandLine.appendSwitch('disable-skia-graphite') } diff --git a/src/main/startup/gpu-fallback-marker.test.ts b/src/main/startup/gpu-fallback-marker.test.ts index c52fc3c1f11..6aeacf8ec3e 100644 --- a/src/main/startup/gpu-fallback-marker.test.ts +++ b/src/main/startup/gpu-fallback-marker.test.ts @@ -79,6 +79,21 @@ describe('gpu-fallback-marker', () => { expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(false) }) + // Why: enableMainProcessGpuFeatures() is skipped while GPU fallback is active, and that function + // carries the macOS disable-skia-graphite fix. A marker that survived on darwin would silently + // strip the fix from the Macs it targets, so pin the platform gate for darwin specifically. + it('clears an active marker on macOS so the Graphite fix is never skipped', () => { + writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment) + + expect( + readActiveGpuFallbackMarker(userDataPath, { + ...environment, + platform: 'darwin' + }) + ).toBeNull() + expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(false) + }) + it('clears a corrupt or wrong-version marker', () => { writeFileSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE), '{ not json') expect(readGpuFallbackMarker(userDataPath)).toBeNull() From ae614443b4a42143aa1e482c85ff94983bad5317 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:49:46 -0700 Subject: [PATCH 055/150] fix(crash-reporting): coalesce suppressed process-gone breadcrumbs so a recoverable-service crash loop can't erase the crash trail (#10648) Co-authored-by: Orca --- .../durable-crash-breadcrumb.ts | 69 +++++++-- .../process-gone-recorder.test.ts | 141 +++++++++++++++++- .../crash-reporting/process-gone-recorder.ts | 41 ++++- 3 files changed, 234 insertions(+), 17 deletions(-) diff --git a/src/main/crash-reporting/durable-crash-breadcrumb.ts b/src/main/crash-reporting/durable-crash-breadcrumb.ts index c7fc41bf645..e4271fb61d8 100644 --- a/src/main/crash-reporting/durable-crash-breadcrumb.ts +++ b/src/main/crash-reporting/durable-crash-breadcrumb.ts @@ -4,29 +4,28 @@ import { type CrashReportBreadcrumbData } from '../../shared/crash-reporting' import { flushActiveSink, startSpan } from '../observability/tracer' -import { recordCrashBreadcrumb } from './crash-breadcrumb-store' +import { recordCoalescedCrashBreadcrumb, recordCrashBreadcrumb } from './crash-breadcrumb-store' import { getMainProcessLifecycleIdentity } from './main-process-lifecycle-identity' -export function recordDurableCrashBreadcrumb( - name: string, - data?: CrashReportBreadcrumbData, - failureCause?: string -): void { - const sanitizedName = sanitizeCrashReportString(name) - const sanitizedData = data ? sanitizeCrashReportDetails(data) : {} +function buildLifecycleData(data?: CrashReportBreadcrumbData): CrashReportBreadcrumbData { // Why: durable events survive renderer replacement, so carrying the main // identity here distinguishes renderer recovery from a true app relaunch. - const lifecycleData = { - ...sanitizedData, + return { + ...(data ? sanitizeCrashReportDetails(data) : {}), ...getMainProcessLifecycleIdentity() } - recordCrashBreadcrumb(sanitizedName, lifecycleData) +} +function traceDurableBreadcrumb( + name: string, + data: CrashReportBreadcrumbData, + failureCause?: string +): void { const span = startSpan('crash.breadcrumb', { attributes: { kind: 'crash-breadcrumb', - 'breadcrumb.name': sanitizedName, - 'breadcrumb.data': lifecycleData + 'breadcrumb.name': name, + 'breadcrumb.data': data } }) if (failureCause) { @@ -38,3 +37,47 @@ export function recordDurableCrashBreadcrumb( // normal trace batching window would recreate the diagnostic blind spot. flushActiveSink() } + +export function recordDurableCrashBreadcrumb( + name: string, + data?: CrashReportBreadcrumbData, + failureCause?: string +): void { + const sanitizedName = sanitizeCrashReportString(name) + const lifecycleData = buildLifecycleData(data) + recordCrashBreadcrumb(sanitizedName, lifecycleData) + traceDurableBreadcrumb(sanitizedName, lifecycleData, failureCause) +} + +/** Durable counterpart to `recordCoalescedCrashBreadcrumb`: identical repeats + * inside `minIntervalMs` collapse into the next emitted breadcrumb's + * `suppressedSinceLast` instead of each costing a span plus a forced flush. */ +export function recordCoalescedDurableCrashBreadcrumb({ + name, + data, + coalesceKey, + minIntervalMs +}: { + name: string + data?: CrashReportBreadcrumbData + coalesceKey: string + minIntervalMs: number +}): void { + const sanitizedName = sanitizeCrashReportString(name) + const lifecycleData = buildLifecycleData(data) + const coalesced = recordCoalescedCrashBreadcrumb({ + name: sanitizedName, + data: lifecycleData, + coalesceKey, + minIntervalMs + }) + if (!coalesced) { + return + } + traceDurableBreadcrumb( + sanitizedName, + coalesced.suppressedSinceLast > 0 + ? { ...lifecycleData, suppressedSinceLast: coalesced.suppressedSinceLast } + : lifecycleData + ) +} diff --git a/src/main/crash-reporting/process-gone-recorder.test.ts b/src/main/crash-reporting/process-gone-recorder.test.ts index 0226186fc90..07513616046 100644 --- a/src/main/crash-reporting/process-gone-recorder.test.ts +++ b/src/main/crash-reporting/process-gone-recorder.test.ts @@ -7,7 +7,11 @@ vi.mock('electron', () => ({ } })) -import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' +import { + clearCrashBreadcrumbsForTest, + getCrashBreadcrumbSnapshot, + recordCrashBreadcrumb +} from './crash-breadcrumb-store' import { ProcessGoneDedupe } from './process-gone-dedupe' import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder' import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer' @@ -101,6 +105,141 @@ describe('recordProcessGoneCrash', () => { expect(sink.flushMock).toHaveBeenCalledOnce() }) + it('coalesces a recoverable-service crash loop instead of flushing every event', () => { + const record = vi.fn() + const dedupe = new ProcessGoneDedupe() + const networkServiceCrash = event({ + source: 'child', + processType: 'Utility', + reason: 'crashed', + expectedTeardown: 'none', + details: { serviceName: 'network.mojom.NetworkService', type: 'Utility' } + }) + + // Observed peak in a real diagnostic bundle: 1459 suppressed crashes in one minute. + for (let i = 0; i < 1_459; i++) { + recordProcessGoneCrash({ record } as never, networkServiceCrash, dedupe) + } + + expect(record).not.toHaveBeenCalled() + expect(sink.records).toHaveLength(1) + expect(sink.flushMock).toHaveBeenCalledOnce() + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ serviceName: 'network.mojom.NetworkService' }) + }) + ]) + }) + + it('keeps the pre-crash breadcrumb trail through a crash loop', () => { + const dedupe = new ProcessGoneDedupe() + recordCrashBreadcrumb('renderer_error', { message: 'boom' }) + + for (let i = 0; i < 1_459; i++) { + recordProcessGoneCrash( + { record: vi.fn() } as never, + event({ + source: 'child', + processType: 'Utility', + reason: 'crashed', + details: { serviceName: 'network.mojom.NetworkService' } + }), + dedupe + ) + } + + // Why: the ring holds 30 entries, so an uncoalesced loop evicts every real breadcrumb. + expect(getCrashBreadcrumbSnapshot().map((breadcrumb) => breadcrumb.name)).toEqual([ + 'renderer_error', + 'process_gone_suppressed' + ]) + }) + + it('reports how many repeats a coalesced suppression stands for', () => { + const dedupe = new ProcessGoneDedupe() + const utilityCrash = event({ + source: 'child', + processType: 'Utility', + reason: 'crashed', + details: { serviceName: 'network.mojom.NetworkService' } + }) + const nowSpy = vi.spyOn(Date, 'now') + + nowSpy.mockReturnValue(0) + for (let i = 0; i < 700; i++) { + recordProcessGoneCrash({ record: vi.fn() } as never, utilityCrash, dedupe) + } + nowSpy.mockReturnValue(30_000) + recordProcessGoneCrash({ record: vi.fn() } as never, utilityCrash, dedupe) + + expect(getCrashBreadcrumbSnapshot()).toEqual([ + expect.objectContaining({ name: 'process_gone_suppressed' }), + expect.objectContaining({ + name: 'process_gone_suppressed', + data: expect.objectContaining({ suppressedSinceLast: 699 }) + }) + ]) + // Why: the ring gets this count from the store itself, so only the span proves + // the exported telemetry carries it too. + expect(sink.records).toEqual([ + expect.objectContaining({ name: 'crash.breadcrumb' }), + expect.objectContaining({ + attributes: expect.objectContaining({ + 'breadcrumb.data': expect.objectContaining({ suppressedSinceLast: 699 }) + }) + }) + ]) + }) + + it('keeps suppressions with different exit codes separate', () => { + const dedupe = new ProcessGoneDedupe() + const utilityCrash = (exitCode: number) => + event({ + source: 'child', + processType: 'Utility', + reason: 'crashed', + exitCode, + details: { serviceName: 'network.mojom.NetworkService' } + }) + + recordProcessGoneCrash({ record: vi.fn() } as never, utilityCrash(11), dedupe) + recordProcessGoneCrash({ record: vi.fn() } as never, utilityCrash(139), dedupe) + + // Why: a clean shutdown code and a segfault are different failures; collapsing + // them would hide the second behind the first for a full window. + expect(getCrashBreadcrumbSnapshot().map((breadcrumb) => breadcrumb.data?.exitCode)).toEqual([ + 11, 139 + ]) + }) + + it('never lets one recoverable service suppress another service evidence', () => { + const dedupe = new ProcessGoneDedupe() + const utilityCrash = (serviceName: string) => + event({ + source: 'child', + processType: 'Utility', + reason: 'crashed', + details: { serviceName } + }) + + recordProcessGoneCrash( + { record: vi.fn() } as never, + utilityCrash('network.mojom.NetworkService'), + dedupe + ) + recordProcessGoneCrash( + { record: vi.fn() } as never, + utilityCrash('audio.mojom.AudioService'), + dedupe + ) + + expect(getCrashBreadcrumbSnapshot().map((breadcrumb) => breadcrumb.data?.serviceName)).toEqual([ + 'network.mojom.NetworkService', + 'audio.mojom.AudioService' + ]) + }) + it('persists a report and flushes the process-gone trace before recovery', async () => { const record = vi.fn().mockResolvedValue({ id: 'report-1' }) diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts index 9d6c0edb1a8..9725e7bdad9 100644 --- a/src/main/crash-reporting/process-gone-recorder.ts +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -1,9 +1,16 @@ import os from 'node:os' import { app } from 'electron' -import { isCrashReportReason, sanitizeCrashReportString } from '../../shared/crash-reporting' +import { + isCrashReportReason, + sanitizeCrashReportString, + type CrashReportBreadcrumbData +} from '../../shared/crash-reporting' import type { CrashReportStore } from './crash-report-store' import { getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store' -import { recordDurableCrashBreadcrumb } from './durable-crash-breadcrumb' +import { + recordCoalescedDurableCrashBreadcrumb, + recordDurableCrashBreadcrumb +} from './durable-crash-breadcrumb' import { shouldRecordProcessGoneCrash, type ExpectedTeardownScope, @@ -32,10 +39,29 @@ export type ProcessGoneCrashEvent = { type CrashReportRecorderStore = Pick +// Why: the coalesce map prunes every key against the calling window, so a shorter +// one here would weaken the other 30s coalescers. Stay uniform with them. +const SUPPRESSED_PROCESS_GONE_COALESCE_MS = 30_000 + function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) { return buildSuppressedProcessGoneBreadcrumbData(event) } +// Why: key off the emitted breadcrumb, not the crash-report dedupe key, so two +// different recoverable services can never suppress each other's evidence. +function suppressedProcessGoneCoalesceKey(data: CrashReportBreadcrumbData): string { + return JSON.stringify([ + data.source, + data.processType, + data.reason, + data.exitCode, + data.expectedTeardown, + data.serviceName ?? null, + data.name ?? null, + data.type ?? null + ]) +} + function persistFailureData(event: ProcessGoneCrashEvent, error: unknown) { const errorCode = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string' @@ -68,7 +94,16 @@ export function recordProcessGoneCrash( expectedTeardown: event.expectedTeardown }) ) { - recordDurableCrashBreadcrumb('process_gone_suppressed', processGoneBreadcrumbData(event)) + // Why: Chromium can crash-loop a recoverable child (network service seen at + // 1459/min) and each suppressed event costs a span plus a forced disk flush, + // which both floods the 30-entry ring and evicts the real pre-crash trail. + const suppressedData = processGoneBreadcrumbData(event) + recordCoalescedDurableCrashBreadcrumb({ + name: 'process_gone_suppressed', + data: suppressedData, + coalesceKey: suppressedProcessGoneCoalesceKey(suppressedData), + minIntervalMs: SUPPRESSED_PROCESS_GONE_COALESCE_MS + }) return } if (!store) { From c06bf64b4873a3040045aa0482be3ba85bf92559 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:06:19 -0700 Subject: [PATCH 056/150] test(e2e): make Codex typing-latency harness measure real echo latency (#10660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(e2e): make Codex typing-latency harness measure real echo latency The local Codex typing-latency spec produced meaningless numbers. Four defects, all fixed here: 1. False-positive readiness. `/Ask Codex|OpenAI/i` matched "OpenAI's command-line coding agent" on the *sign-in* screen, so the test went "ready" against a login prompt and measured typing into a non-composer. Now gated on the composer status bar (`/Context \d+% used/i`), which only the live composer draws. Banner text is unusable: the serialized buffer interleaves ANSI escapes through those glyphs. 2. Missing auth. The E2E profile runs an isolated HOME with a managed CODEX_HOME that has no auth.json, guaranteeing the sign-in screen. The launch now pins the real ~/.codex, and skips with a clear message when auth.json is absent instead of silently measuring a login screen. 3. Measurement overhead swamped the signal. Per-key latency was measured by polling getTerminalContent() every 5ms, so each sample was real echo latency + full buffer serialize + CDP round-trip + poll granularity. Measurement now happens entirely in-renderer: an in-page hook stamps performance.now() on keydown (window capture phase, before xterm forwards to the PTY) and again in xterm's onWriteParsed once the glyph is in the viewport, with onRender giving a separate time-to-paint. Samples are drained in one page.evaluate after typing ends — zero CDP round-trips inside the measured window. 4. Thresholds were meaningless (median<150ms / worst<500ms). Replaced with p50<35 / p95<60 / max<120, based on 10 local runs. Also: 60 keystrokes instead of 24 with the first 10 discarded as warmup, p50/p95/max instead of a lone median, lowercase-only input so the slash and file-mention popups can't perturb later keys, an assertion that no keystroke went unechoed, and a terminal dump on readiness failure. Measured (10 local runs, headless, real Codex 0.145.0): echo (key->parse) p50 21.6-22.6ms, p95 23.2-41.5ms, max 23.4-58.7ms paint (key->render) p50 25.5-32.9ms, p95 34.3-49.7ms A plain-shell control on the same probe reads p50 2.0ms / p95 3.0ms, confirming the ~22ms is Codex composer redraw cost rather than a harness floor — the old harness reported ~29-30ms for everything. Co-authored-by: Orca * test(e2e): widen Codex latency tail budgets and assert terminal focus Follow-up calibration over ~20 local runs: the per-key distribution is unimodal at p50 21.3-22.7ms with rare isolated spikes to ~90-125ms that are not a steady-state shift. Tail budgets move to p95<80 / max<150 so only a sustained regression fails; p50<35 still gates the steady state. Also assert the xterm helper textarea actually took focus. One run typed all 60 keys with only 5 parse events because focus was lost, which previously surfaced as an opaque sample-count mismatch. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../e2e/codex-composer-echo-latency-probe.ts | 203 ++++++++++++++++++ ...erminal-codex-local-typing-latency.spec.ts | 156 +++++++++----- 2 files changed, 309 insertions(+), 50 deletions(-) create mode 100644 tests/e2e/codex-composer-echo-latency-probe.ts diff --git a/tests/e2e/codex-composer-echo-latency-probe.ts b/tests/e2e/codex-composer-echo-latency-probe.ts new file mode 100644 index 00000000000..9fc9cabf2c7 --- /dev/null +++ b/tests/e2e/codex-composer-echo-latency-probe.ts @@ -0,0 +1,203 @@ +import type { Page } from '@stablyai/playwright-test' + +export type CodexEchoLatencySample = { + index: number + char: string + /** keydown -> xterm finished parsing the echoed glyph (real echo latency). */ + keyToParseMs: number + /** keydown -> xterm renderer painted the row carrying that glyph. */ + keyToRenderMs: number | null +} + +export type CodexEchoProbeReport = { + samples: CodexEchoLatencySample[] + keysObserved: number + parseEvents: number + renderEvents: number + cols: number + rows: number +} + +declare global { + // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface + interface Window { + __codexEchoProbe?: { + report(): CodexEchoProbeReport + dispose(): void + } + } +} + +/** + * Installs an in-renderer echo-latency recorder on the active terminal pane. + * + * Why in-page: polling a serialized buffer over CDP adds serialize + IPC + + * poll-granularity cost to every sample, which swamped the signal it measured. + * Timestamps here are taken inside the renderer with performance.now(), so the + * measured window contains no cross-process work at all. + */ +export async function installCodexEchoLatencyProbe(page: Page, target: string): Promise { + await page.evaluate((target) => { + type PendingSample = { + index: number + char: string + expected: string + startedAt: number + parsedAt: number | null + } + + 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('Codex echo probe: no active terminal pane') + } + const terminal = pane.terminal + if (typeof terminal.onWriteParsed !== 'function') { + throw new Error('Codex echo probe: xterm build has no onWriteParsed') + } + + const samples: CodexEchoLatencySample[] = [] + const awaitingRender: { sample: CodexEchoLatencySample; startedAt: number }[] = [] + // Why a queue, not one slot: a slow echo can still be outstanding when the + // next key is pressed, and a single slot silently discards that sample. + const pending: PendingSample[] = [] + let keysObserved = 0 + let parseEvents = 0 + let renderEvents = 0 + + // Why concatenated without a separator: a composer line that wraps splits the + // token across rows, and trailing-trimmed rows rejoin exactly at the break. + const viewportText = (): string => { + const buffer = terminal.buffer.active + let text = '' + for (let row = 0; row < terminal.rows; row += 1) { + text += buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '' + } + return text + } + + const observeParse = (): void => { + parseEvents += 1 + if (pending.length === 0) { + return + } + const text = viewportText() + // Why drain in order: one parse can land several queued keystrokes at + // once, and each still gets credited against its own keydown timestamp. + while (pending.length > 0 && text.includes(pending[0].expected)) { + const entry = pending.shift() + if (!entry) { + break + } + entry.parsedAt = performance.now() + const sample: CodexEchoLatencySample = { + index: entry.index, + char: entry.char, + keyToParseMs: entry.parsedAt - entry.startedAt, + keyToRenderMs: null + } + samples.push(sample) + awaitingRender.push({ sample, startedAt: entry.startedAt }) + } + } + + const observeRender = (): void => { + renderEvents += 1 + const paintedAt = performance.now() + for (const entry of awaitingRender.splice(0, awaitingRender.length)) { + entry.sample.keyToRenderMs = paintedAt - entry.startedAt + } + } + + // Why window capture: a listener on an ancestor in the capture phase is + // guaranteed to run before xterm's own keydown handler forwards to the PTY, + // so t0 is stamped before any of the work being measured starts. + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key.length !== 1 || keysObserved >= target.length) { + return + } + const index = keysObserved + keysObserved += 1 + pending.push({ + index, + char: target[index], + expected: target.slice(0, index + 1), + startedAt: performance.now(), + parsedAt: null + }) + } + + window.addEventListener('keydown', onKeyDown, { capture: true }) + const parsedDisposable = terminal.onWriteParsed(observeParse) + const renderDisposable = terminal.onRender(observeRender) + + window.__codexEchoProbe = { + report: () => ({ + samples: [...samples], + keysObserved, + parseEvents, + renderEvents, + cols: terminal.cols, + rows: terminal.rows + }), + dispose: () => { + window.removeEventListener('keydown', onKeyDown, { capture: true }) + parsedDisposable.dispose() + renderDisposable.dispose() + } + } + }, target) +} + +/** Drains every recorded sample in a single round-trip once typing has finished. */ +export async function collectCodexEchoLatencyReport(page: Page): Promise { + return page.evaluate(() => { + const probe = window.__codexEchoProbe + if (!probe) { + throw new Error('Codex echo probe was never installed') + } + const report = probe.report() + probe.dispose() + return report + }) +} + +export type LatencyDistribution = { + count: number + p50: number + p95: number + max: number +} + +function percentile(sorted: number[], quantile: number): number { + if (sorted.length === 0) { + return 0 + } + const rank = Math.min(sorted.length - 1, Math.ceil(quantile * sorted.length) - 1) + return sorted[Math.max(0, rank)] +} + +export function summarizeLatencies(values: number[]): LatencyDistribution { + const sorted = [...values].sort((a, b) => a - b) + return { + count: sorted.length, + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + max: sorted.at(-1) ?? 0 + } +} + +export function formatDistribution(label: string, distribution: LatencyDistribution): string { + return ( + `${label} n=${distribution.count} p50=${distribution.p50.toFixed(1)}ms ` + + `p95=${distribution.p95.toFixed(1)}ms max=${distribution.max.toFixed(1)}ms` + ) +} diff --git a/tests/e2e/terminal-codex-local-typing-latency.spec.ts b/tests/e2e/terminal-codex-local-typing-latency.spec.ts index e0bb08eb32a..e738e60700a 100644 --- a/tests/e2e/terminal-codex-local-typing-latency.spec.ts +++ b/tests/e2e/terminal-codex-local-typing-latency.spec.ts @@ -1,5 +1,5 @@ import type { Page } from '@stablyai/playwright-test' -import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' @@ -13,18 +13,45 @@ import { analyzeRasterCursorCells, type TerminalRasterProbeTarget } from './terminal-cursor-raster-probe' +import { + collectCodexEchoLatencyReport, + formatDistribution, + installCodexEchoLatencyProbe, + summarizeLatencies +} from './codex-composer-echo-latency-probe' -const CODEX_READY_RE = /Ask Codex|OpenAI/i +// Why: only the live composer draws this status bar. Banner text like "OpenAI's +// command-line coding agent" also renders on the sign-in screen, and the +// serialized buffer interleaves ANSI codes through the banner glyphs. +const CODEX_COMPOSER_READY_RE = /Context \d+% used/i +const CODEX_SIGN_IN_RE = /Sign in with ChatGPT|Sign in to|press Enter to log in/i const CODEX_TRUST_PROMPT_RE = /Do you trust|trust this folder|Trust this/i const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now/i -const MAX_MEDIAN_KEY_LATENCY_MS = 150 -const MAX_WORST_KEY_LATENCY_MS = 500 +// Why lowercase ASCII only: digits/punctuation trigger the composer's slash and +// file-mention popups, which redraw the whole pane and skew later keystrokes. +const TYPING_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' +const TOTAL_KEYSTROKES = 60 +// Why: the first keystrokes pay one-time costs (composer first-paint, WebGL +// atlas fill), so they measure startup rather than steady-state typing. +const WARMUP_KEYSTROKES = 10 +const KEYSTROKE_INTERVAL_MS = 60 +const TERMINAL_DUMP_CHARS = 4_000 +// Why these budgets: ~20 local runs put p50 in a tight 21.5-22.6ms band with a +// unimodal per-key distribution and rare isolated spikes to ~90ms. p50 gates the +// steady state at ~1.6x observed; the tail budgets absorb those spikes so only a +// sustained shift fails. A plain-shell control on this same probe reads p50 2ms, +// so the ~22ms is Codex composer redraw cost, not harness overhead. +const MAX_P50_ECHO_LATENCY_MS = 35 +const MAX_P95_ECHO_LATENCY_MS = 80 +const MAX_WORST_ECHO_LATENCY_MS = 150 type CodexCursorBlinkSample = { elapsedMs: number paintedCursorCellCount: number } +// Why the focus assert: a run that types into an unfocused pane records zero +// echoes and would otherwise fail as an opaque "sample count" mismatch. async function focusActiveTerminalInput(page: Page): Promise { await page.evaluate(() => { const state = window.__store?.getState() @@ -43,6 +70,11 @@ async function focusActiveTerminalInput(page: Page): Promise { } pane.terminal.focus() textarea.focus() + if (document.activeElement !== textarea) { + throw new Error( + 'Terminal helper textarea did not take focus; keystrokes would not reach Codex' + ) + } }) } @@ -121,10 +153,10 @@ async function sampleCursorBlink(page: Page): Promise } async function dismissCodexPromptsIfPresent(page: Page): Promise { - const deadline = Date.now() + 15_000 + const deadline = Date.now() + 20_000 while (Date.now() < deadline) { - const content = await getTerminalContent(page, 12_000) - if (CODEX_READY_RE.test(content) && !CODEX_TRUST_PROMPT_RE.test(content)) { + const content = await getTerminalContent(page, TERMINAL_DUMP_CHARS) + if (CODEX_COMPOSER_READY_RE.test(content)) { return } if (CODEX_TRUST_PROMPT_RE.test(content)) { @@ -142,29 +174,23 @@ async function dismissCodexPromptsIfPresent(page: Page): Promise { } } -async function waitForCodexReady(page: Page): Promise { - await expect - .poll(async () => CODEX_READY_RE.test(await getTerminalContent(page, 12_000)), { - timeout: 45_000, - message: 'Codex TUI did not render' - }) - .toBe(true) -} - -async function waitForPromptText(page: Page, text: string): Promise { - const start = performance.now() - while (performance.now() - start < MAX_WORST_KEY_LATENCY_MS) { - if ((await getTerminalContent(page, 12_000)).includes(text)) { - return performance.now() - start +// Why the dump: a run that "went ready" on the sign-in screen produced garbage +// numbers silently before; failures must show what the pane actually rendered. +async function waitForCodexComposer(page: Page): Promise { + const deadline = Date.now() + 60_000 + let lastContent = '' + while (Date.now() < deadline) { + lastContent = await getTerminalContent(page, TERMINAL_DUMP_CHARS) + const readyMarker = CODEX_COMPOSER_READY_RE.exec(lastContent) + if (readyMarker) { + return readyMarker[0] } - await page.waitForTimeout(5) + await page.waitForTimeout(250) } - throw new Error(`Codex prompt did not show ${text}`) -} - -function median(values: number[]): number { - const sorted = [...values].sort((a, b) => a - b) - return sorted[Math.floor(sorted.length / 2)] ?? 0 + const reason = CODEX_SIGN_IN_RE.test(lastContent) + ? 'Codex stopped on the sign-in screen — CODEX_HOME auth was not visible to the TUI' + : 'Codex never reached the composer' + throw new Error(`${reason}\n--- terminal tail ---\n${lastContent.slice(-1_500)}\n--- end ---`) } test.describe('local Codex terminal typing latency', () => { @@ -175,45 +201,71 @@ test.describe('local Codex terminal typing latency', () => { ) test.skip(process.platform === 'win32', 'local Codex command is POSIX-shell oriented') + const homeDir = process.env.HOME ?? '' + const codexSource = path.join(homeDir, 'projects', 'codex') + // Why: the E2E profile runs an isolated HOME with a managed CODEX_HOME that + // has no auth.json, so an unpinned launch lands on the sign-in screen. + const realCodexHome = path.join(homeDir, '.codex') + test.skip( + !existsSync(path.join(realCodexHome, 'auth.json')), + 'Codex auth.json is missing; the TUI would render the sign-in screen instead of a composer' + ) + test.skip(!existsSync(codexSource), 'local Codex checkout is missing') + await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) await waitForActiveTerminalManager(orcaPage, 30_000) const ptyId = await waitForActivePanePtyId(orcaPage) - const codexSource = path.join(process.env.HOME ?? '', 'projects', 'codex') const launchCommand = - `cd ${JSON.stringify(codexSource)} && ` + + `cd ${JSON.stringify(codexSource)} && CODEX_HOME=${JSON.stringify(realCodexHome)} ` + 'codex --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust\r' try { await sendToTerminal(orcaPage, ptyId, launchCommand) await dismissCodexPromptsIfPresent(orcaPage) - await waitForCodexReady(orcaPage) + const composerMarker = await waitForCodexComposer(orcaPage) + testInfo.annotations.push({ + type: 'codex-composer-ready-marker', + description: composerMarker + }) await focusActiveTerminalInput(orcaPage) await forceCursorProbeTheme(orcaPage) const blinkSamples = await sampleCursorBlink(orcaPage) + await focusActiveTerminalInput(orcaPage) - const runId = randomUUID().replaceAll('-', '').slice(0, 8) - const prompt = `orca_codex_latency_${runId}` - const latencies: number[] = [] - let typed = '' - for (const char of prompt) { - typed += char - const start = performance.now() + const typed = Array.from( + { length: TOTAL_KEYSTROKES }, + (_value, index) => TYPING_ALPHABET[index % TYPING_ALPHABET.length] + ).join('') + await installCodexEchoLatencyProbe(orcaPage, typed) + for (const char of typed) { await orcaPage.keyboard.type(char) - await waitForPromptText(orcaPage, typed) - latencies.push(performance.now() - start) + // Why: spacing keys past one frame keeps each sample an isolated echo + // instead of measuring a burst the scheduler coalesced into one write. + await orcaPage.waitForTimeout(KEYSTROKE_INTERVAL_MS) } + // Why: the last keystroke's echo can still be in flight when typing ends. + await orcaPage.waitForTimeout(1_000) + const report = await collectCodexEchoLatencyReport(orcaPage) - const medianLatency = median(latencies) - const worstLatency = Math.max(...latencies) - testInfo.annotations.push({ - type: 'codex-local-typing-latency', - description: `median=${medianLatency.toFixed(1)}ms worst=${worstLatency.toFixed( - 1 - )}ms samples=${latencies.map((value) => value.toFixed(1)).join(',')}` - }) + const measured = report.samples.filter((sample) => sample.index >= WARMUP_KEYSTROKES) + const parseLatencies = measured.map((sample) => sample.keyToParseMs) + const renderLatencies = measured + .map((sample) => sample.keyToRenderMs) + .filter((value): value is number => value !== null) + const echo = summarizeLatencies(parseLatencies) + const painted = summarizeLatencies(renderLatencies) + + const summary = + `${formatDistribution('echo(key->parse)', echo)} | ` + + `${formatDistribution('paint(key->render)', painted)} | ` + + `keys=${report.keysObserved} parseEvents=${report.parseEvents}` + testInfo.annotations.push({ type: 'codex-local-typing-latency', description: summary }) + // Why stdout too: annotations are invisible in the default list reporter, + // and these numbers are the whole point of the run. + console.log(`[codex-typing-latency] ready="${composerMarker}" ${summary}`) testInfo.annotations.push({ type: 'codex-local-cursor-blink', description: blinkSamples @@ -223,8 +275,12 @@ test.describe('local Codex terminal typing latency', () => { expect(blinkSamples.some((sample) => sample.paintedCursorCellCount > 0)).toBe(true) expect(blinkSamples.some((sample) => sample.paintedCursorCellCount === 0)).toBe(true) - expect(medianLatency).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) - expect(worstLatency).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + // Why: a dropped keystroke means the composer stopped echoing, which the + // latency percentiles alone would silently hide. + expect(report.samples.length).toBe(TOTAL_KEYSTROKES) + expect(echo.p50).toBeLessThan(MAX_P50_ECHO_LATENCY_MS) + expect(echo.p95).toBeLessThan(MAX_P95_ECHO_LATENCY_MS) + expect(echo.max).toBeLessThan(MAX_WORST_ECHO_LATENCY_MS) } finally { await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) } From 438d387711106e44c519c5cfbebb589fd13721bc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:09:53 -0700 Subject: [PATCH 057/150] fix(projects): keep the local badge color when a remote host shares the project (#10676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local and remote repos that resolve to the same git identity collapse into a single Project, but badgeColor is per-host repo metadata. The host-refresh merge spread the fetched project over the previous one, so whichever host's rows landed last repainted the color — a remote Orca server sharing a project name would overwrite the color chosen locally. The sidebar reads the per-host repo row and stayed correct, while the create-worktree composer reads the merged project and went grey, which is how the mismatch surfaced. Anchor the merged color to the local host's repo row when the project has one, so the result no longer depends on host fetch order. Remote-only projects keep their own host color. --- .../repos-shared-project-badge-color.test.ts | 181 ++++++++++++++++++ src/renderer/src/store/slices/repos.ts | 22 ++- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts diff --git a/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts b/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts new file mode 100644 index 00000000000..77948ba5ed7 --- /dev/null +++ b/src/renderer/src/store/slices/repos-shared-project-badge-color.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +// Mirrors the real report: one project name ("orca") set up on the local Mac and on a +// remote Orca server, where only the local repo row carries the user's chosen color. +const SHARED_PROJECT_ID = 'github:stablyai/orca' +const LOCAL_GREEN = '#22c55e' +const REMOTE_NEUTRAL = '#737373' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local/orca', + displayName: 'orca', + badgeColor: LOCAL_GREEN, + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1 +} + +const remoteRepo: Repo = { + id: 'remote-repo', + path: '/srv/orca', + displayName: 'orca', + badgeColor: REMOTE_NEUTRAL, + upstream: { owner: 'stablyai', repo: 'orca' }, + addedAt: 1 +} + +const localProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: LOCAL_GREEN, + sourceRepoIds: ['local-repo'], + createdAt: 1, + updatedAt: 1 +} + +const remoteProject: Project = { + id: SHARED_PROJECT_ID, + displayName: 'orca', + badgeColor: REMOTE_NEUTRAL, + sourceRepoIds: ['remote-repo'], + createdAt: 2, + updatedAt: 2 +} + +function setup(projectId: string, repoId: string, path: string): ProjectHostSetup { + return { + id: `${repoId}-setup`, + projectId, + hostId: 'local', + repoId, + path, + displayName: 'orca', + setupState: 'ready', + setupMethod: 'imported-existing-folder', + createdAt: 1, + updatedAt: 1 + } +} + +const reposList = vi.fn() +const reposUpdate = vi.fn() +const projectsList = vi.fn() +const listHostSetups = vi.fn() +const runtimeEnvironmentsList = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function runtimeResult(method: string): unknown { + if (method === 'repo.list') { + return { repos: [remoteRepo] } + } + if (method === 'project.list') { + return { projects: [remoteProject] } + } + if (method === 'projectHostSetup.list') { + return { setups: [setup(SHARED_PROJECT_ID, 'remote-repo', '/srv/orca')] } + } + return {} +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + reposList.mockReset() + reposUpdate.mockReset() + projectsList.mockReset() + listHostSetups.mockReset() + runtimeEnvironmentsList.mockReset() + runtimeEnvironmentCall.mockReset() + runtimeEnvironmentTransportCall.mockReset() + + reposList.mockResolvedValue([localRepo]) + reposUpdate.mockImplementation(({ updates }: { updates: Partial }) => ({ + ...localRepo, + ...updates + })) + projectsList.mockResolvedValue([localProject]) + listHostSetups.mockResolvedValue([setup(SHARED_PROJECT_ID, 'local-repo', '/local/orca')]) + runtimeEnvironmentsList.mockResolvedValue([{ id: 'env-1', name: 'awin' }]) + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => ({ + id: `rpc-${args.method}`, + ok: true, + result: runtimeResult(args.method), + _meta: { runtimeId: 'runtime-remote' } + })) + runtimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + ) + + vi.stubGlobal('window', { + api: { + repos: { list: reposList, update: reposUpdate }, + projects: { list: projectsList, listHostSetups }, + projectGroups: { list: vi.fn().mockResolvedValue([]) }, + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { + call: runtimeEnvironmentTransportCall, + list: runtimeEnvironmentsList + } + }, + dispatchEvent: vi.fn() + }) +}) + +function badgeColor(store: ReturnType): string | undefined { + return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID)?.badgeColor +} + +describe('shared project badge color across hosts', () => { + it('keeps the local color when a remote host shares the project name', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(badgeColor(store)).toBe(LOCAL_GREEN) + }) + + it('keeps the local color when a runtime-only refresh lands after startup', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + await store.getState().fetchRuntimeEnvironmentRepos('env-1') + + expect(badgeColor(store)).toBe(LOCAL_GREEN) + }) + + it('reflects a recolor of the local repo even while the remote host stays connected', async () => { + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + const recolored = '#ec4899' + await store.getState().updateRepo('local-repo', { badgeColor: recolored }, { hostId: 'local' }) + + expect(store.getState().repos.find((repo) => repo.id === 'local-repo')?.badgeColor).toBe( + recolored + ) + expect(badgeColor(store)).toBe(recolored) + }) + + it('leaves a remote-only project showing its own host color', async () => { + reposList.mockResolvedValue([]) + projectsList.mockResolvedValue([]) + listHostSetups.mockResolvedValue([]) + const store = createTestStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) + + await store.getState().fetchReposForAllHosts() + + expect(badgeColor(store)).toBe(REMOTE_NEUTRAL) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index ce1f6c13f13..f2275a8b96e 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -638,6 +638,20 @@ function projectWithCurrentSourceRepoIds( : { ...project, sourceRepoIds } } +function getLocalHostRepoBadgeColor( + project: Project, + reposById: ReadonlyMap +): string | null { + for (const repoId of project.sourceRepoIds) { + for (const repo of reposById.get(repoId) ?? []) { + if (getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) { + return repo.badgeColor + } + } + } + return null +} + function mergePreviousProjectMetadata( previous: Project, current: Project, @@ -645,6 +659,12 @@ function mergePreviousProjectMetadata( hostId: string ): Project { const project = mergeProjectCompatibilityProject(previous, current) + const sourceRepoIds = getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) + const localBadgeColor = getLocalHostRepoBadgeColor({ ...project, sourceRepoIds }, reposById) + if (localBadgeColor !== null) { + // Why: badge color is per-host repo metadata; a remote host sharing the project must not repaint the color the user chose locally. + project.badgeColor = localBadgeColor + } if (hostId === LOCAL_EXECUTION_HOST_ID) { // Why: localWindowsRuntimePreference belongs to the local host; a local refresh that omits it is authoritative and clears stale renderer state. if ('localWindowsRuntimePreference' in current) { @@ -663,7 +683,7 @@ function mergePreviousProjectMetadata( return { ...project, // Why: fetched project metadata can lag repo.list; track ownership to the reconciled repos so removed-host repos don't linger. - sourceRepoIds: getMergedSourceRepoIdsForHostRefresh(previous, current, reposById, hostId) + sourceRepoIds } } From 8e785dd0bccddf118fab5e38b1b9b5a6fa5a13df Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:14:15 -0700 Subject: [PATCH 058/150] fix(daemon): preserve completion inspection for legacy protocols (#10679) --- .../daemon/daemon-protocol-version.test.ts | 2 + src/main/daemon/daemon-protocol-version.ts | 1 + src/main/daemon/daemon-pty-adapter.test.ts | 87 ++++++++++++------- src/main/daemon/daemon-pty-adapter.ts | 15 +++- src/main/daemon/daemon-pty-router.test.ts | 20 +++++ src/main/daemon/daemon-pty-router.ts | 5 +- .../degraded-daemon-pty-provider.test.ts | 24 +++++ src/main/daemon/types.ts | 1 + src/main/ipc/pty.test.ts | 18 ++++ .../providers/pty-process-inspection.test.ts | 12 +++ src/main/providers/pty-process-inspection.ts | 1 + src/main/providers/ssh-pty-provider.test.ts | 12 +++ src/main/providers/ssh-pty-provider.ts | 7 +- src/main/runtime/orca-runtime.test.ts | 20 ++++- src/main/runtime/orca-runtime.ts | 4 +- src/preload/api-types.ts | 8 +- src/preload/index.ts | 7 +- .../agent-completion-coordinator.test.ts | 74 ++++++++++++++++ .../runtime-terminal-inspection.test.ts | 35 ++++++++ 19 files changed, 305 insertions(+), 48 deletions(-) diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts index 1b7498e9962..025d7838e7b 100644 --- a/src/main/daemon/daemon-protocol-version.test.ts +++ b/src/main/daemon/daemon-protocol-version.test.ts @@ -3,6 +3,7 @@ import { AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './daemon-protocol-version' @@ -11,6 +12,7 @@ describe('daemon protocol version', () => { it('ships preflight-cache replacement after completion inspection', () => { expect(PROTOCOL_VERSION).toBe(28) expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27) + expect(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION).toBe(11) expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26) expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26) expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual( diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index c5b4f2c1b20..f2a890aac09 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -2,6 +2,7 @@ // v28 replaces v26/v27 daemons that can retain permanent macOS preflight rejections (#9756). export const PROTOCOL_VERSION = 28 export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27 +export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11 export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25 export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26 export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26 diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 2ad8ef28fce..980f29f1766 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -6,7 +6,11 @@ import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync import { DaemonClient } from './client' import { DaemonProtocolError } from './daemon-errors' import { DaemonPtyAdapter } from './daemon-pty-adapter' -import { COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION } from './daemon-protocol-version' +import { + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + PROTOCOL_VERSION +} from './daemon-protocol-version' import { DaemonServer } from './daemon-server' import { HeadlessEmulator } from './headless-emulator' import { getHistorySessionDirName } from './history-paths' @@ -929,31 +933,53 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) describe('inspectProcess on pre-inspection daemon protocols', () => { - // Why: daemons outlive an in-place app update, so a v26 daemon must still answer inspections - // client-side; throwing here leaves agent-completion detection permanently retrying. type ClientInternals = { client: { request: ReturnType; disconnect: ReturnType } } - function createLegacyAdapter(request: ReturnType): DaemonPtyAdapter { - const legacy = new DaemonPtyAdapter({ + function createInspectionAdapter( + protocolVersion: number, + request: ReturnType + ): DaemonPtyAdapter { + const inspectionAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, - protocolVersion: COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1 + protocolVersion }) - ;(legacy as unknown as ClientInternals).client = { request, disconnect: vi.fn() } - return legacy + ;(inspectionAdapter as unknown as ClientInternals).client = { + request, + disconnect: vi.fn() + } + return inspectionAdapter } - it('composes a real inspection from the foreground call the daemon does support', async () => { + it('reports protocol 10 inspection as unavailable without unsupported RPCs', async () => { + const request = vi.fn() + const legacy = createInspectionAdapter(GET_FOREGROUND_PROCESS_PROTOCOL_VERSION - 1, request) + + await expect(legacy.inspectProcess('sess-a')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + await expect(legacy.getForegroundProcess('sess-a')).resolves.toBeNull() + await expect(legacy.hasChildProcesses('sess-a')).resolves.toBe(true) + expect(request).not.toHaveBeenCalled() + + legacy.dispose() + }) + + it.each([ + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1 + ])('composes protocol %s inspection from getForegroundProcess', async (protocolVersion) => { const request = vi.fn(async () => ({ foregroundProcess: 'codex' })) - const legacy = createLegacyAdapter(request) + const legacy = createInspectionAdapter(protocolVersion, request) expect(await legacy.inspectProcess('sess-a')).toEqual({ foregroundProcess: 'codex', hasChildProcesses: true }) - // Why: the fallback must not depend on a capability the legacy daemon lacks. expect(request).toHaveBeenCalledWith('getForegroundProcess', { sessionId: 'sess-a' }) expect(request).not.toHaveBeenCalledWith('inspectProcess', expect.anything()) @@ -961,7 +987,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) it('reports an idle shell as having no child processes', async () => { - const legacy = createLegacyAdapter(vi.fn(async () => ({ foregroundProcess: 'bash' }))) + const legacy = createInspectionAdapter( + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1, + vi.fn(async () => ({ foregroundProcess: 'bash' })) + ) expect(await legacy.inspectProcess('sess-a')).toEqual({ foregroundProcess: 'bash', @@ -972,11 +1001,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) it('reports a null foreground as idle, matching what the legacy daemon can report', async () => { - // Why: pins the one response shape whose semantics differ from v27, where inspectProcess goes - // through getAliveSession() and throws for a vanished session while getForegroundProcess stays - // null-not-throw. Reading it as idle is deliberate — this is also the only shape that reaches a - // user-visible completion — so the divergence must not change silently. - const legacy = createLegacyAdapter(vi.fn(async () => ({ foregroundProcess: null }))) + const legacy = createInspectionAdapter( + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1, + vi.fn(async () => ({ foregroundProcess: null })) + ) expect(await legacy.inspectProcess('sess-a')).toEqual({ foregroundProcess: null, @@ -989,7 +1017,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { it('rejects rather than reading as idle when the daemon call fails', async () => { // Why: getForegroundProcess swallows errors into null; composing through it would turn a dead // socket into a false "agent exited" completion, the mirror of the bug this path fixes. - const legacy = createLegacyAdapter( + const legacy = createInspectionAdapter( + COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - 1, vi.fn(async () => { throw new Error('socket_closed') }) @@ -1000,21 +1029,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { legacy.dispose() }) - it('still delegates to the daemon once the protocol supports inspectProcess', async () => { - const request = vi.fn(async () => ({ foregroundProcess: 'codex', hasChildProcesses: true })) - const current = new DaemonPtyAdapter({ - socketPath, - tokenPath, - protocolVersion: COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION - }) - ;(current as unknown as ClientInternals).client = { request, disconnect: vi.fn() } + it.each([COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, PROTOCOL_VERSION])( + 'delegates protocol %s inspection to inspectProcess', + async (protocolVersion) => { + const request = vi.fn(async () => ({ foregroundProcess: 'codex', hasChildProcesses: true })) + const current = createInspectionAdapter(protocolVersion, request) - await current.inspectProcess('sess-a') + await current.inspectProcess('sess-a') - expect(request).toHaveBeenCalledWith('inspectProcess', { sessionId: 'sess-a' }) + expect(request).toHaveBeenCalledWith('inspectProcess', { sessionId: 'sess-a' }) - current.dispose() - }) + current.dispose() + } + ) }) describe('serialize / revive', () => { diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 8d0cdf796fa..e977ed6fbe6 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -11,6 +11,7 @@ import { CODEX_SHELL_READY_TIMEOUT_MS } from './session' import { CLEAN_DISCONNECT_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION, AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, @@ -38,6 +39,7 @@ import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' import { isShellProcess } from '../../shared/agent-detection' import { resolveWslSessionContext } from './wsl-session-context' import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd' @@ -824,12 +826,16 @@ export class DaemonPtyAdapter implements IPtyProvider { } async hasChildProcesses(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return true + } return this.hasChildProcessesFromForeground(await this.getForegroundProcess(id)) } - async inspectProcess( - id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + async inspectProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return { foregroundProcess: null, hasChildProcesses: true, unavailable: true } + } if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) { // Why: pre-v27 daemons survive an in-place app update; compose the inspection client-side from the // one call they do support instead of throwing, or completion detection stays dead until recreate. @@ -850,6 +856,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } async getForegroundProcess(id: string): Promise { + if (this.protocolVersion < GET_FOREGROUND_PROCESS_PROTOCOL_VERSION) { + return null + } try { const result = await this.client.request<{ foregroundProcess: string | null }>( 'getForegroundProcess', diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 0c66cf38d47..0861df788e0 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -150,6 +150,26 @@ it('rejects completion inspection when no daemon owns the session', async () => await expect(router.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone') }) +it('preserves unavailable inspection from the owning legacy daemon', async () => { + const legacy = createAdapter('legacy', ['legacy-session']) + vi.mocked(legacy.inspectProcess).mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + const router = new DaemonPtyRouter({ + current: createAdapter('current'), + legacy: [legacy] + }) + await router.discoverLegacySessions() + + await expect(router.inspectProcess('legacy-session')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) +}) + describe('DaemonPtyRouter', () => { it('reports separate conservative resume and fresh-create boundaries', () => { const current = createAdapter( diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index e30021a6f0f..8f2a8b4108c 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -8,6 +8,7 @@ import type { PtySpawnResult } from '../providers/types' import type { PtyIncarnationId } from '../../shared/pty-incarnation' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -189,9 +190,7 @@ export class DaemonPtyRouter implements IPtyProvider { return this.adapterFor(id).getForegroundProcess(id) } - async inspectProcess( - id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + async inspectProcess(id: string): Promise { return this.adapterForInspection(id).inspectProcess(id) } diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index a8211b3615c..cccd6e0cbab 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it, vi } from 'vitest' import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { PtyProcessInspection } from '../providers/pty-process-inspection' type ProviderMock = IPtyProvider & { + inspectProcess: (id: string) => Promise emitData: (id: string, data: string, sequenceChars?: number) => void emitReplay: (id: string, data: string) => void emitExit: (id: string, code: number) => void @@ -42,6 +44,7 @@ function createProvider( acknowledgeDataEvent: vi.fn(), hasChildProcesses: vi.fn(async () => false), getForegroundProcess: vi.fn(async () => null), + inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })), confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`), serialize: vi.fn(async () => '{}'), revive: vi.fn(async () => {}), @@ -123,6 +126,27 @@ it('rejects completion inspection instead of borrowing the fallback provider', a await expect(provider.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone') }) +it('preserves unavailable inspection from an owning daemon', async () => { + const daemon = createDaemonAdapter('daemon', ['daemon-session']) + vi.mocked(daemon.inspectProcess).mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + const provider = new DegradedDaemonPtyProvider({ + current: daemon, + legacy: [], + fallback: createProvider('fallback') + }) + await provider.discoverDaemonSessions() + + await expect(provider.inspectProcess('daemon-session')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) +}) + describe('DegradedDaemonPtyProvider', () => { it('only delegates owner-listing authority to the provider that owns the id', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 2ee1ebd9535..aaa65028c60 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -27,6 +27,7 @@ export { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, CLEAN_DISCONNECT_PROTOCOL_VERSION, COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION, + GET_FOREGROUND_PROCESS_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION, PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION, diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 534d48d1cf7..79f2cf76531 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -5905,6 +5905,24 @@ describe('registerPtyHandlers', () => { expect(provider.confirmForegroundProcess).not.toHaveBeenCalled() }) + it('preserves unavailable process inspection results from the provider', async () => { + const inspectProcess = vi.fn(async () => ({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + })) + registerPtyHandlers(mainWindow as never) + setLocalPtyProvider({ inspectProcess } as never) + + await expect( + handlers.get('pty:inspectProcess')!(null, { id: 'legacy-daemon-pty' }) + ).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + }) + // Why: daemon resize is fire-and-forget, so pty:getSize must report the APPLIED size, not the requested one (Claude-Code split-pane desync). describe('pty:getSize reports applied size, not requested size', () => { function setupProviderWithAppliedSize(args: { diff --git a/src/main/providers/pty-process-inspection.test.ts b/src/main/providers/pty-process-inspection.test.ts index 60ce53bc568..b18214b3352 100644 --- a/src/main/providers/pty-process-inspection.test.ts +++ b/src/main/providers/pty-process-inspection.test.ts @@ -25,6 +25,18 @@ describe('PTY provider process inspection', () => { expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') }) + it('preserves an unavailable inspection result', async () => { + const inspection = { + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + } + const inspectProcess = vi.fn().mockResolvedValue(inspection) + const provider = { inspectProcess } as unknown as IPtyProvider + + await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual(inspection) + }) + it('falls back to the existing provider process APIs', async () => { const getForegroundProcess = vi.fn().mockResolvedValue('codex') const hasChildProcesses = vi.fn().mockResolvedValue(true) diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts index 68e174ff886..760095c2e1f 100644 --- a/src/main/providers/pty-process-inspection.ts +++ b/src/main/providers/pty-process-inspection.ts @@ -3,6 +3,7 @@ import type { IPtyProvider } from './types' export type PtyProcessInspection = { foregroundProcess: string | null hasChildProcesses: boolean + unavailable?: true } type CompletionSensitivePtyProvider = IPtyProvider & { diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index f204369c9ef..99bb651ed99 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -835,6 +835,18 @@ describe('SshPtyProvider', () => { expect(mux.request).toHaveBeenCalledWith('pty.getForegroundProcess', { id: 'pty-1' }) }) + it('preserves unavailable process inspection', async () => { + const inspection = { + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + } + mux.request.mockResolvedValue(inspection) + + await expect(provider.inspectProcess(scopedPty1)).resolves.toEqual(inspection) + expect(mux.request).toHaveBeenCalledWith('pty.inspectProcess', { id: 'pty-1' }) + }) + it('serializes scoped app ids using raw relay ids', async () => { mux.request.mockResolvedValue('serialized') diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 6dc0cb39a1d..59f20bfdf0a 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -23,6 +23,7 @@ import { import { buildSshPtySpawnRequest } from './ssh-pty-spawn-request' import { SshPtySpawnExitRaceTracker } from './ssh-pty-spawn-exit-race' import { SshAgentSessionCapabilities } from './ssh-agent-session-capabilities' +import type { PtyProcessInspection } from './pty-process-inspection' // Why: sequential relay teardown calls share one absolute budget; convert to the mux-relative timeout only at dispatch. function relayTimeoutOptions(deadlineMs: number | undefined): { timeoutMs: number } | undefined { @@ -281,12 +282,10 @@ export class SshPtyProvider implements IPtyProvider { return result as string | null } - async inspectProcess( - id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + async inspectProcess(id: string): Promise { return (await this.mux.request('pty.inspectProcess', { id: this.toRelayPtyId(id) - })) as { foregroundProcess: string | null; hasChildProcesses: boolean } + })) as PtyProcessInspection } async serialize(ids: string[]): Promise { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 0029ed696bd..d16233a141b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1297,7 +1297,7 @@ async function createExplicitAgentStatusHarness(options: { getForegroundProcess: (ptyId: string) => Promise inspectProcess?: ( ptyId: string - ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> + ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?: (ptyId: string) => Promise title?: string }): Promise<{ @@ -10545,6 +10545,24 @@ describe('OrcaRuntimeService', () => { expect(getForegroundProcess).not.toHaveBeenCalled() }) + it('preserves provider unavailable results during process inspection', async () => { + const inspection = { + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + } + const inspectProcess = vi.fn(async () => inspection) + const getForegroundProcess = vi.fn(async () => null) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + inspectProcess + }) + + await expect(runtime.inspectTerminalProcess(handle)).resolves.toEqual(inspection) + expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + it('calls foreground confirmation with its controller receiver', async () => { const getForegroundProcess = vi.fn(async () => 'powershell.exe') const confirmForegroundProcess = vi.fn( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index d3a9cb25785..49be1d9c1f4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1464,7 +1464,7 @@ type RuntimePtyController = { getForegroundProcess(ptyId: string): Promise inspectProcess?( ptyId: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise @@ -15832,7 +15832,7 @@ export class OrcaRuntimeService { async inspectTerminalProcess( terminalSelector: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> { + ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean; unavailable?: true }> { const leaf = this.resolveLiveLeafForHandle(terminalSelector) if (!leaf?.ptyId || !this.ptyController) { throw new Error('terminal_gone') diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b72739e9854..dc3cbba64f9 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1374,9 +1374,11 @@ export type PreloadApi = { publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise - inspectProcess: ( - id: string - ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> + inspectProcess: (id: string) => Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> confirmForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> diff --git a/src/preload/index.ts b/src/preload/index.ts index 7de16fc46f0..8aa289018f7 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -984,8 +984,11 @@ const api = { ipcRenderer.invoke('pty:getForegroundProcess', { id }), inspectProcess: ( id: string - ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> => - ipcRenderer.invoke('pty:inspectProcess', { id }), + ): Promise<{ + foregroundProcess: string | null + hasChildProcesses: boolean + unavailable?: true + }> => ipcRenderer.invoke('pty:inspectProcess', { id }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts index 957e0a9bb1e..b2dfffb9277 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts @@ -284,6 +284,80 @@ describe('agent completion coordinator', () => { }) }) + it('keeps hook working evidence across unavailable inspections', async () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => ({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + })), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + coordinator.observeHookStatus({ state: 'working', agentType: 'codex', prompt: 'test' }) + await vi.advanceTimersByTimeAsync(2_000) + coordinator.observeHookStatus({ state: 'done', agentType: 'codex', prompt: 'test' }) + + expect(dispatchCompletion).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(HOOK_DONE_QUIET_MS) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + + it('keeps explicit-title evidence across unavailable inspections', async () => { + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => ({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true as const + })), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + coordinator.observeTitle('Codex working') + await vi.advanceTimersByTimeAsync(2_000) + coordinator.observeClassifiedTitleCompletion('done') + + expect(dispatchCompletion).toHaveBeenCalledExactlyOnceWith('done') + }) + + it('resets exit confirmation across an unavailable inspection', async () => { + let result: RuntimeTerminalProcessInspection = processResult('codex') + const dispatchCompletion = vi.fn() + const coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => result), + dispatchCompletion, + isLive: () => true + }) + + coordinator.startProcessTracking() + await vi.advanceTimersByTimeAsync(2_000) + result = processResult(null, false) + await vi.advanceTimersByTimeAsync(750) + result = { foregroundProcess: null, hasChildProcesses: true, unavailable: true } + await vi.advanceTimersByTimeAsync(750) + result = processResult(null, false) + await vi.advanceTimersByTimeAsync(1_500) + expect(dispatchCompletion).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(750) + expect(dispatchCompletion).toHaveBeenCalledTimes(1) + }) + it('does not mark an agent-to-agent process replacement as terminal idle', async () => { let foregroundProcess = 'codex' const dispatchCompletion = vi.fn() diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts index 8202f5aa9f7..4223f6d2b82 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts @@ -116,6 +116,27 @@ describe('runtime terminal owner routing', () => { expect(localHasChildren).not.toHaveBeenCalled() }) + it('preserves unavailable inspection from the PTY owning environment', async () => { + runtimeCall.mockResolvedValue({ + ok: true, + result: { + process: { foregroundProcess: null, hasChildProcesses: true, unavailable: true } + }, + _meta: { runtimeId: 'runtime-1' } + }) + + await expect( + inspectRuntimeTerminalProcess( + { activeRuntimeEnvironmentId: 'env-2' }, + 'remote:env-1@@terminal-1' + ) + ).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + }) + it('uses strict main-process inspection for a direct SSH PTY', async () => { localInspect.mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: true }) @@ -128,6 +149,20 @@ describe('runtime terminal owner routing', () => { expect(localHasChildren).not.toHaveBeenCalled() }) + it('preserves unavailable inspection for a direct SSH PTY', async () => { + localInspect.mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + + await expect(inspectRuntimeTerminalProcess(null, 'ssh:host@@pty-1')).resolves.toEqual({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + }) + it.each(['no_connected_pty', 'terminal_handle_stale', 'terminal_gone'])( 'reports %s remote process inspection as unavailable', async (code) => { From 8cb45318d76db4899e0790f255e56c2c1d37d7f1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:28:07 -0700 Subject: [PATCH 059/150] fix(windows): fail closed on unknown PTY identity (#10674) * fix(windows): fail closed on unknown PTY identity * Gate Windows tree-kill on PTY identity verification Only taskkill when the identity probe returns `own`; skip tree-kill for `unknown`/`foreign`/`absent` to avoid terminating unrelated processes. Clarifies the fail-closed behavior across agent and plain shell teardown paths, matching the POSIX descendant-snapshot discipline. --- src/main/daemon/terminal-session-teardown.ts | 7 ++++--- src/main/providers/local-pty-provider.ts | 9 +++++---- src/main/pty-descendant-termination.test.ts | 8 ++++---- src/main/pty-descendant-termination.ts | 10 +++++----- src/main/windows-pty-root-identity.test.ts | 14 +++++++++++--- src/main/windows-pty-root-identity.ts | 17 +++++++++-------- 6 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/main/daemon/terminal-session-teardown.ts b/src/main/daemon/terminal-session-teardown.ts index d9f9d996aa7..1ba58393721 100644 --- a/src/main/daemon/terminal-session-teardown.ts +++ b/src/main/daemon/terminal-session-teardown.ts @@ -49,9 +49,10 @@ export class TerminalSessionTeardown { * reap orphaned children (node-pty `useConptyDll` skips the console-process reap), so a * live `pnpm i`/`node` survives shell exit, keeps the ConPTY console non-empty, and holds * the worktree cwd — failing destructive worktree removal with "Failed to physically stop - * every PTY". taskkill /T /F the tree first so physical exit becomes verifiable. Mirrors - * the agent path (#10004/#10100). POSIX shells already reach their child pgroup on - * forceKill, so they stay on the plain force-kill path. + * every PTY". Tree-kill only when the OS identity probe returns `own`; `unknown`/`foreign`/ + * `absent` skip taskkill and rely on root close alone. Mirrors the agent path + * (#10004/#10100). POSIX shells already reach their child pgroup on forceKill, so they + * stay on the plain force-kill path. */ private async forceKillPlainShellSession(sessionId: string, session: Session): Promise { if (process.platform === 'win32') { diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 3b864408c46..e31110525fa 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -1123,16 +1123,17 @@ export class LocalPtyProvider implements IPtyProvider { this.requestTrackedPtyShutdown(id, proc, operation.immediate) } if (ptyAgentSessionIds.has(id)) { - // Why: POSIX needs a pre-kill descendant snapshot; Windows uses taskkill /T so - // agent/MCP orphans cannot hold the worktree cwd after shell stop (#10004). + // Why: POSIX needs a pre-kill descendant snapshot; Windows tree-kills only when the + // identity probe returns `own` so agent/MCP orphans cannot hold the worktree cwd + // (#10004). `unknown`/`foreign`/`absent` skip taskkill and rely on root close alone. await killWithDescendantSweep(proc.pid, signalRoot, { ownsRoot: () => ptyProcesses.get(id) === proc }) } else if (process.platform === 'win32' && operation.immediate) { // Why: a plain shell's ConPTY teardown doesn't reap orphaned children (useConptyDll // skips the console reap), so a live `pnpm i`/`node` keeps the ConPTY console alive and - // holds the worktree cwd, failing destructive removal. taskkill /T /F clears the tree so - // physical stop is verifiable. POSIX shells reach their child pgroup on forceKill (#10004). + // holds the worktree cwd. Tree kill runs only when the OS identity probe returns `own`; + // otherwise root close alone, and detached children may block physical stop (#10004). await killWithDescendantSweep(proc.pid, signalRoot, { ownsRoot: () => ptyProcesses.get(id) === proc }) diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index e17e186280f..c6941e1cb28 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -467,7 +467,7 @@ describe('killWithDescendantSweep', () => { expect(killRoot).toHaveBeenCalledOnce() }) - it('on Windows still taskkills when identity is unknown, keeping orphan cleanup', async () => { + it('on Windows skips taskkill when identity is unknown', async () => { const killWindowsTree = vi.fn(async () => {}) const killRoot = vi.fn() await killWithDescendantSweep(4242, killRoot, { @@ -475,11 +475,11 @@ describe('killWithDescendantSweep', () => { killWindowsTree, verifyTreeKillTarget: async () => 'unknown' }) - expect(killWindowsTree).toHaveBeenCalledWith(4242) + expect(killWindowsTree).not.toHaveBeenCalled() expect(killRoot).toHaveBeenCalledOnce() }) - it('on Windows still taskkills when the identity probe throws', async () => { + it('on Windows skips taskkill when the identity probe throws', async () => { const killWindowsTree = vi.fn(async () => {}) const killRoot = vi.fn() await killWithDescendantSweep(4242, killRoot, { @@ -489,7 +489,7 @@ describe('killWithDescendantSweep', () => { throw new Error('probe exploded') } }) - expect(killWindowsTree).toHaveBeenCalledWith(4242) + expect(killWindowsTree).not.toHaveBeenCalled() expect(killRoot).toHaveBeenCalledOnce() }) diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index db1f7638ae1..34ad425b86c 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -204,7 +204,7 @@ type SnapshotDeps = { * signalled: once the root dies, surviving descendants reparent to pid 1 and * can no longer be found by a ppid walk. Resolves null (never rejects) on * Windows, ps failure, or timeout — callers then degrade to shell-only kill - * on POSIX, or Windows `taskkill /T` via killWithDescendantSweep. + * on POSIX, or identity-gated Windows `taskkill /T` via killWithDescendantSweep. */ export async function captureDescendantSnapshot( rootPid: number, @@ -237,9 +237,9 @@ type KillSweepDeps = SnapshotDeps & /** * Standard agent-session kill sequencing. * - POSIX: snapshot the descendant tree, signal members, then killRoot. - * - Windows: taskkill /T /F walks the ConPTY tree (shell → agent → MCP) so - * worktree teardown is not blocked by orphans holding the cwd handle, but only - * after the OS confirms the root PID is still ours and not a recycled stranger. + * - Windows: taskkill /T /F walks the ConPTY tree only when the identity probe + * returns `own` (and ownsRoot still holds). `unknown`/`foreign`/`absent` skip + * tree kill; killRoot always runs. Detached children may survive probe failure. * Callers must not signal the root before this runs on POSIX — a dead root's * descendants reparent to pid 1 and become unfindable. Snapshot failure * degrades to killRoot alone on POSIX. @@ -260,7 +260,7 @@ export async function killWithDescendantSweep( const verify = deps.verifyTreeKillTarget ?? verifyWindowsTreeKillTarget const target = await verify(rootPid).catch((): WindowsTreeKillTarget => 'unknown') // Re-check ownership: the identity query awaits, so exit can land meanwhile. - if (target !== 'absent' && target !== 'foreign' && (deps.ownsRoot?.() ?? true)) { + if (target === 'own' && (deps.ownsRoot?.() ?? true)) { const killTree = deps.killWindowsTree ?? terminateWindowsProcessTree // Why: taskkill may race an already-exited tree; never block killRoot on that. await killTree(rootPid).catch(() => {}) diff --git a/src/main/windows-pty-root-identity.test.ts b/src/main/windows-pty-root-identity.test.ts index d6852b91d98..c4be64e27fd 100644 --- a/src/main/windows-pty-root-identity.test.ts +++ b/src/main/windows-pty-root-identity.test.ts @@ -36,6 +36,14 @@ describe('classifyWindowsTreeKillTarget', () => { expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('own') }) + it('documents that a recycled PID under another Orca pane still classifies as own', () => { + // Dead PTY root 4242 recycled as a tool under a different pane's agent tree. + // Ancestry still reaches us, so taskkill is allowed — wrong process, own tree. + // Closing this needs spawn-time CreationDate / Job Object (#10680). + const rows = [link(ORCA_PID, 900), link(7000, ORCA_PID), link(7100, 7000), link(4242, 7100)] + expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('own') + }) + it('rejects a recycled pid whose ancestry never reaches this process', () => { const rows = [...SYSTEM_CHAIN, link(ORCA_PID, 900), link(4242, 900)] expect(classifyWindowsTreeKillTarget(4242, rows, ORCA_PID)).toBe('foreign') @@ -100,21 +108,21 @@ describe('verifyWindowsTreeKillTarget', () => { ).resolves.toBe('foreign') }) - it('falls open to unknown when both Windows process probes are unavailable', async () => { + it('returns unknown when both Windows process probes are unavailable', async () => { const readRows = vi.fn().mockResolvedValue(null) await expect( verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' }) ).resolves.toBe('unknown') }) - it('falls open to unknown when the process query rejects', async () => { + it('returns unknown when the process query rejects', async () => { const readRows = vi.fn().mockRejectedValue(new Error('powershell missing')) await expect( verifyWindowsTreeKillTarget(4242, { readRows, ownerPid: ORCA_PID, platform: 'win32' }) ).resolves.toBe('unknown') }) - it('falls open to unknown when the query throws synchronously', async () => { + it('returns unknown when the query throws synchronously', async () => { const readRows = vi.fn(() => { throw new Error('spawn EPERM') }) diff --git a/src/main/windows-pty-root-identity.ts b/src/main/windows-pty-root-identity.ts index e8cc16c45a0..b2f6ba43e81 100644 --- a/src/main/windows-pty-root-identity.ts +++ b/src/main/windows-pty-root-identity.ts @@ -5,10 +5,10 @@ import { queryWindowsProcessRowsFresh } from './providers/windows-foreground-pro * subtree membership, not root identity: a recycled PID that lands on any other * Orca descendant also reads `own`. It bounds the blast radius of a bad * `taskkill /T /F` to our own tree; it does not prove we spawned this PTY. - * - `own`: ancestry reaches us, so the tree is ours to kill. + * - `own`: ancestry reaches us, so the tree is eligible for guarded teardown. * - `absent`: the PID is gone; `taskkill` would no-op anyway. * - `foreign`: the PID resolves to a process we did not start (PID recycle). - * - `unknown`: no usable evidence; callers keep their prior behavior. + * - `unknown`: no usable evidence; callers must not force-kill the tree. */ export type WindowsTreeKillTarget = 'own' | 'absent' | 'foreign' | 'unknown' @@ -28,11 +28,12 @@ export type WindowsProcessLinkReader = () => Promise Date: Sat, 25 Jul 2026 22:48:53 -0700 Subject: [PATCH 060/150] fix(github): align PR source and review head origin (#10677) * fix(github): align PR source and review head origin * fix(github): pin number-based work item open to the repo source preference Open-by-number and details still ran the upstream-first multi-candidate PR probe, so a fork and its upstream sharing a PR number opened different PRs than the list and start-point paths did once #10677 pinned those to origin. Thread repo.issueSourcePreference through dispatchWorkItem, getWorkItemDetails, getRepoWorkItem, and getRepoWorkItemDetails. getWorkItemByOwnerRepo is left alone: explicit owner/repo already pins identity. auto/upstream/undefined keep the multi-candidate probe. Co-authored-by: Orca * test(github): enforce origin preference in review head origin resolution The explicit origin preference must short-circuit before any identity probe, so no remote queries should occur. Add validation to reject unexpected remotes and tighten the test assertion to verify no remote get-url calls happen at all. * fix(github): enforce origin preference in issue open-by-number lookup listWorkItems and getWorkItem must share preference so origin/upstream toggles cannot disagree. Explicit origin preference now fail-closes when origin identity is unresolved (no bare-lookup fallback), matching the PR candidate resolution rule. --------- Co-authored-by: Orca --- .../client-issue-origin-preference.test.ts | 181 ++++++++++++++++++ src/main/github/client-issue-source.test.ts | 48 +++++ src/main/github/client.test.ts | 38 ++++ src/main/github/client.ts | 73 +++++-- src/main/github/pr-start-point.test.ts | 26 ++- src/main/github/pr-start-point.ts | 15 +- src/main/github/review-head-remote.test.ts | 29 +++ src/main/github/review-head-remote.ts | 15 +- .../work-item-details-enterprise-host.test.ts | 8 +- src/main/github/work-item-details.test.ts | 37 +++- src/main/github/work-item-details.ts | 7 +- src/main/ipc/github-work-item-args.test.ts | 35 +++- src/main/ipc/github-work-item-args.ts | 16 +- src/main/ipc/github.test.ts | 54 +++++- src/main/ipc/worktrees.test.ts | 29 ++- src/main/ipc/worktrees.ts | 6 +- src/main/runtime/orca-runtime.test.ts | 92 +++++++-- src/main/runtime/orca-runtime.ts | 14 +- 18 files changed, 654 insertions(+), 69 deletions(-) create mode 100644 src/main/github/client-issue-origin-preference.test.ts diff --git a/src/main/github/client-issue-origin-preference.test.ts b/src/main/github/client-issue-origin-preference.test.ts new file mode 100644 index 00000000000..951962a3c25 --- /dev/null +++ b/src/main/github/client-issue-origin-preference.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GithubApiRepositoryModule from './github-api-repository' +import type * as GhUtils from './gh-utils' + +const { + ghExecFileAsyncMock, + getOwnerRepoMock, + getIssueOwnerRepoMock, + getOwnerRepoForRemoteMock, + resolvePRRepositoryCandidatesMock, + resolveIssueSourceMock, + rateLimitGuardMock, + noteRateLimitSpendMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + getOwnerRepoMock: vi.fn(), + getIssueOwnerRepoMock: vi.fn(), + getOwnerRepoForRemoteMock: vi.fn(), + resolvePRRepositoryCandidatesMock: vi.fn(), + resolveIssueSourceMock: vi.fn(), + rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn(), + acquireMock: vi.fn(), + releaseMock: vi.fn() +})) + +vi.mock('./gh-utils', async () => { + const actual = await vi.importActual('./gh-utils') + return { + ...actual, + execFileAsync: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + getOwnerRepo: getOwnerRepoMock, + getIssueOwnerRepo: getIssueOwnerRepoMock, + getOwnerRepoForRemote: getOwnerRepoForRemoteMock, + resolveIssueSource: resolveIssueSourceMock, + acquire: acquireMock, + release: releaseMock, + _resetOwnerRepoCache: vi.fn() + } +}) + +vi.mock('./rate-limit', () => ({ + rateLimitGuard: rateLimitGuardMock, + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })), + repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })), + noteRepositoryRateLimitSpend: vi.fn(), + spendsSharedGitHubComQuota: () => true +})) + +vi.mock('./github-api-repository', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + resolveIssueGitHubApiRepositorySource: ( + repoPath: string, + preference: unknown, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions), + getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) => + getIssueOwnerRepoMock(repoPath, connectionId), + getOriginGitHubApiRepository: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => getOwnerRepoMock(repoPath, connectionId, localGitOptions), + getGitHubApiRepositoryForRemote: ( + repoPath: string, + remoteName: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => + remoteName === 'origin' + ? getOwnerRepoMock(repoPath, connectionId, localGitOptions) + : getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions), + resolveGitHubApiRepositoryCandidates: ( + repoPath: string, + connectionId?: string | null, + localGitOptions?: unknown + ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions) + } +}) + +import { getWorkItem, _resetOwnerRepoCache } from './client' + +describe('GitHub issue open-by-number origin preference', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + getOwnerRepoMock.mockReset() + getIssueOwnerRepoMock.mockReset() + getOwnerRepoForRemoteMock.mockReset() + resolvePRRepositoryCandidatesMock.mockReset() + resolveIssueSourceMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + getOwnerRepoForRemoteMock.mockImplementation( + async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => + remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null + ) + resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { + const origin = await getOwnerRepoMock(repoPath, connectionId) + const repository = origin ? { host: 'github.com', ...origin } : null + return { candidates: repository ? [repository] : [], headRepo: repository } + }) + _resetOwnerRepoCache() + }) + + it('pins typed issue metadata to explicit origin preference', async () => { + const source = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolveIssueSourceMock.mockResolvedValueOnce({ source, fellBack: false }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'Origin issue', + state: 'open', + labels: [], + url: 'https://github.com/fork/orca/issues/7', + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' } + }) + }) + + const item = await getWorkItem('/repo-root', 7, 'issue', null, {}, 'origin') + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/fork/orca/issues/7'], + expect.objectContaining({ cwd: '/repo-root', host: 'github.com' }) + ) + expect(item).toMatchObject({ number: 7, title: 'Origin issue', type: 'issue' }) + }) + + it('does not run a bare issue lookup when explicit origin identity is unresolved', async () => { + resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) + + await expect(getWorkItem('/repo-root', 7, 'issue', null, {}, 'origin')).resolves.toBeNull() + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('skips the issue probe on untyped open when origin identity is unresolved', async () => { + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + resolveIssueSourceMock.mockResolvedValueOnce({ source: null, fellBack: false }) + getOwnerRepoMock.mockResolvedValue(origin) + resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [origin], headRepo: origin }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'Origin PR', + state: 'open', + labels: [], + isDraft: false, + url: 'https://github.com/fork/orca/pull/7', + baseRefName: 'main', + headRefName: 'origin/fix', + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' } + }) + }) + + const item = await getWorkItem('/repo-root', 7, undefined, null, {}, 'origin') + + expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'origin', null, {}) + expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) + ) + expect(item).toMatchObject({ number: 7, type: 'pr' }) + }) +}) diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index aa16bed0c81..17820ddd0f1 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -513,6 +513,54 @@ describe('GitHub issue source split', () => { expect(item?.prRepo).toEqual(upstream) }) + it('pins typed PR metadata to explicit origin when upstream has the same number', async () => { + const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' } + const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } + getOwnerRepoMock.mockResolvedValue(origin) + mockUpstreamCandidate(upstream) + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [upstream, origin], + headRepo: origin + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 42, + title: 'Origin PR', + state: 'open', + url: 'https://github.com/fork/orca/pull/42', + labels: [], + updatedAt: '2026-04-02T00:00:00Z', + author: { login: 'octocat' }, + isDraft: false, + headRefName: 'origin/fix', + baseRefName: 'main' + }) + }) + + const item = await getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin') + + expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) + ) + expect( + ghExecFileAsyncMock.mock.calls.some((call) => + (call[0] as string[]).some((arg) => arg.includes('upstream/orca')) + ) + ).toBe(false) + expect(item?.prRepo).toEqual(origin) + }) + + it('does not run a bare PR lookup when explicit origin identity is unresolved', async () => { + getOwnerRepoMock.mockResolvedValue(null) + mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' }) + + await expect(getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull() + + expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('does not run a bare gh lookup for an SSH repo without candidates', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 70c4ea276b4..4b4ede36e45 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -3530,6 +3530,44 @@ describe('getPRForBranch', () => { }) }) + it('pins explicit origin push-target lookup when upstream has the same PR number', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'fork', repo: 'orca' }) + resolvePRRepositoryCandidatesMock.mockResolvedValue({ + candidates: [ + { owner: 'upstream', repo: 'orca' }, + { owner: 'fork', repo: 'orca' } + ], + headRepo: { owner: 'fork', repo: 'orca' } + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'contributor/fix', + repo: { + full_name: 'contributor/orca', + name: 'orca', + clone_url: 'https://github.com/contributor/orca.git', + ssh_url: 'git@github.com:contributor/orca.git', + owner: { login: 'contributor' } + } + } + }) + }) + getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:fork/orca.git') + + await getPullRequestPushTarget('/repo-root', 1738, null, {}, 'origin') + + expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/1738'], { + cwd: '/repo-root', + host: 'github.com' + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( + ['api', 'repos/upstream/orca/pulls/1738'], + expect.anything() + ) + }) + it('surfaces maintainer_can_modify=false alongside a fork PR push target', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3f7cdffffd6..f1df222b93d 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -76,7 +76,6 @@ import { shouldHideNonOpenReviewOnDefaultBranch } from '../source-control/repo-d import { readLocalGitConfigSignature } from './local-git-config-signature' import { getGitHubApiRepositoryForRemote, - getIssueGitHubApiRepository, getOriginGitHubApiRepository, githubHostExecOptions, githubRepositorySlugArg, @@ -283,16 +282,35 @@ export type PullRequestPushTarget = { maintainerCanModify?: boolean } +// Why: only an explicit `origin` preference is origin-only; `upstream`/`auto`/ +// undefined keep the multi-candidate probe ordered upstream-first, matching +// resolvePrWorkItemSource list semantics. +async function resolvePullRequestLookupCandidates( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'origin') { + const origin = await getOriginGitHubApiRepository(repoPath, connectionId, localGitOptions) + return origin ? [origin] : [] + } + return (await resolveGitHubApiRepositoryCandidates(repoPath, connectionId, localGitOptions)) + .candidates +} + export async function getPullRequestPushTarget( repoPath: string, prNumber: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) - const { candidates } = await resolveGitHubApiRepositoryCandidates( + const candidates = await resolvePullRequestLookupCandidates( repoPath, + preference, connectionId, localGitOptions ) @@ -954,14 +972,19 @@ async function fetchPullRequestWorkItemFromCandidates( repoPath: string, number: number, connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { - const { candidates } = await resolveGitHubApiRepositoryCandidates( + const candidates = await resolvePullRequestLookupCandidates( repoPath, + preference, connectionId, localGitOptions ) if (candidates.length === 0) { + if (preference === 'origin') { + return null + } return fetchPullRequestWorkItem(repoPath, null, number, connectionId, localGitOptions) } for (const candidate of candidates) { @@ -1953,38 +1976,55 @@ export async function getWorkItem( number: number, type?: 'issue' | 'pr', connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { await acquire() try { + // Why: listWorkItems uses resolveIssueGitHubApiRepositorySource; open-by-number + // must share that preference so origin/upstream toggles cannot disagree. if (type === 'issue') { - return await fetchIssueWorkItem( + const { source } = await resolveIssueGitHubApiRepositorySource( repoPath, - await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), - number, + preference, connectionId, localGitOptions ) + // Why: explicit origin with no origin identity must not bare-lookup ambient gh + // (same fail-closed rule as origin-pinned PR candidate resolution). + if (!source && preference === 'origin') { + return null + } + return await fetchIssueWorkItem(repoPath, source, number, connectionId, localGitOptions) } if (type === 'pr') { return await fetchPullRequestWorkItemFromCandidates( repoPath, number, connectionId, - localGitOptions + localGitOptions, + preference ) } try { - const issue = await fetchIssueWorkItem( + const { source } = await resolveIssueGitHubApiRepositorySource( repoPath, - await getIssueGitHubApiRepository(repoPath, connectionId, localGitOptions), - number, + preference, connectionId, localGitOptions ) - if (issue) { - return issue + if (source || preference !== 'origin') { + const issue = await fetchIssueWorkItem( + repoPath, + source, + number, + connectionId, + localGitOptions + ) + if (issue) { + return issue + } } } catch (err) { // Why: only fall through to PR #N on a genuine 404; re-throw transient errors so a flake can't surface an unrelated PR. @@ -1997,7 +2037,8 @@ export async function getWorkItem( repoPath, number, connectionId, - localGitOptions + localGitOptions, + preference ) } catch { return null diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index 57eaacb67ba..9669ef29a93 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -111,7 +111,13 @@ describe('resolveGitHubPrStartPoint', () => { resolveRemote: async () => 'origin' }) - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/repo-root', + 1849, + null, + {}, + undefined + ) expect(result).toEqual({ baseBranch: 'def456', headSha: 'def456', @@ -144,7 +150,13 @@ describe('resolveGitHubPrStartPoint', () => { resolveRemote: async () => 'origin' }) - expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/repo-root', + 1849, + null, + {}, + undefined + ) expect(fetchPullRequestHeadRefMock).toHaveBeenCalledWith('origin', 1849) expect(result).toEqual({ baseBranch: 'abc123', @@ -383,13 +395,21 @@ describe('resolveGitHubPrStartPoint', () => { const result = await resolveGitHubPrStartPoint({ repoPath: '/repo-root', prNumber: 1738, + issueSourcePreference: 'origin', gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef: fetchPullRequestHeadRefMock, resolveRemote: async () => 'origin' }) - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null) + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null, {}, 'origin') + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/repo-root', + 1738, + null, + {}, + 'origin' + ) expect(result).toEqual({ baseBranch: 'abc123', compareBaseRef: 'refs/remotes/origin/main', diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index 303bc1ed08f..05c012c1ab6 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -1,4 +1,4 @@ -import type { GitHubPrStartPoint, GitPushTarget } from '../../shared/types' +import type { GitHubPrStartPoint, GitPushTarget, IssueSourcePreference } from '../../shared/types' import { fetchCompareBaseRefWithLocalFallback } from '../git/compare-base-ref-fetch' import { isMissingRemoteRefGitError, @@ -18,6 +18,7 @@ type ResolveGitHubPrStartPointArgs = { headRefName?: string baseRefName?: string isCrossRepository?: boolean + issueSourcePreference?: IssueSourcePreference connectionId?: string | null localGitOptions?: { wslDistro?: string } gitExec: GitExec @@ -30,12 +31,6 @@ type ResolveGitHubPrStartPointArgs = { type ResolveGitHubPrStartPointResult = GitHubPrStartPoint | { error: string } -function localGitOptionArgs( - options: { wslDistro?: string } | undefined -): [] | [{ wslDistro?: string }] { - return options && Object.keys(options).length > 0 ? [options] : [] -} - export async function resolveGitHubPrStartPoint( args: ResolveGitHubPrStartPointArgs ): Promise { @@ -54,7 +49,8 @@ export async function resolveGitHubPrStartPoint( args.repoPath, args.prNumber, args.connectionId ?? null, - ...localGitOptionArgs(args.localGitOptions) + args.localGitOptions ?? {}, + args.issueSourcePreference ) pushTarget = resolved?.pushTarget maintainerCanModify = resolved?.maintainerCanModify @@ -71,7 +67,8 @@ export async function resolveGitHubPrStartPoint( args.prNumber, 'pr', args.connectionId ?? null, - ...localGitOptionArgs(args.localGitOptions) + args.localGitOptions ?? {}, + args.issueSourcePreference ) if (!item || item.type !== 'pr') { return { error: `PR #${args.prNumber} not found.` } diff --git a/src/main/github/review-head-remote.test.ts b/src/main/github/review-head-remote.test.ts index f5ff2fff296..216907a7790 100644 --- a/src/main/github/review-head-remote.test.ts +++ b/src/main/github/review-head-remote.test.ts @@ -38,6 +38,7 @@ describe('resolveGitHubReviewHeadRemote', () => { const remote = await resolveGitHubReviewHeadRemote({ repoPath: '/repo', + issueSourcePreference: 'auto', gitExec: gitExecWithRemotes(['origin', 'upstream']) }) @@ -52,12 +53,40 @@ describe('resolveGitHubReviewHeadRemote', () => { const remote = await resolveGitHubReviewHeadRemote({ repoPath: '/repo', + issueSourcePreference: 'upstream', gitExec: gitExecWithRemotes(['origin', 'upstream']) }) expect(remote).toBe('origin') }) + it('uses explicit origin without probing hosting identity on a dual-remote clone', async () => { + getGitHubApiRepositoryForRemoteMock.mockResolvedValue({ owner: 'org', repo: 'project' }) + + const remote = await resolveGitHubReviewHeadRemote({ + repoPath: '/repo', + issueSourcePreference: 'origin', + gitExec: gitExecWithRemotes(['origin', 'upstream']) + }) + + expect(remote).toBe('origin') + expect(getGitHubApiRepositoryForRemoteMock).not.toHaveBeenCalled() + expect(getDefaultRemoteMock).not.toHaveBeenCalled() + }) + + it('rejects explicit origin when that remote is not configured', async () => { + await expect( + resolveGitHubReviewHeadRemote({ + repoPath: '/repo', + issueSourcePreference: 'origin', + gitExec: gitExecWithRemotes(['upstream']) + }) + ).rejects.toThrow('Repo has no configured origin remote.') + + expect(getGitHubApiRepositoryForRemoteMock).not.toHaveBeenCalled() + expect(getDefaultRemoteMock).not.toHaveBeenCalled() + }) + it('skips identity probes for a single-remote clone and uses the local default', async () => { getDefaultRemoteMock.mockResolvedValue('origin') diff --git a/src/main/github/review-head-remote.ts b/src/main/github/review-head-remote.ts index 2df7574712e..2a0dd53ca7f 100644 --- a/src/main/github/review-head-remote.ts +++ b/src/main/github/review-head-remote.ts @@ -1,16 +1,15 @@ +import type { IssueSourcePreference } from '../../shared/types' import { pickPreferredGitRemote } from '../../shared/preferred-git-remote' import { getDefaultRemote } from '../git/repo' import { getGitHubApiRepositoryForRemote } from './github-api-repository' type GitExec = (args: string[]) => Promise<{ stdout: string; stderr: string }> -// Why: PR work-item/API resolution probes upstream before origin -// (resolveGitHubApiRepositoryCandidates), so review-head fetches must target -// the same hosting project — a contributor clone's fork `origin` has no -// refs/pull//head for an upstream PR. Local and SSH share this resolver so -// the two surfaces cannot pick different remotes. +// Why: explicit origin must match issue listing; otherwise hosting identity +// keeps contributor clones on the upstream project's PR namespace. export async function resolveGitHubReviewHeadRemote(args: { repoPath: string + issueSourcePreference?: IssueSourcePreference connectionId?: string | null localGitOptions?: { wslDistro?: string } gitExec: GitExec @@ -20,6 +19,12 @@ export async function resolveGitHubReviewHeadRemote(args: { .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) + if (args.issueSourcePreference === 'origin') { + if (remotes.includes('origin')) { + return 'origin' + } + throw new Error('Repo has no configured origin remote.') + } // Why: identity probes cost a `remote get-url` (plus a possible gh auth // lookup) each; only multi-remote clones are ambiguous enough to need them. if (remotes.length > 1) { diff --git a/src/main/github/work-item-details-enterprise-host.test.ts b/src/main/github/work-item-details-enterprise-host.test.ts index aca6e4a7c1c..be2576051b5 100644 --- a/src/main/github/work-item-details-enterprise-host.test.ts +++ b/src/main/github/work-item-details-enterprise-host.test.ts @@ -137,7 +137,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { const details = await getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1') expect(details?.body).toBe('Enterprise issue body') - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(1) expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(enterpriseRepository, 'graphql', { @@ -156,7 +156,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { await expect(getWorkItemDetails('/remote/repo', 7, 'issue', 'ssh-1')).resolves.toBeNull() - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'issue', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -247,7 +247,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { viewerViewedState: 'VIEWED' } ]) - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(getPRCommentsMock).toHaveBeenCalledWith( '/remote/repo', @@ -280,7 +280,7 @@ describe('getWorkItemDetails Enterprise host routing', () => { await expect(getWorkItemDetails('/remote/repo', 7, 'pr', 'ssh-1')).resolves.toBeNull() - expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1') + expect(getWorkItemMock).toHaveBeenCalledWith('/remote/repo', 7, 'pr', 'ssh-1', {}, undefined) expect(getWorkItemByOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index bc2bef2a722..11a063b51ba 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -206,7 +206,14 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/repo-root', 923, 'issue') - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined) + expect(getWorkItemMock).toHaveBeenCalledWith( + '/repo-root', + 923, + 'issue', + undefined, + {}, + undefined + ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api') expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql') @@ -579,7 +586,14 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1') + expect(getWorkItemMock).toHaveBeenCalledWith( + '/home/tester/widgets', + 923, + 'issue', + 'ssh-test-1', + {}, + undefined + ) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/home/tester/widgets', 'upstream', @@ -649,7 +663,14 @@ describe('getWorkItemDetails', () => { const details = await getWorkItemDetails('/repo-root', 42, 'pr', null, localGitOptions) expect(details?.body).toBe('PR body') - expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, localGitOptions) + expect(getWorkItemMock).toHaveBeenCalledWith( + '/repo-root', + 42, + 'pr', + null, + localGitOptions, + undefined + ) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith( '/repo-root', 'origin', @@ -677,6 +698,16 @@ describe('getWorkItemDetails', () => { ) }) + // Why: details open by number, so it must pin the same source as the list; + // otherwise a fork and its upstream sharing PR #42 render different PRs. + it('forwards the explicit origin source preference to the work item lookup', async () => { + getWorkItemMock.mockResolvedValueOnce(null) + + await expect(getWorkItemDetails('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull() + + expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 42, 'pr', null, {}, 'origin') + }) + // Why: a rate-limited/auth-failed file fetch must not render as an empty PR; // the Files tab keys its retry state off details.filesUnavailable. it('flags filesUnavailable when the PR file fetch fails but leaves the PR empty otherwise intact', async () => { diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 5e389be8581..2df6d9200e9 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -8,6 +8,7 @@ import type { GitHubIssueTimelineTarget, GitHubWorkItem, GitHubWorkItemDetails, + IssueSourcePreference, PRCheckDetail, PRComment } from '../../shared/types' @@ -1047,14 +1048,16 @@ export async function getWorkItemDetails( number: number, type?: 'issue' | 'pr', connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} + localGitOptions: LocalGitExecOptions = {}, + preference?: IssueSourcePreference ): Promise { const item: Omit | null = await getWorkItem( repoPath, number, type, connectionId, - ...localGitOptionArgs(localGitOptions) + localGitOptions, + preference ) if (!item) { return null diff --git a/src/main/ipc/github-work-item-args.test.ts b/src/main/ipc/github-work-item-args.test.ts index 234906a376e..8e253bc95cc 100644 --- a/src/main/ipc/github-work-item-args.test.ts +++ b/src/main/ipc/github-work-item-args.test.ts @@ -33,19 +33,19 @@ describe('dispatchWorkItem', () => { type: 'bogus' as unknown as 'issue' | 'pr' } await dispatchWorkItem(bogus, repo, fn) - expect(fn).toHaveBeenCalledWith('/r', 42, undefined, null, undefined) + expect(fn).toHaveBeenCalledWith('/r', 42, undefined, null, undefined, undefined) }) it('passes valid issue type through', async () => { const fn = vi.fn().mockResolvedValue(null) await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'issue' }, repo, fn) - expect(fn).toHaveBeenCalledWith('/r', 42, 'issue', null, undefined) + expect(fn).toHaveBeenCalledWith('/r', 42, 'issue', null, undefined, undefined) }) it('passes valid pr type through', async () => { const fn = vi.fn().mockResolvedValue(null) await dispatchWorkItem({ repoPath: '/r', number: 42, type: 'pr' }, repo, fn) - expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null, undefined) + expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null, undefined, undefined) }) it('passes SSH connection context through', async () => { @@ -55,6 +55,33 @@ describe('dispatchWorkItem', () => { { path: '/remote/repo', connectionId: 'ssh-1' }, fn ) - expect(fn).toHaveBeenCalledWith('/remote/repo', 42, 'issue', 'ssh-1', undefined) + expect(fn).toHaveBeenCalledWith('/remote/repo', 42, 'issue', 'ssh-1', undefined, undefined) + }) + + it('pins the repo issue source preference for open-by-number', async () => { + const fn = vi.fn().mockResolvedValue(null) + await dispatchWorkItem( + { repoPath: '/r', number: 42, type: 'pr' }, + { path: '/r', connectionId: null, issueSourcePreference: 'origin' }, + fn, + { wslDistro: 'Ubuntu' } + ) + expect(fn).toHaveBeenCalledWith('/r', 42, 'pr', null, { wslDistro: 'Ubuntu' }, 'origin') + }) + + it('leaves upstream and auto preferences on the multi-candidate probe', async () => { + const fn = vi.fn().mockResolvedValue(null) + await dispatchWorkItem( + { repoPath: '/r', number: 7, type: 'pr' }, + { path: '/r', connectionId: null, issueSourcePreference: 'upstream' }, + fn + ) + await dispatchWorkItem( + { repoPath: '/r', number: 7, type: 'pr' }, + { path: '/r', connectionId: null, issueSourcePreference: 'auto' }, + fn + ) + expect(fn).toHaveBeenNthCalledWith(1, '/r', 7, 'pr', null, undefined, 'upstream') + expect(fn).toHaveBeenNthCalledWith(2, '/r', 7, 'pr', null, undefined, 'auto') }) }) diff --git a/src/main/ipc/github-work-item-args.ts b/src/main/ipc/github-work-item-args.ts index e3dc63b2e9e..0aff921ee24 100644 --- a/src/main/ipc/github-work-item-args.ts +++ b/src/main/ipc/github-work-item-args.ts @@ -1,4 +1,5 @@ import type { TaskSourceContext } from '../../shared/task-source-context' +import type { IssueSourcePreference } from '../../shared/types' export type WorkItemArgs = { repoPath: string @@ -11,6 +12,7 @@ export type WorkItemArgs = { type RegisteredRepoContext = { path: string connectionId?: string | null + issueSourcePreference?: IssueSourcePreference } type LocalGitExecOptions = { @@ -29,7 +31,8 @@ export function dispatchWorkItem( n: number, t?: 'issue' | 'pr', connectionId?: string | null, - localGitOptions?: LocalGitExecOptions + localGitOptions?: LocalGitExecOptions, + preference?: IssueSourcePreference ) => Promise, localGitOptions?: LocalGitExecOptions ): Promise | null { @@ -38,5 +41,14 @@ export function dispatchWorkItem( return null } const safeType = type === 'issue' || type === 'pr' ? type : undefined - return fn(repo.path, number, safeType, repo.connectionId ?? null, localGitOptions) + // Why: open-by-number must pin the same source the list and start-point use, + // else a fork and its upstream sharing a PR number resolve to different PRs. + return fn( + repo.path, + number, + safeType, + repo.connectionId ?? null, + localGitOptions, + repo.issueSourcePreference + ) } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index bb1dd0cc1f6..bbd26f84d6a 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -843,6 +843,48 @@ describe('registerGitHubHandlers', () => { ) }) + // Why: open-by-number must pin the same source the list uses, else a fork and + // its upstream sharing PR #42 open different PRs from the same click. + it('pins the repo origin source preference on work item and details IPC', async () => { + repos = [ + { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + issueSourcePreference: 'origin' + } + ] + getWorkItemMock.mockResolvedValue(null) + getWorkItemDetailsMock.mockResolvedValue(null) + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:workItem'](null, { repoPath: '/workspace/repo', number: 42, type: 'pr' }) + await handlers['gh:workItemDetails'](null, { + repoPath: '/workspace/repo', + number: 42, + type: 'pr' + }) + + expect(getWorkItemMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'pr', + null, + undefined, + 'origin' + ) + expect(getWorkItemDetailsMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'pr', + null, + undefined, + 'origin' + ) + }) + it('routes local WSL project GitHub PR detail and action IPC through project git options', async () => { setPlatform('win32') projects = [ @@ -1027,7 +1069,14 @@ describe('registerGitHubHandlers', () => { } ) - expect(getWorkItemMock).toHaveBeenCalledWith('/workspace/repo', 42, 'pr', null, localGitOptions) + expect(getWorkItemMock).toHaveBeenCalledWith( + '/workspace/repo', + 42, + 'pr', + null, + localGitOptions, + undefined + ) expect(getWorkItemByOwnerRepoMock).toHaveBeenCalledWith( '/workspace/repo', prRepo, @@ -1041,7 +1090,8 @@ describe('registerGitHubHandlers', () => { 42, 'pr', null, - localGitOptions + localGitOptions, + undefined ) expect(getPRFileContentsMock).toHaveBeenCalledWith( expect.objectContaining({ repoPath: '/workspace/repo', localGitOptions, prRepo }) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 67b197e91a1..e632b03f0e0 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -2037,7 +2037,16 @@ describe('registerWorktreeHandlers', () => { ) }) - it('returns the PR head push target when resolving a fork PR base', async () => { + it('threads explicit origin preference into dual-remote PR head resolution', async () => { + store.getRepo.mockReturnValue({ + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + issueSourcePreference: 'origin', + worktreeBaseRef: null + }) getPullRequestPushTargetMock.mockResolvedValue({ pushTarget: { remoteName: 'pr-prateek-orca', @@ -2047,7 +2056,12 @@ describe('registerWorktreeHandlers', () => { }) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'remote' && args[1] === 'get-url') { - return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' } + const url = + args[2] === 'origin' ? ORIGIN_REMOTE_URL : 'git@github.com:org/upstream-repo.git' + return { stdout: `${url}\n`, stderr: '' } + } + if (args[0] === 'remote') { + return { stdout: 'origin\nupstream\n', stderr: '' } } if (args[0] === 'rev-parse') { return { stdout: 'abc123\n', stderr: '' } @@ -2071,6 +2085,17 @@ describe('registerWorktreeHandlers', () => { ], { cwd: '/workspace/repo', timeout: REVIEW_HEAD_FETCH_TIMEOUT_MS } ) + expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith( + ['remote', 'get-url', 'upstream'], + expect.anything() + ) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/workspace/repo', + 1738, + null, + {}, + 'origin' + ) expect(result).toMatchObject({ baseBranch: 'abc123', headSha: 'abc123', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ccbf77951b4..22aec867378 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -1341,16 +1341,18 @@ export function registerWorktreeHandlers( headRefName: args.headRefName, baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), gitExec, fetchRemoteTrackingRef, fetchPullRequestHeadRef, - // Why: one shared resolver for local and SSH so origin-vs-upstream - // cannot diverge by surface; it prefers the remote hosting the PR's project. + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. resolveRemote: () => resolveGitHubReviewHeadRemote({ repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: getLocalProjectWorktreeGitOptions(store, repo), gitExec diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index d16233a141b..6d815452211 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -6123,6 +6123,45 @@ describe('OrcaRuntimeService', () => { ) }) + it('pins explicit origin preference on runtime open-by-number work item lookups', async () => { + const originRepo = { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + issueSourcePreference: 'origin' as const + } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [originRepo], + getRepo: (id: string) => (id === originRepo.id ? originRepo : undefined) + } as never) + const prRepo = { owner: 'acme', repo: 'orca' } + + await runtime.getRepoWorkItem('id:repo-1', 42, 'pr') + await runtime.getRepoWorkItemDetails('id:repo-1', 42, 'pr') + await runtime.getRepoWorkItemByOwnerRepo('id:repo-1', prRepo, 42, 'pr') + + expect(getGitHubWorkItemMock).toHaveBeenCalledWith(TEST_REPO_PATH, 42, 'pr', null, {}, 'origin') + expect(getGitHubWorkItemDetailsMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 42, + 'pr', + null, + {}, + 'origin' + ) + // Why: explicit owner/repo already pins identity, so it stays preference-free. + expect(getGitHubWorkItemByOwnerRepoMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + prRepo, + 42, + 'pr', + null + ) + }) + it('routes runtime GitHub PR details and actions through the selected WSL project runtime', async () => { setPlatform('win32') const runtimeStore = { @@ -6220,7 +6259,8 @@ describe('OrcaRuntimeService', () => { 42, 'pr', null, - localGitOptions + localGitOptions, + undefined ) expect(getGitHubWorkItemByOwnerRepoMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -6235,7 +6275,8 @@ describe('OrcaRuntimeService', () => { 42, 'pr', null, - localGitOptions + localGitOptions, + undefined ) expect(getGitHubPRChecksMock).toHaveBeenCalledWith( TEST_REPO_PATH, @@ -34533,10 +34574,20 @@ describe('OrcaRuntimeService', () => { expect(updateSettings).not.toHaveBeenCalled() }) - it('routes runtime GitHub PR base git calls through the selected WSL project runtime', async () => { + it('threads explicit origin preference through runtime WSL PR base resolution', async () => { setPlatform('win32') + const localRepo = { + id: TEST_REPO_ID, + path: TEST_REPO_PATH, + displayName: 'repo', + badgeColor: 'blue', + addedAt: 1, + issueSourcePreference: 'origin' as const + } const runtimeStore = { ...store, + getRepos: () => [localRepo], + getRepo: (id: string) => (id === localRepo.id ? localRepo : undefined), getProjects: () => [ { id: 'project-1', @@ -34564,8 +34615,18 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'config') { return { stdout: 'origin\n', stderr: '' } } + if (args[0] === 'remote' && args[1] === 'get-url') { + if (args[2] !== 'origin' && args[2] !== 'upstream') { + throw new Error(`unexpected remote: ${String(args[2])}`) + } + const url = + args[2] === 'origin' + ? 'git@github.com:org/repo.git' + : 'git@github.com:org/upstream-repo.git' + return { stdout: `${url}\n`, stderr: '' } + } if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } + return { stdout: 'origin\nupstream\n', stderr: '' } } if (args[0] === 'fetch') { return { stdout: '', stderr: '' } @@ -34593,11 +34654,6 @@ describe('OrcaRuntimeService', () => { headSha: 'pr-head-sha', branchNameOverride: 'feature/add-feature' }) - expect(gitSpy).toHaveBeenCalledWith(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { - cwd: TEST_REPO_PATH, - timeout: 15_000, - wslDistro: 'Ubuntu' - }) expect(gitSpy).toHaveBeenCalledWith( [ 'fetch', @@ -34610,6 +34666,12 @@ describe('OrcaRuntimeService', () => { cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' }) + // Why: the explicit origin preference must short-circuit before any + // identity probe, so no remote — not just upstream — gets a get-url. + expect(gitSpy).not.toHaveBeenCalledWith( + ['remote', 'get-url', expect.anything()], + expect.anything() + ) } finally { gitSpy.mockRestore() } @@ -34622,7 +34684,8 @@ describe('OrcaRuntimeService', () => { displayName: 'repo', badgeColor: 'blue', addedAt: 1, - connectionId: 'ssh-1' + connectionId: 'ssh-1', + issueSourcePreference: 'origin' as const } const runtimeStore = { ...store, @@ -34635,7 +34698,7 @@ describe('OrcaRuntimeService', () => { return { stdout: `${ORIGIN_REMOTE_URL}\n`, stderr: '' } } if (args[0] === 'remote') { - return { stdout: 'origin\n', stderr: '' } + return { stdout: 'origin\nupstream\n', stderr: '' } } if ( args[0] === 'rev-parse' && @@ -34666,6 +34729,13 @@ describe('OrcaRuntimeService', () => { branchNameOverride: 'contributor/fix' }) expect(provider.fetchGitHubPullRequestHead).toHaveBeenCalledWith('/remote/repo', 'origin', 42) + expect(getPullRequestPushTargetMock).toHaveBeenCalledWith( + '/remote/repo', + 42, + 'ssh-1', + {}, + 'origin' + ) expect(provider.exec).not.toHaveBeenCalledWith( expect.arrayContaining(['fetch']), '/remote/repo' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 49be1d9c1f4..a37a37c5e95 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -16139,12 +16139,15 @@ export class OrcaRuntimeService { type?: 'issue' | 'pr' ): Promise>> { const repo = await this.resolveRepoSelector(repoSelector) + // Why: open-by-number must pin the same source the list and start-point use, + // else a fork and its upstream sharing a PR number resolve to different PRs. return getWorkItem( repo.path, number, type, repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + repo.issueSourcePreference ) } @@ -16176,7 +16179,8 @@ export class OrcaRuntimeService { number, type, repo.connectionId ?? null, - ...this.getLocalGitExecutionOptionArgs(repo) + this.getLocalGitExecutionOptionArgs(repo)[0] ?? {}, + repo.issueSourcePreference ) } @@ -20142,11 +20146,12 @@ export class OrcaRuntimeService { const gitExec = sshGitProvider ? (gitArgs: string[]) => sshGitProvider.exec(gitArgs, repo.path) : (gitArgs: string[]) => gitExecFileAsync(gitArgs, localGitExecOptions ?? { cwd: repo.path }) - // Why: one shared resolver for local and SSH so origin-vs-upstream cannot - // diverge by surface; it prefers the remote hosting the PR's project. + // Why: one resolver keeps source preference and hosting identity aligned + // across local, WSL, and SSH worktree creation. const resolveRemote = (): Promise => resolveGitHubReviewHeadRemote({ repoPath: repo.path, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: localWorktreeGitOptions, gitExec @@ -20176,6 +20181,7 @@ export class OrcaRuntimeService { headRefName: args.headRefName, baseRefName: args.baseRefName, isCrossRepository: args.isCrossRepository, + issueSourcePreference: repo.issueSourcePreference, connectionId: repo.connectionId ?? null, localGitOptions: localWorktreeGitOptions, gitExec, From c67aadbc1894e1dc58ea53748ff1d1f16705d733 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:54:20 -0700 Subject: [PATCH 061/150] fix(crash-reporting): record exact V8 heap sizes, not Blink's quantized ones (#10683) --- src/preload/api-types.ts | 3 + src/preload/index.ts | 5 +- .../renderer-heap-statistics-reader.test.ts | 74 +++++++++++ .../renderer-heap-statistics-reader.ts | 37 ++++++ .../src/lib/crash-diagnostics.test.ts | 116 ++++++++++++++++++ src/renderer/src/lib/crash-diagnostics.ts | 50 +++++++- src/renderer/src/web/web-preload-api.ts | 4 +- src/shared/renderer-heap-statistics.ts | 24 ++++ 8 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 src/preload/renderer-heap-statistics-reader.test.ts create mode 100644 src/preload/renderer-heap-statistics-reader.ts create mode 100644 src/shared/renderer-heap-statistics.ts diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index dc3cbba64f9..0b72440f58f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -354,6 +354,7 @@ import type { ReactErrorBoundaryReportArgs, ReactErrorBoundaryReportResult } from '../shared/crash-reporting' +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' export type { ShellOpenExternalEditorRequest, @@ -1505,6 +1506,8 @@ export type PreloadApi = { copyLatestDiagnostics: ( args?: CrashReportCopyDiagnosticsArgs ) => Promise<{ ok: true } | { ok: false; error: string }> + /** Exact V8/Blink heap sizes; null when the runtime withholds them. */ + readHeapStatistics: () => RendererHeapStatistics | null } export: ExportApi gh: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 8aa289018f7..dee8542eebd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -239,6 +239,8 @@ import type { ReactErrorBoundaryReportArgs, ReactErrorBoundaryReportResult } from '../shared/crash-reporting' +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' +import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' import type { PreloadApi } from './api-types' import { createUpdaterQuitAbortRelay, @@ -1154,7 +1156,8 @@ const api = { submit: (args: CrashReportSubmitArgs): Promise => ipcRenderer.invoke('crashReports:submit', args), copyLatestDiagnostics: (args?: CrashReportCopyDiagnosticsArgs) => - ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args) + ipcRenderer.invoke('crashReports:copyLatestDiagnostics', args), + readHeapStatistics: (): RendererHeapStatistics | null => readRendererHeapStatistics() }, export: { diff --git a/src/preload/renderer-heap-statistics-reader.test.ts b/src/preload/renderer-heap-statistics-reader.test.ts new file mode 100644 index 00000000000..892a9e865b7 --- /dev/null +++ b/src/preload/renderer-heap-statistics-reader.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' + +const heapStatistics = { + totalHeapSize: 2048, + totalHeapSizeExecutable: 0, + totalPhysicalSize: 2048, + totalAvailableSize: 4_000_000, + usedHeapSize: 1536, + heapSizeLimit: 4_292_608, + mallocedMemory: 64, + peakMallocedMemory: 96, + doesZapGarbage: false +} + +const source = (overrides: { + heap?: () => Electron.HeapStatistics + blink?: () => Electron.BlinkMemoryInfo +}): Parameters[0] => + ({ + getHeapStatistics: overrides.heap ?? ((): Electron.HeapStatistics => heapStatistics), + getBlinkMemoryInfo: + overrides.blink ?? ((): Electron.BlinkMemoryInfo => ({ allocated: 1227, total: 1280 })) + }) as Parameters[0] + +describe('readRendererHeapStatistics', () => { + it('reports V8 sizes in the kilobytes Electron returns', () => { + expect(readRendererHeapStatistics(source({}))).toEqual({ + usedHeapKB: 1536, + totalHeapKB: 2048, + heapLimitKB: 4_292_608, + mallocedKB: 64, + blinkAllocatedKB: 1227 + }) + }) + + it('keeps the exact V8 read when only the Blink metric throws', () => { + // Why: Blink's number is supplementary. Discarding the V8 read over its + // absence would send callers back to the quantized `performance.memory` + // this reader exists to replace — silently defeating the whole feature. + const result = readRendererHeapStatistics( + source({ + blink: () => { + throw new Error('getBlinkMemoryInfo unavailable') + } + }) + ) + + expect(result).toEqual({ + usedHeapKB: 1536, + totalHeapKB: 2048, + heapLimitKB: 4_292_608, + mallocedKB: 64, + blinkAllocatedKB: undefined + }) + }) + + it('returns null only when the primary V8 read fails', () => { + const blink = vi.fn() + + expect( + readRendererHeapStatistics( + source({ + heap: () => { + throw new Error('getHeapStatistics unavailable') + }, + blink: blink as never + }) + ) + ).toBeNull() + // Why: no reason to pay for the auxiliary call once the result is unusable. + expect(blink).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/renderer-heap-statistics-reader.ts b/src/preload/renderer-heap-statistics-reader.ts new file mode 100644 index 00000000000..4d1abecd848 --- /dev/null +++ b/src/preload/renderer-heap-statistics-reader.ts @@ -0,0 +1,37 @@ +import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' + +type HeapStatisticsSource = Pick + +/** + * Reads exact V8 heap sizes, which `performance.memory` cannot express: Blink + * quantizes that API and caches it ~20 minutes, so heap growth is invisible to + * it. Available in a sandboxed, context-isolated preload. + */ +export function readRendererHeapStatistics( + source: HeapStatisticsSource = process +): RendererHeapStatistics | null { + let heap: Electron.HeapStatistics + try { + heap = source.getHeapStatistics() + } catch { + // Why: diagnostics must never break the renderer if Electron drops an API. + return null + } + + let blinkAllocatedKB: number | undefined + try { + // Why a separate try: Blink's number is supplementary. Losing it must not + // discard the exact V8 read and send callers back to the quantized metric. + blinkAllocatedKB = source.getBlinkMemoryInfo().allocated + } catch { + blinkAllocatedKB = undefined + } + + return { + usedHeapKB: heap.usedHeapSize, + totalHeapKB: heap.totalHeapSize, + heapLimitKB: heap.heapSizeLimit, + mallocedKB: heap.mallocedMemory, + blinkAllocatedKB + } +} diff --git a/src/renderer/src/lib/crash-diagnostics.test.ts b/src/renderer/src/lib/crash-diagnostics.test.ts index 049a9fdf706..0b93aa15511 100644 --- a/src/renderer/src/lib/crash-diagnostics.test.ts +++ b/src/renderer/src/lib/crash-diagnostics.test.ts @@ -81,6 +81,9 @@ describe('renderer crash diagnostics', () => { usedHeapMB: 32, totalHeapMB: 64, heapLimitMB: 512, + // Why: without this tag a reader cannot tell an exact heap number from a + // Blink-quantized one, which is what made earlier bundles unanalyzable. + heapSource: 'quantized', browserWebviews: 4, registeredBrowserGuests: 3 } @@ -242,4 +245,117 @@ describe('renderer crash diagnostics', () => { ) ).toBe(false) }) + + describe('exact V8 heap statistics', () => { + const KB = 1024 + let readHeapStatistics: ReturnType + + const stubHeap = (usedMB: number, limitMB = 512): void => { + readHeapStatistics.mockReturnValue({ + usedHeapKB: usedMB * KB, + totalHeapKB: usedMB * KB * 2, + heapLimitKB: limitMB * KB, + mallocedKB: 3 * KB, + blinkAllocatedKB: 7 * KB + }) + } + + beforeEach(() => { + readHeapStatistics = vi.fn() + ;(window.api.crashReports as unknown as { readHeapStatistics: unknown }).readHeapStatistics = + readHeapStatistics + }) + + it('prefers exact statistics over performance.memory and labels the source', () => { + stubHeap(101) + + diagnostics.installRendererCrashDiagnostics() + + // Why: performance.memory still says 32MB here. Reporting 101 proves the + // exact reading wins rather than merely being recorded alongside. + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory', + data: expect.objectContaining({ + usedHeapMB: 101, + heapLimitMB: 512, + heapSource: 'v8', + mallocedMB: 3, + blinkAllocatedMB: 7 + }) + }) + }) + + it('observes growth that performance.memory quantizes away', () => { + // Why: this is the whole point. Blink pins usedJSHeapSize to a bucket and + // caches it ~20min, so a real climb reports byte-identical values and a + // highwater threshold never fires. Exact stats must still cross it. + const quantized = (window.performance as unknown as { memory: Record }).memory + quantized.usedJSHeapSize = 32 * 1024 * 1024 + stubHeap(100) + vi.stubGlobal('document', { + getElementsByTagName: () => ({ length: 1 }), + querySelectorAll: () => ({ length: 0 }) + }) + + diagnostics.installRendererCrashDiagnostics() + const highwaterCalls = (): unknown[] => + recordBreadcrumbMock.mock.calls.filter( + (call) => (call[0] as { name: string }).name === 'renderer_memory_highwater' + ) + expect(highwaterCalls()).toHaveLength(0) + + stubHeap(400) // 78% of 512 — past the 60% threshold, still below 80%. + const tick = setIntervalMock.mock.calls[0][0] as () => void + tick() + + expect(quantized.usedJSHeapSize).toBe(32 * 1024 * 1024) + expect(highwaterCalls()).toHaveLength(1) + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory_highwater', + data: expect.objectContaining({ thresholdPct: 60, usedHeapMB: 400, heapSource: 'v8' }) + }) + }) + + it('omits the Blink field instead of emitting a junk value for it', () => { + // Why: `undefined * 1024` is NaN. The breadcrumb must carry no + // blinkAllocatedMB at all rather than a meaningless number. + readHeapStatistics.mockReturnValue({ + usedHeapKB: 77 * KB, + totalHeapKB: 154 * KB, + heapLimitKB: 512 * KB, + mallocedKB: 3 * KB, + blinkAllocatedKB: undefined + }) + + diagnostics.installRendererCrashDiagnostics() + + const call = recordBreadcrumbMock.mock.calls.find( + ([entry]) => (entry as { name: string }).name === 'renderer_memory' + )?.[0] as { data: Record } + expect(call.data).toMatchObject({ usedHeapMB: 77, heapSource: 'v8', mallocedMB: 3 }) + expect(call.data).not.toHaveProperty('blinkAllocatedMB') + }) + + it('falls back to performance.memory when the bridge returns null', () => { + readHeapStatistics.mockReturnValue(null) + + diagnostics.installRendererCrashDiagnostics() + + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory', + data: expect.objectContaining({ usedHeapMB: 32, heapSource: 'quantized' }) + }) + }) + + it('samples on an older shell whose preload lacks the bridge', () => { + ;(window.api.crashReports as unknown as Record).readHeapStatistics = + undefined + + expect(() => diagnostics.installRendererCrashDiagnostics()).not.toThrow() + expect(recordBreadcrumbMock).toHaveBeenCalledWith({ + name: 'renderer_memory', + data: expect.objectContaining({ usedHeapMB: 32, heapSource: 'quantized' }) + }) + }) + }) }) diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index 3fd2a4794ee..90fa45ebba0 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -11,6 +11,7 @@ import { collectRendererMemoryProfileCounts } from './renderer-memory-profile' const RENDERER_MEMORY_SAMPLE_INTERVAL_MS = 60_000 const BYTES_PER_MEGABYTE = 1024 * 1024 +const BYTES_PER_KILOBYTE = 1024 // Why: one detailed breadcrumb per threshold names what grew before an OOM. const RENDERER_MEMORY_HIGHWATER_RATIOS = [0.6, 0.8] as const @@ -22,6 +23,13 @@ type BrowserPerformanceMemory = { jsHeapSizeLimit?: number } +/** Heap sizes in bytes, tagged with whether they are exact or Blink-quantized. */ +type HeapMetrics = BrowserPerformanceMemory & { + mallocedBytes?: number + blinkAllocatedBytes?: number + exact: boolean +} + let rendererCrashDiagnosticsInstalled = false let rendererMemoryInterval: number | null = null let rendererSurface: RendererSurface = 'main' @@ -42,7 +50,7 @@ export function installRendererCrashDiagnostics(surface: RendererSurface = 'main window.addEventListener('error', recordRendererError) window.addEventListener('unhandledrejection', recordRendererUnhandledRejection) - if (getPerformanceMemory()) { + if (readHeapMetrics()) { recordRendererMemory('startup') rendererMemoryInterval = window.setInterval( () => recordRendererMemory('interval'), @@ -108,7 +116,7 @@ function recordRendererUnhandledRejection(event: PromiseRejectionEvent): void { } function recordRendererMemory(reason: string): void { - const memory = getPerformanceMemory() + const memory = readHeapMetrics() if (!memory) { return } @@ -121,6 +129,9 @@ function recordRendererMemory(reason: string): void { usedHeapMB: toMegabytes(memory.usedJSHeapSize), totalHeapMB: toMegabytes(memory.totalJSHeapSize), heapLimitMB: toMegabytes(memory.jsHeapSizeLimit), + heapSource: memory.exact ? 'v8' : 'quantized', + mallocedMB: toMegabytes(memory.mallocedBytes), + blinkAllocatedMB: toMegabytes(memory.blinkAllocatedBytes), browserWebviews: browserWebviews.browserWebviewCount, registeredBrowserGuests: browserWebviews.registeredBrowserGuestCount }) @@ -129,7 +140,7 @@ function recordRendererMemory(reason: string): void { } function recordRendererMemoryHighwater( - memory: BrowserPerformanceMemory, + memory: HeapMetrics, browserWebviews: BrowserWebviewMemoryProfile ): void { const used = memory.usedJSHeapSize @@ -156,6 +167,9 @@ function recordRendererMemoryHighwater( usedHeapMB: toMegabytes(used), totalHeapMB: toMegabytes(memory.totalJSHeapSize), heapLimitMB: toMegabytes(limit), + heapSource: memory.exact ? 'v8' : 'quantized', + mallocedMB: toMegabytes(memory.mallocedBytes), + blinkAllocatedMB: toMegabytes(memory.blinkAllocatedBytes), domNodes: document.getElementsByTagName('*').length, terminalElements: document.querySelectorAll('.xterm').length, browserWebviews: browserWebviews.browserWebviewCount, @@ -185,6 +199,36 @@ function getPerformanceMemory(): BrowserPerformanceMemory | undefined { return (window.performance as Performance & { memory?: BrowserPerformanceMemory }).memory } +/** + * Prefers V8's exact numbers; falls back to `performance.memory` only when the + * preload bridge is unavailable (older shell, or a surface without it). + * + * Both are normalized to bytes so callers and the emitted MB fields stay + * comparable with breadcrumbs recorded before this bridge existed. + */ +function readHeapMetrics(): HeapMetrics | undefined { + if (typeof window === 'undefined') { + return undefined + } + const exact = window.api?.crashReports?.readHeapStatistics?.() + if (exact) { + return { + usedJSHeapSize: exact.usedHeapKB * BYTES_PER_KILOBYTE, + totalJSHeapSize: exact.totalHeapKB * BYTES_PER_KILOBYTE, + jsHeapSizeLimit: exact.heapLimitKB * BYTES_PER_KILOBYTE, + mallocedBytes: exact.mallocedKB * BYTES_PER_KILOBYTE, + // Why guarded: undefined * 1024 is NaN, which would emit a junk field. + blinkAllocatedBytes: + exact.blinkAllocatedKB === undefined + ? undefined + : exact.blinkAllocatedKB * BYTES_PER_KILOBYTE, + exact: true + } + } + const fallback = getPerformanceMemory() + return fallback ? { ...fallback, exact: false } : undefined +} + function describeUnknownValue( prefix: string, value: unknown diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index e36c0249da2..bd454183ecc 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -677,7 +677,9 @@ function createWebPreloadApi(): Partial { Promise.resolve({ ok: false, error: translate('auto.web.web.preload.api.fb290366b2', 'Unavailable on web.') - }) + }), + // Why: no Electron process on web; the caller falls back to performance.memory. + readHeapStatistics: () => null }, diagnostics: { getStatus: () => diff --git a/src/shared/renderer-heap-statistics.ts b/src/shared/renderer-heap-statistics.ts new file mode 100644 index 00000000000..4dfc5fbcd07 --- /dev/null +++ b/src/shared/renderer-heap-statistics.ts @@ -0,0 +1,24 @@ +/** + * V8 heap statistics read from the renderer's own process. + * + * Why this exists rather than `window.performance.memory`: Blink quantizes that + * API onto ~100 logarithmic buckets and caches each reading for ~20 minutes as a + * Spectre mitigation. Measured here: a renderer climbing 1MB -> 93MB reported an + * identical `usedJSHeapSize` on all 7 samples, so heap growth is invisible to it + * at any sampling rate. `process.getHeapStatistics()` is exact and uncached, and + * works in a sandboxed, context-isolated preload. + * + * Electron reports these in kilobytes; we keep that unit unconverted here. + */ +export type RendererHeapStatistics = { + usedHeapKB: number + totalHeapKB: number + heapLimitKB: number + /** Off-heap V8 allocations, which `usedJSHeapSize` never included. */ + mallocedKB: number + /** + * Blink's own allocator (DOM, layout), invisible to the V8 heap counters. + * Optional: supplementary, so its absence must never discard the V8 numbers. + */ + blinkAllocatedKB?: number +} From 19d082a16405bfd3f69f5807450caf68f97f7cf1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:34:17 -0700 Subject: [PATCH 062/150] fix(github): resolve owner/repo through SSH Host aliases (#10284) (#10361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(github): resolve owner/repo through SSH Host aliases (#10284) Expand OpenSSH Host → HostName via ssh -G before classifying github.com identity so PR merge works when origin is git@alias:owner/repo.git. Transport URLs stay unchanged so IdentityFile selection is preserved. Do not long-negative-cache indeterminate ssh -G failures. * fix(github): harden SSH alias resolution --- src/main/git/runner-command-exec.test.ts | 26 ++ src/main/git/runner.ts | 18 +- src/main/github/gh-utils.test.ts | 14 +- src/main/github/gh-utils.ts | 17 +- .../github-enterprise-repository.test.ts | 95 ++++- .../github/github-enterprise-repository.ts | 30 +- .../github/github-owner-repo-selection.ts | 91 +++++ .../github-remote-identity-parsing.test.ts | 83 +++- .../github/github-remote-identity-parsing.ts | 74 +++- ...epository-identity.fork-owner-repo.test.ts | 8 +- ...repository-identity.ssh-host-alias.test.ts | 358 ++++++++++++++++++ src/main/github/github-repository-identity.ts | 143 ++----- .../github-ssh-host-alias-resolution.ts | 192 ++++++++++ src/main/ipc/repos-remote.test.ts | 1 + src/main/ipc/worktrees.test.ts | 1 + 15 files changed, 1011 insertions(+), 140 deletions(-) create mode 100644 src/main/github/github-owner-repo-selection.ts create mode 100644 src/main/github/github-repository-identity.ssh-host-alias.test.ts create mode 100644 src/main/github/github-ssh-host-alias-resolution.ts diff --git a/src/main/git/runner-command-exec.test.ts b/src/main/git/runner-command-exec.test.ts index 97f8eb8254e..41975f9d665 100644 --- a/src/main/git/runner-command-exec.test.ts +++ b/src/main/git/runner-command-exec.test.ts @@ -501,6 +501,32 @@ describe('runner execFile timeout handling', () => { }) }) + it('routes fixed commands through an explicitly selected WSL distro', async () => { + await withPlatform('win32', async () => { + const child = createMockChildProcess(1234) + execFileMock.mockImplementation((_cmd, _args, _opts, cb) => { + cb(null, 'hostname github.com\n', '') + return child + }) + + await commandExecFileAsync('ssh', ['-G', '--', 'github-work'], { + cwd: String.raw`C:\repo`, + timeout: 5_000, + wslDistro: 'Ubuntu' + }) + + expect(execFileMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', expect.any(String)], + expect.objectContaining({ cwd: undefined }), + expect.any(Function) + ) + const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string + expect(shellCommand).toContain('/mnt/c/repo') + expect(shellCommand).toContain("'ssh' '-G' '--' 'github-work'") + }) + }) + it('forwards synthesized network SSH policy into the selected WSL distro', async () => { await withPlatform('win32', async () => { const child = createMockChildProcess(1234) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 716cf20524f..b75cfa4ab5e 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -278,6 +278,7 @@ type CommandExecOptions = { timeout?: number env?: NodeJS.ProcessEnv signal?: AbortSignal + wslDistro?: string } function isMissingCommandError(error: unknown): boolean { @@ -879,23 +880,24 @@ export async function commandExecFileAsync( args: string[], options: CommandExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - const resolved = resolveCommand(command, args, options.cwd) + const { wslDistro, ...execOptions } = options + const resolved = resolveCommand(command, args, options.cwd, wslDistro) const binary = resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary if (isWindowsBatchScript(binary)) { return spawnCommandCapture(binary, resolved.args, { - ...options, + ...execOptions, cwd: resolved.cwd }) } try { const { stdout, stderr } = await execFileCapture(binary, resolved.args, { cwd: resolved.cwd, - encoding: options.encoding ?? 'utf-8', - maxBuffer: options.maxBuffer, - timeout: options.timeout, - env: options.env, - signal: options.signal + encoding: execOptions.encoding ?? 'utf-8', + maxBuffer: execOptions.maxBuffer, + timeout: execOptions.timeout, + env: execOptions.env, + signal: execOptions.signal }) return { stdout: stdout as string, stderr: stderr as string } } catch (error) { @@ -904,7 +906,7 @@ export async function commandExecFileAsync( resolveWindowsCommand(`${resolved.binary}.cmd`, options.env), resolved.args, { - ...options, + ...execOptions, cwd: resolved.cwd } ) diff --git a/src/main/github/gh-utils.test.ts b/src/main/github/gh-utils.test.ts index 04c76eb5e53..ae7b6c414e4 100644 --- a/src/main/github/gh-utils.test.ts +++ b/src/main/github/gh-utils.test.ts @@ -3,10 +3,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({ - gitExecFileAsyncMock: vi.fn(), - getSshGitProviderMock: vi.fn() -})) +const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted( + () => ({ + gitExecFileAsyncMock: vi.fn(), + getSshGitProviderGenerationMock: vi.fn(() => 0), + getSshGitProviderMock: vi.fn() + }) +) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -14,6 +17,7 @@ vi.mock('../git/runner', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProviderGeneration: getSshGitProviderGenerationMock, getSshGitProvider: getSshGitProviderMock })) @@ -38,6 +42,8 @@ import { describe('github owner/repo resolution', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() + getSshGitProviderGenerationMock.mockReset() + getSshGitProviderGenerationMock.mockReturnValue(0) getSshGitProviderMock.mockReset() _resetOwnerRepoCache() __resetLocalGitConfigSignatureCacheForTests() diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index b294e90ac1a..d2148edc3c1 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -13,25 +13,26 @@ export { classifyGhError, classifyListIssuesError } from './gh-error-classificat export { _getOwnerRepoCacheSize, _resetOwnerRepoCache, - getIssueOwnerRepo, - getOwnerRepo, getOwnerRepoForRemote, getRemoteUrlForRepo, ghRepoExecOptions, githubRepoContext, parseGitHubOwnerRepo, - parseGitHubRemoteIdentity, - resolveIssueSource, - resolvePRRepositoryCandidates + parseGitHubRemoteIdentity } from './github-repository-identity' export type { GitHubRemoteIdentity, GitHubRepoContext, LocalGitExecOptions, - OwnerRepo, - PRRepositoryCandidates, - ResolvedIssueSource + OwnerRepo } from './github-repository-identity' +export { + getIssueOwnerRepo, + getOwnerRepo, + resolveIssueSource, + resolvePRRepositoryCandidates +} from './github-owner-repo-selection' +export type { PRRepositoryCandidates, ResolvedIssueSource } from './github-owner-repo-selection' const MAX_CONCURRENT = 4 let running = 0 diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index b2c453582e3..caee8e0bccf 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -1,23 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({ - ghExecFileAsyncMock: vi.fn(), - gitExecFileAsyncMock: vi.fn() -})) +const { commandExecFileAsyncMock, ghExecFileAsyncMock, gitExecFileAsyncMock, resolveWithSshGMock } = + vi.hoisted(() => ({ + commandExecFileAsyncMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + resolveWithSshGMock: vi.fn() + })) // Mock only the exec boundary so the real remote-identity parsing, runtime // option resolution, and `gh auth status` parsing run against controlled output. vi.mock('../git/runner', () => ({ + commandExecFileAsync: commandExecFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, gitExecFileAsync: gitExecFileAsyncMock })) +vi.mock('../ssh/ssh-g-config-resolution', () => ({ + resolveWithSshG: resolveWithSshGMock +})) + import { _resetGitHubHostAuthCache, getEnterpriseGitHubRepoSlug, isGitHubHostAuthenticated, isGitHubHostAuthenticatedForGlobalCli } from './github-enterprise-repository' +import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution' function mockOriginRemote(url: string): void { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { @@ -28,6 +37,19 @@ function mockOriginRemote(url: string): void { }) } +function sshConfig(hostname: string, port = 22) { + return { + hostname, + port, + identityFile: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no' + } +} + // gh auth status inventory entries represent hosts with configured credentials. function mockHostAuthenticated(host = 'github.acme-corp.com'): void { mockAuthenticatedHosts([host]) @@ -54,9 +76,13 @@ function mockHostNotAuthenticated(): void { describe('getEnterpriseGitHubRepoSlug', () => { beforeEach(() => { + commandExecFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() gitExecFileAsyncMock.mockReset() + resolveWithSshGMock.mockReset() + resolveWithSshGMock.mockResolvedValue(null) _resetGitHubHostAuthCache() + _resetSshHostnameResolutionCache() }) it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => { @@ -84,6 +110,67 @@ describe('getEnterpriseGitHubRepoSlug', () => { }) }) + it('expands an SSH Host alias to the authenticated GHES HostName (#10284)', async () => { + mockOriginRemote('git@ghe-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.acme-corp.com')) + mockHostAuthenticated('github.acme-corp.com') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + expect(resolveWithSshGMock).toHaveBeenCalledWith('ghe-work') + }) + + it('keeps a failed GHES alias probe indeterminate and recovers on retry', async () => { + vi.useFakeTimers() + mockOriginRemote('git@ghe-work:team/orca.git') + resolveWithSshGMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(sshConfig('github.acme-corp.com')) + mockHostNotAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeUndefined() + + await vi.advanceTimersByTimeAsync(5_001) + ghExecFileAsyncMock.mockReset() + mockHostAuthenticated('github.acme-corp.com') + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + }) + + it('returns null for a Host alias that resolves to github.com (dotcom path owns it)', async () => { + mockOriginRemote('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('expands aliases in the repository WSL runtime', async () => { + mockOriginRemote('git@github-work:team/orca.git') + commandExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'hostname github.com\nport 22\n', + stderr: '' + }) + + await expect( + getEnterpriseGitHubRepoSlug('/repo', null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + ).resolves.toBeNull() + expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], { + cwd: '/repo', + timeout: 5_000, + wslDistro: 'Ubuntu' + }) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + it('uses the unique ported auth host for a hostname-only SSH remote', async () => { mockOriginRemote('git@ghe.acme.com:team/orca.git') mockHostAuthenticated('ghe.acme.com:8443') diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index f0277068471..fd4401e3f49 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -12,6 +12,11 @@ import { parseGitHubRemoteIdentity, type LocalGitExecOptions } from './github-repository-identity' +import { + effectiveGitHubRemoteHost, + gitHubSshConfigHostAlias +} from './github-remote-identity-parsing' +import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution' import { parseWslPath } from '../wsl' export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } @@ -225,11 +230,32 @@ export async function getEnterpriseGitHubRepoSlugForRemote( return null } const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null - if (!identity || identity.host === 'github.com') { + if (!identity) { + return null + } + // Why: GHES routing needs the effective host behind an SSH alias. + let effectiveHost = identity.host + const aliasHost = remoteUrl ? gitHubSshConfigHostAlias(remoteUrl) : null + if (aliasHost) { + const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context) + if (!resolved || !hostname) { + const authenticatedLiteralHost = await resolveAuthenticatedGitHubHost( + identity.host, + repoPath, + connectionId, + localGitOptions + ) + return authenticatedLiteralHost + ? { owner: identity.owner, repo: identity.repo, host: authenticatedLiteralHost } + : undefined + } + effectiveHost = effectiveGitHubRemoteHost(identity.host, hostname) + } + if (effectiveHost === 'github.com') { return null } const authenticatedHost = await resolveAuthenticatedGitHubHost( - identity.host, + effectiveHost, repoPath, connectionId, localGitOptions diff --git a/src/main/github/github-owner-repo-selection.ts b/src/main/github/github-owner-repo-selection.ts new file mode 100644 index 00000000000..63b6d75678e --- /dev/null +++ b/src/main/github/github-owner-repo-selection.ts @@ -0,0 +1,91 @@ +import type { IssueSourcePreference } from '../../shared/types' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { + getOwnerRepoForRemote, + type LocalGitExecOptions, + type OwnerRepo +} from './github-repository-identity' + +export async function getOwnerRepo( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + // Why: on a fork checkout PRs live on the upstream parent, not origin (#7331). + const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) + if (upstream) { + return upstream + } + return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) +} + +export const getIssueOwnerRepo = getOwnerRepo + +export type PRRepositoryCandidates = { + candidates: OwnerRepo[] + headRepo: OwnerRepo | null +} + +export async function resolvePRRepositoryCandidates( + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const [upstream, origin] = await Promise.all([ + getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions), + getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + ]) + const seen = new Set() + const candidates: OwnerRepo[] = [] + + for (const candidate of [upstream, origin]) { + if (!candidate) { + continue + } + const key = githubRepoIdentityKey(candidate) + if (seen.has(key)) { + continue + } + seen.add(key) + candidates.push(candidate) + } + + return { candidates, headRepo: origin } +} + +export type ResolvedIssueSource = { + source: OwnerRepo | null + /** True when explicit upstream is gone and resolver fell back to origin. */ + fellBack: boolean +} + +export async function resolveIssueSource( + repoPath: string, + preference: IssueSourcePreference | undefined, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + if (preference === 'upstream') { + const upstream = await getOwnerRepoForRemote( + repoPath, + 'upstream', + connectionId, + localGitOptions + ) + if (upstream) { + return { source: upstream, fellBack: false } + } + const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) + return { source: origin, fellBack: origin !== null } + } + if (preference === 'origin') { + return { + source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), + fellBack: false + } + } + return { + source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), + fellBack: false + } +} diff --git a/src/main/github/github-remote-identity-parsing.test.ts b/src/main/github/github-remote-identity-parsing.test.ts index c0b8b599520..d2b9f1e629f 100644 --- a/src/main/github/github-remote-identity-parsing.test.ts +++ b/src/main/github/github-remote-identity-parsing.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' -import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing' +import { + effectiveGitHubRemoteHost, + gitHubSshConfigHostAlias, + parseGitHubOwnerRepo, + parseGitHubOwnerRepoWithResolvedSshHostname, + parseGitHubRemoteIdentity, + remoteUrlUsesSshTransport +} from './github-remote-identity-parsing' describe('parseGitHubRemoteIdentity', () => { it('parses a plain github.com https remote', () => { @@ -106,4 +113,78 @@ describe('parseGitHubOwnerRepo', () => { repo: 'orca' }) }) + + it('returns null for an SSH Host alias remote without HostName resolution', () => { + expect(parseGitHubOwnerRepo('git@github-work:team/orca.git')).toBeNull() + expect(parseGitHubOwnerRepo('git@github.com-work:team/orca.git')).toBeNull() + expect(parseGitHubOwnerRepo('ssh://git@github-work/team/orca.git')).toBeNull() + }) +}) + +describe('SSH Host alias identity (#10284)', () => { + it('detects SCP and ssh:// remotes as SSH transport', () => { + expect(remoteUrlUsesSshTransport('git@github-work:team/orca.git')).toBe(true) + expect(remoteUrlUsesSshTransport('ssh://git@github-work/team/orca.git')).toBe(true) + expect(remoteUrlUsesSshTransport('git+ssh://git@github-work/team/orca.git')).toBe(true) + expect(remoteUrlUsesSshTransport('https://github.com/team/orca.git')).toBe(false) + }) + + it('exposes Host aliases that need ssh -G expansion', () => { + expect(gitHubSshConfigHostAlias('git@github-work:team/orca.git')).toBe('github-work') + expect(gitHubSshConfigHostAlias('git@github.com-work:team/orca.git')).toBe('github.com-work') + expect(gitHubSshConfigHostAlias('ssh://git@github-work/team/orca.git')).toBe('github-work') + expect(gitHubSshConfigHostAlias('git@github.com:team/orca.git')).toBeNull() + expect(gitHubSshConfigHostAlias('https://github.com/team/orca.git')).toBeNull() + }) + + it('preserves SSH Host alias case for OpenSSH Host matching', () => { + expect(gitHubSshConfigHostAlias('git@GitHub-Work:team/orca.git')).toBe('GitHub-Work') + expect(gitHubSshConfigHostAlias('ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work') + expect(gitHubSshConfigHostAlias('git+ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work') + }) + + it('returns owner/repo when resolved HostName is github.com', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'github.com') + ).toEqual({ owner: 'team', repo: 'orca' }) + }) + + it('returns owner/repo when resolved HostName is ssh.github.com (SSH-over-HTTPS)', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'ssh.github.com') + ).toEqual({ owner: 'team', repo: 'orca' }) + }) + + it('keeps owner/repo for literal github.com even if resolved host is unused', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github.com:team/orca.git', null) + ).toEqual({ owner: 'team', repo: 'orca' }) + }) + + it('returns null when resolved HostName is a non-GitHub forge', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@gitlab-work:team/orca.git', 'gitlab.com') + ).toBeNull() + }) + + it('does not apply SSH HostName resolution to HTTPS remotes', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('https://github-work/team/orca.git', 'github.com') + ).toBeNull() + }) + + it('returns null when SSH resolution is missing', () => { + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', null) + ).toBeNull() + expect( + parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', ' ') + ).toBeNull() + }) + + it('normalizes effective host for enterprise routing after HostName expansion', () => { + expect(effectiveGitHubRemoteHost('github-work', 'ssh.github.com')).toBe('github.com') + expect(effectiveGitHubRemoteHost('ghe-work', 'ghe.acme.com')).toBe('ghe.acme.com') + expect(effectiveGitHubRemoteHost('github.com', null)).toBe('github.com') + }) }) diff --git a/src/main/github/github-remote-identity-parsing.ts b/src/main/github/github-remote-identity-parsing.ts index 6f13e62014d..cc8e283c11c 100644 --- a/src/main/github/github-remote-identity-parsing.ts +++ b/src/main/github/github-remote-identity-parsing.ts @@ -2,7 +2,7 @@ import type { GitHubOwnerRepo } from '../../shared/types' export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string } -function normalizeGitHubRemoteHost(host: string): string { +export function normalizeGitHubRemoteHost(host: string): string { const normalizedHost = host.toLowerCase() // Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos. return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost @@ -28,6 +28,44 @@ function parseGitHubRemotePath(path: string): Pick ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) -import { - getOwnerRepo, - getIssueOwnerRepo, - getOwnerRepoForRemote, - _resetOwnerRepoCache -} from './github-repository-identity' +import { getOwnerRepoForRemote, _resetOwnerRepoCache } from './github-repository-identity' +import { getOwnerRepo, getIssueOwnerRepo } from './github-owner-repo-selection' import { getRepoUpstream } from './client' const FORK_PATH = '/tmp/fork-checkout' diff --git a/src/main/github/github-repository-identity.ssh-host-alias.test.ts b/src/main/github/github-repository-identity.ssh-host-alias.test.ts new file mode 100644 index 00000000000..26ad3504bfa --- /dev/null +++ b/src/main/github/github-repository-identity.ssh-host-alias.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as GitRunner from '../git/runner' + +const { + commandExecFileAsyncMock, + getSshGitProviderGenerationMock, + getSshGitProviderMock, + gitExecFileAsyncMock, + resolveWithSshGMock, + readLocalGitConfigSignatureMock +} = vi.hoisted(() => ({ + commandExecFileAsyncMock: vi.fn(), + getSshGitProviderGenerationMock: vi.fn(() => 0), + getSshGitProviderMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + resolveWithSshGMock: vi.fn(), + readLocalGitConfigSignatureMock: vi.fn(async () => 'sig-10284') +})) + +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + commandExecFileAsync: commandExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock, + getSshGitProviderGeneration: getSshGitProviderGenerationMock +})) + +vi.mock('./local-git-config-signature', () => ({ + readLocalGitConfigSignature: readLocalGitConfigSignatureMock +})) + +vi.mock('../ssh/ssh-g-config-resolution', () => ({ + resolveWithSshG: resolveWithSshGMock +})) + +import { + getOwnerRepoForRemote, + _resetOwnerRepoCache, + _getOwnerRepoCacheSize +} from './github-repository-identity' +import { + classifyGitHubOwnerRepoFromRemoteUrl, + resolveGitHubOwnerRepoFromRemoteUrl, + _resetSshHostnameResolutionCache +} from './github-ssh-host-alias-resolution' + +const REPO = '/tmp/ssh-alias-checkout' + +function sshConfig(hostname: string, port = 22) { + return { + hostname, + port, + identityFile: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no' + } +} + +function mockRemoteUrl(url: string): void { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${url}\n` } + } + throw new Error(`unexpected git args: ${args.join(' ')}`) + }) +} + +function sshProvider(hostname: string, remoteUrl = 'git@github-work:team/orca.git') { + return { + exec: vi.fn().mockResolvedValue({ + stdout: `${remoteUrl}\n`, + stderr: '' + }), + execNonInteractive: vi.fn().mockResolvedValue({ + stdout: `hostname ${hostname}\nport 22\n`, + stderr: '', + exitCode: 0, + timedOut: false, + canceled: false + }) + } +} + +beforeEach(() => { + _resetOwnerRepoCache() + _resetSshHostnameResolutionCache() + commandExecFileAsyncMock.mockReset() + getSshGitProviderGenerationMock.mockReset() + getSshGitProviderGenerationMock.mockReturnValue(0) + getSshGitProviderMock.mockReset() + gitExecFileAsyncMock.mockReset() + resolveWithSshGMock.mockReset() + readLocalGitConfigSignatureMock.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('#10284 SSH Host alias → github.com owner/repo', () => { + it('resolveGitHubOwnerRepoFromRemoteUrl expands HostName ssh.github.com', async () => { + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) + + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') + ).resolves.toEqual({ owner: 'team', repo: 'orca' }) + expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work') + }) + + it('getOwnerRepoForRemote resolves SCP alias remote used for multi-account GitHub', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work') + }) + + it('getOwnerRepoForRemote resolves ssh:// Host alias remotes', async () => { + mockRemoteUrl('ssh://git@github.com-work/acme/widgets.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443)) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'acme', + repo: 'widgets' + }) + expect(resolveWithSshGMock).toHaveBeenCalledWith('github.com-work') + }) + + it('resolves aliases inside the repository WSL runtime', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + commandExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'hostname github.com\nport 22\n', + stderr: '' + }) + + await expect( + getOwnerRepoForRemote(REPO, 'origin', null, { wslDistro: 'Ubuntu' }) + ).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], { + cwd: REPO, + timeout: 5_000, + wslDistro: 'Ubuntu' + }) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('resolves aliases inside the repository SSH runtime', async () => { + const provider = sshProvider('github.com') + getSshGitProviderMock.mockReturnValue(provider) + getSshGitProviderGenerationMock.mockReturnValue(4) + + await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(provider.execNonInteractive).toHaveBeenCalledWith( + 'ssh', + ['-G', '--', 'github-work'], + '/remote/repo', + 5_000 + ) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('isolates the same alias across native and WSL runtimes', async () => { + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) + commandExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'hostname gitlab.com\nport 22\n', + stderr: '' + }) + + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git') + ).resolves.toEqual({ owner: 'team', repo: 'orca' }) + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', { + repoPath: REPO, + wslDistro: 'Ubuntu' + }) + ).resolves.toBeNull() + expect(resolveWithSshGMock).toHaveBeenCalledTimes(1) + expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('invalidates alias resolution when an SSH provider reconnects', async () => { + const context = { repoPath: '/remote/repo', connectionId: 'ssh-1' } + getSshGitProviderGenerationMock.mockReturnValue(1) + getSshGitProviderMock.mockReturnValue(sshProvider('github.com')) + + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) + ).resolves.toEqual({ owner: 'team', repo: 'orca' }) + + getSshGitProviderGenerationMock.mockReturnValue(2) + getSshGitProviderMock.mockReturnValue(sshProvider('gitlab.com')) + await expect( + resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context) + ).resolves.toBeNull() + }) + + it('invalidates owner/repo identity when an SSH provider reconnects', async () => { + getSshGitProviderGenerationMock.mockReturnValue(1) + getSshGitProviderMock.mockReturnValue( + sshProvider('github.com', 'git@github-work:team/orca.git') + ) + + await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + + getSshGitProviderGenerationMock.mockReturnValue(2) + getSshGitProviderMock.mockReturnValue( + sshProvider('github.com', 'git@github-work:acme/widgets.git') + ) + await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({ + owner: 'acme', + repo: 'widgets' + }) + }) + + it('does not call ssh -G for literal github.com remotes', async () => { + mockRemoteUrl('git@github.com:team/orca.git') + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('does not call ssh -G for https remotes', async () => { + mockRemoteUrl('https://github.com/team/orca.git') + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).not.toHaveBeenCalled() + }) + + it('returns null when alias resolves to a non-GitHub host', async () => { + mockRemoteUrl('git@gitlab-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + }) + + it('returns null when ssh -G fails for an alias', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValueOnce(null) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + }) + + it('does not rewrite transport: identity resolution only consumes HostName', async () => { + const remote = 'git@github-work:team/orca.git' + mockRemoteUrl(remote) + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com')) + + await getOwnerRepoForRemote(REPO, 'origin') + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['remote', 'get-url', 'origin'], + expect.objectContaining({ cwd: REPO }) + ) + const gitArgLists = gitExecFileAsyncMock.mock.calls.map(([args]) => args.join(' ')) + expect(gitArgLists.every((cmd) => cmd.startsWith('remote get-url'))).toBe(true) + }) + + it('classifies ssh -G failure as indeterminate (not stable not-github)', async () => { + resolveWithSshGMock.mockResolvedValueOnce(null) + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') + ).resolves.toEqual({ kind: 'indeterminate' }) + }) + + it('does not long-negative-cache owner/repo when ssh -G is indeterminate', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValue(null) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + expect(_getOwnerRepoCacheSize()).toBe(0) + + _resetSshHostnameResolutionCache() + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + expect(resolveWithSshGMock).toHaveBeenCalledTimes(2) + expect(_getOwnerRepoCacheSize()).toBe(0) + }) + + it('does not pin an SSH-config-dependent miss to the Git config signature', async () => { + vi.useFakeTimers() + mockRemoteUrl('git@forge-work:team/orca.git') + resolveWithSshGMock + .mockResolvedValueOnce(sshConfig('gitlab.com')) + .mockResolvedValueOnce(sshConfig('github.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull() + await vi.advanceTimersByTimeAsync(60_001) + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + }) + + it('caches a successful HostName expansion so repeat probes skip ssh -G', async () => { + mockRemoteUrl('git@github-work:team/orca.git') + resolveWithSshGMock.mockResolvedValue(sshConfig('github.com')) + + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({ + owner: 'team', + repo: 'orca' + }) + expect(resolveWithSshGMock).toHaveBeenCalledTimes(1) + }) + + it('isolates case-sensitive OpenSSH Host aliases in the cache', async () => { + resolveWithSshGMock.mockImplementation(async (host: string) => + sshConfig(host === 'GitHub-Work' ? 'github.com' : 'gitlab.com') + ) + + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@GitHub-Work:team/orca.git') + ).resolves.toEqual({ + kind: 'github', + ownerRepo: { owner: 'team', repo: 'orca' } + }) + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git') + ).resolves.toEqual({ + kind: 'not-github', + cacheWithGitConfigSignature: false + }) + expect(resolveWithSshGMock).toHaveBeenCalledTimes(2) + }) + + it('classifies a resolved non-GitHub HostName as not-github', async () => { + resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com')) + await expect( + classifyGitHubOwnerRepoFromRemoteUrl('git@gitlab-work:team/orca.git') + ).resolves.toEqual({ kind: 'not-github', cacheWithGitConfigSignature: false }) + }) +}) diff --git a/src/main/github/github-repository-identity.ts b/src/main/github/github-repository-identity.ts index 1996d7b2b54..f66b3dbe0d6 100644 --- a/src/main/github/github-repository-identity.ts +++ b/src/main/github/github-repository-identity.ts @@ -1,14 +1,14 @@ import { gitExecFileAsync } from '../git/runner' -import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types' -import { getSshGitProvider } from '../providers/ssh-git-dispatch' +import type { GitHubOwnerRepo } from '../../shared/types' +import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' import { readLocalGitConfigSignature } from './local-git-config-signature' import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity, type GitHubRemoteIdentity } from './github-remote-identity-parsing' +import { classifyGitHubOwnerRepoFromRemoteUrl } from './github-ssh-host-alias-resolution' import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error' -import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' export type OwnerRepo = GitHubOwnerRepo @@ -122,7 +122,9 @@ export async function getOwnerRepoForRemote( localGitOptions: LocalGitExecOptions = {} ): Promise { const context = githubRepoContext(repoPath, connectionId, localGitOptions) - const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}` + const runtimeKey = context.connectionId + ? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}` + : `local:${context.wslDistro ?? 'host'}` const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}` const now = Date.now() pruneOwnerRepoCache(now) @@ -177,15 +179,40 @@ async function resolveOwnerRepoForRemote( const now = Date.now() try { const remoteUrl = await getRemoteUrlForRepo(context, remoteName) - const result = remoteUrl ? parseGitHubOwnerRepo(remoteUrl) : null - if (result) { + if (!remoteUrl) { + // Empty remote URL is stable until git config changes. ownerRepoCache.set(cacheKey, { - value: result, - expiresAt: now + getOwnerRepoCacheTtl(result, configSignature) + value: null, + expiresAt: now + getOwnerRepoCacheTtl(null, configSignature), + ...(configSignature ? { configSignature } : {}) }) pruneOwnerRepoCache(now) - return result + return null } + // Why: PR mutations need the effective host behind an SSH alias. + const classification = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context) + if (classification.kind === 'github') { + ownerRepoCache.set(cacheKey, { + value: classification.ownerRepo, + expiresAt: now + getOwnerRepoCacheTtl(classification.ownerRepo, configSignature) + }) + pruneOwnerRepoCache(now) + return classification.ownerRepo + } + if (classification.kind === 'indeterminate') { + // Why: a failed ssh -G probe is not a stable "not GitHub" result. + return null + } + const stableConfigSignature = classification.cacheWithGitConfigSignature + ? configSignature + : undefined + ownerRepoCache.set(cacheKey, { + value: null, + expiresAt: now + getOwnerRepoCacheTtl(null, stableConfigSignature), + ...(stableConfigSignature ? { configSignature: stableConfigSignature } : {}) + }) + pruneOwnerRepoCache(now) + return null } catch (error) { // Why: only stable "no such remote" misses are safe to hold for minutes. // Transient git lock/IO failures must retry on the next lookup. @@ -193,7 +220,7 @@ async function resolveOwnerRepoForRemote( return null } } - // Why: a missing/non-GitHub remote is stable until `.git/config` changes. + // Why: a missing remote is stable until `.git/config` changes. // Holding that negative longer avoids Git process churn across PR polling. ownerRepoCache.set(cacheKey, { value: null, @@ -203,99 +230,3 @@ async function resolveOwnerRepoForRemote( pruneOwnerRepoCache(now) return null } - -export async function getOwnerRepo( - repoPath: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - // Why: on a fork checkout PRs live on the upstream parent, not origin (#7331). - const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) - if (upstream) { - return upstream - } - return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) -} - -export async function getIssueOwnerRepo( - repoPath: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) - if (upstream) { - return upstream - } - return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) -} - -export type PRRepositoryCandidates = { - candidates: OwnerRepo[] - headRepo: OwnerRepo | null -} - -function ownerRepoKey(ownerRepo: OwnerRepo): string { - return githubRepoIdentityKey(ownerRepo) -} - -export async function resolvePRRepositoryCandidates( - repoPath: string, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions) - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) - const seen = new Set() - const candidates: OwnerRepo[] = [] - - for (const candidate of [upstream, origin]) { - if (!candidate) { - continue - } - const key = ownerRepoKey(candidate) - if (seen.has(key)) { - continue - } - seen.add(key) - candidates.push(candidate) - } - - return { candidates, headRepo: origin } -} - -export type ResolvedIssueSource = { - source: OwnerRepo | null - /** True when explicit upstream is gone and resolver fell back to origin. */ - fellBack: boolean -} - -export async function resolveIssueSource( - repoPath: string, - preference: IssueSourcePreference | undefined, - connectionId?: string | null, - localGitOptions: LocalGitExecOptions = {} -): Promise { - if (preference === 'upstream') { - const upstream = await getOwnerRepoForRemote( - repoPath, - 'upstream', - connectionId, - localGitOptions - ) - if (upstream) { - return { source: upstream, fellBack: false } - } - const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions) - return { source: origin, fellBack: origin !== null } - } - if (preference === 'origin') { - return { - source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions), - fellBack: false - } - } - return { - source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions), - fellBack: false - } -} diff --git a/src/main/github/github-ssh-host-alias-resolution.ts b/src/main/github/github-ssh-host-alias-resolution.ts new file mode 100644 index 00000000000..a4988471977 --- /dev/null +++ b/src/main/github/github-ssh-host-alias-resolution.ts @@ -0,0 +1,192 @@ +import type { GitHubOwnerRepo } from '../../shared/types' +import { commandExecFileAsync } from '../git/runner' +import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { parseWslPath } from '../wsl' +import { resolveWithSshG } from '../ssh/ssh-g-config-resolution' +import { + gitHubSshConfigHostAlias, + parseGitHubOwnerRepo, + parseGitHubOwnerRepoWithResolvedSshHostname +} from './github-remote-identity-parsing' + +/** `indeterminate` means SSH alias expansion failed and must remain retryable. */ +export type GitHubOwnerRepoResolution = + | { kind: 'github'; ownerRepo: GitHubOwnerRepo } + | { kind: 'not-github'; cacheWithGitConfigSignature: boolean } + | { kind: 'indeterminate' } + +const SSH_HOSTNAME_CACHE_TTL_MS = 60_000 +const SSH_HOSTNAME_FAILURE_CACHE_TTL_MS = 5_000 +const SSH_HOSTNAME_CACHE_MAX = 256 +const SSH_G_TIMEOUT_MS = 5_000 + +export type SshConfigResolutionContext = { + repoPath: string + connectionId?: string | null + wslDistro?: string +} + +type SshHostnameCacheEntry = { + hostname: string | null + resolved: boolean + expiresAt: number +} + +const sshHostnameCache = new Map() +const sshHostnameInFlight = new Map>() + +/** @internal - tests only */ +export function _resetSshHostnameResolutionCache(): void { + sshHostnameCache.clear() + sshHostnameInFlight.clear() +} + +function pruneSshHostnameCache(now: number): void { + for (const [key, entry] of sshHostnameCache) { + if (entry.expiresAt <= now) { + sshHostnameCache.delete(key) + } + } + while (sshHostnameCache.size > SSH_HOSTNAME_CACHE_MAX) { + const oldest = sshHostnameCache.keys().next().value + if (oldest === undefined) { + return + } + sshHostnameCache.delete(oldest) + } +} + +function sshRuntimeCacheKey(context: SshConfigResolutionContext): string { + if (context.connectionId) { + const generation = getSshGitProviderGeneration(context.connectionId) + return `ssh:${context.connectionId}:${generation}` + } + const distro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro + return `local:${distro?.toLowerCase() ?? 'host'}` +} + +function parseSshGHostname(stdout: string): string | null { + for (const line of stdout.split(/\r?\n/)) { + const match = line.match(/^hostname\s+(.+)$/i) + if (match?.[1].trim()) { + return match[1].trim() + } + } + return null +} + +async function resolveSshHostnameInRuntime( + host: string, + context: SshConfigResolutionContext +): Promise { + if (context.connectionId) { + const provider = getSshGitProvider(context.connectionId) + if (!provider) { + return null + } + try { + const result = await provider.execNonInteractive( + 'ssh', + ['-G', '--', host], + context.repoPath, + SSH_G_TIMEOUT_MS + ) + return result.exitCode === 0 && !result.timedOut && !result.canceled + ? parseSshGHostname(result.stdout) + : null + } catch { + return null + } + } + + const wslDistro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro + if (!wslDistro) { + return (await resolveWithSshG(host))?.hostname?.trim() || null + } + try { + const { stdout } = await commandExecFileAsync('ssh', ['-G', '--', host], { + cwd: context.repoPath, + timeout: SSH_G_TIMEOUT_MS, + wslDistro + }) + return parseSshGHostname(stdout) + } catch { + return null + } +} + +/** Resolve OpenSSH Host → HostName in the repository runtime. */ +export async function resolveSshConfigHostname( + host: string, + context: SshConfigResolutionContext = { repoPath: '' } +): Promise<{ + hostname: string | null + resolved: boolean +}> { + const cacheKey = `${sshRuntimeCacheKey(context)}\0${host}` + const now = Date.now() + pruneSshHostnameCache(now) + const cached = sshHostnameCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return { hostname: cached.hostname, resolved: cached.resolved } + } + const inFlight = sshHostnameInFlight.get(cacheKey) + if (inFlight) { + const entry = await inFlight + return { hostname: entry.hostname, resolved: entry.resolved } + } + const probe = (async (): Promise => { + const hostname = await resolveSshHostnameInRuntime(host, context) + const resolved = hostname != null && hostname.length > 0 + const entry: SshHostnameCacheEntry = { + hostname: resolved ? hostname : null, + resolved, + expiresAt: + Date.now() + (resolved ? SSH_HOSTNAME_CACHE_TTL_MS : SSH_HOSTNAME_FAILURE_CACHE_TTL_MS) + } + sshHostnameCache.set(cacheKey, entry) + pruneSshHostnameCache(Date.now()) + return entry + })() + sshHostnameInFlight.set(cacheKey, probe) + try { + const entry = await probe + return { hostname: entry.hostname, resolved: entry.resolved } + } finally { + if (sshHostnameInFlight.get(cacheKey) === probe) { + sshHostnameInFlight.delete(cacheKey) + } + } +} + +/** Resolve github.com identity without rewriting the Git transport URL. */ +export async function classifyGitHubOwnerRepoFromRemoteUrl( + remoteUrl: string, + context: SshConfigResolutionContext = { repoPath: '' } +): Promise { + const direct = parseGitHubOwnerRepo(remoteUrl) + if (direct) { + return { kind: 'github', ownerRepo: direct } + } + const aliasHost = gitHubSshConfigHostAlias(remoteUrl) + if (!aliasHost) { + return { kind: 'not-github', cacheWithGitConfigSignature: true } + } + const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context) + if (!resolved || !hostname) { + return { kind: 'indeterminate' } + } + const ownerRepo = parseGitHubOwnerRepoWithResolvedSshHostname(remoteUrl, hostname) + return ownerRepo + ? { kind: 'github', ownerRepo } + : { kind: 'not-github', cacheWithGitConfigSignature: false } +} + +/** Convenience wrapper for callers that only need owner/repo or null. */ +export async function resolveGitHubOwnerRepoFromRemoteUrl( + remoteUrl: string, + context: SshConfigResolutionContext = { repoPath: '' } +): Promise { + const result = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context) + return result.kind === 'github' ? result.ownerRepo : null +} diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 61bbbc76ead..b67afba7e62 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -120,6 +120,7 @@ vi.mock('../worktree-root-preparation', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProviderGeneration: () => 0, getSshGitProvider: vi.fn().mockImplementation((id: string) => { if (id === 'conn-1') { return mockGitProvider diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index e632b03f0e0..4879edcdb08 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -156,6 +156,7 @@ vi.mock('../source-control/hosted-review', () => ({ })) vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProviderGeneration: () => 0, getSshGitProvider: getSshGitProviderMock, SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped. Click Reconnect on the SSH target before retrying.', From 1c174054316ed2a13aa6439fde10369f0d6cb836 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:11:08 +0000 Subject: [PATCH 063/150] Update README downloads badge --- docs/assets/readme-downloads.svg | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index a10c0826aab..72eea780cf3 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,21 +1,21 @@ - - downloads: 10.0m + + downloads: 10m - + - - + + downloads downloads - 10.0m - 10.0m + 10m + 10m From f7b49ad77b1cee96a93377ce0c766526d17b1dcd Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:13:48 -0700 Subject: [PATCH 064/150] fix(persistence): fsync state writes so a rename is actually durable (#10631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(persistence): fsync state writes so a rename is actually durable `Store` wrote `orca-data.json` to a temp file and renamed it. rename() is atomic for readers but says nothing about durability: without an fsync the directory entry can reach disk before the data does. After power loss or a hard crash the file can come back holding the previous state or, worse, zero bytes — and `JSON.parse('')` throws, so an empty file takes the full corrupt-file path rather than degrading. This is the same empty-file symptom as #1158 from a different cause. That issue fixed a logic path that persisted empty state and added the .bak ring as a safety net; the ring also catches this, which is why it went unnoticed. Recovery costs up to an hour of tabs/layouts/session state (backups are throttled to >=1h spacing), and a user in their first hour has no backup slot yet, so they land on defaults indistinguishable from a fresh install. Both write paths now fsync the temp file *before* the rename, then fsync the containing directory. Directory fsync is best-effort by design: Windows cannot open a directory for fsync and some filesystems reject it, so it is swallowed. The file fsync is the load-bearing part and works everywhere. Measured cost on a 3 MB payload: ~0.2 ms per write, against a 1s debounce. The async path does not block the main thread. The syscall-order test mocks `node:fs` and counts fsync targets at the module boundary, asserting ['file', 'directory'] — proving the ordering rather than inferring it from reading the implementation, since a fsync after the rename would still pass every content assertion. * test(persistence): make the syscall proof platform-aware and actually prove the order Two problems, both found from CodeRabbit's Windows observation. The assertion hardcoded ['file', 'directory']. Directory fsync is deliberately best-effort — Windows cannot open a directory for fsync and some filesystems reject it — so on Windows the helper swallows the failure, only the file fsync is observed, and the test fails. The expectation now probes the real platform instead of assuming, keeping the guarantee tight where directory fsync works rather than dropping it everywhere. Worse, the test did not prove what its name claimed. Moving the fsync to *after* the rename still passes: the file is fsynced either way, and only fsyncs were recorded, so the correct and broken orders produced an identical log. Mutation-testing the "before rename" claim is what surfaced this — the mutation passed. The rename is now recorded in the same sequence, since it is the boundary the ordering is defined against. Re-running the same mutation fails, so the ordering claim is now backed by the test rather than asserted in a comment. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../durable-file-write-syscall-proof.test.ts | 67 ++++++++++++++ src/main/durable-file-write.test.ts | 87 +++++++++++++++++++ src/main/durable-file-write.ts | 83 ++++++++++++++++++ src/main/persistence.ts | 19 ++-- 4 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 src/main/durable-file-write-syscall-proof.test.ts create mode 100644 src/main/durable-file-write.test.ts create mode 100644 src/main/durable-file-write.ts diff --git a/src/main/durable-file-write-syscall-proof.test.ts b/src/main/durable-file-write-syscall-proof.test.ts new file mode 100644 index 00000000000..bf18d6c0092 --- /dev/null +++ b/src/main/durable-file-write-syscall-proof.test.ts @@ -0,0 +1,67 @@ +// Empirical proof that the durable write fsyncs the file, and the directory where the platform +// allows it. Counted at the module boundary rather than inferred from reading the implementation. +import { closeSync, fsyncSync, mkdtempSync, openSync, readFileSync, rmSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' + +/** Why the rename is recorded too: an fsync moved after the rename still fsyncs a file, so a + * fsync-only log reads identically for the correct and the broken order. The rename is the boundary + * the ordering is defined against, so it has to appear in the same sequence. */ +const syscalls: ('fsync:file' | 'fsync:directory' | 'rename')[] = [] + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs') + return { + ...actual, + fsyncSync: (fd: number) => { + syscalls.push(actual.fstatSync(fd).isDirectory() ? 'fsync:directory' : 'fsync:file') + return actual.fsyncSync(fd) + }, + renameSync: (from: NodeFs.PathLike, to: NodeFs.PathLike) => { + syscalls.push('rename') + return actual.renameSync(from, to) + } + } +}) + +/** Windows cannot open a directory for fsync, and some filesystems reject it; probe rather than + * assume, so the expectation tracks the real platform instead of a hardcoded OS list. */ +function directoryFsyncSupported(directory: string): boolean { + let fd: number | null = null + try { + fd = openSync(directory, 'r') + fsyncSync(fd) + return true + } catch { + return false + } finally { + if (fd !== null) { + try { + closeSync(fd) + } catch { + // Nothing actionable in a probe. + } + } + } +} + +it('fsyncs the file before rename, and the directory after where supported', async () => { + const { writeFileDurableSync } = await import('./durable-file-write') + const dir = mkdtempSync(join(tmpdir(), 'orca-fsync-')) + try { + const supported = directoryFsyncSupported(dir) + syscalls.length = 0 // Discard the probe's own fsync. + const target = join(dir, 'x.json') + writeFileDurableSync(`${target}.tmp`, target, '{"ok":1}') + expect(readFileSync(target, 'utf-8')).toBe('{"ok":1}') + // The data fsync must precede the rename: that ordering is the entire fix. Publishing the name + // first is what lets a crash expose a stale or zero-length file. + expect(syscalls).toEqual( + supported ? ['fsync:file', 'rename', 'fsync:directory'] : ['fsync:file', 'rename'] + ) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src/main/durable-file-write.test.ts b/src/main/durable-file-write.test.ts new file mode 100644 index 00000000000..c2a44bf384c --- /dev/null +++ b/src/main/durable-file-write.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { writeFileDurable, writeFileDurableSync } from './durable-file-write' + +describe('durable file write', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-durable-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + for (const [label, write] of [ + ['async', (t: string, f: string, p: string) => writeFileDurable(t, f, p)], + [ + 'sync', + (t: string, f: string, p: string) => { + writeFileDurableSync(t, f, p) + return Promise.resolve() + } + ] + ] as const) { + describe(label, () => { + it('publishes the payload at the final path', async () => { + const final = join(dir, 'state.json') + await write(`${final}.tmp`, final, '{"a":1}') + expect(readFileSync(final, 'utf-8')).toBe('{"a":1}') + }) + + it('replaces existing content atomically', async () => { + const final = join(dir, 'state.json') + writeFileSync(final, 'stale', 'utf-8') + await write(`${final}.tmp`, final, 'fresh') + expect(readFileSync(final, 'utf-8')).toBe('fresh') + }) + + it('leaves no temp file behind on success', async () => { + const final = join(dir, 'state.json') + const tmp = `${final}.tmp` + await write(tmp, final, 'x') + expect(() => readFileSync(tmp, 'utf-8')).toThrow() + }) + + it('round-trips a multi-megabyte payload without truncation', async () => { + // Why: the real orca-data.json is large; a partial fsync would surface here. + const final = join(dir, 'big.json') + const payload = JSON.stringify({ blob: 'x'.repeat(4 * 1024 * 1024) }) + await write(`${final}.tmp`, final, payload) + expect(readFileSync(final, 'utf-8')).toHaveLength(payload.length) + }) + + it('preserves exact bytes for multibyte and escape-sensitive content', async () => { + const final = join(dir, 'utf8.json') + const payload = JSON.stringify({ s: 'emoji 🚀 + 日本語 + \u0000 + "quotes"' }) + await write(`${final}.tmp`, final, payload) + expect(readFileSync(final, 'utf-8')).toBe(payload) + }) + + it('surfaces an unwritable temp path instead of silently succeeding', async () => { + const final = join(dir, 'state.json') + const tmp = join(dir, 'missing-subdir', 'state.json.tmp') + // The sync variant throws synchronously and the async one rejects; both must fail loudly + // and neither may publish a partial file. + let failed = false + try { + await write(tmp, final, 'x') + } catch { + failed = true + } + expect(failed).toBe(true) + expect(() => readFileSync(final, 'utf-8')).toThrow() + }) + }) + } + + it('keeps the last writer when async and sync paths target one file', async () => { + const final = join(dir, 'state.json') + await writeFileDurable(`${final}.a.tmp`, final, 'from-async') + writeFileDurableSync(`${final}.b.tmp`, final, 'from-sync') + expect(readFileSync(final, 'utf-8')).toBe('from-sync') + }) +}) diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts new file mode 100644 index 00000000000..7f827ba8a34 --- /dev/null +++ b/src/main/durable-file-write.ts @@ -0,0 +1,83 @@ +// Why: rename() is atomic for readers but not durable. Without fsync on the file and its directory, +// a power loss after a successful rename can leave the old contents, or an empty inode — the same +// empty-file symptom as issue #1158, from a different cause. The .bak ring recovers it at up to an +// hour's loss; fsync stops it from happening. + +import { closeSync, fsyncSync, openSync, renameSync, writeFileSync } from 'node:fs' +import { open, rename } from 'node:fs/promises' +import { dirname } from 'node:path' + +/** + * fsync a directory so a rename within it is durable. Best-effort by design: Windows cannot open a + * directory for fsync, and some filesystems reject it. The file fsync above it is the load-bearing + * part; this closes the "rename recorded but not persisted" window where the platform allows it. + */ +async function syncDirectory(directory: string): Promise { + let handle: Awaited> | null = null + try { + handle = await open(directory, 'r') + await handle.sync() + } catch { + // Expected on Windows and on filesystems without directory fsync. + } finally { + await handle?.close().catch(() => {}) + } +} + +function syncDirectorySync(directory: string): void { + let fd: number | null = null + try { + fd = openSync(directory, 'r') + fsyncSync(fd) + } catch { + // Same platform caveats as syncDirectory. + } finally { + if (fd !== null) { + try { + closeSync(fd) + } catch { + // Nothing actionable; the fsync already happened or the open failed. + } + } + } +} + +/** + * Rename and then fsync the containing directory. For callers that already fsynced the temp file + * themselves and need the rename made durable. + */ +export async function renameDurable(tmpPath: string, finalPath: string): Promise { + await rename(tmpPath, finalPath) + await syncDirectory(dirname(finalPath)) +} + +/** Write `payload` to `tmpPath`, fsync it, then rename onto `finalPath` and fsync the directory. */ +export async function writeFileDurable( + tmpPath: string, + finalPath: string, + payload: string +): Promise { + const handle = await open(tmpPath, 'w') + try { + await handle.writeFile(payload, 'utf-8') + // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. + await handle.sync() + } finally { + await handle.close() + } + await rename(tmpPath, finalPath) + await syncDirectory(dirname(finalPath)) +} + +/** Synchronous counterpart for quit and crash paths that cannot await. */ +export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void { + writeFileSync(tmpPath, payload, 'utf-8') + const fd = openSync(tmpPath, 'r+') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } + renameSync(tmpPath, finalPath) + syncDirectorySync(dirname(finalPath)) +} diff --git a/src/main/persistence.ts b/src/main/persistence.ts index b26c68a9af5..48495b7a1e0 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -11,7 +11,8 @@ import { statSync, realpathSync } from 'node:fs' -import { writeFile, rename, mkdir, rm, copyFile } from 'node:fs/promises' +import { rename, mkdir, rm, copyFile, open } from 'node:fs/promises' +import { renameDurable, writeFileDurableSync } from './durable-file-write' import { join, dirname, isAbsolute, resolve, sep } from 'node:path' import { homedir } from 'node:os' import { createHash, randomUUID } from 'node:crypto' @@ -3686,12 +3687,19 @@ export class Store { // Why: on any write/rename failure, remove the tmp file so it doesn't leave a multi-MB orphan. let renamed = false try { - await writeFile(tmpFile, payload, 'utf-8') + // Why: fsync before rename, then fsync the directory; see writeFileDurable. + const handle = await open(tmpFile, 'w') + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } // Why: if flush() bumped writeGeneration mid-write, it already wrote fresher state; don't overwrite it. if (this.writeGeneration !== gen) { return } - await rename(tmpFile, dataFile) + await renameDurable(tmpFile, dataFile) renamed = true // Why re-check gen: a sync flush during the rename await may have written fresher state; don't record a stale hash over it. if (this.writeGeneration === gen) { @@ -3732,8 +3740,9 @@ export class Store { // Why: on any write/rename failure, remove the tmp file so shutdown crashes don't leak orphans. let renamed = false try { - writeFileSync(tmpFile, payload, 'utf-8') - renameSync(tmpFile, dataFile) + // Why: fsync the temp file and the directory; a bare rename can survive as stale or empty + // content after power loss, losing projects/tabs back to the newest usable .bak slot. + writeFileDurableSync(tmpFile, dataFile, payload) renamed = true this.lastWrittenStateHash = stateHash } finally { From 627cde33d58910b6422b63aacb88d035a3ba0582 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:21:52 -0700 Subject: [PATCH 065/150] fix(window): stop burning macOS GPU on an invisible blur effect (#8482) (#10682) Co-authored-by: Orca --- src/main/window/createMainWindow.test.ts | 53 ++++++++++++++++++++++++ src/main/window/createMainWindow.ts | 12 ++---- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 6076a505582..1e01e55974d 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -311,6 +311,59 @@ describe('createMainWindow', () => { } }) + it('never requests macOS vibrancy or transparency when window blur is enabled (#8482)', () => { + for (const [platform, expected] of [ + ['darwin', { backgroundMaterial: undefined }], + ['win32', { backgroundMaterial: 'acrylic' }], + ['linux', { backgroundMaterial: undefined }] + ] satisfies [NodeJS.Platform, { backgroundMaterial: string | undefined }][]) { + browserWindowMock.mockReset() + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn(), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + getBounds: vi.fn(() => ({ x: 10, y: 20, width: 1000, height: 700 })), + setSize: vi.fn(), + setWindowButtonPosition: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + withPlatform(platform, () => + createMainWindow({ + getUI: () => ({}), + getSettings: () => ({ windowBackgroundBlur: true }), + updateUI: vi.fn() + } as never) + ) + + const browserWindowOptions = browserWindowMock.mock.calls[0]?.[0] + expect(browserWindowOptions.vibrancy).toBeUndefined() + expect(browserWindowOptions.transparent).toBeUndefined() + expect(browserWindowOptions.backgroundMaterial).toBe(expected.backgroundMaterial) + expect(browserWindowOptions.backgroundColor).toBe('#ffffff') + } + }) + it('keeps main-window background throttling enabled while repainting macOS visibility transitions', () => { vi.useFakeTimers() const windowHandlers = new Map void)[]>() diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 29e92a8de10..387f0d2b5f4 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -253,14 +253,10 @@ export function createMainWindow( return false }) const blur = settings?.windowBackgroundBlur ?? false - // Why: blur uses platform APIs (macOS vibrancy+transparent, Windows backgroundMaterial, Linux none) and only applies at creation, needs restart. - const platformBlurOptions = blur - ? process.platform === 'darwin' - ? { vibrancy: 'under-window' as const, transparent: true } - : process.platform === 'win32' - ? { backgroundMaterial: 'acrylic' as const } - : {} - : {} + // Why: only Windows acrylic is ever visible; macOS vibrancy+transparent sat behind our opaque background yet + // forced per-frame WindowServer alpha compositing (#8482). Applies at creation only, so it needs a restart. + const platformBlurOptions = + blur && process.platform === 'win32' ? { backgroundMaterial: 'acrylic' as const } : {} const mainWindow = new BrowserWindow({ width: savedBounds?.width ?? defaultBounds.width, From 28dfc1365499516bd47293da5645ff1d06043b75 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:41:03 -0700 Subject: [PATCH 066/150] feat(sidebar): distinguish and filter CLI-created workspaces (#10712) --- src/cli/handlers/worktree.ts | 3 + src/cli/index.test.ts | 70 ++++++++-- src/main/ipc/worktree-metadata-merge.ts | 1 + src/main/ipc/worktree-remote.ts | 4 + src/main/ipc/worktrees.ts | 4 + src/main/runtime/orca-runtime.ts | 9 +- .../runtime/rpc/methods/client-ui-schemas.ts | 1 + .../runtime/rpc/methods/worktree-schemas.ts | 10 +- src/main/runtime/rpc/methods/worktree.ts | 5 + src/main/worktree-removal-safety.ts | 3 +- src/renderer/src/App.tsx | 3 + .../src/components/WorktreeJumpPalette.tsx | 6 + .../src/components/sidebar/SidebarFilter.tsx | 23 +++- .../sidebar/SidebarWorkspaceFilterSection.tsx | 13 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 3 + .../src/components/sidebar/WorktreeCard.tsx | 12 +- .../sidebar/WorktreeCardCliDetailSection.tsx | 51 +++++++ .../components/sidebar/WorktreeCardMeta.tsx | 115 +++------------- .../sidebar/WorktreeCardMetaBadges.tsx | 127 ++++++++++++++++++ .../src/components/sidebar/WorktreeList.tsx | 10 ++ .../sidebar/sidebar-workspace-option-items.ts | 7 + ...e-visible-workspace-kanban-worktree-ids.ts | 3 + .../sidebar/visible-worktrees.test.ts | 77 +++++++++++ .../components/sidebar/visible-worktrees.ts | 14 ++ .../sidebar/worktree-card-meta-types.ts | 7 +- src/renderer/src/i18n/locales/en.json | 16 ++- src/renderer/src/i18n/locales/es.json | 16 ++- src/renderer/src/i18n/locales/ja.json | 16 ++- src/renderer/src/i18n/locales/ko.json | 16 ++- src/renderer/src/i18n/locales/zh.json | 16 ++- src/renderer/src/lib/startup-ui-hydration.ts | 1 + src/renderer/src/lib/worktree-activation.ts | 3 + .../slices/store-session-cascades.test.ts | 2 + src/renderer/src/store/slices/ui.ts | 5 + src/shared/cli-workspace-provenance.test.ts | 49 +++++++ src/shared/cli-workspace-provenance.ts | 22 +++ src/shared/constants.ts | 1 + src/shared/types.ts | 20 +++ src/shared/worktree-card-properties.ts | 2 + 39 files changed, 629 insertions(+), 137 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardMetaBadges.tsx create mode 100644 src/shared/cli-workspace-provenance.test.ts create mode 100644 src/shared/cli-workspace-provenance.ts diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts index b4547cfe69a..d4dd90fdb4b 100644 --- a/src/cli/handlers/worktree.ts +++ b/src/cli/handlers/worktree.ts @@ -246,6 +246,9 @@ export const WORKTREE_HANDLERS: Record = { ...(cwdParentWorktree ? { cwdParentWorktree } : {}), noParent, callerTerminalHandle, + // Why: marks the workspace as CLI-created so the sidebar can badge and + // filter it. Sent on every `worktree create` — hand-typed or agent-run. + cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {}, ...(startupAgent ? { startupAgent, diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index f61b6fc09ff..529c8c78a6a 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1163,7 +1163,8 @@ describe('orca cli worktree awareness', () => { parentWorktree: undefined, cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1211,7 +1212,8 @@ describe('orca cli worktree awareness', () => { activate: false, parentWorktree: undefined, noParent: true, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1330,7 +1332,8 @@ describe('orca cli worktree awareness', () => { parentWorktree: undefined, cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1400,7 +1403,8 @@ describe('orca cli worktree awareness', () => { activate: false, parentWorktree: undefined, noParent: true, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1539,7 +1543,8 @@ describe('orca cli worktree awareness', () => { activate: false, parentWorktree: 'id:repo-1::/tmp/repo/parent', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1584,7 +1589,8 @@ describe('orca cli worktree awareness', () => { activate: false, parentWorktree: 'branch:feature/parent', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1651,7 +1657,8 @@ describe('orca cli worktree awareness', () => { parentWorktree: undefined, parentWorkspace: testCase.parentWorkspace, noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) } }) @@ -1695,7 +1702,8 @@ describe('orca cli worktree awareness', () => { envParentWorkspace: 'folder:folder-1', cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1737,7 +1745,8 @@ describe('orca cli worktree awareness', () => { activate: false, parentWorktree: 'id:repo-1::/tmp/repo/parent', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -1798,7 +1807,8 @@ describe('orca cli worktree awareness', () => { parentWorktree: undefined, parentWorkspace: 'folder:folder-1', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) } }) @@ -1945,7 +1955,8 @@ describe('orca cli worktree awareness', () => { parentWorktree: undefined, parentWorkspace: 'folder:missing', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) expect(output).toContain('"ok": false') expect(output).toContain('Parent selector was not found.') @@ -1985,7 +1996,8 @@ describe('orca cli worktree awareness', () => { activate: false, parentWorktree: undefined, noParent: true, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -2020,10 +2032,37 @@ describe('orca cli worktree awareness', () => { parentWorktree: undefined, cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, - callerTerminalHandle: 'term_parent' + callerTerminalHandle: 'term_parent', + cliProvenanceRequest: { callerTerminalHandle: 'term_parent' } }) }) + it('marks every worktree.create as CLI-created even from an external shell', async () => { + // Why: the sidebar badge/filter must catch hand-typed creates too, so the + // provenance request is sent with no terminal handle rather than omitted. + delete process.env.ORCA_TERMINAL_HANDLE + queueFixtures( + callMock, + okFixture('req_create_external', { + worktree: buildWorktree('/tmp/repo/child', 'child', 'abc', 'repo-1'), + lineage: null, + warnings: [] + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main( + ['worktree', 'create', '--repo', 'id:repo-1', '--name', 'child', '--no-parent', '--json'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith( + 'worktree.create', + expect.objectContaining({ cliProvenanceRequest: {} }) + ) + }) + it('starts a foreground headless server through `serve`', async () => { serveOrcaAppMock.mockResolvedValue(0) process.env.ORCA_ENVIRONMENT = 'stale-env' @@ -2766,7 +2805,8 @@ describe('orca cli worktree awareness', () => { parentWorktree: undefined, cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, - callerTerminalHandle: undefined + callerTerminalHandle: undefined, + cliProvenanceRequest: {} }) }) @@ -2814,6 +2854,7 @@ describe('orca cli worktree awareness', () => { cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, callerTerminalHandle: undefined, + cliProvenanceRequest: {}, startupAgent: 'codex', startupPrompt: 'hi' }) @@ -2858,6 +2899,7 @@ describe('orca cli worktree awareness', () => { cwdParentWorktree: 'id:repo-1::/tmp/repo', noParent: false, callerTerminalHandle: undefined, + cliProvenanceRequest: {}, startupAgent: 'codex', startupPrompt: 'hi' }) diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index cd3ba8385a4..8c4aa0efead 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -47,6 +47,7 @@ export function mergeWorktree( ...(meta?.automationProvenance !== undefined ? { automationProvenance: meta.automationProvenance } : {}), + ...(meta?.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), ...(meta?.pendingFirstAgentMessageRename !== undefined ? { pendingFirstAgentMessageRename: meta.pendingFirstAgentMessageRename } : {}), diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 01bbd31bfef..b86e5c0ec42 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -8,6 +8,7 @@ import { randomUUID } from 'node:crypto' import type { Store } from '../persistence' import type { AutomationWorkspaceProvenance, + CliWorkspaceProvenance, CreateWorktreeArgs, CreateWorktreeResult, GitPushTarget, @@ -66,6 +67,7 @@ import { type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance } import { sanitizeWorktreeName, @@ -1823,6 +1825,7 @@ export async function createRemoteWorktree( orcaCreationSource: 'ssh', orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), @@ -2405,6 +2408,7 @@ export async function createLocalWorktree( orcaCreationSource: 'desktop', orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), baseRef: metadataBaseRef, ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 22aec867378..d41a92f90ff 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -17,6 +17,7 @@ import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-l import { deleteWorktreeHistoryDir } from '../terminal-history' import type { AutomationWorkspaceProvenance, + CliWorkspaceProvenance, CreateWorktreeArgs, CreateWorktreeResult, DetectedWorktree, @@ -110,6 +111,7 @@ import { shouldEmitBoundedWarning } from './bounded-warning-dedupe' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance } type RemoveWorktreeArgs = { @@ -841,6 +843,7 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta ...(meta.automationProvenance !== undefined ? { automationProvenance: meta.automationProvenance } : {}), + ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, @@ -931,6 +934,7 @@ function createFolderWorkspace( orcaCreatedAt: now, orcaCreationSource: 'desktop', ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.createdWithAgent ? { createdWithAgent: args.createdWithAgent } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a37a37c5e95..6e66cda4e60 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -105,6 +105,7 @@ import type { } from '../../shared/automations-types' import type { AutomationWorkspaceProvenance, + CliWorkspaceProvenance, BaseRefSearchResult, CreateWorktreeResult, DetectedWorktree, @@ -1855,6 +1856,7 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt ...(meta.automationProvenance !== undefined ? { automationProvenance: meta.automationProvenance } : {}), + ...(meta.cliProvenance !== undefined ? { cliProvenance: meta.cliProvenance } : {}), ...(meta.priorWorktreeIds !== undefined ? { priorWorktreeIds: meta.priorWorktreeIds } : {}), workspaceStatus: meta.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, diffComments: meta.diffComments, @@ -18256,6 +18258,7 @@ export class OrcaRuntimeService { startupPrompt?: string pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance startup?: WorktreeStartupLaunch startupDraft?: string startupDraftPaste?: WorktreeStartupDraftPaste @@ -18316,6 +18319,7 @@ export class OrcaRuntimeService { nestWorkspaces: settings.nestWorkspaces }, ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}), ...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}), ...(args.linkedLinearIssue !== undefined @@ -18921,6 +18925,7 @@ export class OrcaRuntimeService { ? { pendingFirstAgentMessageRename: true } : {}), ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}), ...(args.comment !== undefined ? { comment: args.comment } : {}), ...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}), ...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}) @@ -19270,6 +19275,7 @@ export class OrcaRuntimeService { createdWithAgent?: TuiAgent pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance startup?: WorktreeStartupLaunch startupFollowup?: WorktreeStartupFollowup startupDraftPaste?: WorktreeStartupDraftPaste @@ -19321,7 +19327,8 @@ export class OrcaRuntimeService { ...(args.pendingFirstAgentMessageRename === true ? { pendingFirstAgentMessageRename: true } : {}), - ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}) + ...(args.automationProvenance ? { automationProvenance: args.automationProvenance } : {}), + ...(args.cliProvenance ? { cliProvenance: args.cliProvenance } : {}) }, repo, this.store as unknown as Store, diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 64b0443260f..f7236c61df3 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -197,6 +197,7 @@ export const UiUpdate = z .optional(), hideDefaultBranchWorkspace: z.boolean().optional(), hideAutomationGeneratedWorkspaces: z.boolean().optional(), + hideCliCreatedWorkspaces: z.boolean().optional(), filterRepoIds: StringArray.optional(), collapsedGroups: StringArray.optional(), uiZoomLevel: z.number().finite().optional(), diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 5bac18bf90f..b79fb320f59 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -29,6 +29,13 @@ const AutomationWorkspaceProvenanceRequest = z.object({ createRequestId: z.string() }) +// Why no dispatch token (unlike automation provenance): this is a descriptive +// origin marker for sidebar filtering, not an authority grant. The host stamps +// createdAt itself so a client clock can't skew sort order. +const CliWorkspaceProvenanceRequest = z.object({ + callerTerminalHandle: OptionalString +}) + export const WorktreeListParams = z.object({ repo: OptionalString, limit: OptionalFiniteNumber @@ -149,7 +156,8 @@ export const WorktreeCreate = z // Why: mobile retries a create interrupted by a connection migration with the // same key so the host dedupes instead of spawning a duplicate worktree. clientMutationId: z.string().min(1).max(128).optional(), - automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional() + automationProvenanceRequest: AutomationWorkspaceProvenanceRequest.optional(), + cliProvenanceRequest: CliWorkspaceProvenanceRequest.optional() }) .superRefine((params, ctx) => { if ((params.parentWorkspace || params.parentWorktree) && params.noParent === true) { diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 05e7b0a08a3..0f23bccac5e 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -3,6 +3,7 @@ import { releaseAutomationWorkspaceProvenanceRequest, resolveAutomationWorkspaceProvenance } from '../../../automations/workspace-provenance' +import { buildCliWorkspaceProvenance } from '../../../../shared/cli-workspace-provenance' import { defineMethod, type RpcMethod } from '../core' import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' import { @@ -119,6 +120,10 @@ export const WORKTREE_METHODS: RpcMethod[] = [ setupDecision: params.setupDecision, createdWithAgent: params.createdWithAgent ?? params.startupAgent, automationProvenance, + cliProvenance: buildCliWorkspaceProvenance(params.cliProvenanceRequest, { + startupAgent: params.startupAgent ?? params.createdWithAgent, + createdAt: Date.now() + }), startup: params.startupCommand ? { command: params.startupCommand, diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index a589bce9e10..5aa8d619d92 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -22,7 +22,8 @@ const ORCA_OWNED_PROVENANCE_META_KEYS = [ 'orcaCreatedAt', 'orcaCreationSource', 'orcaCreationWorkspaceLayout', - 'automationProvenance' + 'automationProvenance', + 'cliProvenance' ] as const type UnregisteredOrcaCleanupMeta = Pick< WorktreeMeta, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b0a6f6f539c..842a7a9e213 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -638,6 +638,7 @@ function App(): React.JSX.Element { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey) @@ -1337,6 +1338,7 @@ function App(): React.JSX.Element { showSleepingWorkspaces, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, showDotfilesByWorktree, filterRepoIds, // Why (#9002): activeView is deliberately NOT included here. It used to @@ -1367,6 +1369,7 @@ function App(): React.JSX.Element { showSleepingWorkspaces, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, showDotfilesByWorktree, filterRepoIds, acknowledgedAgentsByPaneKey diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index ae38e76bb3e..538615f1824 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -30,6 +30,7 @@ import type { LinkedWorkItemSummary } from '@/lib/new-workspace' import { sortWorktreesSmart } from '@/components/sidebar/smart-sort' import { isAutomationGeneratedWorkspace, + isCliCreatedWorkspace, isDefaultBranchWorkspace } from '@/components/sidebar/visible-worktrees' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' @@ -392,6 +393,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null) @@ -482,6 +484,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { if (hideAutomationGeneratedWorkspaces && isAutomationGeneratedWorkspace(worktree)) { return false } + if (hideCliCreatedWorkspaces && isCliCreatedWorkspace(worktree)) { + return false + } if ( !showSleepingWorkspaces && isInactiveWorkspace( @@ -500,6 +505,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { allWorktrees, browserTabsByWorktree, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, hideDefaultBranchWorkspace, ptyIdsByTabId, showSleepingWorkspaces, diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 3e66fe5cc48..a594144cb34 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -1,5 +1,14 @@ import React, { useCallback, useMemo, useState } from 'react' -import { CalendarClock, Check, FolderPlus, GitBranch, ListFilter, Moon, Server } from 'lucide-react' +import { + CalendarClock, + Check, + FolderPlus, + GitBranch, + ListFilter, + Moon, + Server, + SquareTerminal +} from 'lucide-react' import { useAppStore } from '@/store' import { Button } from '@/components/ui/button' import { @@ -48,6 +57,8 @@ const SidebarFilter = React.memo(function SidebarFilter({ const setHideAutomationGeneratedWorkspaces = useAppStore( (s) => s.setHideAutomationGeneratedWorkspaces ) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) + const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const repos = useAppStore((s) => s.repos) @@ -98,11 +109,13 @@ const SidebarFilter = React.memo(function SidebarFilter({ hasSleepingFilter || hideDefaultBranchWorkspace || hideAutomationGeneratedWorkspaces || + hideCliCreatedWorkspaces || hasRepoFilter const activeFilterCount = (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + (hideAutomationGeneratedWorkspaces ? 1 : 0) + + (hideCliCreatedWorkspaces ? 1 : 0) + selectedCount const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) @@ -116,11 +129,13 @@ const SidebarFilter = React.memo(function SidebarFilter({ setShowSleepingWorkspaces(DEFAULT_SHOW_SLEEPING_WORKSPACES) setHideDefaultBranchWorkspace(false) setHideAutomationGeneratedWorkspaces(false) + setHideCliCreatedWorkspaces(false) setFilterRepoIds([]) }, [ setShowSleepingWorkspaces, setHideDefaultBranchWorkspace, setHideAutomationGeneratedWorkspaces, + setHideCliCreatedWorkspaces, setFilterRepoIds ]) @@ -208,6 +223,12 @@ const SidebarFilter = React.memo(function SidebarFilter({ checked={hideAutomationGeneratedWorkspaces} onChange={setHideAutomationGeneratedWorkspaces} /> + } + label={translate('auto.components.sidebar.SidebarFilter.cliCreated', 'Hide CLI-created')} + checked={hideCliCreatedWorkspaces} + onChange={setHideCliCreatedWorkspaces} + /> {canFilterRepos && ( <> diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 8190acc5d22..3469d2d24df 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { CalendarClock, GitBranch, Moon } from 'lucide-react' +import { CalendarClock, GitBranch, Moon, SquareTerminal } from 'lucide-react' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -13,6 +13,8 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setHideAutomationGeneratedWorkspaces = useAppStore( (s) => s.setHideAutomationGeneratedWorkspaces ) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) + const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) return ( <> @@ -48,6 +50,15 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter checked={hideAutomationGeneratedWorkspaces} onChange={setHideAutomationGeneratedWorkspaces} /> + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.cliCreated', + 'Hide CLI-created' + )} + checked={hideCliCreatedWorkspaces} + onChange={setHideCliCreatedWorkspaces} + /> ) }) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index fcfbfb9ed95..139ad4ae933 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -38,6 +38,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const repos = useAppStore((s) => s.repos) const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) @@ -80,12 +81,14 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM hasSleepingFilter || hideDefaultBranchWorkspace || hideAutomationGeneratedWorkspaces || + hideCliCreatedWorkspaces || hasRepoFilter || hasHostVisibilityFilter const activeFilterCount = (hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + (hideAutomationGeneratedWorkspaces ? 1 : 0) + + (hideCliCreatedWorkspaces ? 1 : 0) + (hasHostVisibilityFilter ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index b50de019b10..474d3a19dbf 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -640,6 +640,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const showLinearIssue = cardProps.includes('linear-issue') const showPR = cardProps.includes('pr') const showAutomation = cardProps.includes('automation') + const showCli = cardProps.includes('cli') const showComment = cardProps.includes('comment') const showPorts = cardProps.includes('ports') const shouldRefreshHostedReview = newCardStyle ? showStatus : showPR @@ -1056,6 +1057,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ const metaLinearIssue = showLinearIssue ? hoverLinearIssue : null const metaReview = showPR ? hoverReview : null const metaAutomationProvenance = showAutomation ? worktree.automationProvenance : null + const metaCliProvenance = showCli ? worktree.cliProvenance : null const metaComment = showComment ? hoverComment : null const showInlineAgentList = cardProps.includes('inline-agents') && (newCardStyle || !compactCards) const compactInlineAgentRows = useWorktreeAgentRows( @@ -1156,7 +1158,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue: metaLinearIssue, review: newCardStyle ? null : metaReview, comment: metaComment, - automationProvenance: metaAutomationProvenance + automationProvenance: metaAutomationProvenance, + cliProvenance: metaCliProvenance }) const hasPorts = showPorts && workspacePorts.length > 0 const cacheStartedAt = usePromptCacheCountdownStartedAt(worktree.id, showAggregateCacheTimer) @@ -1228,7 +1231,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ linearIssue: hoverLinearIssue, review: hoverReview, comment: hoverComment, - automationProvenance: metaAutomationProvenance + automationProvenance: metaAutomationProvenance, + cliProvenance: metaCliProvenance }) || workspacePorts.length > 0 || hasHoverIdentity) @@ -1245,6 +1249,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ review={metaReview} comment={metaComment} automationProvenance={metaAutomationProvenance} + cliProvenance={metaCliProvenance} automationHostId={worktree.hostId} branchName={showBranchIdentityHover ? branch : undefined} workspaceTitle={worktree.displayName} @@ -1300,6 +1305,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ review={newCardStyle ? null : metaReview} comment={metaComment} automationProvenance={metaAutomationProvenance} + cliProvenance={metaCliProvenance} className="ml-0 pr-0" /> )} @@ -1313,6 +1319,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ review={metaReview} comment={metaComment} automationProvenance={metaAutomationProvenance} + cliProvenance={metaCliProvenance} automationHostId={worktree.hostId} detailsAfter={hasPorts ? : null} hoverControl={detailsHoverControl} @@ -1806,6 +1813,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ review={hoverReview} comment={hoverComment} automationProvenance={metaAutomationProvenance} + cliProvenance={metaCliProvenance} automationHostId={worktree.hostId} branchName={hoverBranchName} workspaceTitle={hoverWorkspaceTitle} diff --git a/src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx b/src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx new file mode 100644 index 00000000000..c5903340002 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardCliDetailSection.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { SquareTerminal } from 'lucide-react' +import type { CliWorkspaceProvenance } from '../../../../shared/types' +import { formatAgentTypeLabel } from '../../../../shared/agent-type-label' +import { + WorktreeCardDetailSection, + WorktreeCardDetailSectionContent +} from './WorktreeCardDetailSection' +import { DetailHeader } from './WorktreeCardMetadataControls' +import { translate } from '@/i18n/i18n' + +export function WorktreeCardCliDetailSection({ + provenance +}: { + provenance: CliWorkspaceProvenance +}): React.JSX.Element { + const agentLabel = provenance.startupAgent + ? formatAgentTypeLabel(provenance.startupAgent) + : undefined + + return ( + + } + label={translate('auto.components.sidebar.WorktreeCardMeta.cliHeader', 'Orca CLI')} + /> + +
+ {provenance.callerTerminalHandle + ? translate( + 'auto.components.sidebar.WorktreeCardMeta.cliCreatedFromAgent', + 'Created by an agent via `orca worktree create`' + ) + : translate( + 'auto.components.sidebar.WorktreeCardMeta.cliCreatedFromShell', + 'Created via `orca worktree create`' + )} +
+ {agentLabel ? ( +
+ {translate( + 'auto.components.sidebar.WorktreeCardMeta.cliStartupAgent', + 'Started with {{value0}}', + { value0: agentLabel } + )} +
+ ) : null} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx index f225a19e0e3..80038ec1a69 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardMeta.tsx @@ -1,9 +1,8 @@ import React from 'react' import { Badge } from '@/components/ui/badge' import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card' -import { CalendarClock, CircleDot, ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' +import { ExternalLink, MonitorUp, Pencil, StickyNote } from 'lucide-react' import { toast } from 'sonner' -import { cn } from '@/lib/utils' import { LinearIcon } from '@/components/icons/LinearIcon' import { SelectedTextCopyMenu } from '@/components/SelectedTextCopyMenu' import CommentMarkdown from './CommentMarkdown' @@ -12,10 +11,11 @@ import { WorktreeCardDetailSection, WorktreeCardDetailSectionContent } from './WorktreeCardDetailSection' -import { DetailHeader, MetaIconBadge, MetadataActionIcon } from './WorktreeCardMetadataControls' +import { DetailHeader, MetadataActionIcon } from './WorktreeCardMetadataControls' +import { hasWorktreeCardDetails, WorktreeCardMetaBadges } from './WorktreeCardMetaBadges' import { LinearStateBadge } from './WorktreeCardMetadataStatusBadges' import { useWorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' -import { getReviewLabel, ReviewIcon } from './worktree-review-helpers' +import { getReviewLabel } from './worktree-review-helpers' import type { WorktreeCardIssueDisplay, WorktreeCardLinearIssueDisplay, @@ -26,6 +26,7 @@ import type { import { translate } from '@/i18n/i18n' import { WorktreeCardReviewDetailSection } from './WorktreeCardReviewDetailSection' import { WorktreeCardAutomationDetailSection } from './WorktreeCardAutomationDetailSection' +import { WorktreeCardCliDetailSection } from './WorktreeCardCliDetailSection' import { WorktreeCardIssueDetailSection } from './WorktreeCardIssueDetailSection' import { WorktreeCardHoverIdentityHeader } from './WorktreeCardHoverIdentityHeader' @@ -37,106 +38,21 @@ export type { WorktreeCardDetailsHoverProps } +// Re-exported so existing importers keep their entry point after the badge +// row moved into its own module. +export { hasWorktreeCardDetails, WorktreeCardMetaBadges } + function hasComment(comment: string | null): boolean { return (comment ?? '').trim().length > 0 } -export function hasWorktreeCardDetails({ - issue, - linearIssue, - review, - comment, - automationProvenance -}: WorktreeCardMetaBadgesProps): boolean { - return Boolean(issue || linearIssue || review || hasComment(comment) || automationProvenance) -} - -export const WorktreeCardMetaBadges = React.forwardRef< - HTMLDivElement, - WorktreeCardMetaBadgesRootProps ->(function WorktreeCardMetaBadges( - { issue, linearIssue, review, comment, automationProvenance, className, ...props }, - ref -): React.JSX.Element | null { - if (!hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance })) { - return null - } - - return ( - // Why: Radix HoverCardTrigger uses `asChild`, so this group must forward - // trigger props/ref to the actual DOM node for attachment-only hover. -
- {hasComment(comment) && ( - - - - )} - {automationProvenance && ( - - - - )} - {issue && ( - - - - )} - {linearIssue && ( - - - - )} - {review && ( - - - - )} -
- ) -}) - export function WorktreeCardDetailsHover({ issue, linearIssue, review, comment, automationProvenance, + cliProvenance, children, branchName, workspaceTitle, @@ -241,7 +157,14 @@ export function WorktreeCardDetailsHover({ if ( !showIdentityHeader && - !hasWorktreeCardDetails({ issue, linearIssue, review, comment, automationProvenance }) && + !hasWorktreeCardDetails({ + issue, + linearIssue, + review, + comment, + automationProvenance, + cliProvenance + }) && !detailsAfter ) { return children @@ -365,6 +288,8 @@ export function WorktreeCardDetailsHover({ /> )} + {cliProvenance && } + {hasComment(comment) && ( 0 +} + +export function hasWorktreeCardDetails({ + issue, + linearIssue, + review, + comment, + automationProvenance, + cliProvenance +}: WorktreeCardMetaBadgesProps): boolean { + return Boolean( + issue || linearIssue || review || hasComment(comment) || automationProvenance || cliProvenance + ) +} + +export const WorktreeCardMetaBadges = React.forwardRef< + HTMLDivElement, + WorktreeCardMetaBadgesRootProps +>(function WorktreeCardMetaBadges( + { issue, linearIssue, review, comment, automationProvenance, cliProvenance, className, ...props }, + ref +): React.JSX.Element | null { + if ( + !hasWorktreeCardDetails({ + issue, + linearIssue, + review, + comment, + automationProvenance, + cliProvenance + }) + ) { + return null + } + + return ( + // Why: Radix HoverCardTrigger uses `asChild`, so this group must forward + // trigger props/ref to the actual DOM node for attachment-only hover. +
+ {hasComment(comment) && ( + + + + )} + {automationProvenance && ( + + + + )} + {cliProvenance && ( + + + + )} + {issue && ( + + + + )} + {linearIssue && ( + + + + )} + {review && ( + + + + )} +
+ ) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 90372e7cf1d..857f8d7f92f 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5212,6 +5212,7 @@ const WorktreeList = React.memo(function WorktreeList({ const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -5497,6 +5498,7 @@ const WorktreeList = React.memo(function WorktreeList({ ), hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -5512,6 +5514,7 @@ const WorktreeList = React.memo(function WorktreeList({ showSleepingWorkspaces, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, workspaceHostScope, visibleWorkspaceHostIds, settings, @@ -6482,6 +6485,7 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, visibleWorkspaceHostIds, workspaceHostScope }), @@ -6490,6 +6494,7 @@ const WorktreeList = React.memo(function WorktreeList({ filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, visibleWorkspaceHostIds, workspaceHostScope ] @@ -6500,6 +6505,7 @@ const WorktreeList = React.memo(function WorktreeList({ const setHideAutomationGeneratedWorkspaces = useAppStore( (s) => s.setHideAutomationGeneratedWorkspaces ) + const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) @@ -6517,6 +6523,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideAutomationGeneratedWorkspaces) { setHideAutomationGeneratedWorkspaces(false) } + if (actions.resetHideCliCreatedWorkspaces) { + setHideCliCreatedWorkspaces(false) + } if (actions.resetVisibleWorkspaceHostIds) { setVisibleWorkspaceHostIds(null) } @@ -6525,6 +6534,7 @@ const WorktreeList = React.memo(function WorktreeList({ setFilterRepoIds, setHideDefaultBranchWorkspace, setHideAutomationGeneratedWorkspaces, + setHideCliCreatedWorkspaces, setVisibleWorkspaceHostIds, filterState ]) diff --git a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts index 8623d35d0bb..ce662d80fb9 100644 --- a/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts +++ b/src/renderer/src/components/sidebar/sidebar-workspace-option-items.ts @@ -96,6 +96,13 @@ const BASE_WORKTREE_CARD_PROPERTY_OPTIONS: WorktreeCardPropertyOption[] = [ ) } }, + { + id: 'cli', + properties: ['cli'], + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.cli', 'Orca CLI') + } + }, { id: 'ports', properties: ['ports'], diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 841c7e35bed..7edcf8a45f9 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -20,6 +20,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) + const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const workspaceHostScope = useAppStore((s) => s.workspaceHostScope) const visibleWorkspaceHostIds = useAppStore((s) => s.visibleWorkspaceHostIds) const settings = useAppStore((s) => s.settings) @@ -57,6 +58,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ worktreeIdsWithLiveAgent, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -73,6 +75,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ filterRepoIds, hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces, workspaceHostScope, visibleWorkspaceHostIds, settings, diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 0525568ec4b..7c3ff699334 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -82,6 +82,7 @@ function visibleOptions(overrides: Partial = {}): VisibleOptions worktreeIdsWithLiveAgent: new Set(), hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, repoMap, workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, @@ -98,6 +99,7 @@ function filterState(overrides: Partial = {}): FilterState { filterRepoIds: [], hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, workspaceHostScope: 'all', ...overrides } @@ -163,6 +165,57 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([manual.id]) }) + it('hides CLI-created workspaces when the CLI filter is enabled', () => { + const manual = makeWorktree('manual') + const cliCreated = { + ...makeWorktree('cli-created'), + cliProvenance: { + kind: 'created-by-cli' as const, + createdAt: 123, + callerTerminalHandle: 'terminal-1', + startupAgent: 'claude' as const + } + } + + const result = computeVisibleWorktreeIds( + { repo1: [manual, cliCreated] }, + [manual.id, cliCreated.id], + visibleOptions({ hideCliCreatedWorkspaces: true }) + ) + + expect(result).toEqual([manual.id]) + }) + + it('keeps CLI-created workspaces visible while the CLI filter is off', () => { + const manual = makeWorktree('manual') + const cliCreated = { + ...makeWorktree('cli-created'), + cliProvenance: { kind: 'created-by-cli' as const, createdAt: 123 } + } + + const result = computeVisibleWorktreeIds( + { repo1: [manual, cliCreated] }, + [manual.id, cliCreated.id], + visibleOptions() + ) + + expect(result).toEqual([manual.id, cliCreated.id]) + }) + + it('keeps workspaces without CLI provenance visible when the CLI filter is enabled', () => { + // Why: workspaces persisted before cliProvenance existed have no marker and + // must never be filtered as CLI-created. + const legacy = makeWorktree('legacy') + + const result = computeVisibleWorktreeIds( + { repo1: [legacy] }, + [legacy.id], + visibleOptions({ hideCliCreatedWorkspaces: true }) + ) + + expect(result).toEqual([legacy.id]) + }) + it('does not treat slept wake-hint tabs as live surfaces', () => { const wt = makeWorktree('wt-slept') @@ -539,6 +592,10 @@ describe('computeVisibleWorktreeIds', () => { hostId: 'local' } expect(run(automationParent, { hideAutomationGeneratedWorkspaces: true })).toEqual([child.id]) + + const cliParent = makeWorktree('cli-parent') + cliParent.cliProvenance = { kind: 'created-by-cli', createdAt: 1 } + expect(run(cliParent, { hideCliCreatedWorkspaces: true })).toEqual([child.id]) }) it('includes inline lineage ancestors when send-target mode forces a filtered child visible', () => { @@ -733,6 +790,10 @@ describe('sidebarHasActiveFilters', () => { ) }) + it('returns true when only CLI-created workspaces are hidden', () => { + expect(sidebarHasActiveFilters(filterState({ hideCliCreatedWorkspaces: true }))).toBe(true) + }) + it('returns true when sleeping workspaces are hidden', () => { expect(sidebarHasActiveFilters(filterState({ showSleepingWorkspaces: false }))).toBe(true) }) @@ -753,6 +814,7 @@ describe('computeClearFilterActions', () => { resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: false, resetHideAutomationGeneratedWorkspaces: false, + resetHideCliCreatedWorkspaces: false, resetVisibleWorkspaceHostIds: false }) }) @@ -766,6 +828,7 @@ describe('computeClearFilterActions', () => { resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: true, resetHideAutomationGeneratedWorkspaces: false, + resetHideCliCreatedWorkspaces: false, resetVisibleWorkspaceHostIds: false }) }) @@ -778,6 +841,18 @@ describe('computeClearFilterActions', () => { resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: false, resetHideAutomationGeneratedWorkspaces: true, + resetHideCliCreatedWorkspaces: false, + resetVisibleWorkspaceHostIds: false + }) + }) + + it('flags only hideCliCreatedWorkspaces for reset when it is the sole filter', () => { + expect(computeClearFilterActions(filterState({ hideCliCreatedWorkspaces: true }))).toEqual({ + resetShowSleepingWorkspaces: false, + resetFilterRepoIds: false, + resetHideDefaultBranchWorkspace: false, + resetHideAutomationGeneratedWorkspaces: false, + resetHideCliCreatedWorkspaces: true, resetVisibleWorkspaceHostIds: false }) }) @@ -801,6 +876,7 @@ describe('computeClearFilterActions', () => { resetFilterRepoIds: false, resetHideDefaultBranchWorkspace: false, resetHideAutomationGeneratedWorkspaces: false, + resetHideCliCreatedWorkspaces: false, resetVisibleWorkspaceHostIds: true }) }) @@ -821,6 +897,7 @@ describe('computeClearFilterActions', () => { resetFilterRepoIds: true, resetHideDefaultBranchWorkspace: true, resetHideAutomationGeneratedWorkspaces: true, + resetHideCliCreatedWorkspaces: false, resetVisibleWorkspaceHostIds: true }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 4287c5caf3a..24c3878e69d 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -33,12 +33,17 @@ export function isAutomationGeneratedWorkspace(worktree: Worktree): boolean { return worktree.automationProvenance?.kind === 'created-by-automation' } +export function isCliCreatedWorkspace(worktree: Worktree): boolean { + return worktree.cliProvenance?.kind === 'created-by-cli' +} + /** Inputs describing sidebar filter settings that the Clear Filters path owns. */ export type SidebarFilterState = { showSleepingWorkspaces: boolean filterRepoIds: readonly string[] hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean + hideCliCreatedWorkspaces: boolean visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null workspaceHostScope?: ExecutionHostScope } @@ -58,6 +63,7 @@ export function sidebarHasActiveFilters(state: SidebarFilterState): boolean { state.filterRepoIds.length > 0 || state.hideDefaultBranchWorkspace || state.hideAutomationGeneratedWorkspaces || + state.hideCliCreatedWorkspaces || state.visibleWorkspaceHostIds != null || (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) ) @@ -70,6 +76,7 @@ export type ClearFilterActions = { resetFilterRepoIds: boolean resetHideDefaultBranchWorkspace: boolean resetHideAutomationGeneratedWorkspaces: boolean + resetHideCliCreatedWorkspaces: boolean resetVisibleWorkspaceHostIds: boolean } @@ -89,6 +96,7 @@ export function computeClearFilterActions(state: SidebarFilterState): ClearFilte resetFilterRepoIds: state.filterRepoIds.length > 0, resetHideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, resetHideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, + resetHideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, resetVisibleWorkspaceHostIds: state.visibleWorkspaceHostIds != null || (state.workspaceHostScope != null && state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE) @@ -123,6 +131,7 @@ export function computeVisibleWorktreeIds( // forgetting to pass it. hideDefaultBranchWorkspace: boolean hideAutomationGeneratedWorkspaces: boolean + hideCliCreatedWorkspaces: boolean repoMap: Map workspaceHostScope: ExecutionHostScope visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null @@ -149,6 +158,10 @@ export function computeVisibleWorktreeIds( all = all.filter((w) => !isAutomationGeneratedWorkspace(w)) } + if (opts.hideCliCreatedWorkspaces) { + all = all.filter((w) => !isCliCreatedWorkspace(w)) + } + const visibleHostIds = opts.visibleWorkspaceHostIds ?? (opts.workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [opts.workspaceHostScope]) @@ -325,6 +338,7 @@ export function getVisibleWorktreeIds(): string[] { ), hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, repoMap, workspaceHostScope: state.workspaceHostScope, visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts index faf61492483..5f72f9b027d 100644 --- a/src/renderer/src/components/sidebar/worktree-card-meta-types.ts +++ b/src/renderer/src/components/sidebar/worktree-card-meta-types.ts @@ -1,5 +1,9 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { AutomationWorkspaceProvenance, IssueInfo } from '../../../../shared/types' +import type { + AutomationWorkspaceProvenance, + CliWorkspaceProvenance, + IssueInfo +} from '../../../../shared/types' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' import type { WorktreeCardDetailsHoverControl } from './worktree-card-details-hover-state' @@ -27,6 +31,7 @@ export type WorktreeCardMetaBadgesProps = { review: WorktreeCardPrDisplay | null comment: string | null automationProvenance?: AutomationWorkspaceProvenance | null + cliProvenance?: CliWorkspaceProvenance | null } export type WorktreeCardMetaBadgesRootProps = WorktreeCardMetaBadgesProps & diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 015e5f37fa5..a904c79bbe7 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4124,7 +4124,8 @@ "75405270ed": "Edit filters ({{value0}} active)", "489d1c8c9f": "Search projects...", "ee240a39eb": "Edit filters", - "automationCreated": "Hide automation-created" + "automationCreated": "Hide automation-created", + "cliCreated": "Hide CLI-created" }, "SidebarHeader": { "25a95899c9": "Add Project", @@ -4187,7 +4188,8 @@ "c3fa13dc2e": "Hide default branch", "ed1611b65b": "Hide sleeping", "82594419ba": "Filters", - "automationCreated": "Hide automation-created" + "automationCreated": "Hide automation-created", + "cliCreated": "Hide CLI-created" }, "sidebarHostOptions": { "3e102f111c": "All hosts", @@ -4250,7 +4252,8 @@ "2d74665a56": "Ports", "65a9820bd1": "Agent statuses", "219ebf1961": "Branch name", - "folderPathIdentity": "Branch / folder path" + "folderPathIdentity": "Branch / folder path", + "cli": "Orca CLI" }, "SshDisconnectedDialog": { "ca4a7892af": "Connecting...", @@ -4371,7 +4374,12 @@ "checkingAutomationAvailability": "Checking automation availability...", "automationMissing": "Automation no longer available.", "automationRunMissing": "Run history no longer available.", - "automationAvailabilityUnavailable": "Automation availability could not be checked." + "automationAvailabilityUnavailable": "Automation availability could not be checked.", + "cliHeader": "Orca CLI", + "cliCreatedFromAgent": "Created by an agent via `orca worktree create`", + "cliCreatedFromShell": "Created via `orca worktree create`", + "cliStartupAgent": "Started with {{value0}}", + "cliCreated": "Created by Orca CLI" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "State: Open", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 643c084310f..ed53674bbc2 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4086,7 +4086,8 @@ "75405270ed": "Editar filtros ({{value0}} activo)", "489d1c8c9f": "Buscar proyectos...", "ee240a39eb": "Editar filtros", - "automationCreated": "Ocultar creados por automatizaciones" + "automationCreated": "Ocultar creados por automatizaciones", + "cliCreated": "Hide CLI-created" }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", @@ -4149,7 +4150,8 @@ "c3fa13dc2e": "Ocultar rama predeterminada", "ed1611b65b": "Ocultar en reposo", "82594419ba": "Filtros", - "automationCreated": "Ocultar creados por automatizaciones" + "automationCreated": "Ocultar creados por automatizaciones", + "cliCreated": "Hide CLI-created" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Diseño de actividad del Agent", @@ -4204,7 +4206,8 @@ "2d74665a56": "Puertos", "65a9820bd1": "Estados de Agents", "219ebf1961": "Nombre de rama", - "folderPathIdentity": "Rama / ruta de carpeta" + "folderPathIdentity": "Rama / ruta de carpeta", + "cli": "Orca CLI" }, "SshDisconnectedDialog": { "ca4a7892af": "Conectando...", @@ -4325,7 +4328,12 @@ "copyLinkSuccess": "{{value0}} copiado", "copyLinkFailure": "No se pudo copiar el enlace", "issueLinkLabel": "Enlace del issue", - "reviewLinkLabel": "Enlace de {{value0}}" + "reviewLinkLabel": "Enlace de {{value0}}", + "cliHeader": "Orca CLI", + "cliCreatedFromAgent": "Created by an agent via `orca worktree create`", + "cliCreatedFromShell": "Created via `orca worktree create`", + "cliStartupAgent": "Started with {{value0}}", + "cliCreated": "Created by Orca CLI" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "Estado: Abierto", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 24b9d9a871b..b151276ec20 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4067,7 +4067,8 @@ "75405270ed": "フィルターの編集 ({{value0}} がアクティブ)", "489d1c8c9f": "プロジェクトを検索...", "ee240a39eb": "フィルターの編集", - "automationCreated": "自動化で作成されたワークスペースを非表示" + "automationCreated": "自動化で作成されたワークスペースを非表示", + "cliCreated": "Hide CLI-created" }, "SidebarHeader": { "92154beb7e": "新規ワークスペース", @@ -4130,7 +4131,8 @@ "c3fa13dc2e": "デフォルトのブランチを非表示にする", "ed1611b65b": "スリープ中を非表示", "82594419ba": "フィルター", - "automationCreated": "自動化で作成されたワークスペースを非表示" + "automationCreated": "自動化で作成されたワークスペースを非表示", + "cliCreated": "Hide CLI-created" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent アクティビティのレイアウト", @@ -4185,7 +4187,8 @@ "65a9820bd1": "Agent ステータス", "219ebf1961": "ブランチ名", "automation": "自動化", - "folderPathIdentity": "ブランチ / フォルダパス" + "folderPathIdentity": "ブランチ / フォルダパス", + "cli": "Orca CLI" }, "SshDisconnectedDialog": { "ca4a7892af": "接続中...", @@ -4306,7 +4309,12 @@ "copyLinkSuccess": "{{value0}} をコピーしました", "copyLinkFailure": "リンクのコピーに失敗しました", "issueLinkLabel": "Issue リンク", - "reviewLinkLabel": "{{value0}} リンク" + "reviewLinkLabel": "{{value0}} リンク", + "cliHeader": "Orca CLI", + "cliCreatedFromAgent": "Created by an agent via `orca worktree create`", + "cliCreatedFromShell": "Created via `orca worktree create`", + "cliStartupAgent": "Started with {{value0}}", + "cliCreated": "Created by Orca CLI" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状態: オープン", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d77d2f79d9f..65d811b84be 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -4067,7 +4067,8 @@ "75405270ed": "필터 편집({{value0}} 활성)", "489d1c8c9f": "프로젝트 검색...", "ee240a39eb": "필터 편집", - "automationCreated": "자동화로 생성된 워크스페이스 숨기기" + "automationCreated": "자동화로 생성된 워크스페이스 숨기기", + "cliCreated": "Hide CLI-created" }, "SidebarHeader": { "92154beb7e": "새로운 워크스페이스", @@ -4130,7 +4131,8 @@ "c3fa13dc2e": "기본 브랜치 숨기기", "ed1611b65b": "슬립 중인 항목 숨기기", "82594419ba": "필터", - "automationCreated": "자동화로 생성된 워크스페이스 숨기기" + "automationCreated": "자동화로 생성된 워크스페이스 숨기기", + "cliCreated": "Hide CLI-created" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 활동 레이아웃", @@ -4185,7 +4187,8 @@ "65a9820bd1": "Agent 상태", "219ebf1961": "브랜치 이름", "automation": "자동화", - "folderPathIdentity": "브랜치 / 폴더 경로" + "folderPathIdentity": "브랜치 / 폴더 경로", + "cli": "Orca CLI" }, "SshDisconnectedDialog": { "ca4a7892af": "연결 중...", @@ -4306,7 +4309,12 @@ "copyLinkSuccess": "{{value0}} 복사됨", "copyLinkFailure": "링크 복사 실패", "issueLinkLabel": "이슈 링크", - "reviewLinkLabel": "{{value0}} 링크" + "reviewLinkLabel": "{{value0}} 링크", + "cliHeader": "Orca CLI", + "cliCreatedFromAgent": "Created by an agent via `orca worktree create`", + "cliCreatedFromShell": "Created via `orca worktree create`", + "cliStartupAgent": "Started with {{value0}}", + "cliCreated": "Created by Orca CLI" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "상태: 열림", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 28e8c153460..182cb5dd573 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -4067,7 +4067,8 @@ "75405270ed": "编辑筛选条件({{value0}} 个已启用)", "489d1c8c9f": "搜索项目...", "ee240a39eb": "编辑筛选条件", - "automationCreated": "隐藏自动化创建的工作区" + "automationCreated": "隐藏自动化创建的工作区", + "cliCreated": "Hide CLI-created" }, "SidebarHeader": { "92154beb7e": "新工作区", @@ -4130,7 +4131,8 @@ "c3fa13dc2e": "隐藏默认分支", "ed1611b65b": "隐藏休眠项", "82594419ba": "筛选条件", - "automationCreated": "隐藏自动化创建的工作区" + "automationCreated": "隐藏自动化创建的工作区", + "cliCreated": "Hide CLI-created" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "智能体活动布局", @@ -4185,7 +4187,8 @@ "65a9820bd1": "智能体状态", "219ebf1961": "分支名称", "automation": "自动化", - "folderPathIdentity": "分支 / 文件夹路径" + "folderPathIdentity": "分支 / 文件夹路径", + "cli": "Orca CLI" }, "SshDisconnectedDialog": { "ca4a7892af": "正在连接...", @@ -4306,7 +4309,12 @@ "copyLinkSuccess": "{{value0}} 已复制", "copyLinkFailure": "复制链接失败", "issueLinkLabel": "议题链接", - "reviewLinkLabel": "{{value0}} 链接" + "reviewLinkLabel": "{{value0}} 链接", + "cliHeader": "Orca CLI", + "cliCreatedFromAgent": "Created by an agent via `orca worktree create`", + "cliCreatedFromShell": "Created via `orca worktree create`", + "cliStartupAgent": "Started with {{value0}}", + "cliCreated": "Created by Orca CLI" }, "WorktreeCardMetadataStatusBadges": { "fe188062a1": "状态:开放", diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index ba8775cbf72..bee87b3052e 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -48,6 +48,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState hideSleepingWorkspaces: DEFAULT_HIDE_SLEEPING_WORKSPACES, showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, + hideCliCreatedWorkspaces: false, hideAutomationGeneratedWorkspaces: false, filterRepoIds: [], collapsedGroups: [], diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index c23feaf74ca..5fef7f80e98 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -364,6 +364,9 @@ export function activateAndRevealWorktree( ) { state.setHideAutomationGeneratedWorkspaces(false) } + if (state.hideCliCreatedWorkspaces && wt.cliProvenance?.kind === 'created-by-cli') { + state.setHideCliCreatedWorkspaces(false) + } // 6. Reveal in sidebar if (opts?.revealInSidebar !== false) { diff --git a/src/renderer/src/store/slices/store-session-cascades.test.ts b/src/renderer/src/store/slices/store-session-cascades.test.ts index ac7d2921772..98761ca5cac 100644 --- a/src/renderer/src/store/slices/store-session-cascades.test.ts +++ b/src/renderer/src/store/slices/store-session-cascades.test.ts @@ -1650,6 +1650,7 @@ describe('reconnectPersistedTerminals', () => { worktreeIdsWithLiveAgent: new Set(), hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, @@ -1995,6 +1996,7 @@ describe('reconnectPersistedTerminals', () => { worktreeIdsWithLiveAgent: new Set(), hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, repoMap: new Map(s.repos.map((repo) => [repo.id, repo])), workspaceHostScope: 'all', defaultHostId: LOCAL_EXECUTION_HOST_ID, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 47def908ed7..6988e5cc474 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -866,6 +866,8 @@ export type UISlice = { setHideDefaultBranchWorkspace: (v: boolean) => void hideAutomationGeneratedWorkspaces: boolean setHideAutomationGeneratedWorkspaces: (v: boolean) => void + hideCliCreatedWorkspaces: boolean + setHideCliCreatedWorkspaces: (v: boolean) => void showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -2029,6 +2031,8 @@ export const createUISlice: StateCreator = (set, get) setHideDefaultBranchWorkspace: (v) => set({ hideDefaultBranchWorkspace: v }), hideAutomationGeneratedWorkspaces: false, setHideAutomationGeneratedWorkspaces: (v) => set({ hideAutomationGeneratedWorkspaces: v }), + hideCliCreatedWorkspaces: false, + setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), showDotfilesByWorktree: {}, setShowDotfilesForWorktree: (worktreeId, showDotfiles) => @@ -2427,6 +2431,7 @@ export const createUISlice: StateCreator = (set, get) repos: orderedRepos, hideDefaultBranchWorkspace: ui.hideDefaultBranchWorkspace ?? false, hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, + hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. filterRepoIds: diff --git a/src/shared/cli-workspace-provenance.test.ts b/src/shared/cli-workspace-provenance.test.ts new file mode 100644 index 00000000000..3fee463480e --- /dev/null +++ b/src/shared/cli-workspace-provenance.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { buildCliWorkspaceProvenance } from './cli-workspace-provenance' + +describe('buildCliWorkspaceProvenance', () => { + it('returns undefined when the caller sent no CLI provenance request', () => { + // Why: desktop/mobile/automation creates must stay unmarked. + expect(buildCliWorkspaceProvenance(undefined, { createdAt: 5 })).toBeUndefined() + }) + + it('marks a bare create with the host-stamped timestamp', () => { + expect(buildCliWorkspaceProvenance({}, { createdAt: 5 })).toEqual({ + kind: 'created-by-cli', + createdAt: 5 + }) + }) + + it('ignores a client-supplied createdAt so a skewed CLI clock cannot drive sort order', () => { + const provenance = buildCliWorkspaceProvenance( + { createdAt: 9_999 } as Parameters[0], + { createdAt: 5 } + ) + expect(provenance?.createdAt).toBe(5) + }) + + it('records the caller terminal handle for agent-issued creates', () => { + expect( + buildCliWorkspaceProvenance({ callerTerminalHandle: 'term-1' }, { createdAt: 5 }) + ).toEqual({ + kind: 'created-by-cli', + createdAt: 5, + callerTerminalHandle: 'term-1' + }) + }) + + it('omits an empty terminal handle rather than persisting a blank field', () => { + expect(buildCliWorkspaceProvenance({ callerTerminalHandle: '' }, { createdAt: 5 })).toEqual({ + kind: 'created-by-cli', + createdAt: 5 + }) + }) + + it('records the startup agent when the create passed --agent', () => { + expect(buildCliWorkspaceProvenance({}, { createdAt: 5, startupAgent: 'claude' })).toEqual({ + kind: 'created-by-cli', + createdAt: 5, + startupAgent: 'claude' + }) + }) +}) diff --git a/src/shared/cli-workspace-provenance.ts b/src/shared/cli-workspace-provenance.ts new file mode 100644 index 00000000000..3334a7b6516 --- /dev/null +++ b/src/shared/cli-workspace-provenance.ts @@ -0,0 +1,22 @@ +import type { CliWorkspaceProvenance, TuiAgent } from './types' + +/** Client-supplied context for a `orca worktree create`. Descriptive only — + * the host stamps `createdAt` so a skewed client clock can't affect sort order. */ +export type CliWorkspaceProvenanceRequest = { + callerTerminalHandle?: string +} + +export function buildCliWorkspaceProvenance( + request: CliWorkspaceProvenanceRequest | undefined, + options: { startupAgent?: TuiAgent; createdAt: number } +): CliWorkspaceProvenance | undefined { + if (!request) { + return undefined + } + return { + kind: 'created-by-cli', + createdAt: options.createdAt, + ...(request.callerTerminalHandle ? { callerTerminalHandle: request.callerTerminalHandle } : {}), + ...(options.startupAgent ? { startupAgent: options.startupAgent } : {}) + } +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e19bbebf1af..3b1ff27ed7d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -459,6 +459,7 @@ export function getDefaultUIState(): PersistedUIState { showSleepingWorkspaces: DEFAULT_SHOW_SLEEPING_WORKSPACES, hideDefaultBranchWorkspace: false, hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/types.ts b/src/shared/types.ts index a76e2429904..d94617f7dc1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -535,8 +535,22 @@ export type Worktree = { diffComments?: DiffComment[] mobileDiffReview?: MobileDiffReviewState automationProvenance?: AutomationWorkspaceProvenance + cliProvenance?: CliWorkspaceProvenance } & GitWorktreeInfo +/** Provenance for workspaces created through `orca worktree create`. Absent on + * workspaces created before this field existed and on every non-CLI create, so + * consumers must read "missing" as "not CLI-created". */ +export type CliWorkspaceProvenance = { + kind: 'created-by-cli' + createdAt: number + /** Orca terminal the CLI ran inside, when the caller had one — distinguishes + * an agent-issued create from one hand-typed in an external shell. */ + callerTerminalHandle?: string + /** Agent requested via `--agent`, when one was passed. */ + startupAgent?: TuiAgent +} + export type AutomationWorkspaceProvenance = { kind: 'created-by-automation' automationId: string @@ -646,6 +660,8 @@ export type WorktreeMeta = { mobileDiffReview?: MobileDiffReviewState /** System-owned provenance for workspaces created by automation new-per-run dispatches. */ automationProvenance?: AutomationWorkspaceProvenance + /** System-owned provenance for workspaces created via `orca worktree create`. */ + cliProvenance?: CliWorkspaceProvenance } export type WorktreeOwnership = 'orca-managed' | 'external' | 'unknown-legacy' | 'agent-scratch' @@ -3137,6 +3153,8 @@ export type WorktreeCardProperty = | 'linear-issue' | 'pr' | 'automation' + // Badge marking workspaces created through `orca worktree create`. + | 'cli' | 'comment' | 'ports' // Inline agent-activity list rendered in each workspace card; on by default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts). @@ -3245,6 +3263,8 @@ export type PersistedUIState = { hideDefaultBranchWorkspace: boolean /** Hide workspaces created by automation new-per-run dispatches. */ hideAutomationGeneratedWorkspaces?: boolean + /** Hide workspaces created through `orca worktree create`. */ + hideCliCreatedWorkspaces?: boolean /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ showDotfilesByWorktree?: Record filterRepoIds: string[] diff --git a/src/shared/worktree-card-properties.ts b/src/shared/worktree-card-properties.ts index 76b1c32eb31..372f444a6ad 100644 --- a/src/shared/worktree-card-properties.ts +++ b/src/shared/worktree-card-properties.ts @@ -14,6 +14,7 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [ ...TASK_WORKTREE_CARD_PROPERTIES, 'pr', 'automation', + 'cli', 'comment', 'ports', // Why: agent activity is the primary reason users opt into the feature, so @@ -47,6 +48,7 @@ const WORKTREE_CARD_PROPERTY_ORDER: WorktreeCardProperty[] = [ 'linear-issue', 'pr', 'automation', + 'cli', 'comment', 'ports', 'inline-agents' From a84dd33afba9f825f6dadb784ca7dd754af57395 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:46:28 -0700 Subject: [PATCH 067/150] perf(relay): stop snapshotting the whole pending-PTY map every drain tick (#10670) --- ...-handler-output-drain-differential.test.ts | 306 ++++++++++++++++++ src/relay/pty-handler.ts | 19 +- 2 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 src/relay/pty-handler-output-drain-differential.test.ts diff --git a/src/relay/pty-handler-output-drain-differential.test.ts b/src/relay/pty-handler-output-drain-differential.test.ts new file mode 100644 index 00000000000..b2527e92ec4 --- /dev/null +++ b/src/relay/pty-handler-output-drain-differential.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' + +const { mockPtySpawn, mockPtyInstance } = vi.hoisted(() => ({ + mockPtySpawn: vi.fn(), + mockPtyInstance: { + pid: process.pid, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + clear: vi.fn() + } +})) + +vi.mock('node-pty', () => ({ spawn: mockPtySpawn })) + +import { PtyHandler } from './pty-handler' +import type { RelayDispatcher } from './dispatcher' + +// Mirrors the relay drain constants; kept local so a constant change fails this oracle loudly. +const CHUNK_CHARS = 16 * 1024 +const MAX_WRITES = 2 +const BATCH_INTERVAL_MS = 8 +const DRAIN_CONTINUE_MS = 1 + +type PendingOutput = { data: string; rawLength?: number; seq?: number } +type DataEvent = { id: string; data: string; seq?: number; rawLength?: number } + +/** + * The pre-optimization implementation, verbatim in behavior: snapshot the whole pending map each + * tick, then consume up to MAX_WRITES from that frozen list. The bounded-prefix capture must match + * it event-for-event — including which entry leads each tick. + */ +function legacyFlushTick(pendingById: Map): { + events: DataEvent[] + writes: number + reschedules: boolean +} { + const events: DataEvent[] = [] + let writes = 0 + for (const [id, pending] of Array.from(pendingById.entries())) { + if (writes >= MAX_WRITES) { + break + } + pendingById.delete(id) + const chunk = pending.data.slice(0, CHUNK_CHARS) + const remaining = pending.data.slice(CHUNK_CHARS) + if (remaining) { + pendingById.set(id, { + data: remaining, + ...(pending.rawLength === undefined ? {} : { rawLength: remaining.length }), + seq: pending.seq + }) + } + events.push({ + id, + data: chunk, + ...(pending.seq === undefined + ? {} + : { seq: pending.seq - (pending.data.length - chunk.length) }), + ...(pending.rawLength === undefined ? {} : { rawLength: chunk.length }) + }) + writes += 1 + } + return { events, writes, reschedules: pendingById.size > 0 && writes > 0 } +} + +function legacyTimeline(initial: Map): DataEvent[] { + const pendingById = new Map(initial) + const timeline: DataEvent[] = [] + for (let tick = 0; tick < 500 && pendingById.size > 0; tick++) { + const result = legacyFlushTick(pendingById) + timeline.push(...result.events) + if (!result.reschedules) { + break + } + } + return timeline +} + +// xorshift32 — deterministic across platforms, no Math.random. +function createRandom(seed: number): () => number { + let state = seed >>> 0 || 1 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + state >>>= 0 + return state / 0x1_0000_0000 + } +} + +describe('relay PTY output drain — differential vs the pre-optimization snapshot loop', () => { + let dispatcher: { + notify: ReturnType + callRequest: (method: string, params?: Record) => Promise + _notifications: { method: string; params?: Record }[] + } + let handler: PtyHandler + let dataCallbacks: Map void> + let exitCallbacks: Map void> + let onNotifyData: ((frame: { id: string; data: string }) => void) | null + + beforeEach(() => { + vi.useFakeTimers() + mockPtySpawn.mockReset() + dataCallbacks = new Map() + exitCallbacks = new Map() + onNotifyData = null + + const requestHandlers = new Map) => Promise>() + const notifications: { method: string; params?: Record }[] = [] + let reentering = false + dispatcher = { + notify: vi.fn((method: string, params?: Record) => { + notifications.push({ method, params }) + if (method !== 'pty.data' || !onNotifyData || reentering) { + return + } + // Why: the relay sink is a synchronous write; this hook models a sink that re-enters + // PTY ingress before the drain returns. + reentering = true + try { + onNotifyData(params as unknown as { id: string; data: string }) + } finally { + reentering = false + } + }), + callRequest: async (method: string, params: Record = {}) => { + const h = requestHandlers.get(method) + if (!h) { + throw new Error(`No handler for ${method}`) + } + return h(params) + }, + _notifications: notifications + } + const full = { + onRequest: vi.fn((method: string, h: (params: Record) => Promise) => + requestHandlers.set(method, h) + ), + onNotification: vi.fn(), + notify: dispatcher.notify + } + handler = new PtyHandler(full as unknown as RelayDispatcher) + }) + + afterEach(async () => { + onNotifyData = null + const cleanup = handler.dispose({ waitForPhysicalExit: false }) + await vi.runAllTimersAsync() + await cleanup.catch(() => {}) + vi.useRealTimers() + }) + + async function spawnPtys(count: number): Promise { + const ids: string[] = [] + for (let i = 0; i < count; i++) { + mockPtySpawn.mockReturnValueOnce({ + ...mockPtyInstance, + onData: vi.fn((cb: (data: string) => void) => { + dataCallbacks.set(`pty-${i + 1}`, cb) + }), + onExit: vi.fn((cb: (event: { exitCode: number }) => void) => { + exitCallbacks.set(`pty-${i + 1}`, cb) + }) + }) + const spawned = (await dispatcher.callRequest('pty.spawn', {})) as { id: string } + ids.push(spawned.id) + } + return ids + } + + function recordedDataEvents(): DataEvent[] { + return dispatcher._notifications + .filter((n) => n.method === 'pty.data') + .map((n) => n.params as unknown as DataEvent) + } + + it('emits the same pty.data timeline as the snapshot loop for multi-PTY multi-chunk drains', async () => { + const sessionCount = 4 + const ids = await spawnPtys(sessionCount) + + const payloads = new Map() + const expectedPending = new Map() + ids.forEach((id, index) => { + // Vary chunk counts so PTYs finish on different ticks. + const data = `${String.fromCharCode(97 + index)}`.repeat(CHUNK_CHARS * (index + 1) + index) + payloads.set(id, data) + expectedPending.set(id, { data }) + }) + + for (const id of ids) { + dataCallbacks.get(id)!(payloads.get(id)!) + } + + await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) + for (let tick = 0; tick < 200; tick++) { + await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) + } + + expect(recordedDataEvents()).toEqual(legacyTimeline(expectedPending)) + }) + + it('matches the snapshot loop across randomized session counts and payload sizes', async () => { + const random = createRandom(0x5eed) + const sessionCount = 5 + const ids = await spawnPtys(sessionCount) + + const expectedPending = new Map() + for (const id of ids) { + const chunks = 1 + Math.floor(random() * 3) + const extra = Math.floor(random() * 64) + const data = 'z'.repeat(CHUNK_CHARS * chunks + extra) + expectedPending.set(id, { data }) + dataCallbacks.get(id)!(data) + } + + await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) + for (let tick = 0; tick < 300; tick++) { + await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) + } + + expect(recordedDataEvents()).toEqual(legacyTimeline(expectedPending)) + }) + + it('keeps one write per tick when a single PTY drains — pacing a lazy iterator walk breaks', async () => { + const [id] = await spawnPtys(1) + const first = 'x'.repeat(CHUNK_CHARS) + dataCallbacks.get(id)!(`${first}tail`) + + await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) + // Iterating the live map lazily would pick the re-queued remainder up again in this same tick. + expect(recordedDataEvents()).toEqual([{ id, data: first }]) + + await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) + expect(recordedDataEvents()).toEqual([ + { id, data: first }, + { id, data: 'tail' } + ]) + }) + + it('writes at most two PTYs per tick and rotates the rest to the next tick', async () => { + const ids = await spawnPtys(3) + for (const id of ids) { + dataCallbacks.get(id)!('a'.repeat(CHUNK_CHARS + 4)) + } + + await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) + // First tick writes the two head PTYs only. + expect(recordedDataEvents().map((event) => event.id)).toEqual([ids[0], ids[1]]) + + await vi.advanceTimersByTimeAsync(DRAIN_CONTINUE_MS) + // Third PTY leads the next tick; the two re-queued remainders sit behind it. + expect(recordedDataEvents().map((event) => event.id)).toEqual([ids[0], ids[1], ids[2], ids[0]]) + }) + + it('freezes the tick batch before the first send, matching the snapshot loop under re-entrancy', async () => { + const ids = await spawnPtys(2) + dataCallbacks.get(ids[0])!('first') + dataCallbacks.get(ids[1])!('second') + + // Append to the second PTY while the first is being sent. Capturing the batch up front freezes + // the same values the whole-map snapshot froze, so this tick's output is unchanged. + onNotifyData = (frame) => { + if (frame.id === ids[0]) { + dataCallbacks.get(ids[1])!('-appended') + } + } + + await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) + expect(recordedDataEvents()).toEqual([ + { id: ids[0], data: 'first' }, + { id: ids[1], data: 'second' } + ]) + + onNotifyData = null + await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) + await vi.advanceTimersByTimeAsync(BATCH_INTERVAL_MS) + // Verbatim pre-change behavior, quirk included: the frozen entry is sent and then deleted, so + // bytes appended to it mid-tick are dropped. Reachable only from a sink that re-enters PTY + // ingress synchronously; the relay's real sinks are stdout/socket writes that cannot. Asserted + // here so the optimization is pinned to "no behavior change" rather than a silent improvement. + expect(recordedDataEvents()).toEqual([ + { id: ids[0], data: 'first' }, + { id: ids[1], data: 'second' } + ]) + }) + + it('flushes pending bytes before pty.exit', async () => { + // PTY exit routes through flushPtyOutput (the whole-entry path), not the chunked drain. + const [id] = await spawnPtys(1) + dataCallbacks.get(id)!('tail bytes') + exitCallbacks.get(id)!({ exitCode: 0 }) + + const methods = dispatcher._notifications.map((n) => n.method) + // Ordering invariant: buffered output must land before the exit frame. + expect(methods.indexOf('pty.data')).toBeGreaterThanOrEqual(0) + expect(methods.indexOf('pty.data')).toBeLessThan(methods.indexOf('pty.exit')) + + const dataFrames = dispatcher._notifications.filter((n) => n.method === 'pty.data') + expect(dataFrames).toHaveLength(1) + expect(dataFrames[0].params).toEqual({ id, data: 'tail bytes' }) + }) +}) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index ce2059e30aa..26dd6e085ca 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -723,11 +723,21 @@ export class PtyHandler { private flushPendingOutput(): void { this.outputFlushTimer = null - let writes = 0 - for (const [id, pending] of Array.from(this.pendingOutputByPty.entries())) { - if (writes >= PTY_OUTPUT_FLUSH_MAX_WRITES) { + // Why: the send loop has no skip path and stops at PTY_OUTPUT_FLUSH_MAX_WRITES, so it can never + // reach a third entry — `Array.from(entries())` allocated one tuple per session every tick to + // consume at most two. Capture only that prefix, and capture it *before* the first send so a + // re-entrant sink still reads the values a whole-map snapshot would have frozen. + // Why the explicit iterator: `for...of` would advance one tuple past the limit and discard it. + const pendingEntries = this.pendingOutputByPty[Symbol.iterator]() + const batch: [string, PendingPtyOutput][] = [] + while (batch.length < PTY_OUTPUT_FLUSH_MAX_WRITES) { + const next = pendingEntries.next() + if (next.done === true) { break } + batch.push(next.value) + } + for (const [id, pending] of batch) { this.pendingOutputByPty.delete(id) const chunk = pending.transformed ? pending.data @@ -754,9 +764,8 @@ export class PtyHandler { ...(chunkRawLength === undefined ? {} : { rawLength: chunkRawLength }), ...(pending.transformed ? { transformed: true } : {}) }) - writes++ } - if (this.pendingOutputByPty.size > 0 && writes > 0) { + if (this.pendingOutputByPty.size > 0 && batch.length > 0) { // Why: yield between slices of a large chunk so client input and control frames can interleave. this.scheduleOutputFlush(PTY_OUTPUT_DRAIN_CONTINUE_MS) } From 4ff2f51782b9c00178f9d2e1dc56d5823a1560dc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:48:04 -0700 Subject: [PATCH 068/150] perf(sidebar): share one worktree-keyed agent orchestration index (#10678) Co-authored-by: Orca --- ...worktree-agent-orchestration-batch.test.ts | 60 ++- .../worktree-agent-orchestration-batch.ts | 18 +- ...worktree-agent-orchestration-index.test.ts | 430 ++++++++++++++++++ .../worktree-agent-orchestration-index.ts | 240 ++++++++++ .../sidebar/worktree-agent-row-selectors.ts | 34 +- 5 files changed, 741 insertions(+), 41 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts index 8865b8e8858..808664cbc7c 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts @@ -403,15 +403,22 @@ describe('selectRuntimeAgentOrchestrationBatch', () => { ) expect(Object.keys(actual)).toEqual(Object.keys(expected)) - const operationBudget = { + // Why the batch stays tighter: it knows which worktrees are on screen. The + // shared index covers all of them, so it saves per *card*, not per worktree. + expect(batched.counts()).toEqual({ runtimeEnumerations: 1, runtimeValueReads: contextCount, contextVisits: contextCount, targetTabIdReads: 1, unrelatedTabIdReads: 0 - } - expect(reference.counts()).toEqual(operationBudget) - expect(batched.counts()).toEqual(operationBudget) + }) + expect(reference.counts()).toEqual({ + runtimeEnumerations: 1, + runtimeValueReads: contextCount, + contextVisits: contextCount, + targetTabIdReads: 1, + unrelatedTabIdReads: tabCount - 1 + }) }) it('collapses multi-worktree runtime scans and caches unchanged publications', () => { @@ -478,10 +485,12 @@ describe('selectRuntimeAgentOrchestrationBatch', () => { } selectRuntimeAgentOrchestrationBatch(batched.state, requested) + // One enumeration for worktreeCount calls: the first builds the shared + // index, the rest are Map lookups. This used to scale with mounted cards. expect(reference.counts()).toEqual({ - runtimeEnumerations: worktreeCount, - runtimeValueReads: worktreeCount * contextCount, - contextVisits: worktreeCount * contextCount, + runtimeEnumerations: 1, + runtimeValueReads: contextCount, + contextVisits: contextCount, tabIdReads: worktreeCount }) expect(batched.counts()).toEqual({ @@ -518,5 +527,42 @@ describe('selectRuntimeAgentOrchestrationBatch', () => { contextVisits: contextCount * (publicationCount + 1), tabIdReads: worktreeCount }) + + // Publications that change nothing the index reads cost nothing, however + // many cards call in. + const referenceBefore = reference.counts() + for (let publication = 0; publication < publicationCount; publication += 1) { + for (const worktreeId of requested) { + selectRuntimeAgentOrchestrationForWorktree(reference.state, worktreeId) + } + } + expect(reference.counts()).toEqual(referenceBefore) + + // Why this is the honest claim: a real live-status ping replaces + // agentStatusByPaneKey, so the index does rebuild once per publication. What + // the shared index removes is the mounted-card multiplier, not the + // per-publication rebuild. Tab reads stay flat because tab membership is + // keyed on the tabs slice, which a live-status ping does not replace. + const churn = makeCountedState() + for (const worktreeId of requested) { + selectRuntimeAgentOrchestrationForWorktree(churn.state, worktreeId) + } + for (let publication = 0; publication < publicationCount; publication += 1) { + const published = { + ...churn.state, + agentStatusByPaneKey: { + [`unrelated-${publication}`]: makeEntry(`unrelated-${publication}`, 'elsewhere') + } + } + for (const worktreeId of requested) { + selectRuntimeAgentOrchestrationForWorktree(published, worktreeId) + } + } + expect(churn.counts()).toEqual({ + runtimeEnumerations: 1, + runtimeValueReads: contextCount, + contextVisits: contextCount * (publicationCount + 1), + tabIdReads: worktreeCount + }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.ts index 8091596bc28..d1d66c3be6e 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.ts @@ -40,7 +40,13 @@ const EMPTY_AGENT_STATUS: RuntimeOrchestrationState['agentStatusByPaneKey'] = {} const EMPTY_RETAINED_AGENTS: RuntimeOrchestrationState['retainedAgentsByPaneKey'] = {} const EMPTY_BATCH: ReadonlyMap = new Map() -export const EMPTY_WORKTREE_AGENT_ORCHESTRATION: RuntimeOrchestrationRecord = {} +export const EMPTY_WORKTREE_AGENT_ORCHESTRATION: RuntimeOrchestrationRecord = Object.freeze({}) + +// Why null-prototype: a pane key of `__proto__` is a plain data key here; on a +// normal object the write vanishes into the prototype setter and repoints it. +function createRecord(): RuntimeOrchestrationRecord { + return Object.create(null) as RuntimeOrchestrationRecord +} let runtimeDomainCache: RuntimeDomainCache | null = null let requestedTabMembershipCache: RequestedTabMembershipCache | null = null @@ -181,12 +187,12 @@ function buildRuntimeBatch( } for (const worktreeId of targets) { - const existing = recordsByWorktree.get(worktreeId) - if (existing) { - existing[paneKey] = orchestration - } else { - recordsByWorktree.set(worktreeId, { [paneKey]: orchestration }) + let record = recordsByWorktree.get(worktreeId) + if (!record) { + record = createRecord() + recordsByWorktree.set(worktreeId, record) } + record[paneKey] = orchestration } } diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts new file mode 100644 index 00000000000..2bd549481c0 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts @@ -0,0 +1,430 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { + AgentStatusEntry, + AgentStatusOrchestrationContext +} from '../../../../shared/agent-status-types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' +import type { TerminalTab } from '../../../../shared/types' +import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' +import { + EMPTY_WORKTREE_AGENT_ORCHESTRATION, + releaseWorktreeAgentOrchestrationIndexCache, + selectWorktreeAgentOrchestration +} from './worktree-agent-orchestration-index' + +type IndexState = Parameters[0] + +const EMPTY_RECORD = {} + +// Pane keys that reach this selector unvalidated. `__proto__` is the one whose +// meaning depends on how the output record is built. +const MALFORMED_RUNTIME_KEYS = ['__proto__', 'constructor', 'toString', 'no-colon', 'a:b:c'] + +// Why not plain assignment: writing `__proto__` onto an object literal hits the +// prototype setter, so the fixture itself would lose the key under test. +function defineKey(map: Record, key: string, value: T): void { + Object.defineProperty(map, key, { value, enumerable: true, writable: true, configurable: true }) +} + +/** + * The pre-index per-card selector, transcribed from the revision this index + * replaced. Kept as the oracle so equivalence is asserted against real prior + * behavior rather than against a restatement of the new implementation. + * + * One deliberate correction: the original accumulated into `{}`, so a pane key + * of `__proto__` hit the prototype setter and vanished. This builds a + * null-prototype record so the oracle expresses intended attribution. + */ +function legacySelectForWorktree( + state: IndexState, + worktreeId: string +): Record { + const tabs = (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? [] + const tabIds = new Set(tabs.map((tab) => tab.id)) + const out: Record = Object.create(null) + const runtimeAgentOrchestrationByPaneKey = + state.runtimeAgentOrchestrationByPaneKey ?? EMPTY_RECORD + const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD + const retainedAgentsByPaneKey = state.retainedAgentsByPaneKey ?? EMPTY_RECORD + for (const [paneKey, orchestration] of Object.entries(runtimeAgentOrchestrationByPaneKey)) { + const parsed = parsePaneKey(paneKey) + const parsedParent = orchestration.parentPaneKey + ? parsePaneKey(orchestration.parentPaneKey) + : null + const liveEntry = agentStatusByPaneKey[paneKey] + const retainedEntry = retainedAgentsByPaneKey[paneKey] + if ( + (parsed && tabIds.has(parsed.tabId)) || + (parsedParent && tabIds.has(parsedParent.tabId)) || + liveEntry?.worktreeId === worktreeId || + retainedEntry?.worktreeId === worktreeId + ) { + out[paneKey] = orchestration + } + } + return out +} + +// Why a seeded PRNG: a randomized differential must reproduce exactly when it +// reports a divergence. +function createRandom(seed: number): () => number { + // Why the multiply: xorshift32 needs a high-entropy state. Seeding it with a + // small integer keeps the first output under 0.019, so `1 + pick(5)` was 1 for + // every seed here and the suite only ever built single-worktree stores. + let state = Math.imul(seed >>> 0 || 1, 0x9e37_79b1) >>> 0 || 1 + return () => { + state ^= state << 13 + state >>>= 0 + state ^= state >> 17 + state ^= state << 5 + state >>>= 0 + return state / 0x1_00_00_00_00 + } +} + +function makeTab(id: string): TerminalTab { + return { + id, + worktreeId: 'unused', + ptyId: null, + title: 'Claude', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function paneKeyFor(tabId: string, index: number): string { + return makePaneKey(tabId, `88888888-8888-4888-8888-${index.toString(16).padStart(12, '0')}`) +} + +function makeEntry(paneKey: string, worktreeId: string): AgentStatusEntry { + return { paneKey, worktreeId, state: 'busy', startedAt: 0 } as unknown as AgentStatusEntry +} + +function makeRetained(paneKey: string, worktreeId: string): RetainedAgentEntry { + return { + worktreeId, + entry: makeEntry(paneKey, worktreeId), + retainedAt: 0 + } as unknown as RetainedAgentEntry +} + +describe('selectWorktreeAgentOrchestration', () => { + beforeEach(() => { + releaseWorktreeAgentOrchestrationIndexCache() + }) + + it('matches the pre-index per-card selector across randomized stores', () => { + for (let seed = 1; seed <= 300; seed += 1) { + releaseWorktreeAgentOrchestrationIndexCache() + const random = createRandom(seed) + const pick = (limit: number): number => Math.floor(random() * limit) + + const worktreeCount = 1 + pick(5) + const worktreeIds = Array.from({ length: worktreeCount }, (_, index) => `wt-${index}`) + const tabsByWorktree: Record = {} + const tabIds: string[] = [] + for (const worktreeId of worktreeIds) { + const tabCount = pick(3) + const tabs: TerminalTab[] = [] + for (let tabIndex = 0; tabIndex < tabCount; tabIndex += 1) { + // Why a small shared id space: tab ids collide across worktrees on + // 131 of these 300 seeds, which is the multi-attribution path. + const tabId = `tab-${pick(4)}` + tabs.push(makeTab(tabId)) + tabIds.push(tabId) + } + tabsByWorktree[worktreeId] = tabs + } + + const runtimeAgentOrchestrationByPaneKey: Record = {} + const agentStatusByPaneKey: Record = {} + const retainedAgentsByPaneKey: Record = {} + const contextCount = pick(8) + for (let index = 0; index < contextCount; index += 1) { + const ownerTabId = + random() < 0.7 && tabIds.length > 0 ? tabIds[pick(tabIds.length)] : 'tab-orphan' + // Why malformed runtime keys: a pane key that is not `tab:uuid` reaches + // this selector unvalidated, and `__proto__` is the one that changes + // meaning depending on how the output record is built. + const keyRoll = random() + const paneKey = + keyRoll < 0.08 + ? MALFORMED_RUNTIME_KEYS[pick(MALFORMED_RUNTIME_KEYS.length)] + : paneKeyFor(ownerTabId, index) + const parentRoll = random() + const parentPaneKey = + parentRoll < 0.3 && tabIds.length > 0 + ? paneKeyFor(tabIds[pick(tabIds.length)], 900 + index) + : parentRoll < 0.4 + ? 'malformed:parent:key' + : undefined + defineKey(runtimeAgentOrchestrationByPaneKey, paneKey, { + taskId: `task-${index}`, + dispatchId: `dispatch-${index}`, + ...(parentPaneKey === undefined ? {} : { parentPaneKey }) + }) + if (random() < 0.35) { + defineKey( + agentStatusByPaneKey, + paneKey, + makeEntry(paneKey, `wt-${pick(worktreeCount + 1)}`) + ) + } + if (random() < 0.25) { + defineKey( + retainedAgentsByPaneKey, + paneKey, + makeRetained(paneKey, `wt-${pick(worktreeCount + 1)}`) + ) + } + } + + const state = { + tabsByWorktree, + runtimeAgentOrchestrationByPaneKey, + agentStatusByPaneKey, + retainedAgentsByPaneKey + } as unknown as IndexState + + // Why the extra id: worktrees with no tabs are still reachable through a + // live or retained attribution, and must resolve identically. + for (const worktreeId of [...worktreeIds, `wt-${worktreeCount}`, 'missing']) { + const expected = legacySelectForWorktree(state, worktreeId) + const actual = selectWorktreeAgentOrchestration(state, worktreeId) + expect(Object.keys(actual), `seed ${seed} / ${worktreeId}`).toEqual(Object.keys(expected)) + for (const paneKey of Object.keys(expected)) { + expect(actual[paneKey], `seed ${seed} / ${worktreeId} / ${paneKey}`).toBe( + expected[paneKey] + ) + } + } + } + }) + + it('returns one shared empty record for worktrees with no orchestration', () => { + const state = { + tabsByWorktree: { 'wt-1': [makeTab('tab-1')] }, + runtimeAgentOrchestrationByPaneKey: { + [paneKeyFor('tab-1', 0)]: { taskId: 't', dispatchId: 'd' } + }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {} + } as unknown as IndexState + + expect(selectWorktreeAgentOrchestration(state, 'wt-2')).toBe(EMPTY_WORKTREE_AGENT_ORCHESTRATION) + expect(selectWorktreeAgentOrchestration(state, 'wt-2')).toBe( + selectWorktreeAgentOrchestration(state, 'wt-3') + ) + }) + + it('keeps record identity stable across publications that change nothing it reads', () => { + const context = { taskId: 't', dispatchId: 'd' } + const base = { + tabsByWorktree: { 'wt-1': [makeTab('tab-1')] }, + runtimeAgentOrchestrationByPaneKey: { [paneKeyFor('tab-1', 0)]: context }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {} + } as unknown as IndexState + + const first = selectWorktreeAgentOrchestration(base, 'wt-1') + expect(selectWorktreeAgentOrchestration({ ...base } as IndexState, 'wt-1')).toBe(first) + + // Why: a live-status ping for an unrelated pane is the highest-frequency + // publication in this store, and must not hand cards a new object. + const liveChurn = { + ...base, + agentStatusByPaneKey: { unrelated: makeEntry('unrelated', 'wt-9') } + } as unknown as IndexState + expect(selectWorktreeAgentOrchestration(liveChurn, 'wt-1')).toBe(first) + + const retainedChurn = { + ...liveChurn, + retainedAgentsByPaneKey: { unrelated: makeRetained('unrelated', 'wt-9') } + } as unknown as IndexState + expect(selectWorktreeAgentOrchestration(retainedChurn, 'wt-1')).toBe(first) + }) + + it('rebuilds when a source it reads actually changes', () => { + const context = { taskId: 't', dispatchId: 'd' } + const paneKey = paneKeyFor('tab-1', 0) + const base = { + tabsByWorktree: { 'wt-1': [makeTab('tab-1')] }, + runtimeAgentOrchestrationByPaneKey: { [paneKey]: context }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {} + } as unknown as IndexState + selectWorktreeAgentOrchestration(base, 'wt-1') + + const replacement = { taskId: 't2', dispatchId: 'd2' } + const replaced = { + ...base, + runtimeAgentOrchestrationByPaneKey: { [paneKey]: replacement } + } as unknown as IndexState + expect(selectWorktreeAgentOrchestration(replaced, 'wt-1')[paneKey]).toBe(replacement) + + // Why: moving the tab to another worktree must re-attribute, which only + // happens if tab membership is keyed on the tabs slice identity. + const movedTab = { + ...replaced, + tabsByWorktree: { 'wt-2': [makeTab('tab-1')] } + } as unknown as IndexState + expect(selectWorktreeAgentOrchestration(movedTab, 'wt-1')).toBe( + EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) + expect(selectWorktreeAgentOrchestration(movedTab, 'wt-2')[paneKey]).toBe(replacement) + }) + + it('treats a missing or emptied orchestration map as empty without reading other slices', () => { + let forbiddenReads = 0 + const coldState = { + runtimeAgentOrchestrationByPaneKey: {}, + get tabsByWorktree() { + forbiddenReads += 1 + return {} + }, + get agentStatusByPaneKey() { + forbiddenReads += 1 + return {} + }, + get retainedAgentsByPaneKey() { + forbiddenReads += 1 + return {} + } + } as unknown as IndexState + + expect(selectWorktreeAgentOrchestration(coldState, 'wt-1')).toBe( + EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) + expect(selectWorktreeAgentOrchestration({} as IndexState, 'wt-1')).toBe( + EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) + expect(forbiddenReads).toBe(0) + }) + + it('does not attribute unowned panes to a nullish worktree id', () => { + const paneKey = paneKeyFor('tab-orphan', 0) + const state = { + tabsByWorktree: {}, + runtimeAgentOrchestrationByPaneKey: { [paneKey]: { taskId: 't', dispatchId: 'd' } }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {} + } as unknown as IndexState + + // Why asserted despite `worktreeId: string`: the pre-index selector tested + // `entry?.worktreeId === worktreeId`, so a nullish id matched every pane + // that had no live/retained entry. This is the one intentional divergence + // from the oracle — the old result was wrong, not merely different. + expect(selectWorktreeAgentOrchestration(state, undefined as unknown as string)).toBe( + EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) + expect(legacySelectForWorktree(state, undefined as unknown as string)).toHaveProperty(paneKey) + }) + + it('stays correct when two stores interleave through the single cache slot', () => { + // Why: the cache is one module-level slot, but the sidebar cards and the + // dashboard snapshot can call in with different state objects. Thrashing may + // cost a rebuild; it must never return another store's answer. + const buildState = (suffix: string): IndexState => + ({ + tabsByWorktree: { [`wt-${suffix}`]: [makeTab(`tab-${suffix}`)] }, + runtimeAgentOrchestrationByPaneKey: { + [paneKeyFor(`tab-${suffix}`, 0)]: { taskId: `t-${suffix}`, dispatchId: `d-${suffix}` } + }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {} + }) as unknown as IndexState + + const stateA = buildState('a') + const stateB = buildState('b') + for (let round = 0; round < 4; round += 1) { + for (const [state, suffix] of [ + [stateA, 'a'], + [stateB, 'b'] + ] as const) { + expect(Object.keys(selectWorktreeAgentOrchestration(state, `wt-${suffix}`))).toEqual( + Object.keys(legacySelectForWorktree(state, `wt-${suffix}`)) + ) + // The other store's worktree must never leak through the shared slot. + const foreign = suffix === 'a' ? 'wt-b' : 'wt-a' + expect(selectWorktreeAgentOrchestration(state, foreign)).toBe( + EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) + } + } + }) + + it('treats a __proto__ pane key as data instead of a prototype write', () => { + // Why: writing this key into a normal object silently drops the entry and + // repoints the record's prototype at the orchestration context. + const context = { taskId: 't', dispatchId: 'd' } + const state = { + tabsByWorktree: {}, + runtimeAgentOrchestrationByPaneKey: Object.fromEntries([['__proto__', context]]), + agentStatusByPaneKey: Object.fromEntries([['__proto__', makeEntry('__proto__', 'wt-1')]]), + retainedAgentsByPaneKey: {} + } as unknown as IndexState + + const record = selectWorktreeAgentOrchestration(state, 'wt-1') + expect(Object.keys(record)).toEqual(['__proto__']) + expect(record['__proto__']).toBe(context) + expect(Object.getPrototypeOf(record)).toBeNull() + }) + + it('keeps the entries cache warm while the orchestration map is empty', () => { + // Why: the empty map is the common case, so re-enumerating it per card is + // exactly the per-publication cost this index exists to remove. + let enumerations = 0 + const runtimeAgentOrchestrationByPaneKey = new Proxy( + {}, + { + ownKeys(target) { + enumerations += 1 + return Reflect.ownKeys(target) + } + } + ) + const state = { + tabsByWorktree: {}, + runtimeAgentOrchestrationByPaneKey, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {} + } as unknown as IndexState + + for (const worktreeId of ['wt-a', 'wt-b', 'wt-c']) { + expect(selectWorktreeAgentOrchestration(state, worktreeId)).toBe( + EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) + } + expect(enumerations).toBe(1) + }) + + it('never mutates a record already handed to a subscriber', () => { + // Why: buildIndex fills record objects in place and can hand back a previous + // build's record. A mounted card holds that object across renders, so a later + // build writing into it would break React's snapshot contract silently. + const paneKey = paneKeyFor('tab-1', 0) + const base = { + tabsByWorktree: { 'wt-1': [makeTab('tab-1')] }, + runtimeAgentOrchestrationByPaneKey: { [paneKey]: { taskId: 't', dispatchId: 'd' } }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {} + } as unknown as IndexState + + const held = selectWorktreeAgentOrchestration(base, 'wt-1') + const heldKeys = Object.keys(held) + + const secondPaneKey = paneKeyFor('tab-1', 1) + const grown = { + ...base, + runtimeAgentOrchestrationByPaneKey: { + ...base.runtimeAgentOrchestrationByPaneKey, + [secondPaneKey]: { taskId: 't2', dispatchId: 'd2' } + } + } as unknown as IndexState + expect(Object.keys(selectWorktreeAgentOrchestration(grown, 'wt-1'))).toHaveLength(2) + expect(Object.keys(held)).toEqual(heldKeys) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts new file mode 100644 index 00000000000..5cad5ec1f48 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts @@ -0,0 +1,240 @@ +import type { AppState } from '@/store/types' +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' +import { parsePaneKey } from '../../../../shared/stable-pane-id' + +type OrchestrationIndexState = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'retainedAgentsByPaneKey' + | 'runtimeAgentOrchestrationByPaneKey' + | 'tabsByWorktree' +> + +type RuntimeOrchestrationRecord = Record + +type RuntimeEntriesCache = { + source: OrchestrationIndexState['runtimeAgentOrchestrationByPaneKey'] + entries: [string, AgentStatusOrchestrationContext][] +} + +type TabMembershipCache = { + tabsSource: OrchestrationIndexState['tabsByWorktree'] + worktreeIdsByTabId: Map> +} + +type OrchestrationIndexCache = { + runtimeSource: OrchestrationIndexState['runtimeAgentOrchestrationByPaneKey'] + tabsSource: OrchestrationIndexState['tabsByWorktree'] + liveSource: OrchestrationIndexState['agentStatusByPaneKey'] + retainedSource: OrchestrationIndexState['retainedAgentsByPaneKey'] + recordsByWorktree: ReadonlyMap +} + +// Why: selector unit tests pass partial store mocks; a missing map must behave +// like an empty slice while keeping a stable identity for the source cache. +const EMPTY_SOURCE = {} + +// Why frozen: these are shared by every card, so an accidental write would +// corrupt unrelated worktrees rather than fail locally. +export const EMPTY_WORKTREE_AGENT_ORCHESTRATION: RuntimeOrchestrationRecord = Object.freeze({}) +export const EMPTY_WORKTREE_AGENT_ORCHESTRATION_INDEX: ReadonlyMap< + string, + RuntimeOrchestrationRecord +> = new Map() + +// Why null-prototype: a pane key of `__proto__` is a plain data key here. On a +// normal object the first write silently vanishes into the prototype setter, +// which both drops the entry and repoints the record's prototype. +function createRecord(): RuntimeOrchestrationRecord { + return Object.create(null) as RuntimeOrchestrationRecord +} + +let runtimeEntriesCache: RuntimeEntriesCache | null = null +let tabMembershipCache: TabMembershipCache | null = null +let orchestrationIndexCache: OrchestrationIndexCache | null = null + +export function releaseWorktreeAgentOrchestrationIndexCache(): void { + runtimeEntriesCache = null + tabMembershipCache = null + orchestrationIndexCache = null +} + +function reuseRecordIfOrderedEqual( + previous: RuntimeOrchestrationRecord | undefined, + next: RuntimeOrchestrationRecord +): RuntimeOrchestrationRecord { + if (!previous) { + return next + } + const previousEntries = Object.entries(previous) + const nextEntries = Object.entries(next) + if (previousEntries.length !== nextEntries.length) { + return next + } + for (let index = 0; index < nextEntries.length; index += 1) { + if ( + previousEntries[index]?.[0] !== nextEntries[index]?.[0] || + previousEntries[index]?.[1] !== nextEntries[index]?.[1] + ) { + return next + } + } + return previous +} + +function getWorktreeIdsByTabId( + tabsByWorktree: OrchestrationIndexState['tabsByWorktree'] +): Map> { + if (tabMembershipCache?.tabsSource === tabsByWorktree) { + return tabMembershipCache.worktreeIdsByTabId + } + // Why a Set per tab: the same tab id can appear under more than one worktree, + // and each of those worktrees must still see the pane's orchestration. + const worktreeIdsByTabId = new Map>() + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs ?? []) { + const tabId = tab.id + const existing = worktreeIdsByTabId.get(tabId) + if (existing) { + existing.add(worktreeId) + } else { + worktreeIdsByTabId.set(tabId, new Set([worktreeId])) + } + } + } + tabMembershipCache = { tabsSource: tabsByWorktree, worktreeIdsByTabId } + return worktreeIdsByTabId +} + +function buildIndex( + runtimeEntries: [string, AgentStatusOrchestrationContext][], + tabsByWorktree: OrchestrationIndexState['tabsByWorktree'], + agentStatusByPaneKey: OrchestrationIndexState['agentStatusByPaneKey'], + retainedAgentsByPaneKey: OrchestrationIndexState['retainedAgentsByPaneKey'] +): ReadonlyMap { + const worktreeIdsByTabId = getWorktreeIdsByTabId(tabsByWorktree) + const recordsByWorktree = new Map() + + for (const [paneKey, orchestration] of runtimeEntries) { + const parsed = parsePaneKey(paneKey) + const parsedParent = orchestration.parentPaneKey + ? parsePaneKey(orchestration.parentPaneKey) + : null + const targets = new Set() + if (parsed) { + for (const worktreeId of worktreeIdsByTabId.get(parsed.tabId) ?? []) { + targets.add(worktreeId) + } + } + // Why: child agent terminals can be attributed to a worktree before their + // tab reaches this renderer, or after the row has been retained as done. + // The parent link must still reach that worktree card. + if (parsedParent) { + for (const worktreeId of worktreeIdsByTabId.get(parsedParent.tabId) ?? []) { + targets.add(worktreeId) + } + } + // Why exact runtime keys: this preserves early SSH attribution and ignores + // stale entry.paneKey fields carried by a live or retained row. + const liveWorktreeId = agentStatusByPaneKey[paneKey]?.worktreeId + if (typeof liveWorktreeId === 'string') { + targets.add(liveWorktreeId) + } + const retainedWorktreeId = retainedAgentsByPaneKey[paneKey]?.worktreeId + if (typeof retainedWorktreeId === 'string') { + targets.add(retainedWorktreeId) + } + + for (const worktreeId of targets) { + let record = recordsByWorktree.get(worktreeId) + if (!record) { + record = createRecord() + recordsByWorktree.set(worktreeId, record) + } + record[paneKey] = orchestration + } + } + + const previousRecords = orchestrationIndexCache?.recordsByWorktree + for (const [worktreeId, record] of recordsByWorktree) { + recordsByWorktree.set( + worktreeId, + reuseRecordIfOrderedEqual(previousRecords?.get(worktreeId), record) + ) + } + return recordsByWorktree +} + +/** + * Worktree-keyed index of runtime agent orchestration contexts, rebuilt only + * when one of its four source maps changes identity. + * + * Why: every mounted worktree card subscribes to its own orchestration slice, + * and Zustand re-runs every subscriber's selector on every store publication. + * Scanning the whole context map per card made that O(cards x contexts). What + * this removes is the per-card multiplier, not the rebuild itself: an agent + * ping replaces the live map, so the index still rebuilds once per publication. + * The first caller through a given store version pays O(tabs + contexts); the + * rest are a Map lookup. + */ +export function selectWorktreeAgentOrchestrationIndex( + state: OrchestrationIndexState +): ReadonlyMap { + const runtimeAgentOrchestrationByPaneKey = + state.runtimeAgentOrchestrationByPaneKey ?? EMPTY_SOURCE + // Why cached separately from the index: enumerating the context map is the + // per-publication cost this index exists to remove, and the entry list stays + // valid even when a churning live/retained slice forces an index rebuild. + if (runtimeEntriesCache?.source !== runtimeAgentOrchestrationByPaneKey) { + runtimeEntriesCache = { + source: runtimeAgentOrchestrationByPaneKey, + entries: Object.entries(runtimeAgentOrchestrationByPaneKey) + } + } + const runtimeEntries = runtimeEntriesCache.entries + // Why here rather than before the enumeration: with no contexts the index is + // empty whatever the other slices hold, and callers rely on them staying unread. + if (runtimeEntries.length === 0) { + // Why the entries cache survives: dropping it would re-enumerate the empty + // map once per card, which is the per-publication cost this index removes. + tabMembershipCache = null + orchestrationIndexCache = null + return EMPTY_WORKTREE_AGENT_ORCHESTRATION_INDEX + } + + const tabsByWorktree = state.tabsByWorktree ?? EMPTY_SOURCE + const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_SOURCE + const retainedAgentsByPaneKey = state.retainedAgentsByPaneKey ?? EMPTY_SOURCE + if ( + orchestrationIndexCache?.runtimeSource === runtimeAgentOrchestrationByPaneKey && + orchestrationIndexCache.tabsSource === tabsByWorktree && + orchestrationIndexCache.liveSource === agentStatusByPaneKey && + orchestrationIndexCache.retainedSource === retainedAgentsByPaneKey + ) { + return orchestrationIndexCache.recordsByWorktree + } + + orchestrationIndexCache = { + runtimeSource: runtimeAgentOrchestrationByPaneKey, + tabsSource: tabsByWorktree, + liveSource: agentStatusByPaneKey, + retainedSource: retainedAgentsByPaneKey, + recordsByWorktree: buildIndex( + runtimeEntries, + tabsByWorktree, + agentStatusByPaneKey, + retainedAgentsByPaneKey + ) + } + return orchestrationIndexCache.recordsByWorktree +} + +export function selectWorktreeAgentOrchestration( + state: OrchestrationIndexState, + worktreeId: string +): RuntimeOrchestrationRecord { + return ( + selectWorktreeAgentOrchestrationIndex(state).get(worktreeId) ?? + EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index f964d024128..75de25923f2 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -12,12 +12,12 @@ import { patchLiveEntriesByWorktree, recordLiveEntriesFullRebuild } from './worktree-agent-live-index-patch' +import { selectWorktreeAgentOrchestration } from './worktree-agent-orchestration-index' import type { TerminalLayoutSnapshot } from '../../../../shared/types' const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = [] const EMPTY_RETAINED: RetainedAgentEntry[] = [] -const EMPTY_RUNTIME_AGENT_ORCHESTRATION: Record = {} // Why: selector unit tests often pass partial store mocks; production state // owns these maps, but missing mock maps should behave like empty slices. const EMPTY_RECORD = {} @@ -227,6 +227,10 @@ export function selectRetainedAgentEntriesForWorktree( return getRetainedEntriesByWorktree(state).get(worktreeId) ?? EMPTY_RETAINED } +// Why: reads a shared worktree-keyed index instead of rescanning every +// orchestration context. Zustand re-runs each mounted card's selector on every +// publication, so the old per-card scan was O(cards x contexts) on unrelated +// traffic; only the first card through a given store version now pays a build. export function selectRuntimeAgentOrchestrationForWorktree( state: Pick< AppState, @@ -237,33 +241,7 @@ export function selectRuntimeAgentOrchestrationForWorktree( >, worktreeId: string ): Record { - const tabs = (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? [] - const tabIds = new Set(tabs.map((tab) => tab.id)) - const out: Record = {} - const runtimeAgentOrchestrationByPaneKey = - state.runtimeAgentOrchestrationByPaneKey ?? EMPTY_RECORD - const agentStatusByPaneKey = state.agentStatusByPaneKey ?? EMPTY_RECORD - const retainedAgentsByPaneKey = state.retainedAgentsByPaneKey ?? EMPTY_RECORD - for (const [paneKey, orchestration] of Object.entries(runtimeAgentOrchestrationByPaneKey)) { - const parsed = parsePaneKey(paneKey) - const parsedParent = orchestration.parentPaneKey - ? parsePaneKey(orchestration.parentPaneKey) - : null - const liveEntry = agentStatusByPaneKey[paneKey] - const retainedEntry = retainedAgentsByPaneKey[paneKey] - // Why: child agent terminals can be attributed to a worktree before their - // tab reaches this renderer, or after the row has been retained as done. - // The parent link must still reach that worktree card. - if ( - (parsed && tabIds.has(parsed.tabId)) || - (parsedParent && tabIds.has(parsedParent.tabId)) || - liveEntry?.worktreeId === worktreeId || - retainedEntry?.worktreeId === worktreeId - ) { - out[paneKey] = orchestration - } - } - return Object.keys(out).length > 0 ? out : EMPTY_RUNTIME_AGENT_ORCHESTRATION + return selectWorktreeAgentOrchestration(state, worktreeId) } export function selectTerminalLayoutsForWorktree( From 07671d4a0630d17c652a2e9dbe36ea62fb64e002 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:48:06 -0700 Subject: [PATCH 069/150] fix(mobile): recover unreliable relay connections (#10709) * fix(mobile): recover unreliable relay connections * test(mobile): use valid raster preview fixtures --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- config/reliability-gates.jsonc | 127 +++++++++++++++++- .../files/mobile-diff-image-preview.test.ts | 19 +-- mobile/src/files/mobile-file-tab-doc.test.ts | 21 ++- .../mobile-direct-endpoint-probe.test.ts | 76 +++++++++++ .../transport/mobile-direct-endpoint-probe.ts | 53 ++++++-- .../mobile-endpoint-supervisor.test.ts | 11 +- .../mobile-relay-reconnect-controller.test.ts | 9 +- .../mobile-relay-reconnect-controller.ts | 20 ++- src/shared/mobile-relay-close-codes.test.ts | 7 + src/shared/mobile-relay-close-codes.ts | 4 +- 10 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 mobile/src/transport/mobile-direct-endpoint-probe.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 03e7bc23c0f..f5200abb1a5 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-22", + "updatedAt": "2026-07-25", "policy": { "maturityLevels": [ "experimental", @@ -16,6 +16,131 @@ } }, "gates": [ + { + "id": "mobile-relay.endpoint-recovery", + "title": "Mobile relay recovery retries offline hosts and races direct endpoints", + "maturity": "experimental", + "protection": "partial", + "owner": "mobile-runtime", + "layer": "shared-mobile-transport-contract", + "surfaces": [ + "paired mobile reconnect", + "cloud relay host-offline recovery", + "LAN direct endpoint", + "Tailscale direct endpoint" + ], + "platforms": [ + "ios", + "android", + "macos", + "linux", + "windows" + ], + "providers": [ + "lan", + "tailscale", + "cloud-relay" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "lan", + "tailscale", + "cloud-relay" + ], + "coverageNotes": "Deterministic TypeScript tests cover shared close-code policy, foreground retry timers, direct-winner cancellation, and concurrent LAN/Tailscale authentication. Physical iOS/Android radios, GFE, and production relay recovery remain live-test gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca-cloud/pull/96" + ], + "invariant": "A foregrounded paired phone must recover from relay HOST_OFFLINE without a foreground or network-change signal, while direct recovery must select the first authenticated configured LAN or Tailscale endpoint without serial timeout delays. Backgrounding, direct success, or stop must cancel pending work, and losing probes must close without affecting the winner.", + "oracle": "Inject deterministic relay close codes, random bytes, fake timers, and independently controlled direct clients. Require HOST_OFFLINE to replace any faster transport timer with one 5-15 second retry, require no retry before the selected delay, race all unique non-relay endpoints, select the first authenticated path, close every loser exactly once, and retain no retry after direct connectivity wins.", + "commands": [ + "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot" + ], + "testFiles": [ + "mobile/src/transport/mobile-direct-endpoint-probe.test.ts", + "mobile/src/transport/mobile-relay-reconnect-controller.test.ts", + "mobile/src/transport/mobile-endpoint-supervisor.test.ts", + "src/shared/mobile-relay-close-codes.test.ts" + ], + "assertionRefs": [ + { + "file": "mobile/src/transport/mobile-direct-endpoint-probe.test.ts", + "assertions": [ + "a reachable Tailscale endpoint authenticates without waiting for a stale primary LAN timeout", + "the stale direct candidate closes while the authenticated winner stays open" + ] + }, + { + "file": "mobile/src/transport/mobile-relay-reconnect-controller.test.ts", + "assertions": [ + "HOST_OFFLINE replaces a pending capacity retry with the bounded host-offline delay", + "direct connectivity cancels the pending relay retry" + ] + }, + { + "file": "mobile/src/transport/mobile-endpoint-supervisor.test.ts", + "assertions": [ + "a foregrounded supervisor retries HOST_OFFLINE without an external lifecycle signal" + ] + }, + { + "file": "src/shared/mobile-relay-close-codes.test.ts", + "assertions": [ + "HOST_OFFLINE maps to self-healing full-jitter recovery" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-25", + "runner": "local", + "platform": "macos", + "command": "pnpm --dir mobile exec vitest run --root .. mobile/src/transport/mobile-direct-endpoint-probe.test.ts mobile/src/transport/mobile-relay-reconnect-controller.test.ts mobile/src/transport/mobile-endpoint-supervisor.test.ts", + "result": "passed", + "durationSeconds": 0.89, + "summary": "Three focused mobile transport files passed with 39 assertions." + }, + { + "date": "2026-07-25", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/mobile-relay-close-codes.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 0.19, + "summary": "The shared close-code contract passed with five assertions." + } + ], + "runtimeBudget": { + "p95Seconds": 5, + "scope": "focused shared and mobile transport unit tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Two deterministic local runs exist; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The prior external-signal HOST_OFFLINE policy fails the retry oracle, and the prior serial direct probe fails the first-authenticated-endpoint timing oracle. Both pass with the candidate behavior." + }, + "performanceBudget": { + "required": true, + "evidence": "All configured direct candidates start in one turn, the first authenticated candidate wins after 100 ms in the deterministic test, and every losing client is closed. A physical-device radio and battery budget is still required before promotion." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Run paired iOS and Android recovery through production-like GFE HOST_OFFLINE responses.", + "Measure reconnect radio and battery impact for the 5-15 second foreground retry window." + ], + "knownGaps": [ + "No physical iOS or Android device was exercised.", + "The deterministic transport seam does not measure production GFE, carrier NAT, DNS, TLS, or Cloud SQL latency.", + "Background-to-foreground recovery remains covered by existing supervisor tests but lacks a physical sleep/wake run." + ], + "demotionRule": "Keep experimental or demote if focused tests flake, HOST_OFFLINE can park indefinitely, direct probes serialize configured endpoints, loser cleanup leaks clients, or physical-device radio cost exceeds the measured budget." + }, { "id": "git-worktree.refresh-event-semantics", "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts index e63f532b28d..46edf681228 100644 --- a/mobile/src/files/mobile-diff-image-preview.test.ts +++ b/mobile/src/files/mobile-diff-image-preview.test.ts @@ -1,17 +1,20 @@ import { describe, expect, it } from 'vitest' import { mobileDiffImageDataUri } from './mobile-diff-image-preview' +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8sm7wAAAABJRU5ErkJggg==' + describe('mobileDiffImageDataUri', () => { it('renders a modified image diff from the post-change bytes', () => { expect( mobileDiffImageDataUri({ kind: 'binary', - originalContent: 'b2xk', - modifiedContent: 'bmV3', + originalContent: PNG_BASE64, + modifiedContent: PNG_BASE64, isImage: true, mimeType: 'image/png' }) - ).toBe('data:image/png;base64,bmV3') + ).toBe(`data:image/png;base64,${PNG_BASE64}`) }) it('renders an added image diff (no original) from the modified bytes', () => { @@ -19,26 +22,26 @@ describe('mobileDiffImageDataUri', () => { mobileDiffImageDataUri({ kind: 'binary', originalContent: '', - modifiedContent: 'bmV3', + modifiedContent: PNG_BASE64, isImage: true, mimeType: 'image/png' }) - ).toBe('data:image/png;base64,bmV3') + ).toBe(`data:image/png;base64,${PNG_BASE64}`) }) it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => { expect( mobileDiffImageDataUri({ kind: 'binary', - originalContent: 'b2xk', + originalContent: PNG_BASE64, originalIsBinary: true, modifiedContent: '', modifiedIsBinary: false, modifiedDeleted: true, isImage: true, - mimeType: 'image/jpeg' + mimeType: 'image/png' }) - ).toBe('data:image/jpeg;base64,b2xk') + ).toBe(`data:image/png;base64,${PNG_BASE64}`) }) // The reviewer's read-failure case: a relay/SSH read returns an empty modified diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts index a9570e6cf44..06ee3510c05 100644 --- a/mobile/src/files/mobile-file-tab-doc.test.ts +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest' import type { RpcResponse } from '../transport/types' import { resolveMobileFileTabDoc } from './mobile-file-tab-doc' +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8sm7wAAAABJRU5ErkJggg==' + function ok(result: unknown): RpcResponse { return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } } } @@ -49,8 +52,8 @@ describe('resolveMobileFileTabDoc', () => { const client = clientOf({ 'git.diff': ok({ kind: 'binary', - originalContent: 'b2xk', - modifiedContent: 'bmV3', + originalContent: PNG_BASE64, + modifiedContent: PNG_BASE64, modifiedIsBinary: true, isImage: true, mimeType: 'image/png' @@ -61,7 +64,11 @@ describe('resolveMobileFileTabDoc', () => { relativePath: 'm1.png', diffSource: 'unstaged' }) - expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + expect(doc).toEqual({ + status: 'ready', + kind: 'image', + dataUri: `data:image/png;base64,${PNG_BASE64}` + }) }) it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => { @@ -89,10 +96,14 @@ describe('resolveMobileFileTabDoc', () => { it('renders a live image preview via files.readPreview', async () => { const client = clientOf({ - 'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' }) + 'files.readPreview': ok({ content: PNG_BASE64, isImage: true, mimeType: 'image/png' }) }) const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) - expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + expect(doc).toEqual({ + status: 'ready', + kind: 'image', + dataUri: `data:image/png;base64,${PNG_BASE64}` + }) expect(client.calls).toEqual(['files.readPreview']) }) diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.test.ts b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts new file mode 100644 index 00000000000..3afa8c08aac --- /dev/null +++ b/mobile/src/transport/mobile-direct-endpoint-probe.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from './rpc-client' +import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +class FakeClient implements RpcClient { + readonly sendRequest = vi.fn( + async (): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn(() => this.publishState('disconnected')) + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'lan', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { id: 'tailscale', kind: 'tailscale', url: 'ws://100.64.0.2:6768' } + ] +} + +describe('mobile direct endpoint probe', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('uses the first authenticated candidate without waiting for a stale primary', async () => { + const clients = new Map() + const openDirect = vi.fn((endpoint: string) => { + const client = new FakeClient('connecting') + clients.set(endpoint, client) + if (endpoint.includes('100.64.0.2')) { + setTimeout(() => client.publishState('connected'), 100) + } + return client + }) + + const probing = openAuthenticatedDirectEndpoint(host, openDirect, 12_000) + await vi.advanceTimersByTimeAsync(100) + const result = await probing + + expect(result?.path).toBe('tailscale') + expect(openDirect).toHaveBeenCalledTimes(2) + expect(clients.get(host.endpoint)?.close).toHaveBeenCalledOnce() + expect(result?.client.close).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-direct-endpoint-probe.ts b/mobile/src/transport/mobile-direct-endpoint-probe.ts index 99bd4e858f6..114a4f29130 100644 --- a/mobile/src/transport/mobile-direct-endpoint-probe.ts +++ b/mobile/src/transport/mobile-direct-endpoint-probe.ts @@ -58,14 +58,49 @@ export async function openAuthenticatedDirectEndpoint( openDirect: (endpoint: string) => RpcClient, timeoutMs: number ): Promise<{ client: RpcClient; path: Exclude } | null> { - for (const endpoint of directEndpointUrls(host)) { - const client = openDirect(endpoint) - try { - await waitForAuthenticatedSession(client, timeoutMs) - return { client, path: directPathForEndpoint(host, endpoint) } - } catch { - client.close() + const endpoints = directEndpointUrls(host) + return await new Promise((resolve) => { + const clients = new Set() + let remaining = endpoints.length + let settled = false + const rejectCandidate = (): void => { + remaining-- + if (!settled && remaining === 0) { + settled = true + resolve(null) + } } - } - return null + for (const endpoint of endpoints) { + let client: RpcClient + try { + client = openDirect(endpoint) + } catch { + rejectCandidate() + continue + } + clients.add(client) + void waitForAuthenticatedSession(client, timeoutMs).then( + () => { + if (settled) { + client.close() + return + } + settled = true + for (const candidate of clients) { + if (candidate !== client) { + candidate.close() + } + } + resolve({ client, path: directPathForEndpoint(host, endpoint) }) + }, + () => { + if (settled) { + return + } + client.close() + rejectCandidate() + } + ) + } + }) } diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 0bfe75d3b73..3ec05f5ef1e 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -578,7 +578,7 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) - it('waits for an external signal instead of polling a host-offline relay', async () => { + it('retries a host-offline relay without requiring an external signal', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4404))) const deps = dependencies({ @@ -591,13 +591,12 @@ describe('mobile endpoint supervisor', () => { expect(openRelay).toHaveBeenCalledOnce() logical.publishState('disconnected') - expect(vi.getTimerCount()).toBe(0) - - supervisor.setForeground(true) expect(vi.getTimerCount()).toBe(1) - await vi.advanceTimersByTimeAsync(250) + await vi.advanceTimersByTimeAsync(9_999) + expect(openRelay).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) expect(openRelay).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(1) supervisor.stop() }) diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index c68ed48a4f8..d630f0db68b 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -17,7 +17,7 @@ describe('relay reconnect controller', () => { vi.useRealTimers() }) - it('keeps one retry timer and cancels it when recovery needs an external signal', () => { + it('keeps one retry timer when recovery changes from capacity to host offline', () => { const onRetry = vi.fn() const reconnect = createController(onRetry) @@ -26,11 +26,12 @@ describe('relay reconnect controller', () => { expect(vi.getTimerCount()).toBe(1) reconnect.registerFailure(new RelayOuterError(4404)) - expect(vi.getTimerCount()).toBe(0) + expect(vi.getTimerCount()).toBe(1) expect(reconnect.shouldDefer()).toBe(true) - expect(vi.getTimerCount()).toBe(0) - vi.runAllTimers() + vi.advanceTimersByTime(9_999) expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onRetry).toHaveBeenCalledOnce() }) it('drops a pending relay retry after direct connectivity wins', () => { diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 7e3e1ec0e13..6f586c17499 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -15,6 +15,8 @@ const RELAY_BACKOFF_MIN_MS = 250 const RELAY_BACKOFF_BASE_MS = 500 const RELAY_BACKOFF_CEILING_MS = 30_000 const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS +const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 +const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 export type RelayReconnectDependencies = { now: () => number @@ -186,7 +188,8 @@ export class RelayReconnectController { // only an authenticated relay that survived the stability window resets the streak. this.activeRelayConnectedAt = null this.consecutiveFailures += 1 - const delay = this.delayMs() + const delay = + recovery?.kind === 'retry-after-host-offline' ? this.hostOfflineDelayMs() : this.delayMs() this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { // Why: pairing state cannot change on a timer; polling only wakes the radio. @@ -194,18 +197,16 @@ export class RelayReconnectController { this.clearTimer() return } - if (recovery?.kind === 'wait-for-host-revival') { - // Why: retrying HOST_OFFLINE without a revival signal is polling a known-negative state. - this.recoveryGate = 'external-signal' - this.clearTimer() - return - } if (recovery?.kind === 'disable-relay-credential') { // Why: a rejected outer credential cannot recover until direct connectivity refreshes it. this.recoveryGate = 'fresh-credential' this.clearTimer() return } + if (recovery?.kind === 'retry-after-host-offline') { + // A prior transport timer must not bypass the slower known-offline retry. + this.clearTimer() + } this.recoveryGate = null if (!scheduleRetry) { this.clearTimer() @@ -269,6 +270,11 @@ export class RelayReconnectController { return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) } + private hostOfflineDelayMs(): number { + const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS + return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + } + private jitterFraction(): number { const [high, low] = this.dependencies.randomBytes(2) return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 diff --git a/src/shared/mobile-relay-close-codes.test.ts b/src/shared/mobile-relay-close-codes.test.ts index 829f966c066..6605c30c864 100644 --- a/src/shared/mobile-relay-close-codes.test.ts +++ b/src/shared/mobile-relay-close-codes.test.ts @@ -42,4 +42,11 @@ describe('mobile relay close-code contract', () => { directUnaffected: true }) }) + + it('keeps host-offline recovery self-healing', () => { + expect(mobileRelayRecoveryFor(4404, 'phone-resume')).toEqual({ + kind: 'retry-after-host-offline', + fullJitter: true + }) + }) }) diff --git a/src/shared/mobile-relay-close-codes.ts b/src/shared/mobile-relay-close-codes.ts index 6eefb151e52..eaff0a3a735 100644 --- a/src/shared/mobile-relay-close-codes.ts +++ b/src/shared/mobile-relay-close-codes.ts @@ -13,7 +13,7 @@ export type MobileRelayLeg = 'host-control' | 'host-data' | 'phone-invite' | 'ph export type MobileRelayRecovery = | { kind: 'disable-relay-credential'; directUnaffected: true } - | { kind: 'wait-for-host-revival' } + | { kind: 'retry-after-host-offline'; fullJitter: true } | { kind: 'reconnect-fresh-e2ee'; fullJitter: true } | { kind: 'resolve-invite-through-director-ws'; requireStrictlyNewerEpoch: true } | { kind: 'resolve-resume-through-director-post' } @@ -29,7 +29,7 @@ export function mobileRelayRecoveryFor( case MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL: return { kind: 'disable-relay-credential', directUnaffected: true } case MOBILE_RELAY_CLOSE_CODE.HOST_OFFLINE: - return { kind: 'wait-for-host-revival' } + return { kind: 'retry-after-host-offline', fullJitter: true } case MOBILE_RELAY_CLOSE_CODE.PEER_DROPPED: return { kind: 'reconnect-fresh-e2ee', fullJitter: true } case MOBILE_RELAY_CLOSE_CODE.WRONG_CELL: From 15c0e4bc7c3e90ea6a472f192d16072d09250e47 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:49:27 +0000 Subject: [PATCH 070/150] release: v1.4.157-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0c596dbf8a0..0ba68ca2941 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.156-rc.1", + "version": "1.4.157-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 54e22402bc6a1069f823a7a0916ed6b0277fc2ef Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:52:04 -0700 Subject: [PATCH 071/150] docs(relay): trim the pending-output drain comment (#10714) Co-authored-by: Orca --- src/relay/pty-handler.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 26dd6e085ca..b108d675725 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -723,11 +723,8 @@ export class PtyHandler { private flushPendingOutput(): void { this.outputFlushTimer = null - // Why: the send loop has no skip path and stops at PTY_OUTPUT_FLUSH_MAX_WRITES, so it can never - // reach a third entry — `Array.from(entries())` allocated one tuple per session every tick to - // consume at most two. Capture only that prefix, and capture it *before* the first send so a - // re-entrant sink still reads the values a whole-map snapshot would have frozen. - // Why the explicit iterator: `for...of` would advance one tuple past the limit and discard it. + // Why batch before the first send: a re-entrant sink must read the values a whole-map snapshot + // would have frozen. Why the raw iterator: `for...of` would consume one entry past the limit. const pendingEntries = this.pendingOutputByPty[Symbol.iterator]() const batch: [string, PendingPtyOutput][] = [] while (batch.length < PTY_OUTPUT_FLUSH_MAX_WRITES) { From e1f0e7689c5346b5778c678514f8c7251ea0b04f Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:13:00 -0700 Subject: [PATCH 072/150] fix(history): quarantine unreadable recovery generations. (#10713) * fix(history): quarantine unreadable recovery files * fix(history): preserve mixed recovery generations * fix(history): reanchor reconciled live sessions * fix(history): serialize final checkpoint queue * fix(history): drain sleep shutdowns before disconnect * fix(history): restore legacy wide sessions * fix(history): preserve malformed mixed logs * fix(history): preserve malformed log tails * refactor tests to reduce file size * refactor(history-recovery): extract freeze helper and improve test robus - Extract takeRecoveryFreeze to eliminate duplicated freeze-and-clear pattern across five call sites - Skip permission-mode tests on root CI containers (chmod 0o500 doesn't block root writes) - Replace fixed sleep with deterministic wait for queued exclusive checkpoints - Distinguish ENOENT (missing) from corrupt in history metadata reads - Add ceiling-dimension restore test and torn-tail exclusion assertion - Wrap chmod operations in try/finally to prevent leaked permissions from masking test failures - Add .catch() to checkpoint promise to prevent unhandled rejections from finally re-throws * test(history-recovery): consolidate checkpoint assertions Wait for both the checkpoint call and set clear atomically to avoid a timing race where the spy fires before the set is cleared. --- ...aemon-pty-adapter-history-recovery.test.ts | 873 ++++++++++++++++++ src/main/daemon/daemon-pty-adapter.test.ts | 2 +- src/main/daemon/daemon-pty-adapter.ts | 350 ++++++- src/main/daemon/history-manager.test.ts | 174 +++- src/main/daemon/history-manager.ts | 375 ++++---- src/main/daemon/history-reader-memory.test.ts | 29 +- src/main/daemon/history-reader.test.ts | 230 ++++- src/main/daemon/history-reader.ts | 231 +++-- .../terminal-history-checkpoint-reader.ts | 71 ++ .../terminal-history-cold-restore-info.ts | 44 + .../daemon/terminal-history-dimensions.ts | 14 + ...rminal-history-incremental-restore.test.ts | 9 +- src/main/daemon/terminal-history-log.test.ts | 8 + src/main/daemon/terminal-history-log.ts | 3 + .../terminal-history-manager-options.ts | 13 + src/main/daemon/terminal-history-metadata.ts | 68 ++ .../terminal-history-recovery-quarantine.ts | 99 ++ .../daemon/terminal-history-session-writer.ts | 131 +++ 18 files changed, 2374 insertions(+), 350 deletions(-) create mode 100644 src/main/daemon/daemon-pty-adapter-history-recovery.test.ts create mode 100644 src/main/daemon/terminal-history-checkpoint-reader.ts create mode 100644 src/main/daemon/terminal-history-cold-restore-info.ts create mode 100644 src/main/daemon/terminal-history-dimensions.ts create mode 100644 src/main/daemon/terminal-history-manager-options.ts create mode 100644 src/main/daemon/terminal-history-metadata.ts create mode 100644 src/main/daemon/terminal-history-recovery-quarantine.ts create mode 100644 src/main/daemon/terminal-history-session-writer.ts diff --git a/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts new file mode 100644 index 00000000000..a1ee3dcfc66 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-history-recovery.test.ts @@ -0,0 +1,873 @@ +/* History recovery / quarantine / reconcile regressions for DaemonPtyAdapter. */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { + chmodSync, + closeSync, + existsSync, + ftruncateSync, + mkdtempSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonServer } from './daemon-server' +import { HistoryManager } from './history-manager' +import { getHistorySessionDirName } from './history-paths' +import { + TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES, + TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES +} from './terminal-history-file-limits' +import { + getTerminalHistoryQuarantineOwnerDir, + hasTerminalHistoryRecoveryProtection +} from './terminal-history-recovery-quarantine' +import { encodeLogBatch, encodeLogHeader } from './terminal-history-log' +import type { HistoryReader } from './history-reader' +import type { SubprocessHandle } from './session' +import type { DaemonFileLog } from './daemon-file-log' +import type * as DaemonHealthModule from './daemon-health' +import { getDaemonSocketPath } from './daemon-spawner' + +const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({ + getMacDaemonSystemResolverHealthMock: vi.fn(async () => 'unknown') +})) + +// Why not just posix: mode 0o500 does not block writes for uid 0, so root CI containers would never hit the failure. +const itOnUnprivilegedPosix = it.skipIf(process.platform === 'win32' || process.getuid?.() === 0) + +vi.mock('./daemon-health', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock + } +}) + +function createTestDir(): string { + return mkdtempSync(join(tmpdir(), 'daemon-adapter-history-recovery-')) +} + +function createSparseFile(path: string, bytes: number): void { + const descriptor = openSync(path, 'w') + ftruncateSync(descriptor, bytes) + closeSync(descriptor) +} + +async function leaveFailedQuarantineProtection( + historyPath: string, + sessionId: string +): Promise { + const manager = new HistoryManager(historyPath) + const recoveryFreeze = await manager.freezeForRecovery(sessionId) + // Occupy the quarantine root with a file so the owner-directory mkdir fails. + writeFileSync( + dirname(getTerminalHistoryQuarantineOwnerDir(historyPath, sessionId)), + 'block quarantine' + ) + await manager.openSession(sessionId, { + cwd: '/replacement', + cols: 80, + rows: 24, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + expect(manager.isSessionDisabled(sessionId)).toBe(true) +} + +function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { + pause: ReturnType void>> + resume: ReturnType void>> + _simulateData: (data: string) => void + _simulateExit: (code: number) => void +} { + let onDataCb: ((data: string) => void) | null = null + let onExitCb: ((code: number) => void) | null = null + return { + // Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd. + pid: 999_999_999, + getForegroundProcess: vi.fn(() => null), + write: vi.fn(), + resize: vi.fn(), + pause: vi.fn<() => void>(), + resume: vi.fn<() => void>(), + kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), + forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), + signal: vi.fn(), + onData(cb) { + onDataCb = cb + if (dataOnSubscribe) { + cb(dataOnSubscribe) + } + }, + onExit(cb) { + onExitCb = cb + }, + dispose: vi.fn(), + _simulateData(data: string) { + onDataCb?.(data) + }, + _simulateExit(code: number) { + onExitCb?.(code) + } + } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error('waitFor timed out') + } + await new Promise((r) => setTimeout(r, 10)) + } +} + +describe('DaemonPtyAdapter history recovery', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + let lastSubprocess: ReturnType + let historyDir: string + let historyAdapter: DaemonPtyAdapter + + beforeEach(async () => { + dir = createTestDir() + historyDir = join(dir, 'history') + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'test.token') + + const daemonLog: DaemonFileLog = { + log: () => {}, + close() {} + } + server = new DaemonServer({ + socketPath, + tokenPath, + log: daemonLog, + spawnSubprocess: () => { + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + adapter = new DaemonPtyAdapter({ socketPath, tokenPath }) + getMacDaemonSystemResolverHealthMock.mockReset() + getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown') + }) + + afterEach(async () => { + historyAdapter?.dispose() + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + it('suspends history when keepHistory cannot read its final checkpoint', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'sleep-unreadable' + }) + const manager = historyAdapter.getHistoryManager()! + const suspend = vi.spyOn(manager, 'suspendSession') + const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader + vi.spyOn(reader, 'detectColdRestoreState').mockResolvedValue({ + status: 'unreadable', + sessionId: id + }) + + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(suspend).toHaveBeenCalledWith(id) + const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json') + expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull() + }) + + it('suspends an empty final checkpoint with unreadable post-checkpoint recovery', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'sleep-empty-mixed-recovery' + }) + const manager = historyAdapter.getHistoryManager()! + const suspend = vi.spyOn(manager, 'suspendSession') + const originalCheckpoint = manager.checkpoint.bind(manager) + let malformedLog!: Buffer + vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => { + await originalCheckpoint(...args) + const sessionDir = join(historyDir, getHistorySessionDirName(id)) + const checkpoint = JSON.parse(readFileSync(join(sessionDir, 'checkpoint.json'), 'utf-8')) + malformedLog = Buffer.concat([ + encodeLogHeader(checkpoint.generation), + encodeLogBatch(1, [ + { kind: 'output', data: 'only post-checkpoint copy\r\n' }, + { kind: 'resize', cols: 1_001, rows: 24 } + ]) + ]) + writeFileSync(join(sessionDir, 'output.log'), malformedLog) + }) + + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(suspend).toHaveBeenCalledWith(id) + const sessionDir = join(historyDir, getHistorySessionDirName(id)) + expect(readFileSync(join(sessionDir, 'output.log'))).toEqual(malformedLog) + expect(JSON.parse(readFileSync(join(sessionDir, 'meta.json'), 'utf-8')).endedAt).toBeNull() + }) + + it('keeps the checkpoint timer out of a final keepHistory checkpoint', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 5 + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'sleep-checkpoint-exclusive' + }) + const manager = historyAdapter.getHistoryManager()! + const originalCheckpoint = manager.checkpoint.bind(manager) + let releaseCheckpoint!: () => void + let checkpointCalls = 0 + vi.spyOn(manager, 'checkpoint').mockImplementation(async (...args) => { + checkpointCalls++ + if (checkpointCalls === 1) { + await new Promise((resolve) => { + releaseCheckpoint = resolve + }) + } + await originalCheckpoint(...args) + }) + + const shuttingDown = historyAdapter.shutdown(id, { + immediate: true, + keepHistory: true + }) + await waitFor(() => releaseCheckpoint !== undefined) + lastSubprocess._simulateData('arrived during final checkpoint') + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(checkpointCalls).toBe(1) + + releaseCheckpoint() + await shuttingDown + expect(checkpointCalls).toBe(1) + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + + it('serializes concurrent keepHistory and disconnectOnly checkpoints', async () => { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const sessionIds = await Promise.all( + ['queued-checkpoint-a', 'queued-checkpoint-b', 'queued-checkpoint-c'].map( + async (sessionId) => + ( + await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId + }) + ).id + ) + ) + const internals = historyAdapter as unknown as { + checkpointSessions( + sessionIds: Iterable, + opts?: { final?: boolean; teardown?: boolean } + ): Promise> + runExclusiveCheckpoint(operation: () => Promise, options?: object): Promise + } + const originalCheckpointSessions = internals.checkpointSessions.bind(historyAdapter) + // Call-through spy: entering the exclusive gate is the observable "queued behind the in-flight checkpoint" moment. + const exclusiveEntries = vi.spyOn(internals, 'runExclusiveCheckpoint') + let activeCheckpoints = 0 + let maxActiveCheckpoints = 0 + let checkpointCalls = 0 + let releaseFirstCheckpoint!: () => void + let firstCheckpointStarted!: () => void + const firstStarted = new Promise((resolve) => { + firstCheckpointStarted = resolve + }) + const firstRelease = new Promise((resolve) => { + releaseFirstCheckpoint = resolve + }) + vi.spyOn(internals, 'checkpointSessions').mockImplementation(async (...args) => { + checkpointCalls++ + activeCheckpoints++ + maxActiveCheckpoints = Math.max(maxActiveCheckpoints, activeCheckpoints) + try { + if (checkpointCalls === 1) { + firstCheckpointStarted() + await firstRelease + } + return await originalCheckpointSessions(...args) + } finally { + activeCheckpoints-- + } + }) + + const firstShutdown = historyAdapter.shutdown(sessionIds[0], { + immediate: true, + keepHistory: true + }) + await firstStarted + const queuedOperations = [ + historyAdapter.shutdown(sessionIds[1], { immediate: true, keepHistory: true }), + historyAdapter.shutdown(sessionIds[2], { immediate: true, keepHistory: true }), + historyAdapter.disconnectOnly() + ] + // Why not a fixed sleep: releasing before both queued shutdowns enter the gate makes maxActiveCheckpoints===1 vacuous. + // (disconnectOnly's own entry lands after it drains the keepHistory shutdowns, so it can't be waited on here.) + await waitFor(() => exclusiveEntries.mock.calls.length >= 3) + releaseFirstCheckpoint() + await Promise.all([firstShutdown, ...queuedOperations]) + + expect(checkpointCalls).toBe(4) + expect(maxActiveCheckpoints).toBe(1) + }) + + it('reschedules another dirty session after a keepHistory checkpoint', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 25 + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const sleeping = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'sleep-with-dirty-peer' + }) + const peer = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'dirty-peer' + }) + const appendSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'appendIncrements') + lastSubprocess._simulateData('peer output before sleep\r\n') + + await historyAdapter.shutdown(sleeping.id, { immediate: true, keepHistory: true }) + await waitFor(() => appendSpy.mock.calls.some(([sessionId]) => sessionId === peer.id)) + + expect(appendSpy).toHaveBeenCalledWith( + peer.id, + expect.any(Number), + expect.arrayContaining([ + expect.objectContaining({ kind: 'output', data: 'peer output before sleep\r\n' }) + ]) + ) + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + + it.each([ + ['checkpoint.json', TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1], + ['scrollback.bin', TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES + 1] + ])('quarantines an unreadable oversized %s instead of deleting it', async (file, bytes) => { + const sessionId = `oversized-${file}` + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/oversized', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + createSparseFile(join(sessionDir, file), bytes) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore).toBeUndefined() + expect(existsSync(join(sessionDir, file))).toBe(false) + const ownerDir = getTerminalHistoryQuarantineOwnerDir(historyDir, sessionId) + const bundles = readdirSync(ownerDir) + expect(bundles).toHaveLength(1) + expect(statSync(join(ownerDir, bundles[0], file)).size).toBe(bytes) + expect(existsSync(join(sessionDir, 'meta.json'))).toBe(true) + }) + + it('quarantines an unreadable checkpoint even when legacy scrollback restores', async () => { + const sessionId = 'oversized-checkpoint-with-fallback' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/oversized', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + const checkpointPath = join(sessionDir, 'checkpoint.json') + const checkpointBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1 + createSparseFile(checkpointPath, checkpointBytes) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'legacy fallback\r\n') + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore?.scrollback).toContain('legacy fallback') + expect(existsSync(checkpointPath)).toBe(false) + const ownerDir = getTerminalHistoryQuarantineOwnerDir(historyDir, sessionId) + const bundles = readdirSync(ownerDir) + expect(bundles).toHaveLength(1) + expect(statSync(join(ownerDir, bundles[0], 'checkpoint.json')).size).toBe(checkpointBytes) + expect(historyAdapter.getHistoryManager()!.hasWriter(sessionId)).toBe(true) + }) + + it('quarantines a malformed log when its checkpoint fallback restores', async () => { + const sessionId = 'malformed-log-with-checkpoint' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/mixed-log', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync( + join(sessionDir, 'checkpoint.json'), + JSON.stringify({ + snapshotAnsi: 'checkpoint fallback\r\n', + scrollbackAnsi: 'checkpoint fallback\r\n', + rehydrateSequences: '', + cwd: '/projects/mixed-log', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + scrollbackLines: 1, + generation: 1, + checkpointedAt: '2026-07-25T10:01:00Z' + }) + ) + const log = Buffer.concat([ + encodeLogHeader(1), + encodeLogBatch(1, [ + { kind: 'output', data: 'only post-checkpoint copy\r\n' }, + { kind: 'resize', cols: 1_001, rows: 24 } + ]) + ]) + writeFileSync(join(sessionDir, 'output.log'), log) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.coldRestore?.scrollback).toContain('checkpoint fallback') + const ownerDir = getTerminalHistoryQuarantineOwnerDir(historyDir, sessionId) + const bundles = readdirSync(ownerDir) + expect(bundles).toHaveLength(1) + expect(readFileSync(join(ownerDir, bundles[0], 'output.log'))).toEqual(log) + expect(historyAdapter.getHistoryManager()!.hasWriter(sessionId)).toBe(true) + }) + + it('keeps unreadable history suspended when a fresh adapter finds the daemon live', async () => { + const sessionId = 'live-with-unreadable-history' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/preserved', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + const checkpointPath = join(sessionDir, 'checkpoint.json') + const checkpointBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1 + createSparseFile(checkpointPath, checkpointBytes) + await leaveFailedQuarantineProtection(historyDir, sessionId) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const suspend = vi.spyOn(historyAdapter.getHistoryManager()!, 'suspendSession') + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.isReattach).toBe(true) + expect(suspend).toHaveBeenCalledWith(sessionId, expect.objectContaining({ sessionId })) + expect(statSync(checkpointPath).size).toBe(checkpointBytes) + expect(existsSync(getTerminalHistoryQuarantineOwnerDir(historyDir, sessionId))).toBe(false) + }) + + it('keeps protected history suspended when its files become readable before reattach', async () => { + const sessionId = 'live-with-recovered-protection' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/preserved', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + const checkpointPath = join(sessionDir, 'checkpoint.json') + createSparseFile(checkpointPath, TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1) + await leaveFailedQuarantineProtection(historyDir, sessionId) + writeFileSync( + checkpointPath, + JSON.stringify({ + snapshotAnsi: 'must stay protected', + scrollbackAnsi: '', + rehydrateSequences: '', + cwd: '/projects/preserved', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: false + }, + scrollbackLines: 0, + checkpointedAt: '2026-07-25T10:01:00Z' + }) + ) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(result.isReattach).toBe(true) + expect(historyAdapter.getHistoryManager()!.hasWriter(sessionId)).toBe(false) + expect(hasTerminalHistoryRecoveryProtection(historyDir, sessionId)).toBe(true) + expect(readFileSync(checkpointPath, 'utf8')).toContain('must stay protected') + }) + + itOnUnprivilegedPosix( + 'full-checks live recovery after a transient protection-marker write failure', + async () => { + const sessionId = 'live-after-marker-write-failure' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/preserved', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + const checkpointPath = join(sessionDir, 'checkpoint.json') + const checkpointBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1 + createSparseFile(checkpointPath, checkpointBytes) + const failedManager = new HistoryManager(historyDir) + const recoveryFreeze = await failedManager.freezeForRecovery(sessionId) + chmodSync(sessionDir, 0o500) + try { + await failedManager.openSession(sessionId, { + cwd: '/replacement', + cols: 80, + rows: 24, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + } finally { + // Why finally: a leaked 0o500 dir turns teardown into a confusing EACCES instead of the real assertion failure. + chmodSync(sessionDir, 0o700) + } + expect(failedManager.isSessionDisabled(sessionId)).toBe(true) + expect(existsSync(join(sessionDir, '.unreadable-recovery'))).toBe(false) + historyAdapter = new DaemonPtyAdapter({ + socketPath, + tokenPath, + historyPath: historyDir + }) + + await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + + expect(historyAdapter.getHistoryManager()!.hasWriter(sessionId)).toBe(false) + expect(statSync(checkpointPath).size).toBe(checkpointBytes) + } + ) + + it('does not manage history under a canonical id adopted from another request', async () => { + const claim = { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + const surface = { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_history_claim' + } + const canonicalId = 'canonical-history-claim' + await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: canonicalId, + agentSessionEnsure: { claim, surface } + }) + const sessionDir = join(historyDir, getHistorySessionDirName(canonicalId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/canonical', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + const checkpointPath = join(sessionDir, 'checkpoint.json') + const checkpointBytes = TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1 + createSparseFile(checkpointPath, checkpointBytes) + await leaveFailedQuarantineProtection(historyDir, canonicalId) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const adopted = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'different-history-request', + agentSessionEnsure: { + claim, + surface: { ...surface, terminalHandle: 'term_history_adopted' } + } + }) + + expect(adopted.id).toBe(canonicalId) + expect(adopted.agentSessionEnsure?.disposition).toBe('adopted') + expect(historyAdapter.getHistoryManager()!.hasWriter(canonicalId)).toBe(false) + expect(statSync(checkpointPath).size).toBe(checkpointBytes) + }) + + it('does not register protected recovery during startup reconciliation', async () => { + const worktreeId = 'repo-a::/wt/protected' + const { id: sessionId } = await adapter.spawn({ cols: 80, rows: 24, worktreeId }) + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/protected', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + createSparseFile(join(sessionDir, 'checkpoint.json'), TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1) + await leaveFailedQuarantineProtection(historyDir, sessionId) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const reconciled = await historyAdapter.reconcileOnStartup(new Set([worktreeId])) + + expect(reconciled.alive).toEqual([sessionId]) + expect(historyAdapter.getHistoryManager()!.hasWriter(sessionId)).toBe(false) + }) + + it('re-anchors ordinary restorable history during startup reconciliation', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 100 + try { + const worktreeId = 'repo-a::/wt/reconciled-history' + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id: sessionId } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + worktreeId + }) + lastSubprocess._simulateData('before adapter restart\r\n') + await historyAdapter.disconnectOnly() + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const manager = historyAdapter.getHistoryManager()! + const checkpointSpy = vi.spyOn(manager, 'checkpoint') + const reconciled = await historyAdapter.reconcileOnStartup(new Set([worktreeId])) + const internals = historyAdapter as unknown as { + sessionsNeedingFullCheckpoint: Set + } + + expect(reconciled.alive).toEqual([sessionId]) + expect(manager.hasWriter(sessionId)).toBe(true) + expect(internals.sessionsNeedingFullCheckpoint.has(sessionId)).toBe(true) + + // Why both: the spy fires inside takeSnapshotAndCheckpoint; the set clears only after that await returns. + await waitFor( + () => + checkpointSpy.mock.calls.some(([id]) => id === sessionId) && + !internals.sessionsNeedingFullCheckpoint.has(sessionId) + ) + + const checkpoint = JSON.parse( + readFileSync( + join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), + 'utf8' + ) + ) + expect(checkpoint.snapshotAnsi).toContain('before adapter restart') + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + + it('serializes explicit shutdown behind an in-progress history-aware spawn', async () => { + const sessionId = 'spawn-shutdown-race' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/race', + cols: 80, + rows: 24, + startedAt: '2026-07-25T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const reader = (historyAdapter as unknown as { historyReader: HistoryReader }).historyReader + let releaseDetection!: () => void + let detectCalls = 0 + // Why only the first call blocks: a second never-resolving promise would hang the test instead of failing it. + vi.spyOn(reader, 'detectColdRestoreState').mockImplementation(() => { + if (detectCalls++ > 0) { + return Promise.resolve({ status: 'none' }) + } + return new Promise((resolve) => { + releaseDetection = () => resolve({ status: 'none' }) + }) + }) + + const spawning = historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + await waitFor(() => releaseDetection !== undefined) + let shutdownSettled = false + const shuttingDown = historyAdapter + .shutdown(sessionId, { immediate: true }) + .then(() => (shutdownSettled = true)) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(shutdownSettled).toBe(false) + + releaseDetection() + await spawning + await shuttingDown + expect(existsSync(sessionDir)).toBe(false) + }) + + it('revalidates a claimed canonical id after replacing a raced spawn', async () => { + const sessionId = 'probe-race-claimed-request' + const canonicalId = 'probe-race-claimed-canonical' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/raced', + cols: 100, + rows: 30, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'raced claimed output\r\n') + const claim = { + digestVersion: 1 as const, + keyId: 'key', + identityDigest: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + worktreeScopeDigest: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + agent: 'codex' as const + } + const surface = { + worktreeId: 'worktree', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claim_race' + } + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const client = ( + historyAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const originalRequest = client.request.bind(client) + let createCalls = 0 + vi.spyOn(client, 'request').mockImplementation(async (type: string, payload?: unknown) => { + if (type === 'getSize') { + return { size: { cols: 100, rows: 30 } } + } + if (type === 'createOrAttach') { + createCalls++ + if (createCalls === 2) { + await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: canonicalId, + agentSessionEnsure: { + claim, + surface: { ...surface, terminalHandle: 'term_claim_race_canonical' } + } + }) + } + } + return await originalRequest(type, payload) + }) + + const result = await historyAdapter.spawn({ + cols: 80, + rows: 24, + sessionId, + agentSessionEnsure: { claim, surface } + }) + + expect(createCalls).toBe(2) + expect(result.id).toBe(canonicalId) + expect(result.agentSessionEnsure?.disposition).toBe('adopted') + expect(result.coldRestore).toBeUndefined() + expect(historyAdapter.getHistoryManager()!.hasWriter(sessionId)).toBe(false) + expect(historyAdapter.getHistoryManager()!.hasWriter(canonicalId)).toBe(false) + expect(readFileSync(join(sessionDir, 'scrollback.bin'), 'utf8')).toContain( + 'raced claimed output' + ) + }) +}) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 980f29f1766..49edc357e94 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { hostname, tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { DaemonClient } from './client' import { DaemonProtocolError } from './daemon-errors' import { DaemonPtyAdapter } from './daemon-pty-adapter' diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index e977ed6fbe6..4622fa630ca 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -3,7 +3,7 @@ import { basename } from 'node:path' import { existsSync } from 'node:fs' import { DaemonClient } from './client' import { getMacDaemonSystemResolverHealth } from './daemon-health' -import { HistoryManager } from './history-manager' +import { HistoryManager, type HistoryRecoveryFreeze } from './history-manager' import { HistoryReader, type ColdRestoreInfo } from './history-reader' import { mintPtySessionId, parsePtySessionId } from './pty-session-id' import { supportsPtyStartupBarrier } from './shell-ready' @@ -56,6 +56,23 @@ type PendingDaemonSpawnOperation = { ignoreNextExit: boolean } +type HistoryRecoveryContext = { + freeze: HistoryRecoveryFreeze | null + unreadableSessionId: string | null + identityChanged: boolean +} + +// Why take-and-clear together: every consuming branch must reset the field, so pairing them stops one from forgetting. +function takeRecoveryFreeze( + historyRecovery: HistoryRecoveryContext, + sessionId: string +): HistoryRecoveryFreeze | undefined { + const freeze = + historyRecovery.freeze?.sessionId === sessionId ? historyRecovery.freeze : undefined + historyRecovery.freeze = null + return freeze +} + function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { // Why: alt-screen snapshots are the TUI buffer; prefer its normal scrollback so a dead TUI isn't revived as the fresh shell's active screen. return restoreInfo.modes.alternateScreen @@ -140,11 +157,14 @@ export class DaemonPtyAdapter implements IPtyProvider { private sessionIncarnations = new Map() private pendingSpawnOperationsBySessionId = new Map>() private pendingClaimSpawnOperations = new Set() + private historySpawnLocks = new Map>() private dirtySessionVersions = new Map() // Why: a cold-restored session is a fresh shell atop a pre-crash log; incremental appends would be rejected on restore, so the first tick re-anchors with a full snapshot. private sessionsNeedingFullCheckpoint = new Set() private checkpointTimer: ReturnType | null = null private checkpointInFlight: Promise | null = null + private keepHistoryShutdowns = new Set>() + private disconnectOnlyPromise: Promise | null = null // Why: checkpoint persistence needs the getSnapshot RPC (v4+); legacy daemons reject it, spamming logs every 5s. private supportsCheckpoints: boolean // Why: incremental checkpoints need the takePendingOutput RPC (v13+); older daemons fall back to full-snapshot checkpoints. @@ -226,9 +246,19 @@ export class DaemonPtyAdapter implements IPtyProvider { if (opts.agentSessionEnsure) { this.pendingClaimSpawnOperations.add(operation) } + const historyRecovery: HistoryRecoveryContext = { + freeze: null, + unreadableSessionId: null, + identityChanged: false + } try { - return await this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation)) + return await this.withHistorySpawnLock(sessionId, () => + this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation, historyRecovery)) + ) } finally { + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + } this.pendingClaimSpawnOperations.delete(operation) operations.delete(operation) if (operations.size === 0) { @@ -239,7 +269,8 @@ export class DaemonPtyAdapter implements IPtyProvider { private async doSpawn( opts: PtySpawnOptions, - operation: PendingDaemonSpawnOperation + operation: PendingDaemonSpawnOperation, + historyRecovery: HistoryRecoveryContext ): Promise { if ( opts.agentSessionEnsure && @@ -254,11 +285,38 @@ export class DaemonPtyAdapter implements IPtyProvider { shellOverride: opts.shellOverride, terminalWindowsWslDistro: opts.terminalWindowsWslDistro })?.distro + const freezeHistory = async (): Promise => { + if (!this.historyManager) { + return + } + if (historyRecovery.freeze?.sessionId === sessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager.abandonRecoveryFreeze(historyRecovery.freeze) + } + historyRecovery.freeze = await this.historyManager.freezeForRecovery(sessionId) + historyRecovery.unreadableSessionId = null + } const detectColdRestore = async (options?: { ignoreCleanEnd?: boolean }): Promise => { - const restoreInfo = - (await this.historyReader?.detectColdRestore(sessionId, { ...options, wslDistro })) ?? null + if (!this.historyReader) { + return null + } + await freezeHistory() + const detection = await this.historyReader.detectColdRestoreState(sessionId, { + ...options, + wslDistro + }) + if (detection.status === 'unreadable') { + historyRecovery.unreadableSessionId = detection.sessionId + return null + } + const restoreInfo = detection.status === 'restored' ? detection.restoreInfo : null + if (detection.status === 'restored' && detection.hasUnreadableRecovery) { + historyRecovery.unreadableSessionId = detection.sessionId + } if (!restoreInfo) { return null } @@ -291,9 +349,14 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why probe aliveness first: detectColdRestore replays up to ~5MB on the main process, but a live session's snapshot supersedes disk, so the replay would be wasted. let restoreInfo: ColdRestoreInfo | null = null let restoreSkippedForLiveSession = false - if (this.historyReader?.hasRestorableHistory(sessionId)) { + const historyProbe = this.historyReader?.probeRestorableHistory(sessionId) + if (historyProbe && historyProbe.status !== 'none') { if ((await this.getAppliedSize(sessionId)) !== null) { restoreSkippedForLiveSession = true + if (this.historyManager && !this.historyManager.hasWriter(sessionId)) { + await detectColdRestore() + restoreInfo = null + } } else { restoreInfo = await detectColdRestore() } @@ -345,14 +408,32 @@ export class DaemonPtyAdapter implements IPtyProvider { } let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null - let result = await createOrAttach(scrollback) - if (opts.agentSessionEnsure && !isAgentSessionClaimedSpawnResult(result.agentSessionEnsure)) { - // Why: a claim-incapable owner may already have spawned before returning - // a malformed response; retire only this requested session before failing closed. - await this.client.request('kill', { sessionId }).catch(() => {}) - throw new Error('agent_session_claim_unavailable') + const adoptSpawnResultSession = async (spawnResult: CreateOrAttachResult): Promise => { + const requestedSessionId = sessionId + if ( + opts.agentSessionEnsure && + !isAgentSessionClaimedSpawnResult(spawnResult.agentSessionEnsure) + ) { + // Why: a claim-incapable owner may already have spawned before returning + // a malformed response; retire only this requested session before failing closed. + await this.client.request('kill', { sessionId: requestedSessionId }).catch(() => {}) + throw new Error('agent_session_claim_unavailable') + } + sessionId = spawnResult.agentSessionEnsure?.owner.ptyId ?? requestedSessionId + if (requestedSessionId === sessionId) { + return + } + if (historyRecovery.freeze) { + this.historyManager?.abandonRecoveryFreeze(historyRecovery.freeze) + historyRecovery.freeze = null + } + historyRecovery.unreadableSessionId = null + historyRecovery.identityChanged = true + restoreInfo = null + scrollback = null } - sessionId = result.agentSessionEnsure?.owner.ptyId ?? sessionId + let result = await createOrAttach(scrollback) + await adoptSpawnResultSession(result) const exitedResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) if (exitedResult) { return exitedResult @@ -387,8 +468,13 @@ export class DaemonPtyAdapter implements IPtyProvider { if (cachedRestore) { // Why: wake-after-sleep lands here too; sleep dropped active tracking + the history writer, so re-register both or the next sleep/wake restores a blank terminal. this.activeSessionIds.add(sessionId) - if (this.historyManager) { - this.historyManager.reopenSession(sessionId) + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + this.historyManager.suspendSession(sessionId, recoveryFreeze) + } else { + this.historyManager.reopenSession(sessionId, recoveryFreeze) + } } return { id: sessionId, @@ -404,7 +490,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path. // Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it. - if (result.isNew && restoreSkippedForLiveSession) { + if (!historyRecovery.identityChanged && result.isNew && restoreSkippedForLiveSession) { restoreInfo = await detectColdRestore({ ignoreCleanEnd: true }) scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null if (restoreInfo && scrollback) { @@ -419,6 +505,7 @@ export class DaemonPtyAdapter implements IPtyProvider { effectiveCols = restoreInfo.cols effectiveRows = restoreInfo.rows result = await createOrAttach(scrollback) + await adoptSpawnResultSession(result) const exitedRetryResult = this.resultForExitBeforeSpawnReply(sessionId, result, operation) if (exitedRetryResult) { return exitedRetryResult @@ -436,7 +523,11 @@ export class DaemonPtyAdapter implements IPtyProvider { pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null this.initialCwds.set(sessionId, effectiveCwd) } - } else if (!result.isNew && result.historySeeded === false) { + } else if ( + !historyRecovery.identityChanged && + !result.isNew && + result.historySeeded === false + ) { restoreInfo = await detectColdRestore() scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null } @@ -450,15 +541,28 @@ export class DaemonPtyAdapter implements IPtyProvider { const coldRestore = this.buildColdRestorePayload(restoreInfo) const canReanchorHistory = !scrollback || result.historySeeded === true // Why: registerWriter (not openSession) avoids deleting checkpoint.json — the only recovery data if the revived daemon crashes before the next tick. - if (this.historyManager) { - if (canReanchorHistory) { - this.historyManager.registerWriter(sessionId) + if (this.historyManager && !historyRecovery.identityChanged) { + const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) + if (historyRecovery.unreadableSessionId === sessionId) { + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + quarantineUnreadableRecovery: true + }) + if (this.historyManager.hasWriter(sessionId)) { + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } + } else if (canReanchorHistory) { + this.historyManager.registerWriter(sessionId, recoveryFreeze) this.sessionsNeedingFullCheckpoint.add(sessionId) // Why: the revived generation has no valid checkpoint yet; a cooldown inherited from the pre-crash generation must not defer this re-anchor. this.lastFullCheckpointAt.delete(sessionId) } else { // Preserve old recovery files when the new daemon can't include them; a fresh-only checkpoint would make the data loss permanent. - this.historyManager.suspendSession(sessionId) + this.historyManager.suspendSession(sessionId, recoveryFreeze) } } if (coldRestore) { @@ -486,20 +590,27 @@ export class DaemonPtyAdapter implements IPtyProvider { } } - if (this.historyManager && result.isNew) { - void this.historyManager - .openSession(sessionId, { - cwd: effectiveCwd ?? '', - cols: effectiveCols, - rows: effectiveRows - }) - .catch((err) => console.warn('[history] openSession failed:', sessionId, err)) - } else if (this.historyManager && result.historySeeded === false) { + if (this.historyManager && !historyRecovery.identityChanged && result.isNew) { + const recoveryFreeze = takeRecoveryFreeze(historyRecovery, sessionId) + await this.historyManager.openSession(sessionId, { + cwd: effectiveCwd ?? '', + cols: effectiveCols, + rows: effectiveRows, + ...(recoveryFreeze ? { recoveryFreeze } : {}), + ...(historyRecovery.unreadableSessionId === sessionId + ? { quarantineUnreadableRecovery: true } + : {}) + }) + } else if ( + this.historyManager && + !historyRecovery.identityChanged && + (result.historySeeded === false || historyRecovery.unreadableSessionId === sessionId) + ) { // Why: the daemon keeps this failure bit with the live session, so a new adapter can't promote its fresh-only snapshot after restart. - this.historyManager.suspendSession(sessionId) - } else if (this.historyManager) { + this.historyManager.suspendSession(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) + } else if (this.historyManager && !historyRecovery.identityChanged) { // Why: on warm reattach after relaunch the HistoryManager is fresh; registerWriter adds a writer without deleting the still-only-valid checkpoint. - this.historyManager.registerWriter(sessionId) + this.historyManager.registerWriter(sessionId, takeRecoveryFreeze(historyRecovery, sessionId)) if (!wasAlreadyManaged) { // Why: a previous adapter may have drained records it never persisted, so appending would leave a seq gap the reader rejects; force a full snapshot to re-anchor. this.sessionsNeedingFullCheckpoint.add(sessionId) @@ -643,6 +754,26 @@ export class DaemonPtyAdapter implements IPtyProvider { async shutdown( id: string, opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } + ): Promise { + if (opts.keepHistory && this.disconnectOnlyPromise) { + throw new Error('Cannot keep history after daemon disconnect has started') + } + const shutdown = this.withHistorySpawnLock(id, () => this.shutdownWithHistoryLock(id, opts)) + if (!opts.keepHistory) { + await shutdown + return + } + this.keepHistoryShutdowns.add(shutdown) + try { + await shutdown + } finally { + this.keepHistoryShutdowns.delete(shutdown) + } + } + + private async shutdownWithHistoryLock( + id: string, + opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } ): Promise { // Why: shutdown can be the first lazy-client operation after restart; connect // before killing so a healthy daemon session is not orphaned (#7742). Connect @@ -652,12 +783,12 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. // Force a final snapshot so wake can restore the pane users left. if (opts.keepHistory) { - if (this.checkpointInFlight) { - await this.checkpointInFlight - } - await this.checkpointSessions([id], { final: true, teardown: true }) + await this.runExclusiveCheckpoint(async () => { + await this.checkpointSessions([id], { final: true, teardown: true }) + }) const wslDistro = this.wslDistrosBySessionId.get(id) - const detected = (await this.historyReader?.detectColdRestore(id, { wslDistro })) ?? null + const detection = await this.historyReader?.detectColdRestoreState(id, { wslDistro }) + const detected = detection?.status === 'restored' ? detection.restoreInfo : null const restoreInfo = detected ? { ...detected, @@ -677,6 +808,11 @@ export class DaemonPtyAdapter implements IPtyProvider { } // Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority. this.historyManager?.suspendSession(id) + } else if ( + detection?.status === 'unreadable' || + (detection?.status === 'restored' && detection.hasUnreadableRecovery) + ) { + this.historyManager?.suspendSession(id) } } await this.client.request( @@ -698,7 +834,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.wslDistrosBySessionId.delete(id) // Why: only remove history on explicit close; sleep also calls shutdown but wake needs the dir intact for cold restore (opts.keepHistory). if (this.historyManager && !opts.keepHistory) { - void this.historyManager + await this.historyManager .removeSession(id) .catch((err) => console.warn('[history] removeSession failed:', id, err)) } @@ -929,13 +1065,61 @@ export class DaemonPtyAdapter implements IPtyProvider { alive.push(session.sessionId) // Why: track background sessions in the checkpoint set so disconnectOnly's final checkpoint doesn't leave stale recovery data. this.activeSessionIds.add(session.sessionId) - this.historyManager?.registerWriter(session.sessionId) + await this.reconcileLiveSessionHistory(session).catch((err) => + console.warn('[history] live-session reconciliation failed:', session.sessionId, err) + ) } } return { alive, killed } } + private async reconcileLiveSessionHistory(session: SessionInfo): Promise { + const historyManager = this.historyManager + const historyReader = this.historyReader + if (!historyManager || !historyReader) { + return + } + await this.withHistorySpawnLock(session.sessionId, async () => { + if (historyManager.hasWriter(session.sessionId)) { + return + } + const probe = historyReader.probeRestorableHistory(session.sessionId) + if (probe.status === 'unreadable') { + return + } + if (probe.status === 'none') { + await historyManager.openSession(session.sessionId, { + cwd: session.cwd ?? '', + cols: session.cols, + rows: session.rows + }) + } else { + const recoveryFreeze = await historyManager.freezeForRecovery(session.sessionId) + try { + const detection = await historyReader.detectColdRestoreState(session.sessionId, { + wslDistro: session.wslDistro ?? undefined + }) + if ( + detection.status === 'unreadable' || + (detection.status === 'restored' && detection.hasUnreadableRecovery) + ) { + historyManager.suspendSession(session.sessionId, recoveryFreeze) + return + } + historyManager.reopenSession(session.sessionId, recoveryFreeze) + } finally { + historyManager.abandonRecoveryFreeze(recoveryFreeze) + } + } + if (historyManager.hasWriter(session.sessionId)) { + this.sessionsNeedingFullCheckpoint.add(session.sessionId) + this.lastFullCheckpointAt.delete(session.sessionId) + this.markSessionDirty(session.sessionId) + } + }) + } + async listProcesses(opts?: { deadlineMs?: number }): Promise { // Why: connect + listSessions share the caller's one absolute deadline so a // wedged handshake cannot burn the whole teardown budget before the list issues. @@ -1129,16 +1313,23 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: unlike dispose(), leave history files unclean (no endedAt) so the next launch treats them as crash-recoverable, // but still write a final checkpoint so a daemon crash while Orca is closed has recovery data. async disconnectOnly(): Promise { - this.respawnAdoptionClosed = true - this.releasePendingRespawnAdoptionLease() - this.stopCheckpointTimer() - // Why: wait out an in-flight timer pass; racing on the shared tmp file risks ENOENT on rename, disabling future writes. - if (this.checkpointInFlight) { - await this.checkpointInFlight + if (!this.disconnectOnlyPromise) { + this.respawnAdoptionClosed = true + this.releasePendingRespawnAdoptionLease() + this.disconnectOnlyPromise = this.finishDisconnectOnly([...this.keepHistoryShutdowns]) } + await this.disconnectOnlyPromise + } + + private async finishDisconnectOnly(keepHistoryShutdowns: Promise[]): Promise { + // Why: sleep shutdowns still detect recovery and kill after checkpointing; disconnecting first rejects those admitted operations. + await Promise.allSettled(keepHistoryShutdowns) + this.respawnAdoptionClosed = true // Why: a final checkpoint covers sessions opened since the last tick (else cold restore finds nothing if the daemon // later dies). Await it — fire-and-forget would race client.disconnect() and reject the pending getSnapshot RPCs. - await this.checkpointAllSessions() + await this.runExclusiveCheckpoint(() => this.checkpointAllSessions(), { + rescheduleDirty: false + }) this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() @@ -1237,10 +1428,17 @@ export class DaemonPtyAdapter implements IPtyProvider { this.scheduleCheckpointTimer() return } - this.checkpointInFlight = this.checkpointDirtySessions().finally(() => { - this.checkpointInFlight = null - this.scheduleCheckpointTimer() - }) + const checkpoint = this.checkpointDirtySessions() + this.checkpointInFlight = checkpoint + void checkpoint + .finally(() => { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + this.scheduleCheckpointTimer() + } + }) + // Why: .finally() re-throws, so a rejected checkpoint would surface as an unhandled rejection here. + .catch(() => {}) }, DaemonPtyAdapter.CHECKPOINT_INTERVAL_MS) } @@ -1275,6 +1473,28 @@ export class DaemonPtyAdapter implements IPtyProvider { this.stopCheckpointTimerIfIdle() } + private async runExclusiveCheckpoint( + operation: () => Promise, + options: { rescheduleDirty?: boolean } = {} + ): Promise { + this.stopCheckpointTimer() + // Why: a promise tail keeps every waiter ordered; awaiting one active operation lets sibling waiters resume together. + const previous = this.checkpointInFlight ?? Promise.resolve() + const checkpoint = previous.catch(() => {}).then(operation) + this.checkpointInFlight = checkpoint + try { + await checkpoint + } finally { + if (this.checkpointInFlight === checkpoint) { + this.checkpointInFlight = null + } + this.stopCheckpointTimer() + if (options.rescheduleDirty !== false) { + this.scheduleCheckpointTimer() + } + } + } + // Why final=true not teardown: clean disconnect needs the full-depth snapshot as the restore source, but the // detached daemon's PTYs keep running for warm reattach, so shell-ready scanner state must stay intact. private async checkpointAllSessions(): Promise { @@ -1444,6 +1664,34 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + private async withHistorySpawnLock( + sessionId: string, + operation: () => Promise + ): Promise { + if (!this.historyManager) { + return await operation() + } + const previous = this.historySpawnLocks.get(sessionId) ?? Promise.resolve() + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const tail = previous.then( + () => current, + () => current + ) + this.historySpawnLocks.set(sessionId, tail) + await previous.catch(() => {}) + try { + return await operation() + } finally { + release() + if (this.historySpawnLocks.get(sessionId) === tail) { + this.historySpawnLocks.delete(sessionId) + } + } + } + private async replaceUnhealthyMacResolverDaemonBeforeNewPty(): Promise { if (!this.respawnFn) { return diff --git a/src/main/daemon/history-manager.test.ts b/src/main/daemon/history-manager.test.ts index 1c91cc72aaf..57c74896a8e 100644 --- a/src/main/daemon/history-manager.test.ts +++ b/src/main/daemon/history-manager.test.ts @@ -1,10 +1,22 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { mkdtempSync, rmSync, readFileSync, existsSync, chmodSync } from 'node:fs' +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync +} from 'node:fs' import { HistoryManager } from './history-manager' import type { TerminalSnapshot, TerminalModes } from './types' import { getHistorySessionDirName } from './history-paths' +import { + getTerminalHistoryQuarantineOwnerDir, + hasTerminalHistoryRecoveryProtection +} from './terminal-history-recovery-quarantine' function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'history-mgr-test-')) @@ -71,6 +83,146 @@ describe('HistoryManager', () => { const sessionDir = join(dir, getHistorySessionDirName('sess-1')) expect(existsSync(sessionDir)).toBe(true) }) + + it('quarantines the complete unreadable generation before opening a replacement', async () => { + const sessionId = 'unreadable-recovery' + await mgr.openSession(sessionId, { cwd: '/old', cols: 80, rows: 24 }) + await mgr.checkpoint(sessionId, makeSnapshot({ snapshotAnsi: 'only recovery copy' })) + writeFileSync(sessionPath(dir, sessionId, 'future-artifact'), 'keep me') + const recoveryFreeze = await mgr.freezeForRecovery(sessionId) + + await mgr.openSession(sessionId, { + cwd: '/new', + cols: 120, + rows: 40, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + + const ownerDir = getTerminalHistoryQuarantineOwnerDir(dir, sessionId) + const bundles = readdirSync(ownerDir) + expect(bundles).toHaveLength(1) + const quarantined = join(ownerDir, bundles[0]) + expect(readdirSync(quarantined).sort()).toEqual([ + '.unreadable-recovery', + 'checkpoint.json', + 'future-artifact', + 'meta.json', + 'output.log' + ]) + expect(readFileSync(join(quarantined, 'checkpoint.json'), 'utf8')).toContain( + 'only recovery copy' + ) + expect(existsSync(sessionPath(dir, sessionId, 'checkpoint.json'))).toBe(false) + expect( + JSON.parse(readFileSync(sessionPath(dir, sessionId, 'meta.json'), 'utf8')) + ).toMatchObject({ + cwd: '/new', + cols: 120, + rows: 40 + }) + }) + + it('fails closed when the frozen recovery generation changes', async () => { + const sessionId = 'changed-recovery' + await mgr.openSession(sessionId, { cwd: '/old', cols: 80, rows: 24 }) + await mgr.checkpoint(sessionId, makeSnapshot()) + const recoveryFreeze = await mgr.freezeForRecovery(sessionId) + writeFileSync(sessionPath(dir, sessionId, 'raced-artifact'), 'new generation') + + await mgr.openSession(sessionId, { + cwd: '/new', + cols: 80, + rows: 24, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + + expect(mgr.isSessionDisabled(sessionId)).toBe(true) + expect(existsSync(sessionPath(dir, sessionId, 'checkpoint.json'))).toBe(true) + expect(existsSync(sessionPath(dir, sessionId, 'raced-artifact'))).toBe(true) + expect(existsSync(getTerminalHistoryQuarantineOwnerDir(dir, sessionId))).toBe(false) + }) + + it('leaves persistent protection when the quarantine rename cannot start', async () => { + const sessionId = 'blocked-quarantine' + await mgr.openSession(sessionId, { cwd: '/old', cols: 80, rows: 24 }) + await mgr.checkpoint(sessionId, makeSnapshot()) + const recoveryFreeze = await mgr.freezeForRecovery(sessionId) + writeFileSync(join(dir, '.recovery-quarantine'), 'block owner directory creation') + + await mgr.openSession(sessionId, { + cwd: '/new', + cols: 80, + rows: 24, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + + expect(mgr.isSessionDisabled(sessionId)).toBe(true) + expect(hasTerminalHistoryRecoveryProtection(dir, sessionId)).toBe(true) + expect(existsSync(sessionPath(dir, sessionId, 'checkpoint.json'))).toBe(true) + }) + + it('rejects an unverified writer for a protected recovery generation', async () => { + const sessionId = 'protected-register' + await mgr.openSession(sessionId, { cwd: '/old', cols: 80, rows: 24 }) + const recoveryFreeze = await mgr.freezeForRecovery(sessionId) + writeFileSync(join(dir, '.recovery-quarantine'), 'block owner directory creation') + await mgr.openSession(sessionId, { + cwd: '/new', + cols: 80, + rows: 24, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + const relaunched = new HistoryManager(dir) + + relaunched.registerWriter(sessionId) + + expect(relaunched.isSessionDisabled(sessionId)).toBe(true) + expect(relaunched.hasWriter(sessionId)).toBe(false) + }) + + it('rejects a freeze-verified writer for a protected recovery generation', async () => { + const sessionId = 'recovered-protection' + await mgr.openSession(sessionId, { cwd: '/old', cols: 80, rows: 24 }) + const recoveryFreeze = await mgr.freezeForRecovery(sessionId) + writeFileSync(join(dir, '.recovery-quarantine'), 'block owner directory creation') + await mgr.openSession(sessionId, { + cwd: '/new', + cols: 80, + rows: 24, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + const relaunched = new HistoryManager(dir) + const verifiedFreeze = await relaunched.freezeForRecovery(sessionId) + relaunched.registerWriter(sessionId, verifiedFreeze) + + await relaunched.checkpoint(sessionId, makeSnapshot({ snapshotAnsi: 'verified recovery' })) + + expect(hasTerminalHistoryRecoveryProtection(dir, sessionId)).toBe(true) + expect(relaunched.hasWriter(sessionId)).toBe(false) + expect(relaunched.isSessionDisabled(sessionId)).toBe(true) + }) + + it('rejects a consumed recovery freeze token', async () => { + const sessionId = 'consumed-freeze' + await mgr.openSession(sessionId, { cwd: '/old', cols: 80, rows: 24 }) + const recoveryFreeze = await mgr.freezeForRecovery(sessionId) + await mgr.openSession(sessionId, { + cwd: '/new', + cols: 80, + rows: 24, + recoveryFreeze + }) + mgr.suspendSession(sessionId) + + mgr.registerWriter(sessionId, recoveryFreeze) + + expect(mgr.isSessionDisabled(sessionId)).toBe(true) + }) }) describe('checkpoint', () => { @@ -216,6 +368,26 @@ describe('HistoryManager', () => { await mgr.removeSession('sess-1') expect(existsSync(join(dir, getHistorySessionDirName('sess-1')))).toBe(false) }) + + it('deletes quarantined recovery owned by the session', async () => { + const sessionId = 'remove-quarantine' + await mgr.openSession(sessionId, { cwd: '/tmp', cols: 80, rows: 24 }) + const recoveryFreeze = await mgr.freezeForRecovery(sessionId) + await mgr.openSession(sessionId, { + cwd: '/new', + cols: 80, + rows: 24, + recoveryFreeze, + quarantineUnreadableRecovery: true + }) + const ownerDir = getTerminalHistoryQuarantineOwnerDir(dir, sessionId) + expect(existsSync(ownerDir)).toBe(true) + + await mgr.removeSession(sessionId) + + expect(existsSync(ownerDir)).toBe(false) + expect(existsSync(join(dir, getHistorySessionDirName(sessionId)))).toBe(false) + }) }) describe('hasHistory', () => { diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 1af27025182..fb574dd64da 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -1,62 +1,34 @@ import { join } from 'node:path' -import { - mkdirSync, - writeFileSync, - readFileSync, - existsSync, - rmSync, - unlinkSync, - openSync, - closeSync, - readSync, - fstatSync, - promises as fsPromises -} from 'node:fs' +import { randomUUID } from 'node:crypto' +import { mkdirSync, writeFileSync, existsSync, rmSync, unlinkSync } from 'node:fs' import { getHistorySessionDirName } from './history-paths' import { - decodeLogHeader, - encodeLogBatch, - encodeLogHeader, - LOG_HEADER_BYTES -} from './terminal-history-log' -import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types' + fingerprintTerminalHistorySession, + hasTerminalHistoryRecoveryProtection, + quarantineTerminalHistorySession, + removeTerminalHistoryQuarantines, + type ActiveHistoryRecoveryFreeze, + type HistoryRecoveryFreeze +} from './terminal-history-recovery-quarantine' +import { TerminalHistorySessionWriter } from './terminal-history-session-writer' +import { + readTerminalHistoryMetaFromDir, + updateTerminalHistoryMeta, + type SessionMeta +} from './terminal-history-metadata' +import type { PendingOutputRecord, TerminalSnapshot } from './types' +import type { HistoryManagerOptions, OpenSessionOptions } from './terminal-history-manager-options' -// Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log. -const LOG_MAX_BYTES = 5 * 1024 * 1024 - -export type SessionMeta = { - cwd: string - cols: number - rows: number - startedAt: string - endedAt: string | null - exitCode: number | null -} - -export type OpenSessionOptions = { - cwd: string - cols: number - rows: number -} - -type SessionWriter = { - dir: string - checkpointPath: string - logPath: string - /** Generation of the on-disk log header. Null until lazily resolved on first append after a warm registerWriter. */ - logGeneration: number | null - /** Current log file size. Null until lazily resolved alongside generation. */ - logBytes: number | null -} - -export type HistoryManagerOptions = { - onWriteError?: (sessionId: string, error: Error) => void -} +export type { SessionMeta } from './terminal-history-metadata' +export type { HistoryRecoveryFreeze } from './terminal-history-recovery-quarantine' +export type { HistoryManagerOptions, OpenSessionOptions } from './terminal-history-manager-options' export class HistoryManager { private basePath: string - private writers = new Map() + private writers = new Map() private disabledSessions = new Set() + private pendingSessionMutations = new Map>>() + private recoveryFreezes = new Map() private onWriteError?: (sessionId: string, error: Error) => void constructor(basePath: string, opts?: HistoryManagerOptions) { @@ -65,9 +37,23 @@ export class HistoryManager { } async openSession(sessionId: string, opts: OpenSessionOptions): Promise { + let recoveryFreeze = opts.recoveryFreeze try { this.disabledSessions.delete(sessionId) const dir = join(this.basePath, getHistorySessionDirName(sessionId)) + recoveryFreeze ??= await this.freezeForRecovery(sessionId) + const activeFreeze = this.requireRecoveryFreeze(sessionId, recoveryFreeze) + + if (opts.quarantineUnreadableRecovery) { + quarantineTerminalHistorySession(this.basePath, sessionId, activeFreeze.fingerprint ?? null) + } else if (hasTerminalHistoryRecoveryProtection(this.basePath, sessionId)) { + throw new Error('terminal_history_recovery_protected') + } else if ( + fingerprintTerminalHistorySession(this.basePath, sessionId) !== activeFreeze.fingerprint + ) { + throw new Error('terminal_history_recovery_generation_changed') + } + this.recoveryFreezes.delete(sessionId) mkdirSync(dir, { recursive: true }) const meta: SessionMeta = { @@ -80,67 +66,128 @@ export class HistoryManager { } writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2)) - // Why: clear stale recovery files (incl. legacy scrollback.bin) so a crash before the first checkpoint can't replay a prior session's content. - const checkpointPath = join(dir, 'checkpoint.json') - const logPath = join(dir, 'output.log') - for (const staleFile of [checkpointPath, join(dir, 'scrollback.bin'), logPath]) { - try { - unlinkSync(staleFile) - } catch { - // ENOENT is expected for new sessions + if (!opts.quarantineUnreadableRecovery) { + // Why: a crash before the first checkpoint must not replay a cleanly ended prior session. + for (const staleFile of [ + join(dir, 'checkpoint.json'), + join(dir, 'scrollback.bin'), + join(dir, 'output.log') + ]) { + try { + unlinkSync(staleFile) + } catch { + // ENOENT is expected for new sessions + } } } - this.writers.set(sessionId, { - dir, - checkpointPath, - logPath, - logGeneration: 0, - logBytes: 0 - }) + this.writers.set(sessionId, new TerminalHistorySessionWriter(dir, true)) } catch (err) { + if (recoveryFreeze) { + this.abandonRecoveryFreeze(recoveryFreeze) + } this.handleWriteError(sessionId, err) } } + async freezeForRecovery(sessionId: string): Promise { + if (this.recoveryFreezes.has(sessionId)) { + throw new Error('terminal_history_recovery_already_frozen') + } + + this.writers.delete(sessionId) + const handle: HistoryRecoveryFreeze = { + sessionId, + token: randomUUID() + } + const activeFreeze: ActiveHistoryRecoveryFreeze = { handle } + this.recoveryFreezes.set(sessionId, activeFreeze) + try { + await this.waitForSessionMutations(sessionId) + activeFreeze.fingerprint = fingerprintTerminalHistorySession(this.basePath, sessionId) + return handle + } catch (err) { + if (this.recoveryFreezes.get(sessionId) === activeFreeze) { + this.recoveryFreezes.delete(sessionId) + } + throw err + } + } + + abandonRecoveryFreeze(freeze?: HistoryRecoveryFreeze): void { + const activeFreeze = freeze ? this.recoveryFreezes.get(freeze.sessionId) : undefined + if (activeFreeze && activeFreeze.handle === freeze) { + this.recoveryFreezes.delete(activeFreeze.handle.sessionId) + } + } + // Why: warm reattach has no in-memory writers; re-register without touching meta.json or checkpoint.json (only recovery data until the next tick). - registerWriter(sessionId: string): void { + registerWriter(sessionId: string, recoveryFreeze?: HistoryRecoveryFreeze): void { if (this.writers.has(sessionId)) { return } + if (hasTerminalHistoryRecoveryProtection(this.basePath, sessionId)) { + this.abandonRecoveryFreeze(recoveryFreeze) + return void this.disabledSessions.add(sessionId) + } + if (recoveryFreeze) { + try { + const activeFreeze = this.requireRecoveryFreeze(sessionId, recoveryFreeze) + if ( + fingerprintTerminalHistorySession(this.basePath, sessionId) !== activeFreeze.fingerprint + ) { + throw new Error('terminal_history_recovery_generation_changed') + } + this.recoveryFreezes.delete(sessionId) + } catch (err) { + this.abandonRecoveryFreeze(recoveryFreeze) + this.handleWriteError(sessionId, err) + return + } + } else if (this.recoveryFreezes.has(sessionId)) { + return + } const dir = join(this.basePath, getHistorySessionDirName(sessionId)) - this.writers.set(sessionId, { - dir, - checkpointPath: join(dir, 'checkpoint.json'), - logPath: join(dir, 'output.log'), - logGeneration: null, - logBytes: null - }) + this.writers.set(sessionId, new TerminalHistorySessionWriter(dir, false)) } // Why: wake re-spawns a sleep-killed session; re-register without deleting checkpoint.json, clear endedAt so it can cold-restore again. - reopenSession(sessionId: string): void { + reopenSession(sessionId: string, recoveryFreeze?: HistoryRecoveryFreeze): void { this.disabledSessions.delete(sessionId) - this.registerWriter(sessionId) + this.registerWriter(sessionId, recoveryFreeze) const writer = this.writers.get(sessionId) if (!writer) { return } try { - this.updateMeta(writer.dir, { endedAt: null, exitCode: null }) + updateTerminalHistoryMeta(writer.dir, { endedAt: null, exitCode: null }) } catch (err) { this.handleWriteError(sessionId, err) } } - suspendSession(sessionId: string): void { + suspendSession(sessionId: string, recoveryFreeze?: HistoryRecoveryFreeze): void { // Why: leaving the writer active would let the next checkpoint overwrite the only good recovered-scrollback copy. this.writers.delete(sessionId) + if (recoveryFreeze) { + this.abandonRecoveryFreeze(recoveryFreeze) + } this.disabledSessions.delete(sessionId) } /** Appends one batch to the incremental log; returns 'needs-checkpoint' at capacity, signalling the caller to checkpoint() (which resets the log). */ - async appendIncrements( + appendIncrements( + sessionId: string, + seq: number, + records: PendingOutputRecord[] + ): Promise<'ok' | 'needs-checkpoint'> { + return this.trackSessionMutation( + sessionId, + this.appendIncrementsUntracked(sessionId, seq, records) + ) + } + + private async appendIncrementsUntracked( sessionId: string, seq: number, records: PendingOutputRecord[] @@ -153,21 +200,7 @@ export class HistoryManager { return 'ok' } try { - this.resolveLogState(writer) - const batch = encodeLogBatch(seq, records) - // Why max(..., header): a fresh log's header (written below) must count toward the projected size or the cap overshoots. - const projectedBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) + batch.length - if (projectedBytes > LOG_MAX_BYTES) { - return 'needs-checkpoint' - } - if (writer.logBytes === 0) { - // Why: header ties this log to its base checkpoint; written lazily so warm reattaches don't clobber an appended log. - await fsPromises.writeFile(writer.logPath, encodeLogHeader(writer.logGeneration ?? 0)) - writer.logBytes = LOG_HEADER_BYTES - } - await fsPromises.appendFile(writer.logPath, batch) - writer.logBytes = (writer.logBytes ?? LOG_HEADER_BYTES) + batch.length - return 'ok' + return await writer.appendIncrements(seq, records) } catch (err) { this.handleWriteError(sessionId, err) return 'ok' @@ -175,7 +208,11 @@ export class HistoryManager { } // Full checkpoints are rare (clean disconnect, pending-buffer overflow, log cap); the 5s tick appends increments instead. - async checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise { + checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise { + return this.trackSessionMutation(sessionId, this.checkpointUntracked(sessionId, snapshot)) + } + + private async checkpointUntracked(sessionId: string, snapshot: TerminalSnapshot): Promise { if (this.disabledSessions.has(sessionId)) { return } @@ -185,83 +222,14 @@ export class HistoryManager { } try { - // Why: snapshot.cwd is null until OSC-7; persisting null would clobber meta.json's usable cwd and break cold-restore recovery. - let effectiveCwd = snapshot.cwd - if (effectiveCwd === null) { - const meta = this.readMetaFromDir(writer.dir) - effectiveCwd = meta?.cwd ?? null - } - - this.resolveLogState(writer) - const generation = (writer.logGeneration ?? 0) + 1 - const checkpointFile: TerminalCheckpointFile = { - snapshotAnsi: snapshot.snapshotAnsi, - scrollbackAnsi: snapshot.scrollbackAnsi, - oscLinks: snapshot.oscLinks, - rehydrateSequences: snapshot.rehydrateSequences, - cwd: effectiveCwd, - cols: snapshot.cols, - rows: snapshot.rows, - modes: snapshot.modes, - scrollbackLines: snapshot.scrollbackLines, - generation, - checkpointedAt: new Date().toISOString() - } - const data = JSON.stringify(checkpointFile) // Why: tmp+rename is atomic (corrupt checkpoint > stale); async so a sync ~MB write can't stall IPC (worse under Windows AV). // The adapter's checkpointInFlight guard serializes checkpoints, so concurrent async writes can't collide on the fixed .tmp path. - const tmpPath = `${writer.checkpointPath}.tmp` - await fsPromises.writeFile(tmpPath, data) - await fsPromises.rename(tmpPath, writer.checkpointPath) - // Why: snapshot subsumes logged records, so reset the log to the new generation; a stale-generation log is ignored on restore. - await fsPromises.writeFile(writer.logPath, encodeLogHeader(generation)) - writer.logGeneration = generation - writer.logBytes = LOG_HEADER_BYTES + await writer.checkpoint(snapshot) } catch (err) { this.handleWriteError(sessionId, err) } } - // Why: a warm registerWriter may attach to an existing log; read generation/size once so appends continue it, not clobber it. - private resolveLogState(writer: SessionWriter): void { - if (writer.logBytes !== null && writer.logGeneration !== null) { - return - } - let headerGeneration: number | null = null - let size = 0 - try { - const fd = openSync(writer.logPath, 'r') - try { - size = fstatSync(fd).size - const header = Buffer.alloc(LOG_HEADER_BYTES) - if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) { - headerGeneration = decodeLogHeader(header) - } - } finally { - closeSync(fd) - } - } catch { - // Missing log file — fresh state below. - } - if (headerGeneration !== null) { - writer.logGeneration = headerGeneration - writer.logBytes = size - return - } - // Missing/unreadable header: logBytes = 0 makes the next append truncate-rewrite, so a garbage file can't be extended. - writer.logBytes = 0 - writer.logGeneration = this.readCheckpointGeneration(writer) ?? 0 - } - - private readCheckpointGeneration(writer: SessionWriter): number | null { - try { - const checkpoint = JSON.parse(readFileSync(writer.checkpointPath, 'utf-8')) - return typeof checkpoint.generation === 'number' ? checkpoint.generation : null - } catch { - return null - } - } - async closeSession(sessionId: string, exitCode: number): Promise { const writer = this.writers.get(sessionId) if (!writer) { @@ -272,7 +240,7 @@ export class HistoryManager { // Why: session is dead; without this a transient-error-poisoned id leaks forever (sessionIds never reused). this.disabledSessions.delete(sessionId) try { - this.updateMeta(writer.dir, { endedAt: new Date().toISOString(), exitCode }) + updateTerminalHistoryMeta(writer.dir, { endedAt: new Date().toISOString(), exitCode }) } catch (err) { // Why: an unwritten endedAt looks like an unclean shutdown → false cold restore next launch. this.handleWriteError(sessionId, err) @@ -282,10 +250,16 @@ export class HistoryManager { async removeSession(sessionId: string): Promise { this.writers.delete(sessionId) this.disabledSessions.delete(sessionId) + const activeFreeze = this.recoveryFreezes.get(sessionId) + if (activeFreeze) { + this.recoveryFreezes.delete(sessionId) + } + await this.waitForSessionMutations(sessionId) rmSync(join(this.basePath, getHistorySessionDirName(sessionId)), { recursive: true, force: true }) + removeTerminalHistoryQuarantines(this.basePath, sessionId) } isSessionDisabled(sessionId: string): boolean { @@ -296,27 +270,27 @@ export class HistoryManager { return this.disabledSessions.size } + hasWriter(sessionId: string): boolean { + return this.writers.has(sessionId) + } + hasHistory(sessionId: string): boolean { return existsSync(join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json')) } readMeta(sessionId: string): SessionMeta | null { - const metaPath = join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json') - if (!existsSync(metaPath)) { - return null - } - try { - return JSON.parse(readFileSync(metaPath, 'utf-8')) - } catch { - return null - } + const dir = join(this.basePath, getHistorySessionDirName(sessionId)) + return readTerminalHistoryMetaFromDir(dir) } async dispose(): Promise { // Why: mark open sessions cleanly ended so they don't trigger false cold-restores next launch. for (const [sessionId, writer] of this.writers) { try { - this.updateMeta(writer.dir, { endedAt: new Date().toISOString(), exitCode: null }) + updateTerminalHistoryMeta(writer.dir, { + endedAt: new Date().toISOString(), + exitCode: null + }) } catch { this.disabledSessions.add(sessionId) } @@ -330,24 +304,43 @@ export class HistoryManager { this.onWriteError?.(sessionId, err as Error) } - private readMetaFromDir(dir: string): SessionMeta | null { - const metaPath = join(dir, 'meta.json') - try { - return JSON.parse(readFileSync(metaPath, 'utf-8')) - } catch { - return null + private requireRecoveryFreeze( + sessionId: string, + recoveryFreeze: HistoryRecoveryFreeze + ): ActiveHistoryRecoveryFreeze { + const activeFreeze = this.recoveryFreezes.get(sessionId) + if ( + recoveryFreeze.sessionId !== sessionId || + activeFreeze?.handle !== recoveryFreeze || + activeFreeze.fingerprint === undefined + ) { + throw new Error('terminal_history_recovery_freeze_invalid') + } + return activeFreeze + } + + private trackSessionMutation(sessionId: string, operation: Promise): Promise { + const mutations = this.pendingSessionMutations.get(sessionId) ?? new Set>() + mutations.add(operation) + this.pendingSessionMutations.set(sessionId, mutations) + void operation.then( + () => this.finishSessionMutation(sessionId, operation), + () => this.finishSessionMutation(sessionId, operation) + ) + return operation + } + + private finishSessionMutation(sessionId: string, operation: Promise): void { + const mutations = this.pendingSessionMutations.get(sessionId) + mutations?.delete(operation) + if (mutations?.size === 0) { + this.pendingSessionMutations.delete(sessionId) } } - private updateMeta(dir: string, updates: Partial): void { - const metaPath = join(dir, 'meta.json') - let meta: SessionMeta - try { - meta = JSON.parse(readFileSync(metaPath, 'utf-8')) - } catch { - return + private async waitForSessionMutations(sessionId: string): Promise { + while (this.pendingSessionMutations.has(sessionId)) { + await Promise.allSettled(this.pendingSessionMutations.get(sessionId) ?? []) } - Object.assign(meta, updates) - writeFileSync(metaPath, JSON.stringify(meta, null, 2)) } } diff --git a/src/main/daemon/history-reader-memory.test.ts b/src/main/daemon/history-reader-memory.test.ts index cecb29b1bb8..e50c54bc543 100644 --- a/src/main/daemon/history-reader-memory.test.ts +++ b/src/main/daemon/history-reader-memory.test.ts @@ -14,6 +14,7 @@ import { HistoryReader } from './history-reader' import { getHistorySessionDirName } from './history-paths' import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES, + TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES, TERMINAL_HISTORY_LOG_MAX_BYTES, TERMINAL_HISTORY_META_MAX_BYTES } from './terminal-history-file-limits' @@ -62,6 +63,8 @@ function checkpoint(overrides?: Record): string { applicationCursor: false, alternateScreen: false }, + scrollbackLines: 0, + checkpointedAt: '2026-01-01T00:00:00.000Z', ...overrides }) } @@ -103,9 +106,12 @@ describe('terminal history restore memory limits', () => { expect(() => readTerminalHistoryJson(oversizedCheckpoint, TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES) ).toThrow(/File too large/) - expect( - await new HistoryReader(checkpointSession.basePath).detectColdRestore('oversized-checkpoint') - ).toBeNull() + const checkpointReader = new HistoryReader(checkpointSession.basePath) + expect(await checkpointReader.detectColdRestoreState('oversized-checkpoint')).toEqual({ + status: 'unreadable', + sessionId: 'oversized-checkpoint' + }) + expect(await checkpointReader.detectColdRestore('oversized-checkpoint')).toBeNull() const metadataSession = createSession('oversized-metadata') createSparseFile( @@ -114,7 +120,22 @@ describe('terminal history restore memory limits', () => { ) expect( new HistoryReader(metadataSession.basePath).hasRestorableHistory('oversized-metadata') - ).toBe(false) + ).toBe(true) + }) + + it('reports an oversized legacy scrollback as unreadable recovery', async () => { + const { basePath, sessionPath } = createSession('oversized-legacy-scrollback') + createSparseFile( + join(sessionPath, 'scrollback.bin'), + TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES + 1 + ) + + expect( + await new HistoryReader(basePath).detectColdRestoreState('oversized-legacy-scrollback') + ).toEqual({ + status: 'unreadable', + sessionId: 'oversized-legacy-scrollback' + }) }) // Why: the retired 1M-structural-token pre-scan was reachable by ordinary content — diff --git a/src/main/daemon/history-reader.test.ts b/src/main/daemon/history-reader.test.ts index 87fd1195e89..6a0188a99de 100644 --- a/src/main/daemon/history-reader.test.ts +++ b/src/main/daemon/history-reader.test.ts @@ -93,6 +93,32 @@ describe('HistoryReader', () => { expect(info!.rehydrateSequences).toBe('') }) + it('restores pre-limit 800-column checkpoint history', async () => { + writeSessionWithCheckpoint( + dir, + 'wide-checkpoint', + makeMeta({ cols: 800 }), + makeCheckpoint({ cols: 800 }) + ) + + const info = await reader.detectColdRestore('wide-checkpoint') + + expect(info).toMatchObject({ cols: 800, rows: 24 }) + }) + + it('restores checkpoint history at the exact accepted dimension ceiling', async () => { + writeSessionWithCheckpoint( + dir, + 'ceiling-checkpoint', + makeMeta({ cols: 1_000, rows: 500 }), + makeCheckpoint({ cols: 1_000, rows: 500 }) + ) + + const info = await reader.detectColdRestore('ceiling-checkpoint') + + expect(info).toMatchObject({ cols: 1_000, rows: 500 }) + }) + it('restores terminal modes from checkpoint', async () => { const modes = { bracketedPaste: true, @@ -151,6 +177,61 @@ describe('HistoryReader', () => { expect(await reader.detectColdRestore('corrupt')).toBeNull() }) + it.each([ + 'null', + '[]', + '{}', + JSON.stringify({ cwd: '/tmp', cols: 80, rows: 24 }), + JSON.stringify(makeMeta({ cols: 1.5 })), + JSON.stringify(makeMeta({ cols: 1_001 })), + JSON.stringify(makeMeta({ rows: 501 })) + ])('classifies structurally invalid metadata as unreadable: %s', async (metadata) => { + const sessionDir = join(dir, getHistorySessionDirName('invalid-meta')) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'meta.json'), metadata) + writeFileSync(join(sessionDir, 'checkpoint.json'), JSON.stringify(makeCheckpoint())) + + expect(reader.probeRestorableHistory('invalid-meta')).toEqual({ + status: 'unreadable', + sessionId: 'invalid-meta' + }) + expect(await reader.detectColdRestoreState('invalid-meta')).toEqual({ + status: 'unreadable', + sessionId: 'invalid-meta' + }) + }) + + it.each(['null', '[]', '{}', JSON.stringify({ snapshotAnsi: 'only recovery copy' })])( + 'classifies structurally invalid checkpoint JSON as unreadable: %s', + async (checkpoint) => { + const sessionDir = join(dir, getHistorySessionDirName('invalid-checkpoint')) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta())) + writeFileSync(join(sessionDir, 'checkpoint.json'), checkpoint) + + expect(await reader.detectColdRestoreState('invalid-checkpoint')).toEqual({ + status: 'unreadable', + sessionId: 'invalid-checkpoint' + }) + } + ) + + it.each([ + makeCheckpoint({ cols: 1.5 }), + makeCheckpoint({ cols: 1_001 }), + makeCheckpoint({ rows: 501 }), + makeCheckpoint({ scrollbackLines: -1 }), + makeCheckpoint({ modes: { bracketedPaste: false } }) + ])('classifies unsafe checkpoint fields as unreadable', async (checkpoint) => { + const sessionId = 'unsafe-checkpoint' + writeSessionWithCheckpoint(dir, sessionId, makeMeta(), checkpoint) + + expect(await reader.detectColdRestoreState(sessionId)).toEqual({ + status: 'unreadable', + sessionId + }) + }) + it('falls back to scrollback.bin when checkpoint.json is corrupt', async () => { const sessionDir = join(dir, getHistorySessionDirName('bad-cp')) mkdirSync(sessionDir, { recursive: true }) @@ -158,10 +239,14 @@ describe('HistoryReader', () => { writeFileSync(join(sessionDir, 'checkpoint.json'), 'not json') writeFileSync(join(sessionDir, 'scrollback.bin'), 'fallback data\r\n') - const info = await reader.detectColdRestore('bad-cp') - expect(info).not.toBeNull() - expect(info!.snapshotAnsi).toBe('fallback data\r\n') - expect(info!.rehydrateSequences).toBe('') + const detection = await reader.detectColdRestoreState('bad-cp') + expect(detection.status).toBe('restored') + if (detection.status !== 'restored') { + throw new Error('expected fallback restore') + } + expect(detection.restoreInfo.snapshotAnsi).toBe('fallback data\r\n') + expect(detection.restoreInfo.rehydrateSequences).toBe('') + expect(detection.hasUnreadableRecovery).toBe(true) }) }) @@ -195,6 +280,20 @@ describe('HistoryReader', () => { expect(info!.modes.alternateScreen).toBe(false) }) + it('restores pre-limit 800-column legacy scrollback', async () => { + writeSessionWithScrollback( + dir, + 'wide-legacy', + makeMeta({ cols: 800 }), + 'wide old format data\r\n' + ) + + const info = await reader.detectColdRestore('wide-legacy') + + expect(info).toMatchObject({ cols: 800, rows: 24 }) + expect(info!.snapshotAnsi).toContain('wide old format data') + }) + it('returns null when neither checkpoint.json nor scrollback.bin exist', async () => { const sessionDir = join(dir, getHistorySessionDirName('no-data')) mkdirSync(sessionDir, { recursive: true }) @@ -203,6 +302,120 @@ describe('HistoryReader', () => { expect(await reader.detectColdRestore('no-data')).toBeNull() }) + it('classifies a sole corrupt incremental log as unreadable', async () => { + const sessionId = 'corrupt-log-only' + const sessionDir = join(dir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta())) + writeFileSync(join(sessionDir, 'output.log'), 'not a terminal history log') + + expect(await reader.detectColdRestoreState(sessionId)).toEqual({ + status: 'unreadable', + sessionId + }) + }) + + it('classifies an unsafe sole-log resize as unreadable', async () => { + const sessionId = 'unsafe-log-resize' + const sessionDir = join(dir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta())) + writeFileSync( + join(sessionDir, 'output.log'), + Buffer.concat([ + encodeLogHeader(0), + encodeLogBatch(1, [{ kind: 'resize', cols: 1_001, rows: 24 }]) + ]) + ) + + expect(await reader.detectColdRestoreState(sessionId)).toEqual({ + status: 'unreadable', + sessionId + }) + }) + + it('replays a pre-limit 800-column incremental resize', async () => { + const sessionId = 'wide-log-resize' + const sessionDir = join(dir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta({ cols: 800 }))) + writeFileSync( + join(sessionDir, 'output.log'), + Buffer.concat([ + encodeLogHeader(0), + encodeLogBatch(1, [ + { kind: 'resize', cols: 800, rows: 24 }, + { kind: 'output', data: 'wide incremental data\r\n' } + ]) + ]) + ) + + const detection = await reader.detectColdRestoreState(sessionId) + + expect(detection.status).toBe('restored') + if (detection.status === 'restored') { + expect(detection.restoreInfo).toMatchObject({ cols: 800, rows: 24 }) + expect(detection.restoreInfo.snapshotAnsi).toContain('wide incremental data') + } + }) + + it('flags a malformed current-generation log when checkpoint fallback restores', async () => { + const sessionId = 'malformed-log-with-checkpoint' + writeSessionWithCheckpoint( + dir, + sessionId, + makeMeta(), + makeCheckpoint({ generation: 1, snapshotAnsi: 'checkpoint fallback\r\n' }) + ) + const sessionDir = join(dir, getHistorySessionDirName(sessionId)) + writeFileSync( + join(sessionDir, 'output.log'), + Buffer.concat([ + encodeLogHeader(1), + encodeLogBatch(1, [ + { kind: 'output', data: 'only post-checkpoint copy\r\n' }, + { kind: 'resize', cols: 1_001, rows: 24 } + ]) + ]) + ) + + const detection = await reader.detectColdRestoreState(sessionId) + + expect(detection.status).toBe('restored') + if (detection.status === 'restored') { + expect(detection.restoreInfo.snapshotAnsi).toContain('checkpoint fallback') + expect(detection.hasUnreadableRecovery).toBe(true) + } + }) + + it('flags a torn current-generation log while restoring its complete prefix', async () => { + const sessionId = 'torn-log-with-checkpoint' + writeSessionWithCheckpoint( + dir, + sessionId, + makeMeta(), + makeCheckpoint({ generation: 1, snapshotAnsi: 'checkpoint base\r\n' }) + ) + const fullLog = Buffer.concat([ + encodeLogHeader(1), + encodeLogBatch(1, [{ kind: 'output', data: 'complete prefix\r\n' }]), + encodeLogBatch(2, [{ kind: 'output', data: 'unique torn tail\r\n' }]) + ]) + writeFileSync( + join(dir, getHistorySessionDirName(sessionId), 'output.log'), + fullLog.subarray(0, -3) + ) + + const detection = await reader.detectColdRestoreState(sessionId) + + expect(detection.status).toBe('restored') + if (detection.status === 'restored') { + expect(detection.restoreInfo.snapshotAnsi).toContain('complete prefix') + expect(detection.restoreInfo.snapshotAnsi).not.toContain('unique torn tail') + expect(detection.hasUnreadableRecovery).toBe(true) + } + }) + it('truncates alt-screen from scrollback.bin fallback', async () => { const scrollback = ['normal output\r\n', '\x1b[?1049h', 'vim content here'].join('') @@ -299,6 +512,15 @@ describe('HistoryReader', () => { expect(reader.listRestorable()).toEqual([sessionId]) }) + it('does not enumerate quarantined bundle metadata as live sessions', () => { + writeSessionWithScrollback(dir, 'alive', makeMeta(), 'data') + const quarantined = join(dir, '.recovery-quarantine', 'owner', 'bundle') + mkdirSync(quarantined, { recursive: true }) + writeFileSync(join(quarantined, 'meta.json'), JSON.stringify(makeMeta())) + + expect(reader.listRestorable()).toEqual(['alive']) + }) + it('skips malformed encoded session directories', () => { mkdirSync(join(dir, '%E0%A4%A'), { recursive: true }) writeSessionWithScrollback(dir, 'alive', makeMeta(), 'data') diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index 7f5fba1d537..39092b3aa5e 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -1,39 +1,55 @@ import { join } from 'node:path' import { existsSync, opendirSync } from 'node:fs' import { stat } from 'node:fs/promises' -import type { SessionMeta } from './history-manager' -import type { TerminalCheckpointFile, TerminalModes } from './types' -import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalCheckpointFile } from './types' import { getHistorySessionDirName } from './history-paths' import { decodeTerminalHistoryLog, LOG_HEADER_BYTES } from './terminal-history-log' import { HeadlessEmulator } from './headless-emulator' import { PrioritySemaphore } from './priority-semaphore' import { ColdRestoreReplayWriter } from './cold-restore-replay-writer' -import { - readTerminalHistoryBufferAsync, - readTerminalHistoryJson, - readTerminalHistoryJsonAsync -} from './terminal-history-file-reader' +import { readTerminalHistoryBufferAsync } from './terminal-history-file-reader' import { detectColdRestoreFromLegacyScrollback } from './terminal-history-legacy-scrollback-restore' -import { - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES, - TERMINAL_HISTORY_LOG_MAX_BYTES, - TERMINAL_HISTORY_META_MAX_BYTES -} from './terminal-history-file-limits' +import { TERMINAL_HISTORY_LOG_MAX_BYTES } from './terminal-history-file-limits' import { retainNewestRestorableTerminalHistorySessions, type RestorableTerminalHistorySession } from './terminal-history-restorable-retention' +import { + hasTerminalHistoryRecoveryProtection, + isTerminalHistoryQuarantineEntry +} from './terminal-history-recovery-quarantine' +import { + readTerminalHistoryMeta, + type SessionMeta, + type SessionMetaRead +} from './terminal-history-metadata' +import { + coldRestoreInfoFromSnapshot, + type ColdRestoreInfo +} from './terminal-history-cold-restore-info' +import { readTerminalHistoryCheckpoint } from './terminal-history-checkpoint-reader' +import { isValidTerminalHistorySize } from './terminal-history-dimensions' -export type ColdRestoreInfo = { - snapshotAnsi: string - scrollbackAnsi: string - oscLinks?: TerminalOscLinkRange[] - rehydrateSequences: string - cwd: string - cols: number - rows: number - modes: TerminalModes +export type { ColdRestoreInfo } from './terminal-history-cold-restore-info' + +export type RestorableHistoryProbe = + | { status: 'none' } + | { status: 'restorable'; sessionId: string } + | { status: 'unreadable'; sessionId: string } + +export type ColdRestoreDetection = + | { status: 'none' } + | { + status: 'restored' + sessionId: string + restoreInfo: ColdRestoreInfo + hasUnreadableRecovery: boolean + } + | { status: 'unreadable'; sessionId: string } + +type IncrementalLogRestore = { + restoreInfo: ColdRestoreInfo | null + readFailed: boolean } // Why: parallel pane mounts should interleave with main-process work without multiplying replay slices per turn. @@ -50,41 +66,60 @@ export class HistoryReader { // deciding to pay detectColdRestore's full checkpoint+log replay. Reads only // the small meta.json, using the same unclean-shutdown test detectColdRestore // starts with. + probeRestorableHistory(sessionId: string): RestorableHistoryProbe { + if (hasTerminalHistoryRecoveryProtection(this.basePath, sessionId)) { + return { status: 'unreadable', sessionId } + } + const metaRead = this.readMetaState(sessionId) + if (metaRead.status === 'unreadable') { + return { status: 'unreadable', sessionId } + } + if (metaRead.status === 'missing' || metaRead.meta.endedAt !== null) { + return { status: 'none' } + } + return { status: 'restorable', sessionId } + } + hasRestorableHistory(sessionId: string): boolean { - const meta = this.readMeta(sessionId) - return meta !== null && meta.endedAt === null + return this.probeRestorableHistory(sessionId).status !== 'none' } async detectColdRestore( sessionId: string, opts?: { ignoreCleanEnd?: boolean; wslDistro?: string } ): Promise { - const meta = this.readMeta(sessionId) - if (!meta) { - return null + const detection = await this.detectColdRestoreState(sessionId, opts) + return detection.status === 'restored' ? detection.restoreInfo : null + } + + async detectColdRestoreState( + sessionId: string, + opts?: { ignoreCleanEnd?: boolean; wslDistro?: string } + ): Promise { + if (hasTerminalHistoryRecoveryProtection(this.basePath, sessionId)) { + return { status: 'unreadable', sessionId } } + const metaRead = this.readMetaState(sessionId) + if (metaRead.status === 'missing') { + return { status: 'none' } + } + if (metaRead.status === 'unreadable') { + return { status: 'unreadable', sessionId } + } + const meta = metaRead.meta // Why ignoreCleanEnd: in the spawn probe race, the dying session's exit // event can write endedAt between the aliveness probe and the post-spawn // fallback detect. The caller established restore eligibility before the // probe, so the just-written clean end must not downgrade the restore. if (meta.endedAt !== null && !opts?.ignoreCleanEnd) { - return null + return { status: 'none' } } const sessionDir = join(this.basePath, getHistorySessionDirName(sessionId)) const checkpointPath = join(sessionDir, 'checkpoint.json') - const checkpointExists = existsSync(checkpointPath) - let checkpoint: TerminalCheckpointFile | null = null - if (checkpointExists) { - try { - checkpoint = await readTerminalHistoryJsonAsync( - checkpointPath, - TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES - ) - } catch { - checkpoint = null - } - } + const checkpointRead = await readTerminalHistoryCheckpoint(checkpointPath) + const checkpoint = checkpointRead.status === 'readable' ? checkpointRead.checkpoint : null + const checkpointReadFailed = checkpointRead.status === 'unreadable' // Why log replay is preferred over the checkpoint alone: the log carries // byte-exact output up to ~5s before the crash (up to the full-snapshot @@ -96,17 +131,44 @@ export class HistoryReader { checkpoint, opts?.wslDistro ) - if (logRestore) { - return logRestore + if (logRestore.restoreInfo) { + return { + status: 'restored', + sessionId, + restoreInfo: logRestore.restoreInfo, + hasUnreadableRecovery: checkpointReadFailed || logRestore.readFailed + } } if (!checkpoint) { // Why: backward compatibility with pre-checkpoint sessions, and corrupt // checkpoints — the old scrollback.bin is the best remaining data. - return await detectColdRestoreFromLegacyScrollback(this.basePath, sessionId, meta) + const legacyPath = join(sessionDir, 'scrollback.bin') + const legacyExists = existsSync(legacyPath) + const legacyRestore = await detectColdRestoreFromLegacyScrollback( + this.basePath, + sessionId, + meta + ) + if (legacyRestore) { + return { + status: 'restored', + sessionId, + restoreInfo: legacyRestore, + hasUnreadableRecovery: checkpointReadFailed || logRestore.readFailed + } + } + return checkpointReadFailed || logRestore.readFailed || legacyExists + ? { status: 'unreadable', sessionId } + : { status: 'none' } } - return this.coldRestoreInfoFromSnapshot(checkpoint, checkpoint.cwd, meta) + return { + status: 'restored', + sessionId, + restoreInfo: coldRestoreInfoFromSnapshot(checkpoint, checkpoint.cwd, meta), + hasUnreadableRecovery: logRestore.readFailed + } } listRestorable(): string[] { @@ -133,6 +195,9 @@ export class HistoryReader { if (!entry.isDirectory()) { continue } + if (isTerminalHistoryQuarantineEntry(entry.name)) { + continue + } let sessionId: string try { sessionId = decodeURIComponent(entry.name) @@ -174,15 +239,15 @@ export class HistoryReader { meta: SessionMeta, checkpoint: TerminalCheckpointFile | null, wslDistro?: string - ): Promise { + ): Promise { const logPath = join(sessionDir, 'output.log') try { // Why: final checkpoints leave a header-only log; they need no scarce replay slot and must not queue sleep teardown behind startup restores. if ((await stat(logPath)).size <= LOG_HEADER_BYTES) { - return null + return { restoreInfo: null, readFailed: false } } } catch { - return null + return { restoreInfo: null, readFailed: existsSync(logPath) } } const release = await coldRestoreReplaySemaphore.acquire(0) try { @@ -190,11 +255,11 @@ export class HistoryReader { try { logBuffer = await readTerminalHistoryBufferAsync(logPath, TERMINAL_HISTORY_LOG_MAX_BYTES) } catch { - return null + return { restoreInfo: null, readFailed: true } } const log = decodeTerminalHistoryLog(logBuffer) if (!log || log.batches.length === 0) { - return null + return { restoreInfo: null, readFailed: true } } // Generation mismatch means the log does not continue this checkpoint // (e.g. crash between checkpoint rename and log reset, or a pre-log @@ -202,10 +267,10 @@ export class HistoryReader { // garble content; the checkpoint alone is consistent. if (checkpoint) { if (typeof checkpoint.generation !== 'number' || log.generation !== checkpoint.generation) { - return null + return { restoreInfo: null, readFailed: false } } } else if (log.generation !== 0) { - return null + return { restoreInfo: null, readFailed: true } } const emulator = new HeadlessEmulator({ @@ -221,7 +286,7 @@ export class HistoryReader { !(await replay.write(checkpoint.rehydrateSequences)) || !(await replay.write(checkpoint.snapshotAnsi)) ) { - return null + return { restoreInfo: null, readFailed: true } } emulator.setRestoredOscLinks(checkpoint.oscLinks) } @@ -229,9 +294,12 @@ export class HistoryReader { for (const record of batch.records) { if (record.kind === 'output') { if (!(await replay.write(record.data))) { - return null + return { restoreInfo: null, readFailed: true } } } else if (record.kind === 'resize') { + if (!isValidTerminalHistorySize(record.cols, record.rows)) { + return { restoreInfo: null, readFailed: true } + } await replay.resize(record.cols, record.rows) } else { await replay.clearScrollback() @@ -239,15 +307,18 @@ export class HistoryReader { } } const snapshot = emulator.getSnapshot() - return this.coldRestoreInfoFromSnapshot( - snapshot, - snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd, - meta - ) + return { + restoreInfo: coldRestoreInfoFromSnapshot( + snapshot, + snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd, + meta + ), + readFailed: log.truncatedTail + } } catch { // Why: a replay failure must degrade to checkpoint-only restore, never // surface as a failed spawn. - return null + return { restoreInfo: null, readFailed: true } } finally { emulator.dispose() } @@ -256,44 +327,12 @@ export class HistoryReader { } } - private coldRestoreInfoFromSnapshot( - snapshot: { - snapshotAnsi: string - scrollbackAnsi: string - oscLinks?: TerminalOscLinkRange[] - rehydrateSequences: string - cols: number - rows: number - modes: TerminalModes - }, - cwd: string | null, - meta: SessionMeta - ): ColdRestoreInfo { - // Why: legacy normal snapshots stored their buffer only in snapshotAnsi; - // current alt snapshots carry their normal buffer in scrollbackAnsi. - const scrollbackAnsi = - snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi) - return { - snapshotAnsi: snapshot.snapshotAnsi, - scrollbackAnsi, - oscLinks: snapshot.oscLinks, - rehydrateSequences: snapshot.rehydrateSequences, - cwd: cwd ?? meta.cwd, - cols: snapshot.cols, - rows: snapshot.rows, - modes: snapshot.modes - } + private readMeta(sessionId: string): SessionMeta | null { + const metaRead = this.readMetaState(sessionId) + return metaRead.status === 'readable' ? metaRead.meta : null } - private readMeta(sessionId: string): SessionMeta | null { - const metaPath = join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json') - if (!existsSync(metaPath)) { - return null - } - try { - return readTerminalHistoryJson(metaPath, TERMINAL_HISTORY_META_MAX_BYTES) - } catch { - return null - } + private readMetaState(sessionId: string): SessionMetaRead { + return readTerminalHistoryMeta(this.basePath, sessionId) } } diff --git a/src/main/daemon/terminal-history-checkpoint-reader.ts b/src/main/daemon/terminal-history-checkpoint-reader.ts new file mode 100644 index 00000000000..2b8acbd2963 --- /dev/null +++ b/src/main/daemon/terminal-history-checkpoint-reader.ts @@ -0,0 +1,71 @@ +import { existsSync } from 'node:fs' +import { isTerminalOscLinkRanges } from '../../shared/terminal-osc-link-ranges' +import { readTerminalHistoryJsonAsync } from './terminal-history-file-reader' +import { TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES } from './terminal-history-file-limits' +import { isValidTerminalHistorySize } from './terminal-history-dimensions' +import type { TerminalCheckpointFile, TerminalModes } from './types' + +export type TerminalHistoryCheckpointRead = + | { status: 'missing' } + | { status: 'readable'; checkpoint: TerminalCheckpointFile } + | { status: 'unreadable' } + +export async function readTerminalHistoryCheckpoint( + path: string +): Promise { + if (!existsSync(path)) { + return { status: 'missing' } + } + try { + const value = await readTerminalHistoryJsonAsync( + path, + TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + ) + return isTerminalCheckpointFile(value) + ? { status: 'readable', checkpoint: value } + : { status: 'unreadable' } + } catch { + return { status: 'unreadable' } + } +} + +function isTerminalCheckpointFile(value: unknown): value is TerminalCheckpointFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const checkpoint = value as Partial + return ( + typeof checkpoint.snapshotAnsi === 'string' && + typeof checkpoint.scrollbackAnsi === 'string' && + typeof checkpoint.rehydrateSequences === 'string' && + (checkpoint.cwd === null || typeof checkpoint.cwd === 'string') && + isValidTerminalHistorySize(checkpoint.cols, checkpoint.rows) && + isTerminalModes(checkpoint.modes) && + isNonNegativeSafeInteger(checkpoint.scrollbackLines) && + (checkpoint.generation === undefined || isNonNegativeSafeInteger(checkpoint.generation)) && + typeof checkpoint.checkpointedAt === 'string' && + (checkpoint.oscLinks === undefined || isTerminalOscLinkRanges(checkpoint.oscLinks)) + ) +} + +function isTerminalModes(value: unknown): value is TerminalModes { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const modes = value as Partial + return ( + typeof modes.bracketedPaste === 'boolean' && + typeof modes.mouseTracking === 'boolean' && + typeof modes.applicationCursor === 'boolean' && + typeof modes.alternateScreen === 'boolean' && + (modes.mouseTrackingMode === undefined || + ['none', 'x10', 'vt200', 'drag', 'any'].includes(modes.mouseTrackingMode)) && + (modes.sgrMouseMode === undefined || typeof modes.sgrMouseMode === 'boolean') && + (modes.sgrMousePixelsMode === undefined || typeof modes.sgrMousePixelsMode === 'boolean') && + (modes.kittyKeyboardFlags === undefined || isNonNegativeSafeInteger(modes.kittyKeyboardFlags)) + ) +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 +} diff --git a/src/main/daemon/terminal-history-cold-restore-info.ts b/src/main/daemon/terminal-history-cold-restore-info.ts new file mode 100644 index 00000000000..8a68072a2da --- /dev/null +++ b/src/main/daemon/terminal-history-cold-restore-info.ts @@ -0,0 +1,44 @@ +import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { SessionMeta } from './terminal-history-metadata' +import type { TerminalModes } from './types' + +export type ColdRestoreInfo = { + snapshotAnsi: string + scrollbackAnsi: string + oscLinks?: TerminalOscLinkRange[] + rehydrateSequences: string + cwd: string + cols: number + rows: number + modes: TerminalModes +} + +type RestoredSnapshot = { + snapshotAnsi: string + scrollbackAnsi: string + oscLinks?: TerminalOscLinkRange[] + rehydrateSequences: string + cols: number + rows: number + modes: TerminalModes +} + +export function coldRestoreInfoFromSnapshot( + snapshot: RestoredSnapshot, + cwd: string | null, + meta: SessionMeta +): ColdRestoreInfo { + // Why: legacy normal snapshots stored their buffer only in snapshotAnsi. + const scrollbackAnsi = + snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi) + return { + snapshotAnsi: snapshot.snapshotAnsi, + scrollbackAnsi, + oscLinks: snapshot.oscLinks, + rehydrateSequences: snapshot.rehydrateSequences, + cwd: cwd ?? meta.cwd, + cols: snapshot.cols, + rows: snapshot.rows, + modes: snapshot.modes + } +} diff --git a/src/main/daemon/terminal-history-dimensions.ts b/src/main/daemon/terminal-history-dimensions.ts new file mode 100644 index 00000000000..cf71c9dc6f9 --- /dev/null +++ b/src/main/daemon/terminal-history-dimensions.ts @@ -0,0 +1,14 @@ +// Why separate from live admission: existing recovery was written through a 1,000-column viewport contract. +const MAX_TERMINAL_HISTORY_COLS = 1_000 +const MAX_TERMINAL_HISTORY_ROWS = 500 + +export function isValidTerminalHistorySize(cols: unknown, rows: unknown): boolean { + return ( + Number.isSafeInteger(cols) && + (cols as number) >= 1 && + (cols as number) <= MAX_TERMINAL_HISTORY_COLS && + Number.isSafeInteger(rows) && + (rows as number) >= 1 && + (rows as number) <= MAX_TERMINAL_HISTORY_ROWS + ) +} diff --git a/src/main/daemon/terminal-history-incremental-restore.test.ts b/src/main/daemon/terminal-history-incremental-restore.test.ts index 8c43e0e82de..234f6af8c6a 100644 --- a/src/main/daemon/terminal-history-incremental-restore.test.ts +++ b/src/main/daemon/terminal-history-incremental-restore.test.ts @@ -96,7 +96,12 @@ describe('incremental terminal history restore', () => { const checkpoint = JSON.parse(JSON.stringify(snapshotOf(['base content\r\n']))) writeFileSync( sessionFile('checkpoint.json'), - JSON.stringify({ ...checkpoint, cwd: '/home/user', generation: 1 }) + JSON.stringify({ + ...checkpoint, + cwd: '/home/user', + generation: 1, + checkpointedAt: new Date().toISOString() + }) ) const restore = await reader.detectColdRestore(SESSION_ID) @@ -109,7 +114,7 @@ describe('incremental terminal history restore', () => { const checkpoint = JSON.parse(JSON.stringify(snapshotOf(['old format base\r\n']))) writeFileSync( sessionFile('checkpoint.json'), - JSON.stringify({ ...checkpoint, cwd: '/home/user' }) + JSON.stringify({ ...checkpoint, cwd: '/home/user', checkpointedAt: new Date().toISOString() }) ) writeFileSync( sessionFile('output.log'), diff --git a/src/main/daemon/terminal-history-log.test.ts b/src/main/daemon/terminal-history-log.test.ts index 426056d4725..380c4ea9d7f 100644 --- a/src/main/daemon/terminal-history-log.test.ts +++ b/src/main/daemon/terminal-history-log.test.ts @@ -95,6 +95,14 @@ describe('terminal history log codec', () => { expect(decodeTerminalHistoryLog(orphanRecord)).toBeNull() }) + it('rejects a clear frame with a payload', () => { + const log = buildLog(1, [{ seq: 1, records: [{ kind: 'clear' }] }]) + const clearFrameOffset = log.length - 5 + log.writeUInt32LE(1, clearFrameOffset + 1) + + expect(decodeTerminalHistoryLog(Buffer.concat([log, Buffer.from('x')]))).toBeNull() + }) + it('decodes an empty log (header only)', () => { const log = decodeTerminalHistoryLog(encodeLogHeader(4)) expect(log).toEqual({ generation: 4, batches: [], truncatedTail: false }) diff --git a/src/main/daemon/terminal-history-log.ts b/src/main/daemon/terminal-history-log.ts index 4f7866c37d6..8f5fdd07c8d 100644 --- a/src/main/daemon/terminal-history-log.ts +++ b/src/main/daemon/terminal-history-log.ts @@ -136,6 +136,9 @@ export function decodeTerminalHistoryLog(buffer: Buffer): TerminalHistoryLogCont rows: buffer.readUInt16LE(payloadStart + 2) }) } else if (kind === FRAME_CLEAR) { + if (payloadLength !== 0) { + return null + } current.records.push({ kind: 'clear' }) } else { return null diff --git a/src/main/daemon/terminal-history-manager-options.ts b/src/main/daemon/terminal-history-manager-options.ts new file mode 100644 index 00000000000..7e03e856eb0 --- /dev/null +++ b/src/main/daemon/terminal-history-manager-options.ts @@ -0,0 +1,13 @@ +import type { HistoryRecoveryFreeze } from './terminal-history-recovery-quarantine' + +export type OpenSessionOptions = { + cwd: string + cols: number + rows: number + recoveryFreeze?: HistoryRecoveryFreeze + quarantineUnreadableRecovery?: boolean +} + +export type HistoryManagerOptions = { + onWriteError?: (sessionId: string, error: Error) => void +} diff --git a/src/main/daemon/terminal-history-metadata.ts b/src/main/daemon/terminal-history-metadata.ts new file mode 100644 index 00000000000..b93fd9a4a78 --- /dev/null +++ b/src/main/daemon/terminal-history-metadata.ts @@ -0,0 +1,68 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { getHistorySessionDirName } from './history-paths' +import { isValidTerminalHistorySize } from './terminal-history-dimensions' +import { readTerminalHistoryJson } from './terminal-history-file-reader' +import { TERMINAL_HISTORY_META_MAX_BYTES } from './terminal-history-file-limits' + +export type SessionMeta = { + cwd: string + cols: number + rows: number + startedAt: string + endedAt: string | null + exitCode: number | null +} + +export type SessionMetaRead = + | { status: 'missing' } + | { status: 'readable'; meta: SessionMeta } + | { status: 'unreadable' } + +export function readTerminalHistoryMeta(basePath: string, sessionId: string): SessionMetaRead { + const metaPath = join(basePath, getHistorySessionDirName(sessionId), 'meta.json') + if (!existsSync(metaPath)) { + return { status: 'missing' } + } + try { + const meta = readTerminalHistoryJson(metaPath, TERMINAL_HISTORY_META_MAX_BYTES) + return isSessionMeta(meta) ? { status: 'readable', meta } : { status: 'unreadable' } + } catch (err) { + // Why: a concurrent cleanup/quarantine between existsSync and the read means no history, not corrupt history. + if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') { + return { status: 'missing' } + } + return { status: 'unreadable' } + } +} + +export function readTerminalHistoryMetaFromDir(dir: string): SessionMeta | null { + try { + return JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')) + } catch { + return null + } +} + +export function updateTerminalHistoryMeta(dir: string, updates: Partial): void { + const meta = readTerminalHistoryMetaFromDir(dir) + if (!meta) { + return + } + Object.assign(meta, updates) + writeFileSync(join(dir, 'meta.json'), JSON.stringify(meta, null, 2)) +} + +function isSessionMeta(value: unknown): value is SessionMeta { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const meta = value as Partial + return ( + typeof meta.cwd === 'string' && + typeof meta.startedAt === 'string' && + isValidTerminalHistorySize(meta.cols, meta.rows) && + (meta.endedAt === null || typeof meta.endedAt === 'string') && + (meta.exitCode === null || typeof meta.exitCode === 'number') + ) +} diff --git a/src/main/daemon/terminal-history-recovery-quarantine.ts b/src/main/daemon/terminal-history-recovery-quarantine.ts new file mode 100644 index 00000000000..df7605b5549 --- /dev/null +++ b/src/main/daemon/terminal-history-recovery-quarantine.ts @@ -0,0 +1,99 @@ +import { createHash, randomUUID } from 'node:crypto' +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import { join } from 'node:path' +import { getHistorySessionDirName } from './history-paths' + +const QUARANTINE_DIR_NAME = '.recovery-quarantine' +const RECOVERY_PROTECTION_MARKER = '.unreadable-recovery' + +export type TerminalHistoryDirectoryFingerprint = string | null + +export type HistoryRecoveryFreeze = { + readonly sessionId: string + readonly token: string +} + +export type ActiveHistoryRecoveryFreeze = { + handle: HistoryRecoveryFreeze + fingerprint?: TerminalHistoryDirectoryFingerprint +} + +export function isTerminalHistoryQuarantineEntry(name: string): boolean { + return name === QUARANTINE_DIR_NAME +} + +export function getTerminalHistoryQuarantineOwnerDir(basePath: string, sessionId: string): string { + const sessionHash = createHash('sha256').update(sessionId).digest('hex') + return join(basePath, QUARANTINE_DIR_NAME, sessionHash) +} + +export function hasTerminalHistoryRecoveryProtection(basePath: string, sessionId: string): boolean { + return existsSync(join(basePath, getHistorySessionDirName(sessionId), RECOVERY_PROTECTION_MARKER)) +} + +export function clearTerminalHistoryRecoveryProtection(dir: string): void { + try { + unlinkSync(join(dir, RECOVERY_PROTECTION_MARKER)) + } catch { + // Missing or locked markers remain fail-closed. + } +} + +export function fingerprintTerminalHistorySession( + basePath: string, + sessionId: string +): TerminalHistoryDirectoryFingerprint { + const sessionDir = join(basePath, getHistorySessionDirName(sessionId)) + if (!existsSync(sessionDir)) { + return null + } + + const fingerprint = createHash('sha256') + const entries = readdirSync(sessionDir).sort() + for (const name of ['.', ...entries]) { + const stats = lstatSync(name === '.' ? sessionDir : join(sessionDir, name)) + fingerprint.update(name) + fingerprint.update('\0') + fingerprint.update( + [stats.dev, stats.ino, stats.mode, stats.size, stats.mtimeMs, stats.ctimeMs].join(':') + ) + fingerprint.update('\0') + } + return fingerprint.digest('hex') +} + +export function quarantineTerminalHistorySession( + basePath: string, + sessionId: string, + expectedFingerprint: TerminalHistoryDirectoryFingerprint +): string { + const actualFingerprint = fingerprintTerminalHistorySession(basePath, sessionId) + if (actualFingerprint !== expectedFingerprint) { + throw new Error('terminal_history_recovery_generation_changed') + } + + const sessionDir = join(basePath, getHistorySessionDirName(sessionId)) + const ownerDir = getTerminalHistoryQuarantineOwnerDir(basePath, sessionId) + // Why: if rename is blocked, a later adapter must not attach a writer to the unreadable generation. + writeFileSync(join(sessionDir, RECOVERY_PROTECTION_MARKER), '') + mkdirSync(ownerDir, { recursive: true }) + const quarantineDir = join(ownerDir, randomUUID()) + renameSync(sessionDir, quarantineDir) + return quarantineDir +} + +export function removeTerminalHistoryQuarantines(basePath: string, sessionId: string): void { + rmSync(getTerminalHistoryQuarantineOwnerDir(basePath, sessionId), { + recursive: true, + force: true + }) +} diff --git a/src/main/daemon/terminal-history-session-writer.ts b/src/main/daemon/terminal-history-session-writer.ts new file mode 100644 index 00000000000..1af964e2c05 --- /dev/null +++ b/src/main/daemon/terminal-history-session-writer.ts @@ -0,0 +1,131 @@ +import { + closeSync, + fstatSync, + openSync, + readFileSync, + readSync, + promises as fsPromises +} from 'node:fs' +import { join } from 'node:path' +import { + decodeLogHeader, + encodeLogBatch, + encodeLogHeader, + LOG_HEADER_BYTES +} from './terminal-history-log' +import type { SessionMeta } from './terminal-history-metadata' +import { clearTerminalHistoryRecoveryProtection } from './terminal-history-recovery-quarantine' +import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types' + +// Why 5MB: bounds cold-restore replay time and per-session disk; hitting the cap triggers one checkpoint that resets the log. +const LOG_MAX_BYTES = 5 * 1024 * 1024 + +export class TerminalHistorySessionWriter { + readonly checkpointPath: string + readonly logPath: string + private logGeneration: number | null + private logBytes: number | null + + constructor( + readonly dir: string, + fresh: boolean + ) { + this.checkpointPath = join(dir, 'checkpoint.json') + this.logPath = join(dir, 'output.log') + this.logGeneration = fresh ? 0 : null + this.logBytes = fresh ? 0 : null + } + + async appendIncrements( + seq: number, + records: PendingOutputRecord[] + ): Promise<'ok' | 'needs-checkpoint'> { + this.resolveLogState() + const batch = encodeLogBatch(seq, records) + const projectedBytes = Math.max(this.logBytes ?? 0, LOG_HEADER_BYTES) + batch.length + if (projectedBytes > LOG_MAX_BYTES) { + return 'needs-checkpoint' + } + if (this.logBytes === 0) { + await fsPromises.writeFile(this.logPath, encodeLogHeader(this.logGeneration ?? 0)) + this.logBytes = LOG_HEADER_BYTES + } + await fsPromises.appendFile(this.logPath, batch) + this.logBytes = (this.logBytes ?? LOG_HEADER_BYTES) + batch.length + return 'ok' + } + + async checkpoint(snapshot: TerminalSnapshot): Promise { + // Why: snapshot.cwd is null until OSC-7; preserve meta.json's usable cwd for cold restore. + const effectiveCwd = snapshot.cwd ?? this.readMeta()?.cwd ?? null + this.resolveLogState() + const generation = (this.logGeneration ?? 0) + 1 + const checkpointFile: TerminalCheckpointFile = { + snapshotAnsi: snapshot.snapshotAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + oscLinks: snapshot.oscLinks, + rehydrateSequences: snapshot.rehydrateSequences, + cwd: effectiveCwd, + cols: snapshot.cols, + rows: snapshot.rows, + modes: snapshot.modes, + scrollbackLines: snapshot.scrollbackLines, + generation, + checkpointedAt: new Date().toISOString() + } + const tmpPath = `${this.checkpointPath}.tmp` + await fsPromises.writeFile(tmpPath, JSON.stringify(checkpointFile)) + await fsPromises.rename(tmpPath, this.checkpointPath) + await fsPromises.writeFile(this.logPath, encodeLogHeader(generation)) + this.logGeneration = generation + this.logBytes = LOG_HEADER_BYTES + clearTerminalHistoryRecoveryProtection(this.dir) + } + + // Why: a warm writer must append to the existing generation without clobbering its log. + private resolveLogState(): void { + if (this.logBytes !== null && this.logGeneration !== null) { + return + } + let headerGeneration: number | null = null + let size = 0 + try { + const fd = openSync(this.logPath, 'r') + try { + size = fstatSync(fd).size + const header = Buffer.alloc(LOG_HEADER_BYTES) + if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) { + headerGeneration = decodeLogHeader(header) + } + } finally { + closeSync(fd) + } + } catch { + // Missing log file — fresh state below. + } + if (headerGeneration !== null) { + this.logGeneration = headerGeneration + this.logBytes = size + return + } + this.logBytes = 0 + this.logGeneration = this.readCheckpointGeneration() ?? 0 + } + + private readCheckpointGeneration(): number | null { + try { + const checkpoint = JSON.parse(readFileSync(this.checkpointPath, 'utf-8')) + return typeof checkpoint.generation === 'number' ? checkpoint.generation : null + } catch { + return null + } + } + + private readMeta(): SessionMeta | null { + try { + return JSON.parse(readFileSync(join(this.dir, 'meta.json'), 'utf-8')) + } catch { + return null + } + } +} From 4326707285a87282334756b3385a41600d750cbc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:39:49 -0700 Subject: [PATCH 073/150] fix(persistence): unbreak main by expecting the new 'cli' card property in fresh defaults (#10722) Co-authored-by: Orca --- src/main/persistence.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 1894a42835a..9c0b0d47e9e 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -7072,6 +7072,7 @@ describe('Store', () => { 'linear-issue', 'pr', 'automation', + 'cli', 'comment', 'ports', 'inline-agents' From 6c5af95343a397bf1d1f98923b2f39d9db6ea3a6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:46:02 -0700 Subject: [PATCH 074/150] fix(sidebar): stop worktree drag from spazzing when cards resize mid-drag (#10725) --- .../src/components/sidebar/WorktreeList.tsx | 29 +++- .../worktree-sidebar-drag-autoscroll.test.ts | 10 +- .../worktree-sidebar-drag-autoscroll.ts | 11 +- .../worktree-sidebar-drag-geometry.test.ts | 142 ++++++++++++++++++ .../sidebar/worktree-sidebar-drag-geometry.ts | 34 +++++ .../sidebar/worktree-sidebar-drop-preview.ts | 28 +++- 6 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 857f8d7f92f..c85e035b202 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -194,6 +194,7 @@ import { type WorktreeSidebarDragSession, type WorktreeSidebarDragPoint } from './worktree-sidebar-drag-autoscroll' +import { holdWorktreeSidebarDragRects } from './worktree-sidebar-drag-geometry' import { computeWorktreeSidebarDropPreview, resolveWorktreeSidebarStatusDropCommitTarget, @@ -1404,6 +1405,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [worktreeLineageById, worktreeMap] ) const worktreeDragSessionRef = useRef(null) + const heldStatusDropRectsRef = useRef>(new Map()) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) const worktreePointerAutoscrollLastFrameTimeRef = useRef(null) @@ -1593,6 +1595,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY: number groupKey: string rects: readonly WorktreeSidebarDragRect[] + liveRects?: readonly WorktreeSidebarDragRect[] draggedIds: readonly string[] draggingWorktreeId?: string | null }): WorktreeSidebarDropPreview | null => { @@ -1610,6 +1613,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp containerTop: containerRect.top, scrollTop: container.scrollTop, rects: args.rects, + liveRects: args.liveRects, groupIds: group.worktreeIds, draggedIds: args.draggedIds, draggingWorktreeId: args.draggingWorktreeId @@ -1627,6 +1631,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY, groupKey: session.sourceGroupKey, rects: session.rects, + liveRects: session.liveRects, draggedIds: session.reorderUnitDraggedIds, draggingWorktreeId: session.draggingWorktreeId }) @@ -1644,10 +1649,20 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return null } const groupKey = getWorkspaceStatusGroupKey(args.status) + // Why: cross-group hovers re-measure a group the drag session never + // captured, so hold its geometry here too or a card expanding in the + // target group jumps the insertion line under a still pointer. + const liveRects = getWorktreeSidebarDragRectsForGroup(container, groupKey) + const rects = holdWorktreeSidebarDragRects({ + held: heldStatusDropRectsRef.current.get(groupKey), + measured: liveRects + }) + heldStatusDropRectsRef.current.set(groupKey, rects) return computeWorktreeDropForGroup({ pointerY: args.pointerY, groupKey, - rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), + rects, + liveRects, draggedIds: args.draggedIds, draggingWorktreeId: worktreeDragSessionRef.current?.draggingWorktreeId ?? null }) @@ -2657,6 +2672,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp cleanupWorktreePointerDrag() cancelWorktreeNativeAutoscroll() worktreeDragSessionRef.current = null + heldStatusDropRectsRef.current.clear() setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) }, [cancelWorktreeNativeAutoscroll, cleanupWorktreePointerDrag]) @@ -3078,7 +3094,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp draggedIds: drag.draggedIds, reorderDraggedIds: drag.reorderDraggedIds, reorderUnitDraggedIds: drag.reorderUnitDraggedIds, - rects: drag.rects + rects: drag.rects, + liveRects: drag.rects } setWorktreeDragState({ draggingWorktreeId: drag.worktreeId, @@ -3493,15 +3510,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } const reorderDraggedIds = getReorderDraggedIds(draggedIds) const reorderUnitDraggedIds = getReorderUnitDraggedIds(sourceGroupKey, reorderDraggedIds) + const rects = scrollRef.current + ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) + : [] worktreeDragSessionRef.current = { draggingWorktreeId: worktreeId, sourceGroupKey, draggedIds, reorderDraggedIds, reorderUnitDraggedIds, - rects: scrollRef.current - ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) - : [] + rects, + liveRects: rects } setWorktreeDragState({ draggingWorktreeId: worktreeId, diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index a197759a245..f26d5ef6e60 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -21,7 +21,8 @@ const SESSION: WorktreeSidebarDragSession = { draggedIds: ['b'], reorderDraggedIds: ['b'], reorderUnitDraggedIds: ['b'], - rects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }] + rects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }], + liveRects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }] } describe('getWorktreeSidebarDragAutoscroll', () => { @@ -158,7 +159,8 @@ describe('refreshWorktreeSidebarDragSession', () => { ], rects }) - ).toEqual({ ...SESSION, rects }) + // Why: the row set changed ('a' mounted), so the fresh measurement is adopted. + ).toEqual({ ...SESSION, rects, liveRects: rects }) }) it('clears when the source group is missing', () => { @@ -199,7 +201,7 @@ describe('refreshWorktreeSidebarDragSession', () => { unitGroups: [{ key: 'repo:one', worktreeIds: ['a', 'b'], units: [] }], rects: [] }) - ).toEqual({ ...SESSION, rects: [] }) + ).toEqual({ ...SESSION, rects: [], liveRects: [] }) }) it('keeps child-card reorder drags even when the child is not a top-level unit', () => { @@ -232,7 +234,7 @@ describe('refreshWorktreeSidebarDragSession', () => { ], rects }) - ).toEqual({ ...childSession, rects }) + ).toEqual({ ...childSession, rects, liveRects: rects }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 27e2a3d7d2d..1dd0b5d6989 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -1,5 +1,6 @@ import type { WorktreeDragGroup } from './worktree-manual-order' import type { WorktreeDragUnitGroup } from './worktree-drag-units' +import { holdWorktreeSidebarDragRects } from './worktree-sidebar-drag-geometry' const EDGE_ZONE_PX = 56 const MAX_OUTSIDE_EDGE_PX = 48 @@ -25,7 +26,11 @@ export type WorktreeSidebarDragSession = { draggedIds: readonly string[] reorderDraggedIds: readonly string[] reorderUnitDraggedIds: readonly string[] + // Why: `rects` are held stable for hit testing so resizing cards cannot move + // the drop target; `liveRects` keep the rendered indicator and row previews + // anchored to where the cards actually are right now. rects: readonly WorktreeSidebarDragRect[] + liveRects: readonly WorktreeSidebarDragRect[] } export type WorktreeSidebarAutoscrollResult = { @@ -190,7 +195,11 @@ export function refreshWorktreeSidebarDragSession(args: { return null } - return { ...args.session, rects: args.rects } + return { + ...args.session, + rects: holdWorktreeSidebarDragRects({ held: args.session.rects, measured: args.rects }), + liveRects: args.rects + } } function getVerticalEdgeIntensity( diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts new file mode 100644 index 00000000000..c783d53259f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { computeWorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview' +import { holdWorktreeSidebarDragRects } from './worktree-sidebar-drag-geometry' +import { + refreshWorktreeSidebarDragSession, + type WorktreeSidebarDragRect +} from './worktree-sidebar-drag-autoscroll' + +const GROUP_IDS = ['a', 'b', 'c', 'd', 'e'] +const CARD_HEIGHT = 116 +const ROW_GAP = 6 +// Why: a card with several agent rows expanded runs ~3.5x a collapsed one. +const EXPANDED_CARD_HEIGHT = 404 + +function layout(heightByWorktreeId: Readonly>): WorktreeSidebarDragRect[] { + let top = 0 + return GROUP_IDS.map((worktreeId, groupIndex) => { + const height = heightByWorktreeId[worktreeId] ?? CARD_HEIGHT + const rect = { worktreeId, groupIndex, top, bottom: top + height } + top += height + ROW_GAP + return rect + }) +} + +const COLLAPSED = layout({}) + +function previewAt(args: { + pointerY: number + rects: readonly WorktreeSidebarDragRect[] + liveRects?: readonly WorktreeSidebarDragRect[] +}): { dropIndex: number; dropIndicatorY: number } | null { + const preview = computeWorktreeSidebarDropPreview({ + pointerY: args.pointerY, + containerTop: 0, + scrollTop: 0, + rects: args.rects, + liveRects: args.liveRects, + groupIds: GROUP_IDS, + draggedIds: ['a'], + draggingWorktreeId: 'a' + }) + return preview ? { dropIndex: preview.dropIndex, dropIndicatorY: preview.dropIndicatorY } : null +} + +describe('worktree sidebar drag geometry under mid-drag card growth', () => { + it('keeps the drop target fixed while a card expands under a still pointer', () => { + const pointerY = 250 + const before = previewAt({ pointerY, rects: COLLAPSED }) + + // Card 'b' expands its agent list while the pointer does not move at all. + const grown = layout({ b: EXPANDED_CARD_HEIGHT }) + const liveDropIndex = previewAt({ pointerY, rects: grown })?.dropIndex + const held = holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: grown }) + const after = previewAt({ pointerY, rects: held, liveRects: grown }) + + // Re-measuring live would move the drop target with zero pointer movement. + expect(liveDropIndex).not.toBe(before?.dropIndex) + expect(after?.dropIndex).toBe(before?.dropIndex) + }) + + it('never lets a growing card change the drop target across a whole expansion animation', () => { + const pointerY = 250 + const frames = Array.from({ length: 12 }, (_, frame) => + layout({ b: CARD_HEIGHT + ((EXPANDED_CARD_HEIGHT - CARD_HEIGHT) * frame) / 11 }) + ) + + const live = frames.map((rects) => previewAt({ pointerY, rects })?.dropIndex) + const stabilized = frames.map( + (rects) => + previewAt({ + pointerY, + rects: holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: rects }), + liveRects: rects + })?.dropIndex + ) + + expect(new Set(live).size).toBeGreaterThan(1) + expect(new Set(stabilized)).toEqual( + new Set([previewAt({ pointerY, rects: COLLAPSED })?.dropIndex]) + ) + }) + + it('still tracks the pointer normally while geometry is held', () => { + const held = holdWorktreeSidebarDragRects({ + held: COLLAPSED, + measured: layout({ b: EXPANDED_CARD_HEIGHT }) + }) + + expect(previewAt({ pointerY: 100, rects: held })?.dropIndex).toBeLessThan( + previewAt({ pointerY: 500, rects: held })?.dropIndex ?? -1 + ) + }) + + it('draws the indicator at the live position so a grown card does not strand it', () => { + const grown = layout({ b: EXPANDED_CARD_HEIGHT }) + const held = holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: grown }) + const preview = previewAt({ pointerY: 250, rects: held, liveRects: grown }) + const dropIndex = preview?.dropIndex ?? -1 + + expect(preview?.dropIndicatorY).toBe(grown[dropIndex]!.top - 3) + // Held geometry alone would have parked the line ~288px above the real gap. + expect(preview?.dropIndicatorY).not.toBe(COLLAPSED[dropIndex]!.top - 3) + }) + + it('adopts fresh geometry when rows mount or change slot mid-drag', () => { + const reordered = COLLAPSED.map((rect, index) => ({ + ...rect, + worktreeId: GROUP_IDS[(index + 1) % GROUP_IDS.length]! + })) + + expect(holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: reordered })).toBe(reordered) + expect(holdWorktreeSidebarDragRects({ held: undefined, measured: COLLAPSED })).toBe(COLLAPSED) + expect(holdWorktreeSidebarDragRects({ held: [], measured: COLLAPSED })).toBe(COLLAPSED) + }) + + it('holds hit-test geometry across a session refresh while liveRects stay current', () => { + const grown = layout({ b: EXPANDED_CARD_HEIGHT }) + const refreshed = refreshWorktreeSidebarDragSession({ + session: { + draggingWorktreeId: 'a', + sourceGroupKey: 'repo:one', + draggedIds: ['a'], + reorderDraggedIds: ['a'], + reorderUnitDraggedIds: ['a'], + rects: COLLAPSED, + liveRects: COLLAPSED + }, + groups: [{ key: 'repo:one', worktreeIds: GROUP_IDS }], + unitGroups: [ + { + key: 'repo:one', + worktreeIds: GROUP_IDS, + units: GROUP_IDS.map((worktreeId) => ({ worktreeId, worktreeIds: [worktreeId] })) + } + ], + rects: grown + }) + + expect(refreshed?.rects).toBe(COLLAPSED) + expect(refreshed?.liveRects).toBe(grown) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts new file mode 100644 index 00000000000..77bd680bcd4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts @@ -0,0 +1,34 @@ +import type { WorktreeSidebarDragRect } from './worktree-sidebar-drag-autoscroll' + +function getDragRowSignature(rects: readonly WorktreeSidebarDragRect[]): string { + return rects.map((rect) => `${rect.worktreeId}@${rect.groupIndex}`).join('|') +} + +/** + * Why: the drop index comes from comparing the pointer against row midpoints, + * and sidebar cards keep resizing mid-drag — agent statuses stream in and + * expansion panels animate open underneath the pointer. Measuring afresh every + * frame lets a card that grows while the pointer barely moves shove those + * midpoints past it, so one nudge teleports the insertion line several slots. + * + * Hold the geometry captured when the drag reached this row set, so only pointer + * movement can change the drop target. The set is held whole rather than merged + * per row: mixing held tops with freshly measured ones would describe two + * different layouts at once. When the rows themselves change — mounting during + * autoscroll, or genuinely changing slot — the fresh measurement is adopted + * wholesale so the coordinate space stays consistent. + * + * Only hit testing uses this; the indicator and row previews still render from + * live geometry, so a card growing mid-drag never leaves them stale. + */ +export function holdWorktreeSidebarDragRects(args: { + held: readonly WorktreeSidebarDragRect[] | undefined + measured: readonly WorktreeSidebarDragRect[] +}): readonly WorktreeSidebarDragRect[] { + if (!args.held || args.held.length === 0) { + return args.measured + } + return getDragRowSignature(args.held) === getDragRowSignature(args.measured) + ? args.held + : args.measured +} diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index d3702968e59..dfb7a825aec 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -92,11 +92,27 @@ export function resolveWorktreeSidebarStatusDropCommitTarget(args: { : { target: args.currentTarget, preview: args.currentPreview } } +function getWorktreeSidebarDropIndicatorY(args: { + rects: readonly WorktreeSidebarDragRect[] + dropIndex: number +}): number { + const target = args.rects.find((rect) => rect.groupIndex === args.dropIndex) + if (target) { + return Math.max(0, target.top - 3) + } + const last = args.rects.at(-1) + return last ? last.bottom + 3 : 0 +} + export function computeWorktreeSidebarDropPreview(args: { pointerY: number containerTop: number scrollTop: number rects: readonly WorktreeSidebarDragRect[] + // Why: `rects` are held stable so resizing cards cannot move the drop target + // under a still pointer. The indicator and row previews still draw from live + // geometry, so a card growing mid-drag does not strand them at stale tops. + liveRects?: readonly WorktreeSidebarDragRect[] groupIds: readonly string[] draggedIds: readonly string[] draggingWorktreeId?: string | null @@ -108,6 +124,12 @@ export function computeWorktreeSidebarDropPreview(args: { if (rects.length === 0 || args.groupIds.length === 0) { return null } + const liveUnitRects = args.liveRects + ? getWorktreeSidebarDragUnitRects({ rects: args.liveRects, groupIds: args.groupIds }) + : rects + // Why: an empty live measurement (rows unmounted by virtualization) would + // collapse the indicator to the top of the list; keep the held geometry then. + const renderRects = liveUnitRects.length === rects.length ? liveUnitRects : rects const localY = args.pointerY - args.containerTop + args.scrollTop const first = rects[0]! @@ -123,26 +145,24 @@ export function computeWorktreeSidebarDropPreview(args: { } let dropIndex = last.groupIndex + 1 - let indicatorY = last.bottom + 3 if (boundaryDrop.kind === 'drop') { dropIndex = boundaryDrop.dropIndex - indicatorY = boundaryDrop.indicatorY } else { for (const rect of rects) { const mid = (rect.top + rect.bottom) / 2 if (localY < mid) { dropIndex = rect.groupIndex - indicatorY = Math.max(0, rect.top - 3) break } } } + const indicatorY = getWorktreeSidebarDropIndicatorY({ rects: renderRects, dropIndex }) const previewOffsetsByWorktreeId = buildWorktreeDragPreviewOffsets({ groupIds: args.groupIds, draggedIds: args.draggedIds, draggingWorktreeId: args.draggingWorktreeId, dropIndex, - rects + rects: renderRects }) return { dropIndex, dropIndicatorY: indicatorY, previewOffsetsByWorktreeId } } From 03b65d08e507f7ec7029d12d5ce75c66895562bb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:51:11 -0700 Subject: [PATCH 075/150] fix(sidebar): make Cmd/Ctrl+1-9 match the rendered card order when the sidebar is closed (#10693) --- .../src/components/sidebar/WorktreeList.tsx | 55 ++-- .../rendered-sidebar-worktree-order.test.ts | 262 ++++++++++++++++++ .../rendered-sidebar-worktree-order.ts | 122 ++++++++ .../components/sidebar/visible-worktrees.ts | 41 ++- .../sidebar/worktree-list-host-filtering.ts | 51 +++- 5 files changed, 484 insertions(+), 47 deletions(-) create mode 100644 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts create mode 100644 src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index c85e035b202..7b96520e706 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -219,7 +219,6 @@ import { } from './worktree-multi-selection' import { persistWorktreeSortOrderByHost } from '@/lib/worktree-sort-order-persistence' import { - ALL_EXECUTION_HOSTS_SCOPE, getRepoExecutionHostId, getSettingsFocusedExecutionHostId, getWorktreeExecutionHostId, @@ -294,9 +293,10 @@ import { sidebarWorkspaceStillExists } from './worktree-list-folder-reveal' import { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts, getFolderPathStatusRouteOptionsForRows, - getFolderWorkspaceExecutionHostIdForRows, - getProjectGroupExecutionHostIdForRows + getVisibleSidebarHostIdSet } from './worktree-list-host-filtering' import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' import { @@ -5615,12 +5615,10 @@ const WorktreeList = React.memo(function WorktreeList({ worktreeMap ]) const defaultHostId = getSettingsFocusedExecutionHostId(settings) - const visibleHostIdSet = useMemo(() => { - const visibleHostIds = - visibleWorkspaceHostIds ?? - (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope]) - return visibleHostIds ? new Set(visibleHostIds) : null - }, [visibleWorkspaceHostIds, workspaceHostScope]) + const visibleHostIdSet = useMemo( + () => getVisibleSidebarHostIdSet(visibleWorkspaceHostIds, workspaceHostScope), + [visibleWorkspaceHostIds, workspaceHostScope] + ) const visibleReposForRows = useMemo(() => { if (!visibleHostIdSet) { return repos @@ -5631,29 +5629,20 @@ const WorktreeList = React.memo(function WorktreeList({ return visibleHostIdSet.has(hostId) }) }, [defaultHostId, repos, visibleHostIdSet]) - const visibleProjectGroupsForRows = useMemo(() => { - if (!visibleHostIdSet) { - return projectGroups - } - return projectGroups.filter((group) => { - const hostId = getProjectGroupExecutionHostIdForRows(group, defaultHostId) - return visibleHostIdSet.has(hostId) - }) - }, [defaultHostId, projectGroups, visibleHostIdSet]) - const visibleFolderWorkspacesForRows = useMemo(() => { - if (!visibleHostIdSet) { - return folderWorkspaces - } - const projectGroupById = new Map(projectGroups.map((group) => [group.id, group])) - return folderWorkspaces.filter((folderWorkspace) => { - const hostId = getFolderWorkspaceExecutionHostIdForRows({ - folderWorkspace, - projectGroup: projectGroupById.get(folderWorkspace.projectGroupId), + const visibleProjectGroupsForRows = useMemo( + () => filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), + [defaultHostId, projectGroups, visibleHostIdSet] + ) + const visibleFolderWorkspacesForRows = useMemo( + () => + filterFolderWorkspacesForVisibleHosts( + folderWorkspaces, + projectGroups, + visibleHostIdSet, defaultHostId - }) - return visibleHostIdSet.has(hostId) - }) - }, [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet]) + ), + [defaultHostId, folderWorkspaces, projectGroups, visibleHostIdSet] + ) const repoOrder = useMemo(() => { return getLogicalRepoOrderRankById(repos.map((repo) => repo.id)) }, [repos]) @@ -5974,8 +5963,8 @@ const WorktreeList = React.memo(function WorktreeList({ // Why layout effect: the Cmd/Ctrl+1–9 handler can fire right after commit; publishing after paint would leave the shortcut cache stale. useLayoutEffect(() => { setVisibleWorktreeIds(renderedWorktreeIds) - // Why: unmounting the list clears the rendered-order cache so shortcuts fall back to the live store snapshot. - return () => setVisibleWorktreeIds([]) + // Why null, not []: [] is a real rendered order (all collapsed/filtered); null tells shortcuts the list is unmounted. + return () => setVisibleWorktreeIds(null) }, [renderedWorktreeIds]) const handleCreateForRepo = useCallback( diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts new file mode 100644 index 00000000000..0f549db9f23 --- /dev/null +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.test.ts @@ -0,0 +1,262 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { getVisibleWorktreeIds, setVisibleWorktreeIds } from './visible-worktrees' +import type { AppState } from '@/store/types' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' + +const initialState = useAppStore.getInitialState() + +function makeWorktree(id: string, overrides: Partial = {}): Worktree { + return { + id, + repoId: 'repo1', + path: `/tmp/${id}`, + head: 'abc123', + branch: 'refs/heads/feature', + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } as Worktree +} + +function makeMainWorktree(id: string, overrides: Partial = {}): Worktree { + return makeWorktree(id, { isMainWorktree: true, branch: 'refs/heads/main', ...overrides }) +} + +function makeRepo(id = 'repo1', overrides: Partial = {}): Repo { + return { + id, + name: id, + path: `/tmp/${id}`, + defaultBranch: 'main', + connectionId: null, + ...overrides + } as unknown as Repo +} + +function makeProjectGroup(id: string, overrides: Partial = {}): ProjectGroup { + return { + id, + name: id, + parentPath: `/tmp/${id}`, + connectionId: null, + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1, + ...overrides + } as ProjectGroup +} + +function makeFolderWorkspace(id: string, projectGroupId: string): FolderWorkspace { + return { + id, + projectGroupId, + name: id, + folderPath: `/tmp/${id}`, + connectionId: null, + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } +} + +/** + * Seeds the store with the fields the closed-sidebar order reads, then drops the + * published order so getVisibleWorktreeIds() must recompute it. + */ +function seedStore(worktrees: Worktree[], overrides: Partial = {}): void { + useAppStore.setState( + { + ...initialState, + repos: [makeRepo()], + worktreesByRepo: { repo1: worktrees }, + // Why showSleepingWorkspaces: fixtures have no PTYs, so the activity filter + // would otherwise drop every workspace before ordering runs. + showSleepingWorkspaces: true, + groupBy: 'repo', + sortBy: 'manual', + ...overrides + } as AppState, + true + ) + setVisibleWorktreeIds(null) +} + +describe('closed-sidebar Cmd+1-9 ordering (#9497)', () => { + afterEach(() => { + setVisibleWorktreeIds(null) + useAppStore.setState(initialState, true) + }) + + it('hoists the repo main worktree first, matching the rendered sidebar', () => { + // Manual sort ranks by sortOrder descending, so the base sort puts the feature + // workspace first; only the repo grouping layer hoists main ahead of it. + const feature = makeWorktree('wt-feature', { sortOrder: 1 }) + const main = makeMainWorktree('wt-main', { sortOrder: 0 }) + seedStore([feature, main]) + + const order = getVisibleWorktreeIds() + + expect(order).toEqual(['wt-main', 'wt-feature']) + // The pre-fix flat fallback returned membership order and numbered the wrong card. + expect(order).not.toEqual(['wt-feature', 'wt-main']) + }) + + it('ignores the agent-send collapse override, which only applies to a mounted list', () => { + // Safe to ignore WorktreeList's effectiveCollapsedGroups override only because + // it needs a mounted list, which publishes its order and skips this path. + const main = makeMainWorktree('wt-main') + const feature = makeWorktree('wt-feature') + seedStore([main, feature], { + collapsedGroups: new Set(['project:repo:repo1']), + agentSendPopoverTargetMode: { + worktreeId: 'wt-feature', + id: 'send-1', + instanceId: 'inst-1' + } as AppState['agentSendPopoverTargetMode'] + }) + + expect(getVisibleWorktreeIds()).toEqual([]) + + setVisibleWorktreeIds(['wt-feature', 'wt-main']) + expect(getVisibleWorktreeIds()).toEqual(['wt-feature', 'wt-main']) + }) + + it('places a pinned workspace per the pinned section under both display policies', () => { + const main = makeMainWorktree('wt-main') + const plain = makeWorktree('wt-plain') + const pinned = makeWorktree('wt-pinned', { isPinned: true }) + + for (const showPinnedWorktreesInGroups of [false, true]) { + seedStore([main, plain, pinned], { + settings: { showPinnedWorktreesInGroups } as AppState['settings'] + }) + + const order = getVisibleWorktreeIds() + + // Each workspace owns exactly one ordinal even when pinning duplicates its row. + expect(order).toEqual([...new Set(order)]) + expect([...order].sort()).toEqual(['wt-main', 'wt-pinned', 'wt-plain']) + expect(order[0]).toBe(showPinnedWorktreesInGroups ? 'wt-main' : 'wt-pinned') + } + }) + + it('elides members of a collapsed group, which render no card to number', () => { + const main = makeMainWorktree('wt-main') + const feature = makeWorktree('wt-feature') + seedStore([main, feature], { collapsedGroups: new Set(['project:repo:repo1']) }) + + expect(getVisibleWorktreeIds()).toEqual([]) + }) + + it('numbers folder workspaces, which the flat fallback omitted entirely', () => { + const group = makeProjectGroup('group-1') + const folderWorkspace = makeFolderWorkspace('fw-1', group.id) + seedStore([makeMainWorktree('wt-main')], { + projectGroups: [group], + folderWorkspaces: [folderWorkspace] + }) + + expect(getVisibleWorktreeIds()).toContain(folderWorkspaceKey(folderWorkspace.id)) + }) + + it('numbers a workspace created while nothing was published', () => { + // A retained-cache fix cannot surface this: it can only prune ids it already had. + seedStore([makeMainWorktree('wt-main')]) + expect(getVisibleWorktreeIds()).toEqual(['wt-main']) + + seedStore([makeMainWorktree('wt-main'), makeWorktree('wt-created-while-closed')]) + + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-created-while-closed']) + }) + + it('treats a published order as authoritative, including an explicitly empty one', () => { + seedStore([makeMainWorktree('wt-main'), makeWorktree('wt-feature')]) + + setVisibleWorktreeIds(['x']) + expect(getVisibleWorktreeIds()).toEqual(['x']) + + // An empty rendered sidebar is a real order; the old length check fell through here. + setVisibleWorktreeIds([]) + expect(getVisibleWorktreeIds()).toEqual([]) + + setVisibleWorktreeIds(null) + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-feature']) + }) + + it('honors an explicit host filter and keeps the all-hosts default unfiltered', () => { + const localRepo = makeRepo('repo1') + const sshRepo = makeRepo('repo-ssh', { connectionId: 'my-target' }) + // Base sort interleaves the hosts, so any host grouping has to reorder them. + const localMain = makeMainWorktree('wt-local-main', { sortOrder: 3 }) + const remoteMain = makeMainWorktree('wt-remote-main', { repoId: 'repo-ssh', sortOrder: 2 }) + const localFeature = makeWorktree('wt-local-feature', { sortOrder: 1 }) + const remoteFeature = makeWorktree('wt-remote-feature', { repoId: 'repo-ssh', sortOrder: 0 }) + const storeOverrides = { + repos: [localRepo, sshRepo], + worktreesByRepo: { + repo1: [localMain, localFeature], + 'repo-ssh': [remoteMain, remoteFeature] + }, + sshTargetLabels: new Map([['my-target', 'My Target']]) + } as Partial + + seedStore([], storeOverrides) + // Default all-hosts scope adds no host sections, so repo grouping alone orders it. + expect(getVisibleWorktreeIds()).toEqual([ + 'wt-local-main', + 'wt-local-feature', + 'wt-remote-main', + 'wt-remote-feature' + ]) + + seedStore([], { ...storeOverrides, visibleWorkspaceHostIds: ['local'] }) + expect(getVisibleWorktreeIds()).toEqual(['wt-local-main', 'wt-local-feature']) + + seedStore([], { + ...storeOverrides, + visibleWorkspaceHostIds: ['ssh:my-target', 'local'] + }) + // Host sections keep each host's workspaces contiguous, ordered by the host + // registry (local first) rather than by the filter argument's order. + expect(getVisibleWorktreeIds()).toEqual([ + 'wt-local-main', + 'wt-local-feature', + 'wt-remote-main', + 'wt-remote-feature' + ]) + }) + + it('keeps the sort layer intact for smart and comparator sort modes', () => { + const main = makeMainWorktree('wt-main') + const older = makeWorktree('wt-older', { displayName: 'b-older', lastActivityAt: 1 }) + const newer = makeWorktree('wt-newer', { displayName: 'a-newer', lastActivityAt: 999 }) + + seedStore([older, newer, main], { sortBy: 'smart' }) + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-newer', 'wt-older']) + + seedStore([older, newer, main], { sortBy: 'name' }) + expect(getVisibleWorktreeIds()).toEqual(['wt-main', 'wt-newer', 'wt-older']) + }) +}) diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts new file mode 100644 index 00000000000..fccdc79955c --- /dev/null +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -0,0 +1,122 @@ +import type { Worktree } from '../../../../shared/types' +import type { AppState } from '@/store/types' +import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' +import { + ALL_EXECUTION_HOSTS_SCOPE, + getSettingsFocusedExecutionHostId +} from '../../../../shared/execution-host' +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' +import { buildRows, getPinnedWorktreeDisplayPolicy } from './worktree-list-groups' +import { addHostSectionRows } from './host-section-rows' +import { orderHostSectionOptions } from './host-section-order' +import { buildSidebarHostOptions } from './sidebar-host-options' +import { getLogicalRepoOrderRankById } from './project-header-drop' +import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' +import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' +import { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts, + getVisibleSidebarHostIdSet +} from './worktree-list-host-filtering' + +const EMPTY_REPO_ID_SET: ReadonlySet = Object.freeze(new Set()) +const EMPTY_IMPORTED_BY_REPO = Object.freeze(new Map()) as never +const EMPTY_INBOX_BY_REPO = Object.freeze(new Map()) as never +const EMPTY_PENDING_CREATIONS = Object.freeze([]) as never + +/** + * Orders already-filtered worktrees the way the sidebar would render them, for + * Cmd+1–9 numbering while WorktreeList is unmounted (#9497). + * + * Why replay the pipeline: a flat list drops grouping, pinning, main-worktree + * hoisting and collapse elision, so the shortcut numbered the wrong card. + * Why worktrees are passed in: keeps the dependency on visible-worktrees.ts + * one-way, since test suites mock that module's path. + */ +export function computeRenderedSidebarWorktreeOrder( + state: AppState, + visibleWorktrees: readonly Worktree[] +): string[] { + const defaultHostId = getSettingsFocusedExecutionHostId(state.settings) + const pinnedDisplayPolicy = getPinnedWorktreeDisplayPolicy(state.settings) + const projection = getProjectHostSetupProjectionFromState(state) + const visibleHostIdSet = getVisibleSidebarHostIdSet( + state.visibleWorkspaceHostIds, + state.workspaceHostScope + ) + const projectGroups = state.projectGroups ?? [] + const { prCache } = selectWorktreeListReviewCacheInputs( + state, + state.groupBy, + state.worktreeCardProperties + ) + + const rows = buildRows( + state.groupBy, + [...visibleWorktrees], + getRepoMapFromState(state), + prCache, + state.collapsedGroups, + getLogicalRepoOrderRankById(state.repos.map((repo) => repo.id)), + state.workspaceStatuses, + state.projectOrderBy, + state.worktreeLineageById, + getWorktreeMapFromState(state), + true, + state.settings, + filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIdSet, defaultHostId), + // Why empty: placeholder/imported/inbox/pending inputs never emit item or folder-workspace rows, the only two the order reads. + EMPTY_REPO_ID_SET, + EMPTY_IMPORTED_BY_REPO, + EMPTY_INBOX_BY_REPO, + EMPTY_PENDING_CREATIONS, + { projects: projection.projects, projectHostSetups: projection.setups }, + filterFolderWorkspacesForVisibleHosts( + state.folderWorkspaces, + projectGroups, + visibleHostIdSet, + defaultHostId + ), + // Why no hostLabelById: it only feeds display-only host context labels, never row order. + undefined, + defaultHostId, + pinnedDisplayPolicy + ) + + // Why lazy: with no host filter, addHostSectionRows is a pass-through, so skip building the whole host registry on a keystroke. + // Deliberately a superset of its internal guards — on <=1 host it still no-ops, wasting only the registry build. + const needsHostSections = + state.workspaceHostScope !== ALL_EXECUTION_HOSTS_SCOPE || state.visibleWorkspaceHostIds != null + const sectionRows = needsHostSections + ? addHostSectionRows({ + rows, + hostOptions: orderHostSectionOptions( + buildSidebarHostOptions({ + repos: state.repos, + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + settings: state.settings, + runtimeEnvironments: state.runtimeEnvironments, + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId, + hostLabelOverrides: getHostDisplayLabelOverrides(state.settings) + }), + state.workspaceHostOrder + ), + workspaceHostScope: state.workspaceHostScope, + visibleWorkspaceHostIds: state.visibleWorkspaceHostIds, + defaultHostId, + collapsedHostKeys: state.collapsedGroups, + forceCollapseHosts: false, + preferProjectGrouping: true + }) + : rows + + return Array.from( + new Set( + getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy).map( + (worktree) => worktree.id + ) + ) + ) +} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 24c3878e69d..2ee9f41a0b2 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -2,7 +2,11 @@ import type { Worktree, Repo, TerminalTab, WorktreeLineage } from '../../../../s import { buildWorktreeComparator, sortWorktreesSmart } from './smart-sort' import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { useAppStore } from '@/store' -import { getAllWorktreesFromState, getRepoMapFromState } from '@/store/selectors' +import { + getAllWorktreesFromState, + getRepoMapFromState, + getWorktreeMapFromState +} from '@/store/selectors' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' import { ALL_EXECUTION_HOSTS_SCOPE, @@ -15,6 +19,7 @@ import { getCyclicProjectedWorktreeLineageIds, getLineageRenderInfo } from './worktree-lineage-projection' +import { computeRenderedSidebarWorktreeOrder } from './rendered-sidebar-worktree-order' /** * Whether a worktree represents the repo's default-branch row that the @@ -270,15 +275,19 @@ function addVisibleLineageAncestors( * could target a different worktree than what's rendered at that sidebar * position. By caching the IDs that WorktreeList actually rendered, the * shortcut numbering always matches the sidebar card order. + * + * Why null vs []: [] is a real rendered order (everything collapsed/filtered); + * null means WorktreeList is unmounted. */ -let _cachedVisibleIds: string[] = [] +let _publishedVisibleIds: string[] | null = null /** * Called by WorktreeList after computing visible worktrees so the Cmd+1–9 - * handler can read the exact same ordering the user sees on screen. + * handler can read the exact same ordering the user sees on screen. Pass null + * on unmount. */ -export function setVisibleWorktreeIds(ids: string[]): void { - _cachedVisibleIds = ids +export function setVisibleWorktreeIds(ids: string[] | null): void { + _publishedVisibleIds = ids } /** @@ -286,17 +295,16 @@ export function setVisibleWorktreeIds(ids: string[]): void { * state. Called by the App-level Cmd+1–9 handler (not a React hook — reads * store snapshot at call time). * - * If WorktreeList has rendered at least once, returns the cached IDs so the - * shortcut numbering matches the sidebar. Falls back to a live recomputation - * only before WorktreeList's first render (e.g. app startup). + * If WorktreeList is mounted, returns the exact IDs it rendered. Otherwise + * recomputes the order the sidebar *would* render from the same row pipeline, + * so a closed sidebar numbers workspaces the same way an open one does (#9497). */ export function getVisibleWorktreeIds(): string[] { - // Prefer the cached IDs that mirror the rendered sidebar order. - if (_cachedVisibleIds.length > 0) { - return _cachedVisibleIds + // Prefer the published IDs that mirror the rendered sidebar order. + if (_publishedVisibleIds) { + return _publishedVisibleIds } - // Fallback: live recomputation for the window before WorktreeList renders. const state = useAppStore.getState() const allWorktrees = getAllWorktreesFromState(state).filter((w) => !w.isArchived) @@ -325,7 +333,7 @@ export function getVisibleWorktreeIds(): string[] { sortedIds = sorted.map((w) => w.id) } - return computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, { + const visibleIds = computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, { filterRepoIds: state.filterRepoIds, showSleepingWorkspaces: state.showSleepingWorkspaces, tabsByWorktree: state.tabsByWorktree, @@ -345,4 +353,11 @@ export function getVisibleWorktreeIds(): string[] { defaultHostId: getSettingsFocusedExecutionHostId(state.settings), worktreeLineageById: state.worktreeLineageById }) + + const worktreeMap = getWorktreeMapFromState(state) + // Why the row pipeline: grouping, pinning and main-worktree hoisting reorder cards, so a flat sort numbers the wrong workspace. + return computeRenderedSidebarWorktreeOrder( + state, + visibleIds.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) + ) } diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts index 6f7a62c02df..30273957969 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts @@ -1,12 +1,61 @@ import { + ALL_EXECUTION_HOSTS_SCOPE, normalizeExecutionHostId, parseExecutionHostId, toSshExecutionHostId, - type ExecutionHostId + type ExecutionHostId, + type ExecutionHostScope } from '../../../../shared/execution-host' import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status' import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +/** null means "no host filter" — every host is visible. */ +export function getVisibleSidebarHostIdSet( + visibleWorkspaceHostIds: readonly ExecutionHostId[] | null | undefined, + workspaceHostScope: ExecutionHostScope +): Set | null { + const visibleHostIds = + visibleWorkspaceHostIds ?? + (workspaceHostScope === ALL_EXECUTION_HOSTS_SCOPE ? null : [workspaceHostScope]) + return visibleHostIds ? new Set(visibleHostIds) : null +} + +// Why shared: the sidebar render path and the Cmd+1–9 order must apply the same +// host filtering, or the numbering drifts from the cards whenever a filter is on. +export function filterProjectGroupsForVisibleHosts( + projectGroups: readonly ProjectGroup[], + visibleHostIdSet: ReadonlySet | null, + defaultHostId: ExecutionHostId +): readonly ProjectGroup[] { + if (!visibleHostIdSet) { + return projectGroups + } + return projectGroups.filter((group) => + visibleHostIdSet.has(getProjectGroupExecutionHostIdForRows(group, defaultHostId)) + ) +} + +export function filterFolderWorkspacesForVisibleHosts( + folderWorkspaces: readonly FolderWorkspace[], + projectGroups: readonly ProjectGroup[], + visibleHostIdSet: ReadonlySet | null, + defaultHostId: ExecutionHostId +): readonly FolderWorkspace[] { + if (!visibleHostIdSet) { + return folderWorkspaces + } + const projectGroupById = new Map(projectGroups.map((group) => [group.id, group])) + return folderWorkspaces.filter((folderWorkspace) => + visibleHostIdSet.has( + getFolderWorkspaceExecutionHostIdForRows({ + folderWorkspace, + projectGroup: projectGroupById.get(folderWorkspace.projectGroupId), + defaultHostId + }) + ) + ) +} + export function getProjectGroupExecutionHostIdForRows( group: Pick, defaultHostId: ExecutionHostId From 81e3c62f1915d5c81d47116b472210f456511322 Mon Sep 17 00:00:00 2001 From: grboy6770 <2000grboy@gmail.com> Date: Sun, 26 Jul 2026 17:53:32 +0900 Subject: [PATCH 076/150] fix(native-chat): wrap question text and option descriptions instead of truncating (#10025) --- .../native-chat/NativeChatQuestionCard.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index db068b5c773..d5241e23bdf 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -155,11 +155,8 @@ export function NativeChatQuestionCard({ ) : null}
-
-

+

+

{q.question}

From cd4064689d885a6afc0beaace73447b20297ef99 Mon Sep 17 00:00:00 2001 From: ye4241 <6803102+ye4241@users.noreply.github.com> Date: Sun, 26 Jul 2026 16:57:25 +0800 Subject: [PATCH 077/150] fix(mobile): pop to home when leaving a host so the back chevron animates backward (#9723) --- mobile/src/host-route-exit.test.ts | 8 +++++--- mobile/src/host-route-exit.ts | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/mobile/src/host-route-exit.test.ts b/mobile/src/host-route-exit.test.ts index 2856faa7e0e..ac9231a7b59 100644 --- a/mobile/src/host-route-exit.test.ts +++ b/mobile/src/host-route-exit.test.ts @@ -4,16 +4,18 @@ import { leaveHostRoute } from './host-route-exit' function makeRouter() { return { - replace: vi.fn() + dismissTo: vi.fn() } } describe('leaveHostRoute', () => { - it('returns to home instead of depending on route history', () => { + // Why: dismissTo (not replace) is what makes the chevron animate back like swipe-back, so the + // call shape is the behavior under test, not an implementation detail. + it('dismisses to home instead of depending on route history', () => { const router = makeRouter() leaveHostRoute(router) - expect(router.replace).toHaveBeenCalledWith('/') + expect(router.dismissTo).toHaveBeenCalledWith('/') }) }) diff --git a/mobile/src/host-route-exit.ts b/mobile/src/host-route-exit.ts index 438da03c18d..a5ffee421b3 100644 --- a/mobile/src/host-route-exit.ts +++ b/mobile/src/host-route-exit.ts @@ -1,9 +1,10 @@ type HostRouteExitRouter = { - replace: (href: '/') => void + dismissTo: (href: '/') => void } export function leaveHostRoute(router: HostRouteExitRouter): void { - // Why: direct pairing can open /h/:hostId as the root route, and split-view - // detail history is not the host/home screen the header is meant to exit to. - router.replace('/') + // Why: direct pairing can open /h/:hostId as the root route, and split-view detail history is + // not the host/home screen the header is meant to exit to. dismissTo pops to home when it is on + // the stack, so the chevron animates back like swipe-back, and replaces when it is not. + router.dismissTo('/') } From 81495f4ba09c2cc7fcf3ff1bbcc3aa2e3dd0c2f1 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:58:24 +0900 Subject: [PATCH 078/150] fix(i18n/zh): correct technical literals and clear sense errors (#10048) --- src/renderer/src/i18n/locales/zh.json | 86 +++++++++---------- ...-technical-literal-mistranslations.test.ts | 63 ++++++++++++++ 2 files changed, 106 insertions(+), 43 deletions(-) create mode 100644 src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 182cb5dd573..deb949c95a5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -878,7 +878,7 @@ "f2d02cdf8c": "已查看文件", "1257d1435d": "显示文件树", "a341343303": "已添加评审评论。", - "d1fa2cf888": "没有PR负责人 SHA 无法发表评论。", + "d1fa2cf888": "没有 PR head SHA 就无法发表评论。", "829674460a": "Diff 不可用,因为 PR commit SHA 丢失。", "af924014f8": "已查看", "2d89a38d9d": "{{value0}} {{value1}} 为已查看", @@ -1167,7 +1167,7 @@ "cddd9b04a7": "加载标签", "23886c7eec": "添加标签", "7f7b89b631": "标签: {{value0}}", - "b2376d0179": "正在加载会员", + "b2376d0179": "正在加载成员", "866316f22c": "未分配", "b5675b0694": "保存", "ceeb8c6153": "清除", @@ -1375,7 +1375,7 @@ "89e80af1c7": "已查看文件", "319cf2d54b": "显示文件树", "eff839f438": "已添加评审评论。", - "d8c3ba91c4": "没有PR负责人 SHA 无法发表评论。", + "d8c3ba91c4": "没有 PR head SHA 就无法发表评论。", "74660bd80b": "Diff 不可用,因为 PR commit SHA 丢失。", "2e528e1c2d": "已查看", "ff84e1f54c": "{{value0}} {{value1}} 为已查看", @@ -1534,7 +1534,7 @@ "09623359b9": "开始日期", "7d08e8be0f": "开始", "af9e877f30": "无标签", - "d6cda23ef1": "会员", + "d6cda23ef1": "成员", "cfaadb6b22": "无负责人", "34da8ac06c": "负责人", "579f98afcd": "添加简短摘要...", @@ -1573,7 +1573,7 @@ "8675cd6188": "Linear", "733b8f2421": "Linear/视图", "bc06ed0fb0": "返回视图", - "3cb855080f": "意见", + "3cb855080f": "视图", "b4e10f096e": "所有者", "a04fe7ba73": "可见性", "0aa8525950": "模型", @@ -1694,7 +1694,7 @@ "50387522d7": "没有分组", "d747aed72f": "看板", "a6f7e93d7f": "列表", - "e78ec261ed": "意见", + "e78ec261ed": "视图", "727069bee5": "项目", "137e2a8a01": "PR", "18451e99df": "完成", @@ -1874,7 +1874,7 @@ "8bc9e17d8f": "Show details" }, "WorktreeJumpPalette": { - "ac037cfac2": "手机", + "ac037cfac2": "移动", "75499e01d9": "关闭", "66b5a67bee": "Esc键", "45def60329": "打开", @@ -2111,7 +2111,7 @@ "d6aeb2012b": "显示议题来自", "787c970baf": "议题来源", "643d7e9496": "上游", - "51d1608920": "起源", + "51d1608920": "origin", "cdc9bd64fa": "紧凑", "30b2c9df91": "上游" }, @@ -2254,7 +2254,7 @@ "8bbecb2510": "没有任何", "e1fa97d21d": "选择一个项目以查看其概述。", "1748d3b9af": "标签", - "65bda65159": "会员", + "65bda65159": "成员", "c5f79616c3": "团队", "25a2196732": "目标", "3fb6473111": "开始", @@ -2527,7 +2527,7 @@ }, "TerminalQuickCommandDialog": { "925b8e0f6e": "高级", - "97e96cc027": "/目标", + "97e96cc027": "/goal", "e604bd40d6": "支持技能、文件路径和内置命令,例如", "79af0c0841": "npm run dev", "577a342c7d": "要求智能体调查此工作区", @@ -3008,7 +3008,7 @@ "996295bff2": "孤儿终端", "92924a14e3": "查看非活跃工作区 ({{value0}})", "27a74f91f0": "现在没有任何运行", - "1b24a32d3a": "记忆", + "1b24a32d3a": "内存", "298f4be7f2": "中央处理器", "2aa2de6cb9": "姓名", "30ff2c3c31": "{{value0}} 个孤立项", @@ -3197,7 +3197,7 @@ "eee5240810": "工作区已删除", "9afc97f9a3": "工作区已删除", "792a214457": "删除工作区", - "a998501630": "力量", + "a998501630": "强制", "9155381019": "疏", "f39d291997": "选择", "16988df079": "没有找到文件。", @@ -3946,7 +3946,7 @@ "f3e2b868fb": "运行钩子", "43b7bec4cd": "别跑", "c494b3ccb1": "在", - "79afc6772b": "Orca.yaml", + "79afc6772b": "orca.yaml", "531689199b": "永远信任", "bf800b7e04": "。仅当您信任时才运行", "831f2cd9f0": "在你的机器上运行", @@ -4023,7 +4023,7 @@ "b56d1322f7": "找到一个设置命令", "aef6c0a213": "保存已检测的命令,以便在 Orca 创建工作树时运行它。", "660cdc17f8": "设置脚本。添加本地命令,或在“设置”中更改源。", - "8f6be51aa1": "Orca.yaml", + "8f6be51aa1": "orca.yaml", "bb879db364": "此仓库忽略共享", "0155fb9ed3": "目前无法验证此仓库的设置脚本。", "eefa756190": "手动配置", @@ -4321,7 +4321,7 @@ "2931b42b09": "状态:草案 {{value0}}", "e888362def": "状态: 关闭", "f394b3e86e": "状态:合并", - "af2b07bda5": "州:{{value0}}", + "af2b07bda5": "状态:{{value0}}", "29df45afa2": "MR" }, "WorktreeCardPorts": { @@ -4418,7 +4418,7 @@ "9c1d1e9b71": "评论", "5ae06f40fd": "粘贴PR URL,或输入数字。留空以删除链接。", "077a4f7b5c": "PR # 或 GitHub URL", - "1b91db7e14": "生长激素受体", + "1b91db7e14": "GH PR", "7c454be4c5": "粘贴议题 URL,或输入数字。留空以删除链接。", "029ea5ec57": "打开 GitHub 议题", "741279e7b7": "议题编号或 GitHub URL", @@ -5115,7 +5115,7 @@ "1c9a9679ec": "{{value0}} 可用性", "0d9e293a02": "刷新", "c9b33eb5c0": "刷新中…", - "13647f9f80": "重新读取您的 shell 路径并重新检测已安装的智能体", + "13647f9f80": "重新读取 shell PATH 并重新检测已安装的智能体", "dc4a2ffdc0": "扩展命令覆盖", "cea7d97be1": "折叠命令覆盖", "db9e9e5887": "自定义命令", @@ -5856,7 +5856,7 @@ "92f0b4e472": "清除", "9f741627a7": "Kagi 会话链接已清除。", "d5c8b94c5b": "保存", - "ff450194cd": "卡吉私人会话链接", + "ff450194cd": "Kagi 私密会话链接", "e383683485": "https://kagi.com/search?token=...", "81409d9362": "Kagi 身份验证的可选私人会话链接。", "3e5b7c6c25": "Kagi 会话链接已保存。", @@ -6866,8 +6866,8 @@ "2de6b5a699": "使用所选光标形状的闪烁变体。", "74736cc9b1": "闪烁 Cursor", "2e5aec3cf6": "强调", - "52854a5608": "堵塞", - "e070e8aeba": "酒吧", + "52854a5608": "块状", + "e070e8aeba": "竖线", "db270cc9a9": "Cursor 形状", "d455f2ef4f": "Orca 终端窗格的默认光标外观。", "abcb4dd019": "终端光标", @@ -7009,9 +7009,9 @@ "d06664e889": "黑暗的" }, "TerminalThemeSections": { - "db210115c5": "灯光模式预览", + "db210115c5": "浅色模式预览", "5e0c24b5c8": "控制浅色模式下窗格之间的分割分隔线。", - "ec2e33ad80": "分光器颜色", + "ec2e33ad80": "浅色模式分隔线颜色", "d56af60e6f": "选择 Orca 处于浅色模式时使用的主题。", "8273bc75d7": "浅色主题", "bc8e8a251a": "深色模式预览", @@ -7386,7 +7386,7 @@ "90bdc043ea": "磁盘", "96b4fb0064": "终端", "4ddbde4999": "中央处理器", - "4355f18ac6": "记忆", + "4355f18ac6": "内存", "9c4d5f0894": "经理", "c690a15849": "资源", "81ef5abc2f": "在状态栏中显示 CPU、内存、终端会话和工作区磁盘使用情况。", @@ -8614,7 +8614,7 @@ "7286cd2566": "单词", "3ab64c47d8": "双击选择时将字符视为单词边界。", "957a0203fc": "单词分隔符", - "56fff3d113": "记忆", + "56fff3d113": "内存", "fffdff40a7": "缓冲", "rows": "行", "f7d56b6281": "保留的桌面终端行数。", @@ -8622,11 +8622,11 @@ "411229c636": "浅色", "781f49d942": "分隔线", "77d9f9cd55": "控制浅色模式下窗格之间的分割分隔线。", - "595b97b446": "分光器颜色", + "595b97b446": "浅色模式分隔线颜色", "7718d70356": "预览", "1dee533bd9": "选择 Orca 处于浅色模式时使用的主题。", "1d89457764": "浅色主题", - "da864e6cec": "灯光模式", + "da864e6cec": "浅色模式", "f268092ee3": "浅色模式可以使用自己的终端主题。", "match_dark_mode_title": "匹配深色模式", "f785374072": "深色", @@ -9428,7 +9428,7 @@ "sidebar": { "BulkActionBar": { "79a9f5f712": "取消暂存(", - "ef5f5bd06e": "阶段 (", + "ef5f5bd06e": "暂存(", "60ed678138": "已选择" }, "ChecksPanel": { @@ -9609,7 +9609,7 @@ "d57545ff92": "与远程相同", "b950b1948b": "本地端口", "9e5a4118b0": "远程端口", - "c9d106547a": "向前", + "c9d106547a": "转发", "c7e920aa7c": "公开为 {{value0}}", "e740075063": "移除", "b3548e59f4": "编辑", @@ -9630,7 +9630,7 @@ "153145e675": "证据", "c7b4702b7b": "工作区", "57d930fa45": "PID", - "5dd86dcf2f": "过程", + "5dd86dcf2f": "进程", "b1ff94fa27": "协议", "729be0b4e5": "种类", "0f1d8cd324": "绑定", @@ -9792,7 +9792,7 @@ "eae7a1da5f": "无法清除笔记。", "657e0c90ad": "{{value0}} 注{{value1}}", "df5040e3c3": "取消暂存", - "8cde1a2fb0": "阶段", + "8cde1a2fb0": "暂存", "d54dd48b0b": "放弃更改", "989f3d5e34": "恢复文件", "2830dd64a2": "已删除", @@ -9837,7 +9837,7 @@ "2f609a2e7c": "删除所有未跟踪的", "9bb062a886": "未提交", "9febd8ab5f": "创建_pr", - "f62ce91ade": "起源", + "f62ce91ade": "origin", "3a231c845b": "未知", "3baf6c77b4": "将所有笔记复制到剪贴板", "72f2bea3f4": "展开注释", @@ -9937,7 +9937,7 @@ "1bc0bdbb5e": "启动:", "7ec6abbf2a": "重置", "f4f3c9ca4a": "提示词模板", - "fe119187bb": "——模范十四行诗", + "fe119187bb": "--model sonnet", "bc8dc39f4b": "CLI 参数", "b99c33cec5": "设置", "15c5d85706": "智能体", @@ -9968,7 +9968,7 @@ "25fcd8e49a": "保存默认值", "d91b0a189d": "保存预设", "1f6fcfb6cf": "提示词模板", - "551ffd111b": "——模范十四行诗", + "551ffd111b": "--model sonnet", "4eab815004": "CLI 参数", "914c8f6ac2": "自定义命令", "cce2cbd01d": "选择智能体", @@ -11173,7 +11173,7 @@ "da121ba48d": "计划.md", "e4befee569": "Shell", "606aa93192": "文件", - "94febb0976": "源头控制", + "94febb0976": "源代码管理", "8d6516312d": "2 个终端 · Claude 活跃", "8432787c4e": "壮举/手机页面", "8fd998acd3": "返回" @@ -11446,7 +11446,7 @@ "00078a6134": "在设置中查看", "14327073cc": "保存", "88469e926b": "设置脚本", - "5c5b65044e": "即插即用安装", + "5c5b65044e": "pnpm install", "a7463915b6": "无法保存设置脚本", "6299297dac": "设置脚本已保存", "f0bbf7da77": "尝试一下", @@ -12179,7 +12179,7 @@ "9e384d48dc": "Swift", "3009f722b9": "SQL", "d01f55be57": "shell", - "5af8251002": "社会保障体系", + "5af8251002": "SCSS", "e72e6b03f4": "Rust", "96182a2f64": "Ruby", "2391f9cda9": "Python", @@ -12690,7 +12690,7 @@ "a6914ee43f": "收回", "f4ecd61552": "该选项卡由您的手机控制。收回来在桌面上使用它。", "d9768ec642": "浏览器输入已暂停", - "20539eca03": "手机设备正在推动该浏览器的发展" + "20539eca03": "移动端正在控制此浏览器" }, "BrowserPane": { "1ded0d3168": "复制截图", @@ -12705,7 +12705,7 @@ "a3508d7e6e": "{{value0}} 注释{{value1}} 准备就绪。选择另一个元素或复制所有反馈。", "f796c774a4": "在上面输入 URL 即可开始浏览。", "366bf5d62c": "新标签", - "1c78adc73d": "对外开放", + "1c78adc73d": "在外部打开", "da68d35f7b": "在默认浏览器中打开失败页面", "93be92f8d1": "复制地址", "3c085f638d": "复制失败页面 URL", @@ -12874,7 +12874,7 @@ "b28b140eaf": "工作区", "0d17f4ca8f": "选择项目", "02d351877e": "项目", - "a4ac8fcc62": "/目标", + "a4ac8fcc62": "/goal", "827b25a81e": "支持技能、文件路径和内置命令,例如", "6d778190b7": "每周运行依赖项审计并总结有风险的更改。", "058c23cb3f": "提示词", @@ -12891,7 +12891,7 @@ "58f56b73d9": "自动化名称", "1d9826933e": "工作日仓库审计", "4133d33862": "创建自动化", - "0a75e5e2fa": "创建爱马仕自动化", + "0a75e5e2fa": "创建 Hermes 自动化", "03142e7721": "编辑 Hermes 自动化", "17086b48ee": "编辑自动化" }, @@ -12995,7 +12995,7 @@ "b11170a008": "无法保存自动化。", "2a20596d6b": "自动化已保存。", "244727e655": "自动化已更新。", - "77b81bc4ac": "爱马仕自动化创建。", + "77b81bc4ac": "Hermes 自动化创建。", "08efc3ae12": "Hermes 自动化已更新。", "e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。", "32534e7c9c": "保存前选择一个可用的工作区。", @@ -13008,7 +13008,7 @@ "8705757e27": "工作", "376631ef2b": "恢复", "b457436d6a": "暂停", - "0ae52dd760": "爱马仕", + "0ae52dd760": "Hermes", "e059042585": "只读", "aecdc3681f": "易于管理", "8500baacb4": "外部来源", @@ -13046,7 +13046,7 @@ "c6695e6fbd": "外部自动化", "5524365227": "OpenClaw", "766abf833c": "Hermes", - "bf5f67b590": "爱马仕", + "bf5f67b590": "Hermes", "e66091daf4": "运行", "8e9165af08": "执行", "2b0adbce21": "已暂停", diff --git a/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts b/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts new file mode 100644 index 00000000000..9c4c080e76b --- /dev/null +++ b/src/renderer/src/i18n/zh-technical-literal-mistranslations.test.ts @@ -0,0 +1,63 @@ +/** + * Issue #9574 — high-confidence Simplified Chinese semantic fixes. + * Guards the anchor GH PR mistranslation and a sample of technical literals / + * clear sense errors so bootstrap re-translation cannot silently regress them. + */ +import { describe, expect, it } from 'vitest' +import zh from './locales/zh.json' + +function findByKey(node: unknown, key: string): string | undefined { + if (!node || typeof node !== 'object') { + return undefined + } + if (Array.isArray(node)) { + for (const item of node) { + const found = findByKey(item, key) + if (found !== undefined) { + return found + } + } + return undefined + } + const record = node as Record + if (typeof record[key] === 'string') { + return record[key] as string + } + for (const value of Object.values(record)) { + const found = findByKey(value, key) + if (found !== undefined) { + return found + } + } + return undefined +} + +describe('zh technical literal / sense fixes (#9574)', () => { + it('keeps GH PR as a technical literal (not 生长激素受体)', () => { + expect(findByKey(zh, '1b91db7e14')).toBe('GH PR') + }) + + it('keeps CLI / product technical strings un-translated', () => { + expect(findByKey(zh, 'fe119187bb')).toBe('--model sonnet') + expect(findByKey(zh, '5c5b65044e')).toBe('pnpm install') + expect(findByKey(zh, '5af8251002')).toBe('SCSS') + expect(findByKey(zh, '97e96cc027')).toBe('/goal') + expect(findByKey(zh, 'f62ce91ade')).toBe('origin') + expect(findByKey(zh, '79afc6772b')).toBe('orca.yaml') + }) + + it('uses the correct sense for short UI verbs/nouns', () => { + expect(findByKey(zh, 'ac037cfac2')).toBe('移动') // Move, not 手机 + expect(findByKey(zh, '1b24a32d3a')).toBe('内存') // Memory, not 记忆 + expect(findByKey(zh, '8cde1a2fb0')).toBe('暂存') // Stage, not 阶段 + expect(findByKey(zh, 'af2b07bda5')).toBe('状态:{{value0}}') // State, not 州 + expect(findByKey(zh, 'e070e8aeba')).toBe('竖线') // Bar cursor + expect(findByKey(zh, '52854a5608')).toBe('块状') // Block cursor + }) + + it('preserves brand names in product copy', () => { + expect(findByKey(zh, '855a76343a')).toBe('从 Ghostty 导入') + expect(findByKey(zh, '0a75e5e2fa')).toBe('创建 Hermes 自动化') + expect(findByKey(zh, 'ff450194cd')).toBe('Kagi 私密会话链接') + }) +}) From aeda1b2b5d7cb51b531bfe450af6c54978036527 Mon Sep 17 00:00:00 2001 From: SoonKim Date: Sun, 26 Jul 2026 18:04:37 +0900 Subject: [PATCH 079/150] feat(speech): add Korean streaming zipformer STT model (#9893) Co-authored-by: Claude Opus 4.8 --- src/main/speech/model-catalog.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index b77f6c238d6..10855210fe1 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -123,6 +123,28 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ streaming: true, modelingUnit: 'cjkchar' }, + { + id: 'zipformer-streaming-korean', + label: 'Zipformer Streaming KO', + description: 'Korean only. Low-latency real-time streaming.', + type: 'transducer', + provider: 'local', + language: 'ko', + sizeBytes: 418_218_652, + downloadUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-streaming-zipformer-korean-2024-06-16.tar.bz2', + archiveSha256: 'e346a5882a409650472be17326237e24df7bf409db6b4a8a52e1a61422bf2500', + archiveFormat: 'tar.bz2', + files: [ + 'encoder-epoch-99-avg-1.int8.onnx', + 'decoder-epoch-99-avg-1.int8.onnx', + 'joiner-epoch-99-avg-1.int8.onnx', + 'tokens.txt' + ], + sampleRate: 16000, + streaming: true, + modelingUnit: 'bpe' + }, { id: 'whisper-tiny', label: 'Whisper Tiny', From 7a7c719547f69b1704622fc9164d2469375fee92 Mon Sep 17 00:00:00 2001 From: Ken Fukuyama Date: Sun, 26 Jul 2026 18:05:21 +0900 Subject: [PATCH 080/150] feat(source-control): add copy relative path (#9018) --- .../right-sidebar/SourceControl.tsx | 2 + ...source-control-entry-context-menu.test.tsx | 94 +++++++++++++++++++ .../source-control-entry-context-menu.tsx | 16 ++++ 3 files changed, 112 insertions(+) create mode 100644 src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 9887b10dac7..315c45d948a 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -7823,6 +7823,7 @@ const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ onOpen(entry)} onRevealInExplorer={onRevealInExplorer} @@ -8072,6 +8073,7 @@ function BranchEntryRow({ onOpen()} onRevealInExplorer={onRevealInExplorer} diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx new file mode 100644 index 00000000000..05c4c788409 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SourceControlEntryContextMenu } from './source-control-entry-context-menu' + +type ItemProps = { onSelect?: () => void; children?: React.ReactNode } + +const items = vi.hoisted(() => ({ list: [] as ItemProps[] })) + +vi.mock('@/components/ui/context-menu', async () => { + const React_ = await import('react') + const passthrough = ({ children }: { children?: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children) + + return { + ContextMenu: passthrough, + ContextMenuContent: passthrough, + ContextMenuItem: (props: ItemProps) => { + items.list.push(props) + return React_.createElement(React_.Fragment, null, props.children) + }, + ContextMenuSeparator: () => null, + ContextMenuSub: passthrough, + ContextMenuSubContent: passthrough, + ContextMenuSubTrigger: passthrough, + ContextMenuTrigger: passthrough + } +}) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: { settings: { openInApplications: never[] } }) => unknown) => + selector({ settings: { openInApplications: [] } }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/local-file-manager-label', () => ({ + getLocalFileManagerLabel: () => 'Finder' +})) + +vi.mock('@/lib/open-in-app-catalog', () => ({ + OpenInApplicationIcon: () => null +})) + +vi.mock('@/components/sidebar/WorktreeOpenInMenu', () => ({ + getWorktreeOpenInEntries: () => [], + openOpenInAppsSettings: vi.fn(), + openWorktreePath: vi.fn() +})) + +function childrenText(children: React.ReactNode): string { + return React.Children.toArray(children) + .filter((child): child is string => typeof child === 'string') + .join('') +} + +describe('SourceControlEntryContextMenu', () => { + const writeClipboardText = vi.fn() + + beforeEach(() => { + items.list = [] + writeClipboardText.mockReset() + vi.stubGlobal('window', { + api: { ui: { writeClipboardText } } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('copies the supplied relative path', () => { + renderToStaticMarkup( + +
+ + ) + + const copyRelativePathItem = items.list.find( + (item) => childrenText(item.children) === 'Copy Relative Path' + ) + + expect(copyRelativePathItem).toBeDefined() + copyRelativePathItem?.onSelect?.() + expect(writeClipboardText).toHaveBeenCalledWith('src/example.ts') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx index 218857d1eb4..6d3076d5c4f 100644 --- a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.tsx @@ -24,6 +24,7 @@ import { type SourceControlEntryContextMenuProps = { currentWorktreeId: string absolutePath?: string + relativePath?: string connectionId?: string | null onView?: () => void onRevealInExplorer: (worktreeId: string, absolutePath: string) => void @@ -34,6 +35,7 @@ type SourceControlEntryContextMenuProps = { export function SourceControlEntryContextMenu({ currentWorktreeId, absolutePath, + relativePath, connectionId, onView, onRevealInExplorer, @@ -55,6 +57,13 @@ export function SourceControlEntryContextMenu({ void window.api.ui.writeClipboardText(absolutePath) }, [absolutePath]) + const handleCopyRelativePath = useCallback(() => { + if (!relativePath) { + return + } + void window.api.ui.writeClipboardText(relativePath) + }, [relativePath]) + const handleRevealInOrcaExplorer = useCallback(() => { if (!absolutePath) { return @@ -93,6 +102,13 @@ export function SourceControlEntryContextMenu({ {translate('auto.components.right.sidebar.FileExplorerRow.b5d436aa30', 'Copy Path')} + + + {translate( + 'auto.components.right.sidebar.FileExplorerRow.66a29dde82', + 'Copy Relative Path' + )} + From 79c1b1b32f4a0d9ea9362dc0c6352ce599b4f832 Mon Sep 17 00:00:00 2001 From: SilvaViniF <108766450+SilvaViniF@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:21:06 +0200 Subject: [PATCH 081/150] fix(gitlab): stop refresh button overlapping dialog close X (#9445) * fix(gitlab): stop refresh button overlapping dialog close X The GitLab item dialog's SheetContent renders its own close (X) at absolute right-4, but the header refresh button sat at the header's px-5 right padding and overlapped it. Reserve pr-10 on the header so the refresh button clears the close X, and lift it -mt-1.5 so its icon aligns with the close X on the same line. * fix(gitlab): integrate sheet controls into header --------- Co-authored-by: viniciussilva Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../src/components/GitLabItemDialog.tsx | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index 827d86ad2d8..837fd0b1708 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -24,7 +24,13 @@ import { } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetTitle +} from '@/components/ui/sheet' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { VisuallyHidden } from 'radix-ui' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' @@ -1008,7 +1014,12 @@ export default function GitLabItemDialog({ return ( !open && onClose()}> - + {/* Why: the sheet's absolute default close control would overlap this header's actions. */} + {item @@ -1055,20 +1066,32 @@ export default function GitLabItemDialog({
) : null}
- +
+ + + + +
From 1e2f208b708c5775143075f8ed3594aaec97be32 Mon Sep 17 00:00:00 2001 From: Anishek Kamal Date: Sun, 26 Jul 2026 02:22:51 -0700 Subject: [PATCH 082/150] fix(sidebar): raise selected workspace contrast in dark mode (#8321) --- src/renderer/src/assets/main.css | 20 ++++++--- .../assets/worktree-card-active-style.test.ts | 42 +++++++++++++++++++ .../WorktreeCard.quick-actions.test.tsx | 17 +++++++- .../src/components/sidebar/WorktreeCard.tsx | 7 ++-- 4 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/assets/worktree-card-active-style.test.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 90549525afe..b119d30d515 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1108,26 +1108,34 @@ background: color-mix(in srgb, var(--sidebar-accent) 40%, transparent); } +/* Why: mixing into the concrete sidebar surface keeps selected contrast stable, + including when a custom translucent sidebar theme is active. */ [data-worktree-card-surface][data-worktree-card-active='primary'] { - border-color: color-mix(in srgb, var(--sidebar-border) 40%, transparent); - background: color-mix(in srgb, var(--sidebar-foreground) 8%, transparent); - box-shadow: 0 1px 2px color-mix(in srgb, var(--sidebar-foreground) 4%, transparent); + border-color: color-mix(in srgb, var(--worktree-sidebar-border) 40%, transparent); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 8%, var(--worktree-sidebar)); + box-shadow: 0 1px 2px color-mix(in srgb, var(--worktree-sidebar-foreground) 4%, transparent); } .dark [data-worktree-card-surface][data-worktree-card-active='primary'] { - background: color-mix(in srgb, var(--sidebar-foreground) 10%, transparent); - box-shadow: 0 1px 2px color-mix(in srgb, var(--sidebar-foreground) 3%, transparent); + border-color: color-mix( + in srgb, + var(--worktree-sidebar-foreground) 18%, + var(--worktree-sidebar-border) + ); + background: color-mix(in srgb, var(--worktree-sidebar-foreground) 16%, var(--worktree-sidebar)); + box-shadow: 0 1px 2px color-mix(in srgb, var(--worktree-sidebar-foreground) 3%, transparent); } [data-worktree-card-surface][data-worktree-card-active='secondary'] { border-color: color-mix(in srgb, var(--sidebar-ring) 25%, transparent); background: color-mix(in srgb, var(--sidebar-accent) 45%, transparent); - box-shadow: none; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 15%, transparent); } .dark [data-worktree-card-surface][data-worktree-card-active='secondary'] { border-color: color-mix(in srgb, var(--sidebar-ring) 28%, transparent); background: color-mix(in srgb, var(--sidebar-accent) 34%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 18%, transparent); } .worktree-agent-row-hover:hover { diff --git a/src/renderer/src/assets/worktree-card-active-style.test.ts b/src/renderer/src/assets/worktree-card-active-style.test.ts new file mode 100644 index 00000000000..3d80531036b --- /dev/null +++ b/src/renderer/src/assets/worktree-card-active-style.test.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const mainCss = fs.readFileSync(new URL('./main.css', import.meta.url), 'utf8') + +function getCssRuleBody(selector: string): string { + const ruleMarker = mainCss.indexOf(`\n${selector} {`) + expect(ruleMarker).toBeGreaterThanOrEqual(0) + + const ruleStart = ruleMarker + 1 + const bodyStart = mainCss.indexOf('{', ruleStart) + 1 + const bodyEnd = mainCss.indexOf('}', bodyStart) + return mainCss.slice(bodyStart, bodyEnd) +} + +describe('worktree card active styling', () => { + it('mixes the primary selection into the worktree sidebar surface', () => { + const primary = getCssRuleBody( + "[data-worktree-card-surface][data-worktree-card-active='primary']" + ) + const darkPrimary = getCssRuleBody( + ".dark [data-worktree-card-surface][data-worktree-card-active='primary']" + ) + + expect(primary).toContain('var(--worktree-sidebar-foreground) 8%') + expect(primary).toContain('var(--worktree-sidebar)') + expect(darkPrimary).toContain('var(--worktree-sidebar-foreground) 16%') + expect(darkPrimary).toContain('var(--worktree-sidebar-border)') + }) + + it('keeps the secondary selection ring when CSS owns the active state', () => { + const secondary = getCssRuleBody( + "[data-worktree-card-surface][data-worktree-card-active='secondary']" + ) + const darkSecondary = getCssRuleBody( + ".dark [data-worktree-card-surface][data-worktree-card-active='secondary']" + ) + + expect(secondary).toContain('var(--sidebar-ring) 15%') + expect(darkSecondary).toContain('var(--sidebar-ring) 18%') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index 3d235a6351d..d21085ab3cb 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -180,8 +180,23 @@ describe('WorktreeCard quick actions', () => { ) expect(markup).toContain('data-worktree-card-active="secondary"') - expect(markup).toContain('bg-sidebar-accent/45') expect(markup).not.toContain('bg-black/[0.08]') + expect(markup).not.toContain('dark:bg-white/[0.10]') + expect(markup).not.toContain('bg-sidebar-accent/45') + expect(markup).not.toContain('border-sidebar-ring/25') + expect(markup).not.toContain('ring-sidebar-ring/15') + }) + + it('marks the primary active workspace for token-driven selected styling', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('data-worktree-card-active="primary"') + expect(markup).toContain('data-worktree-card-surface="true"') + expect(markup).not.toContain('bg-black/[0.08]') + expect(markup).not.toContain('dark:bg-white/[0.10]') + expect(markup).not.toContain('border-black/[0.015]') }) it('renders folder directory name in the detailed metadata row without a Folder badge', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 474d3a19dbf..887fa64a9c2 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -257,7 +257,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ const projectGroups = useAppStore((s) => s.projectGroups) const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true const compactCards = !newCardStyle && settings?.compactWorktreeCards === true - const activeSurfaceIsSecondary = isActiveSurface && activeSurfaceVariant === 'secondary' const handleEditIssue = useCallback( (e: React.MouseEvent) => { e.stopPropagation() @@ -1855,12 +1854,12 @@ const WorktreeCard = React.memo(function WorktreeCard({ titleOnlyCard ? 'py-2' : 'pt-1.25 pb-1.5', flushSurface ? 'ml-1 w-[calc(100%-0.25rem)]' : 'ml-1', 'rounded-lg', + // Why: the live data attribute updates before React state during navigation, + // so it must own the complete active style without stale utility classes. isLineageDropTarget ? 'border border-accent-foreground/20 bg-accent/80' : isActiveSurface - ? activeSurfaceIsSecondary - ? 'border border-sidebar-ring/25 bg-sidebar-accent/45 shadow-none ring-1 ring-sidebar-ring/15' - : 'bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] border border-black/[0.015] dark:bg-white/[0.10] dark:border-border/40 dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]' + ? 'border border-transparent' : isMultiSelected ? 'border border-worktree-sidebar-ring/35 bg-worktree-sidebar-accent/70 ring-1 ring-worktree-sidebar-ring/30' : 'border border-transparent worktree-sidebar-card-hover', From f3d8edb29e308e7af79f028a1f21fa7c42bfe344 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:23:36 +0900 Subject: [PATCH 083/150] feat(editor): toggle Word Wrap from file tab actions and Alt+Z (#10086) * feat(editor): toggle Word Wrap from file tab actions and Alt+Z Long single-line and structured files wrap by default and misalign. Surface Word Wrap on the editor more-actions menu for normal file tabs (diff already had it) and add editor.toggleWordWrap (Alt+Z) so users can unwrap without opening Settings. Closes #9974 * fix(editor): toggle diffWordWrap for diff surfaces on Alt+Z CodeRabbit: Alt+Z previously always flipped editorWordWrap, leaving diff panes out of sync with the markdown actions menu. * test(editor): verify word wrap shortcut routing Cover editor/diff setting callbacks and the cross-platform Alt+Z binding. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/renderer/src/components/Terminal.tsx | 19 ++++ .../components/editor/EditorPanelHeader.tsx | 4 + .../EditorPanelMarkdownActionsMenu.test.tsx | 102 ++++++++++++++++++ .../editor/EditorPanelMarkdownActionsMenu.tsx | 30 +++--- src/shared/keybindings.test.ts | 24 +++++ src/shared/keybindings.ts | 10 ++ 6 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 410b3bc8243..ad71a216b01 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1749,6 +1749,25 @@ function Terminal(): React.JSX.Element | null { } } + // Why: long/structured files need a discoverable unwrap path without Settings (#9974). + if (!e.repeat && matchShortcut('editor.toggleWordWrap')) { + const state = useAppStore.getState() + if (state.activeTabType === 'editor' && state.activeFileId) { + e.preventDefault() + notifyTerminalCapture('editor.toggleWordWrap') + // Why: diff surfaces use diffWordWrap; plain editors use editorWordWrap (#10086). + const activeFile = state.openFiles.find((file) => file.id === state.activeFileId) + if (activeFile?.mode === 'diff') { + const wrapOn = state.settings?.diffWordWrap === true + void state.updateSettings({ diffWordWrap: !wrapOn }) + } else { + const wrapOn = state.settings?.editorWordWrap !== false + void state.updateSettings({ editorWordWrap: !wrapOn }) + } + return + } + } + // Cmd/Ctrl+Shift+M - new markdown file if (!e.repeat && matchShortcut('tab.newMarkdown')) { e.preventDefault() diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index c9f8c6e4d1b..a24914bea72 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -90,6 +90,8 @@ export function EditorPanelHeader({ ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[activeFile.worktreeId]) const diffWordWrap = useAppStore((s) => s.settings?.diffWordWrap === true) + // Why: undefined/true mean wrap on; only explicit false turns wrap off (#9974). + const editorWordWrap = useAppStore((s) => s.settings?.editorWordWrap !== false) const updateSettings = useAppStore((s) => s.updateSettings) const fileDiffComments = useMemo( () => diffComments.filter((comment) => comment.filePath === activeFile.relativePath), @@ -313,11 +315,13 @@ export function EditorPanelHeader({ isMarkdown={isMarkdown} isDiffSurface={isDiffSurface} diffWordWrap={diffWordWrap} + editorWordWrap={editorWordWrap} shouldShowMarkdownExportAction={shouldShowMarkdownExportAction} canExportMarkdownToPdf={canExportMarkdownToPdf} canShowMarkdownFrontmatterToggle={canShowMarkdownFrontmatterToggle} markdownFrontmatterVisible={markdownFrontmatterVisible} onToggleDiffWordWrap={() => void updateSettings({ diffWordWrap: !diffWordWrap })} + onToggleEditorWordWrap={() => void updateSettings({ editorWordWrap: !editorWordWrap })} onToggleMarkdownFrontmatter={onToggleMarkdownFrontmatter} onExportMarkdownToPdf={onExportMarkdownToPdf} /> diff --git a/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx new file mode 100644 index 00000000000..9078af7bc2a --- /dev/null +++ b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.test.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { EditorPanelMarkdownActionsMenu } from './EditorPanelMarkdownActionsMenu' + +const checkboxItems = vi.hoisted(() => ({ + list: [] as { + checked?: boolean + label: string + onCheckedChange?: (checked: boolean) => void + }[] +})) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React_ = await import('react') + const passthrough = ({ children }: { children?: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children) + return { + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuItem: passthrough, + DropdownMenuSeparator: () => null, + DropdownMenuTrigger: passthrough, + DropdownMenuCheckboxItem: ({ + checked, + children, + onCheckedChange + }: { + checked?: boolean + children?: React.ReactNode + onCheckedChange?: (checked: boolean) => void + }) => { + const label = React_.Children.toArray(children) + .filter((child): child is string => typeof child === 'string') + .join('') + checkboxItems.list.push({ checked, label, onCheckedChange }) + return React_.createElement(React_.Fragment, null, children) + } + } +}) + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +describe('EditorPanelMarkdownActionsMenu', () => { + beforeEach(() => { + checkboxItems.list = [] + }) + + it('shows Word Wrap for normal file tabs using editorWordWrap (#9974)', () => { + const onToggleDiffWordWrap = vi.fn() + const onToggleEditorWordWrap = vi.fn() + renderToStaticMarkup( + React.createElement(EditorPanelMarkdownActionsMenu, { + isMarkdown: false, + isDiffSurface: false, + diffWordWrap: false, + editorWordWrap: true, + shouldShowMarkdownExportAction: false, + canExportMarkdownToPdf: false, + canShowMarkdownFrontmatterToggle: false, + markdownFrontmatterVisible: false, + onToggleDiffWordWrap, + onToggleEditorWordWrap, + onToggleMarkdownFrontmatter: () => {}, + onExportMarkdownToPdf: () => {} + }) + ) + + expect(checkboxItems.list).toHaveLength(1) + expect(checkboxItems.list[0]).toMatchObject({ checked: true, label: 'Word Wrap' }) + checkboxItems.list[0]?.onCheckedChange?.(false) + expect(onToggleEditorWordWrap).toHaveBeenCalledOnce() + expect(onToggleDiffWordWrap).not.toHaveBeenCalled() + }) + + it('binds Word Wrap to diffWordWrap on diff surfaces', () => { + const onToggleDiffWordWrap = vi.fn() + const onToggleEditorWordWrap = vi.fn() + renderToStaticMarkup( + React.createElement(EditorPanelMarkdownActionsMenu, { + isMarkdown: false, + isDiffSurface: true, + diffWordWrap: true, + editorWordWrap: false, + shouldShowMarkdownExportAction: false, + canExportMarkdownToPdf: false, + canShowMarkdownFrontmatterToggle: false, + markdownFrontmatterVisible: false, + onToggleDiffWordWrap, + onToggleEditorWordWrap, + onToggleMarkdownFrontmatter: () => {}, + onExportMarkdownToPdf: () => {} + }) + ) + + expect(checkboxItems.list).toHaveLength(1) + expect(checkboxItems.list[0]).toMatchObject({ checked: true, label: 'Word Wrap' }) + checkboxItems.list[0]?.onCheckedChange?.(false) + expect(onToggleDiffWordWrap).toHaveBeenCalledOnce() + expect(onToggleEditorWordWrap).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx index e7232096b8e..7b3589d7ada 100644 --- a/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx +++ b/src/renderer/src/components/editor/EditorPanelMarkdownActionsMenu.tsx @@ -13,12 +13,16 @@ import { translate } from '@/i18n/i18n' type EditorPanelMarkdownActionsMenuProps = { isMarkdown: boolean isDiffSurface: boolean + /** Diff-only wrap preference; ignored for normal file tabs. */ diffWordWrap: boolean + /** File editor wrap preference (`settings.editorWordWrap`). */ + editorWordWrap: boolean shouldShowMarkdownExportAction: boolean canExportMarkdownToPdf: boolean canShowMarkdownFrontmatterToggle: boolean markdownFrontmatterVisible: boolean onToggleDiffWordWrap: () => void + onToggleEditorWordWrap: () => void onToggleMarkdownFrontmatter: () => void onExportMarkdownToPdf: () => void } @@ -27,19 +31,21 @@ export function EditorPanelMarkdownActionsMenu({ isMarkdown, isDiffSurface, diffWordWrap, + editorWordWrap, shouldShowMarkdownExportAction, canExportMarkdownToPdf, canShowMarkdownFrontmatterToggle, markdownFrontmatterVisible, onToggleDiffWordWrap, + onToggleEditorWordWrap, onToggleMarkdownFrontmatter, onExportMarkdownToPdf }: EditorPanelMarkdownActionsMenuProps): React.JSX.Element | null { const hasMarkdownActions = isMarkdown && (shouldShowMarkdownExportAction || canShowMarkdownFrontmatterToggle) - if (!isDiffSurface && !hasMarkdownActions) { - return null - } + // Why: normal files always get Word Wrap so long/structured lines can unwrap without Settings (#9974). + const wordWrapChecked = isDiffSurface ? diffWordWrap : editorWordWrap + const onToggleWordWrap = isDiffSurface ? onToggleDiffWordWrap : onToggleEditorWordWrap return ( @@ -60,17 +66,13 @@ export function EditorPanelMarkdownActionsMenu({ - {isDiffSurface ? ( - <> - - {translate( - 'auto.components.editor.EditorPanelMarkdownActionsMenu.1eef809708', - 'Word Wrap' - )} - - {hasMarkdownActions ? : null} - - ) : null} + + {translate( + 'auto.components.editor.EditorPanelMarkdownActionsMenu.1eef809708', + 'Word Wrap' + )} + + {hasMarkdownActions ? : null} {canShowMarkdownFrontmatterToggle ? ( <> { expect(formatKeybindingList(['Mod+Shift+O'], 'darwin')).toBe('⌘⇧O') }) + it.each(['darwin', 'linux', 'win32'] as const)( + 'binds editor word wrap to Alt+Z on %s', + (platform) => { + expect(getEffectiveKeybindingsForAction('editor.toggleWordWrap', platform)).toEqual(['Alt+Z']) + } + ) + + it('matches macOS Option+Z through its composed key', () => { + expect( + keybindingMatchesAction( + 'editor.toggleWordWrap', + { + key: 'Ω', + code: 'KeyZ', + meta: false, + control: false, + alt: true, + shift: false + }, + 'darwin' + ) + ).toBe(true) + }) + it('defines a default shortcut for adding an editor review note', () => { expect(getEffectiveKeybindingsForAction('editor.addReviewNote', 'darwin')).toEqual([ 'Mod+Shift+A' diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 6fa3318afba..b57eef7a1a8 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -87,6 +87,7 @@ export type KeybindingActionId = | 'editor.replace' | 'editor.save' | 'editor.markdownPreview' + | 'editor.toggleWordWrap' | 'editor.copyContext' | 'editor.previousChange' | 'editor.nextChange' @@ -803,6 +804,15 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ searchKeywords: ['shortcut', 'editor', 'markdown', 'preview'], defaultBindings: platformBindings(['Mod+Shift+V']) }, + { + id: 'editor.toggleWordWrap', + title: 'Toggle Word Wrap', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'word wrap', 'wrap', 'long lines', 'soft wrap'], + // Why: Alt+Z matches VS Code; bare Alt+letter is not AltGr, so it stays cross-platform (#9974). + defaultBindings: platformBindings(['Alt+Z']) + }, { id: 'editor.copyContext', title: 'Copy Context', From 248c0d9cda7b9e6ee9331a9375ef42167aab871f Mon Sep 17 00:00:00 2001 From: BingZ Date: Sun, 26 Jul 2026 17:32:05 +0800 Subject: [PATCH 084/150] fix(repo-icon): detect Tauri and WebP icons (#7942) Expand repository icon auto-detection to conventional Tauri and public/icon paths with PNG/WebP magic and dimension validation. Bound SSH probing while preserving candidate priority and PNG-only user uploads; SVG remains rejected. --- src/main/repo-icon-autodetect.test.ts | 30 ++ src/main/repo-icon-autodetect.ts | 194 +------------ src/main/repo-icon-file-detection.test.ts | 82 ++++++ src/main/repo-icon-file-detection.ts | 266 ++++++++++++++++++ .../terminal-pane/pty-connection.test.ts | 1 + src/shared/repo-icon.test.ts | 19 ++ src/shared/repo-icon.ts | 9 +- 7 files changed, 409 insertions(+), 192 deletions(-) create mode 100644 src/main/repo-icon-file-detection.test.ts create mode 100644 src/main/repo-icon-file-detection.ts diff --git a/src/main/repo-icon-autodetect.test.ts b/src/main/repo-icon-autodetect.test.ts index c491f344422..03de06f15e3 100644 --- a/src/main/repo-icon-autodetect.test.ts +++ b/src/main/repo-icon-autodetect.test.ts @@ -37,6 +37,36 @@ describe('detectRepoIcon', () => { }) }) + it('detects Tauri bundle icons under src-tauri/icons', async () => { + const repoPath = await makeTempRepoDir() + await mkdir(join(repoPath, 'src-tauri', 'icons'), { recursive: true }) + await writeFile( + join(repoPath, 'src-tauri', 'icons', 'icon.png'), + Buffer.from(PNG_1X1_BASE64, 'base64') + ) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/png;base64,${PNG_1X1_BASE64}`, + source: 'file', + label: 'src-tauri/icons/icon.png' + }) + }) + + it('detects public WebP icons used by CLI tools', async () => { + const repoPath = await makeTempRepoDir() + const webpBase64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' + await mkdir(join(repoPath, 'public'), { recursive: true }) + await writeFile(join(repoPath, 'public', 'icon.webp'), Buffer.from(webpBase64, 'base64')) + + await expect(detectRepoIcon({ repoPath, kind: 'folder' })).resolves.toEqual({ + type: 'image', + src: `data:image/webp;base64,${webpBase64}`, + source: 'file', + label: 'public/icon.webp' + }) + }) + it('uses a package homepage favicon when no local icon file exists', async () => { const repoPath = await makeTempRepoDir() await writeFile( diff --git a/src/main/repo-icon-autodetect.ts b/src/main/repo-icon-autodetect.ts index 233721a8086..e0dcd15c839 100644 --- a/src/main/repo-icon-autodetect.ts +++ b/src/main/repo-icon-autodetect.ts @@ -1,51 +1,13 @@ import { readFile, stat } from 'node:fs/promises' import type { GitHubRepositoryIdentity, RepoKind } from '../shared/types' -import { - faviconUrlFromWebsite, - githubAvatarIcon, - MAX_REPO_ICON_UPLOAD_BYTES, - type RepoIcon -} from '../shared/repo-icon' +import { faviconUrlFromWebsite, githubAvatarIcon, type RepoIcon } from '../shared/repo-icon' import { getRepoSlug, getRepoUpstream } from './github/client' import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch' import type { IFilesystemProvider } from './providers/types' import { detectGitRemoteIdentity } from './repo-git-remote-identity' -import { iconHrefCandidates } from './repo-icon-href-candidates' +import { detectRepoFileIcon } from './repo-icon-file-detection' import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' -const REPO_ICON_FILE_CANDIDATES = [ - 'favicon.png', - 'public/favicon.png', - 'app/favicon.png', - 'app/icon.png', - 'src/favicon.png', - 'src/app/icon.png', - 'assets/favicon.png', - 'assets/icon.png', - 'static/favicon.png', - 'logo.png', - 'public/logo.png' -] - -const REPO_ICON_SOURCE_FILE_CANDIDATES = [ - 'index.html', - 'public/index.html', - 'app/routes/__root.tsx', - 'src/routes/__root.tsx', - 'app/root.tsx', - 'src/root.tsx', - 'src/index.html' -] - -// Why: repo icon detection runs while adding repos; declared-icon probing should -// not read large app entrypoints just to find a small favicon href. -const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 - -const LINK_ICON_HTML_RE = - /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i -const LINK_ICON_OBJECT_RE = - /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i - const WEBSITE_HOSTS_TO_SKIP = new Set([ 'github.com', 'www.github.com', @@ -55,20 +17,6 @@ const WEBSITE_HOSTS_TO_SKIP = new Set([ 'www.bitbucket.org' ]) -function isPngBuffer(buffer: Buffer): boolean { - return ( - buffer.length >= 8 && - buffer[0] === 0x89 && - buffer[1] === 0x50 && - buffer[2] === 0x4e && - buffer[3] === 0x47 && - buffer[4] === 0x0d && - buffer[5] === 0x0a && - buffer[6] === 0x1a && - buffer[7] === 0x0a - ) -} - function shouldUseWebsiteFavicon(rawUrl: string): boolean { try { const url = new URL(rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`) @@ -78,140 +26,6 @@ function shouldUseWebsiteFavicon(rawUrl: string): boolean { } } -function extractIconHref(source: string): string | null { - return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null -} - -async function readLocalPngIcon(repoPath: string, relativePath: string): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await stat(filePath) - if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const buffer = await readFile(filePath) - if (!isPngBuffer(buffer)) { - return null - } - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -async function readRemotePngIcon( - repoPath: string, - fsProvider: IFilesystemProvider, - relativePath: string -): Promise { - const filePath = joinWorktreeRelativePath(repoPath, relativePath) - const info = await fsProvider.stat(filePath) - if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { - return null - } - const result = await fsProvider.readFile(filePath) - if (!result.isBinary || result.mimeType !== 'image/png' || !result.content) { - return null - } - const buffer = Buffer.from(result.content, 'base64') - if (!isPngBuffer(buffer)) { - return null - } - return { - type: 'image', - src: `data:image/png;base64,${buffer.toString('base64')}`, - source: 'file', - label: relativePath - } -} - -async function detectLocalPngIcon(repoPath: string): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await stat(sourcePath) - if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const source = await readFile(sourcePath, 'utf8') - const href = extractIconHref(source) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readLocalPngIcon(repoPath, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - -async function detectRemotePngIcon( - repoPath: string, - fsProvider: IFilesystemProvider -): Promise { - for (const relativePath of REPO_ICON_FILE_CANDIDATES) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next conventional icon path. - } - } - for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { - try { - const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) - const sourceInfo = await fsProvider.stat(sourcePath) - if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { - continue - } - const result = await fsProvider.readFile(sourcePath) - if (result.isBinary) { - continue - } - const href = extractIconHref(result.content) - if (!href) { - continue - } - for (const relativePath of iconHrefCandidates(href, sourceFile)) { - try { - const icon = await readRemotePngIcon(repoPath, fsProvider, relativePath) - if (icon) { - return icon - } - } catch { - // Try the next href resolution. - } - } - } catch { - // Try the next source file. - } - } - return null -} - function packageHomepageIcon(packageJson: unknown): RepoIcon | null { if (!packageJson || typeof packageJson !== 'object') { return null @@ -284,9 +98,7 @@ export async function detectRepoIcon({ }): Promise { try { const fsProvider = connectionId ? getSshFilesystemProvider(connectionId) : undefined - const fileIcon = fsProvider - ? await detectRemotePngIcon(repoPath, fsProvider) - : await detectLocalPngIcon(repoPath) + const fileIcon = await detectRepoFileIcon(repoPath, fsProvider) if (fileIcon) { return fileIcon } diff --git a/src/main/repo-icon-file-detection.test.ts b/src/main/repo-icon-file-detection.test.ts new file mode 100644 index 00000000000..5aec20d90d0 --- /dev/null +++ b/src/main/repo-icon-file-detection.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FileReadResult, FileStat, IFilesystemProvider } from './providers/types' +import { detectRepoFileIcon } from './repo-icon-file-detection' + +const WEBP_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' + +function remoteFilesystemProvider({ + stat, + readFile +}: { + stat: (filePath: string) => Promise + readFile: (filePath: string) => Promise +}): IFilesystemProvider { + return { stat, readFile } as IFilesystemProvider +} + +describe('detectRepoFileIcon remote probing', () => { + it('detects binary WebP icons through a remote filesystem provider', async () => { + const provider = remoteFilesystemProvider({ + stat: async (filePath) => { + if (!filePath.endsWith('/public/icon.webp')) { + throw new Error('ENOENT') + } + return { type: 'file', size: 34, mtime: 0 } + }, + readFile: async () => ({ content: WEBP_BASE64, isBinary: true, mimeType: 'image/webp' }) + }) + + await expect(detectRepoFileIcon('/repo', provider)).resolves.toEqual({ + type: 'image', + src: `data:image/webp;base64,${WEBP_BASE64}`, + source: 'file', + label: 'public/icon.webp' + }) + }) + + it('keeps conventional-path priority when probes resolve concurrently', async () => { + const provider = remoteFilesystemProvider({ + stat: async (filePath) => { + if (filePath.endsWith('/favicon.png') || filePath.endsWith('/public/favicon.png')) { + return { type: 'file', size: 8, mtime: 0 } + } + throw new Error('ENOENT') + }, + readFile: async (filePath) => { + if (filePath.endsWith('/favicon.png')) { + await Promise.resolve() + } + return { content: PNG_BASE64, isBinary: true, mimeType: 'image/png' } + } + }) + + await expect(detectRepoFileIcon('/repo', provider)).resolves.toMatchObject({ + source: 'file', + label: 'favicon.png' + }) + }) + + it('bounds concurrent remote probes when no conventional icon exists', async () => { + let activeStats = 0 + let maxActiveStats = 0 + const stat = vi.fn(async (): Promise => { + activeStats += 1 + maxActiveStats = Math.max(maxActiveStats, activeStats) + await Promise.resolve() + activeStats -= 1 + throw new Error('ENOENT') + }) + const provider = remoteFilesystemProvider({ + stat, + readFile: async () => { + throw new Error('unexpected read') + } + }) + + await expect(detectRepoFileIcon('/repo', provider)).resolves.toBeNull() + expect(maxActiveStats).toBeGreaterThan(1) + expect(maxActiveStats).toBeLessThanOrEqual(6) + }) +}) diff --git a/src/main/repo-icon-file-detection.ts b/src/main/repo-icon-file-detection.ts new file mode 100644 index 00000000000..d3339e2cd7b --- /dev/null +++ b/src/main/repo-icon-file-detection.ts @@ -0,0 +1,266 @@ +import { readFile, stat } from 'node:fs/promises' +import { buildImageDataUri } from '../shared/image-data-uri' +import { MAX_REPO_ICON_UPLOAD_BYTES, type RepoIcon } from '../shared/repo-icon' +import type { IFilesystemProvider } from './providers/types' +import { iconHrefCandidates } from './repo-icon-href-candidates' +import { joinWorktreeRelativePath } from './runtime/runtime-relative-paths' + +// Why: conventional locations only — keep the list short so add-repo stays +// snappy. Support bounded raster images only. +const REPO_ICON_FILE_STEMS = [ + 'favicon', + 'public/favicon', + 'app/favicon', + 'app/icon', + 'src/favicon', + 'src/app/icon', + 'assets/favicon', + 'assets/icon', + 'static/favicon', + 'logo', + 'public/logo', + // Why: CLI tools and branded assets often use public/icon.* (issue #7902). + 'public/icon', + // Why: Tauri's default bundle icon path (issue #7902). + 'src-tauri/icons/icon', + 'app-icon', + 'icon' +] as const + +const REPO_ICON_FILE_EXTENSIONS = ['.png', '.webp'] as const +const REPO_ICON_FILE_PROBE_CONCURRENCY = 6 + +export const REPO_ICON_FILE_CANDIDATES = REPO_ICON_FILE_STEMS.flatMap((stem) => + REPO_ICON_FILE_EXTENSIONS.map((extension) => `${stem}${extension}`) +) + +const REPO_ICON_SOURCE_FILE_CANDIDATES = [ + 'index.html', + 'public/index.html', + 'app/routes/__root.tsx', + 'src/routes/__root.tsx', + 'app/root.tsx', + 'src/root.tsx', + 'src/index.html' +] + +// Why: repo icon detection runs while adding repos; declared-icon probing should +// not read large app entrypoints just to find a small favicon href. +const MAX_REPO_ICON_SOURCE_BYTES = 256 * 1024 + +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i +const LINK_ICON_OBJECT_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i + +type DetectedImageFormat = { + mimeType: 'image/png' | 'image/webp' +} + +function isPngBuffer(buffer: Buffer): boolean { + return ( + buffer.length >= 8 && + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) +} + +function isWebpBuffer(buffer: Buffer): boolean { + // Why: RIFF container with WEBP fourcc — enough to reject non-images without + // a full decoder; the sidebar only needs a valid data URL for . + return ( + buffer.length >= 12 && + buffer[0] === 0x52 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x46 && + buffer[8] === 0x57 && + buffer[9] === 0x45 && + buffer[10] === 0x42 && + buffer[11] === 0x50 + ) +} + +function detectImageFormat(buffer: Buffer): DetectedImageFormat | null { + if (isPngBuffer(buffer)) { + return { mimeType: 'image/png' } + } + if (isWebpBuffer(buffer)) { + return { mimeType: 'image/webp' } + } + return null +} + +function extractIconHref(source: string): string | null { + return source.match(LINK_ICON_HTML_RE)?.[1] ?? source.match(LINK_ICON_OBJECT_RE)?.[1] ?? null +} + +function repoIconFromImageBuffer(buffer: Buffer, relativePath: string): RepoIcon | null { + const format = detectImageFormat(buffer) + if (!format) { + return null + } + const src = buildImageDataUri(format.mimeType, buffer.toString('base64')) + if (!src) { + return null + } + return { + type: 'image', + src, + source: 'file', + label: relativePath + } +} + +async function readLocalImageIcon( + repoPath: string, + relativePath: string +): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await stat(filePath) + if (!info.isFile() || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const buffer = await readFile(filePath) + return repoIconFromImageBuffer(buffer, relativePath) +} + +async function readRemoteImageIcon( + repoPath: string, + fsProvider: IFilesystemProvider, + relativePath: string +): Promise { + const filePath = joinWorktreeRelativePath(repoPath, relativePath) + const info = await fsProvider.stat(filePath) + if (info.type !== 'file' || info.size > MAX_REPO_ICON_UPLOAD_BYTES) { + return null + } + const result = await fsProvider.readFile(filePath) + if (!result.content) { + return null + } + // Why: detect the binary format after decoding remote file content. + const buffer = result.isBinary + ? Buffer.from(result.content, 'base64') + : Buffer.from(result.content, 'utf8') + return repoIconFromImageBuffer(buffer, relativePath) +} + +async function detectConventionalImageIcon( + readIcon: (relativePath: string) => Promise +): Promise { + // Why: SSH stats are network round trips; bounded batches avoid making the + // expanded candidate list serial without flooding the remote filesystem. + for ( + let offset = 0; + offset < REPO_ICON_FILE_CANDIDATES.length; + offset += REPO_ICON_FILE_PROBE_CONCURRENCY + ) { + const batch = REPO_ICON_FILE_CANDIDATES.slice(offset, offset + REPO_ICON_FILE_PROBE_CONCURRENCY) + const icons = await Promise.all( + batch.map(async (relativePath) => { + try { + return await readIcon(relativePath) + } catch { + return null + } + }) + ) + const icon = icons.find((candidate): candidate is RepoIcon => candidate !== null) + if (icon) { + return icon + } + } + return null +} + +async function detectLocalImageIcon(repoPath: string): Promise { + const conventionalIcon = await detectConventionalImageIcon((relativePath) => + readLocalImageIcon(repoPath, relativePath) + ) + if (conventionalIcon) { + return conventionalIcon + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await stat(sourcePath) + if (!sourceInfo.isFile() || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const source = await readFile(sourcePath, 'utf8') + const href = extractIconHref(source) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readLocalImageIcon(repoPath, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +async function detectRemoteImageIcon( + repoPath: string, + fsProvider: IFilesystemProvider +): Promise { + const conventionalIcon = await detectConventionalImageIcon((relativePath) => + readRemoteImageIcon(repoPath, fsProvider, relativePath) + ) + if (conventionalIcon) { + return conventionalIcon + } + for (const sourceFile of REPO_ICON_SOURCE_FILE_CANDIDATES) { + try { + const sourcePath = joinWorktreeRelativePath(repoPath, sourceFile) + const sourceInfo = await fsProvider.stat(sourcePath) + if (sourceInfo.type !== 'file' || sourceInfo.size > MAX_REPO_ICON_SOURCE_BYTES) { + continue + } + const result = await fsProvider.readFile(sourcePath) + if (result.isBinary) { + continue + } + const href = extractIconHref(result.content) + if (!href) { + continue + } + for (const relativePath of iconHrefCandidates(href, sourceFile)) { + try { + const icon = await readRemoteImageIcon(repoPath, fsProvider, relativePath) + if (icon) { + return icon + } + } catch { + // Try the next href resolution. + } + } + } catch { + // Try the next source file. + } + } + return null +} + +export function detectRepoFileIcon( + repoPath: string, + fsProvider?: IFilesystemProvider +): Promise { + return fsProvider ? detectRemoteImageIcon(repoPath, fsProvider) : detectLocalImageIcon(repoPath) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ab40d22466b..422c25307ca 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -933,6 +933,7 @@ describe('connectPanePty', () => { // Why: drain in-flight foreground-confirm microtasks while this test still owns the store mock, so its async fallout can't leak into (and flake) the next test. await flushAsyncTicks() vi.useRealTimers() + vi.restoreAllMocks() if (originalRequestAnimationFrame) { globalThis.requestAnimationFrame = originalRequestAnimationFrame } else { diff --git a/src/shared/repo-icon.test.ts b/src/shared/repo-icon.test.ts index f8d54c437f0..3875f517427 100644 --- a/src/shared/repo-icon.test.ts +++ b/src/shared/repo-icon.test.ts @@ -3,6 +3,7 @@ import { githubAvatarIcon, sanitizeRepoIcon } from './repo-icon' const PNG_1X1_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=' +const WEBP_1X1_BASE64 = 'UklGRhoAAABXRUJQVlA4IA4AAAAwAQCdASoBAAEAAQIlSkwAAA==' function pngBase64(width: number, height: number): string { const bytes = Buffer.alloc(24) @@ -83,6 +84,17 @@ describe('sanitizeRepoIcon', () => { src: `data:image/png;base64,${PNG_1X1_BASE64}`, source: 'file' }) + expect( + sanitizeRepoIcon({ + type: 'image', + src: `data:image/webp;base64,${WEBP_1X1_BASE64}`, + source: 'file' + }) + ).toEqual({ + type: 'image', + src: `data:image/webp;base64,${WEBP_1X1_BASE64}`, + source: 'file' + }) }) it('keeps null as an explicit reset', () => { @@ -118,6 +130,13 @@ describe('sanitizeRepoIcon', () => { source: 'upload' }) ).toBeUndefined() + expect( + sanitizeRepoIcon({ + type: 'image', + src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=', + source: 'file' + }) + ).toBeUndefined() expect( sanitizeRepoIcon({ type: 'image', diff --git a/src/shared/repo-icon.ts b/src/shared/repo-icon.ts index 55e9e29fb76..f5520b57032 100644 --- a/src/shared/repo-icon.ts +++ b/src/shared/repo-icon.ts @@ -64,13 +64,20 @@ function normalizeGitHubAvatarHost(rawHost?: string): string { } function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean { - if (source === 'upload' || source === 'file') { + if (source === 'upload') { return ( /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src) && validateRasterImageDataUri(src) !== null ) } + if (source === 'file') { + return ( + /^data:image\/(?:png|webp);base64,[A-Za-z0-9+/=\s]+$/i.test(src) && + validateRasterImageDataUri(src) !== null + ) + } + let url: URL try { url = new URL(src) From 6577b79e2e8ef693086f33ab9923d92f2e890e5a Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 26 Jul 2026 10:32:08 +0100 Subject: [PATCH 085/150] Add bulk tab closing to mobile long-press sheets (Close Others / Left / Right) and complete the desktop tab context menus (#9323) * Add Close Tabs to the Left and complete Close Others across tab menus and mobile long-press sheets * Fold the per-sheet Close action into the bulk-close module (session route max-lines) * fix(mobile): preserve pinned tabs during bulk close --------- Co-authored-by: Tom de Bres Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../app/h/[hostId]/session/[worktreeId].tsx | 46 ++++---- .../session/MobileBrowserTabActionSheet.tsx | 10 +- .../mobile-bulk-close-sheet-actions.ts | 96 +++++++++++++++++ .../mobile-tab-close-selection.test.ts | 45 ++++++++ .../src/session/mobile-tab-close-selection.ts | 38 +++++++ .../mobile-terminal-action-sheet-actions.ts | 9 +- src/renderer/src/components/Terminal.tsx | 100 +++++++----------- .../FloatingTerminalPanel.tsx | 21 +++- .../components/tab-bar/BrowserTab.test.tsx | 4 + .../src/components/tab-bar/BrowserTab.tsx | 14 +++ .../components/tab-bar/EditorFileTab.test.tsx | 4 + .../src/components/tab-bar/EditorFileTab.tsx | 12 +++ .../tab-bar/EditorFileTabContextMenu.test.tsx | 15 +++ .../tab-bar/EditorFileTabContextMenu.tsx | 17 +++ .../SortableTab.rename-shortcut.test.tsx | 2 + .../src/components/tab-bar/SortableTab.tsx | 6 ++ .../tab-bar/SortableTabContextMenu.test.tsx | 25 +++++ .../tab-bar/SortableTabContextMenu.tsx | 10 ++ .../tab-bar/TabBar.context-menu.test.ts | 28 +++++ .../src/components/tab-bar/TabBar.tsx | 16 +++ .../TabBar.windows-shell-launch.test.ts | 9 ++ .../tab-bar/tab-title-tooltip.test.tsx | 12 +++ .../components/tab-group/TabGroupPanel.tsx | 6 ++ .../tab-group/useTabGroupWorkspaceModel.ts | 20 ++++ src/renderer/src/i18n/locales/en.json | 11 +- src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- src/renderer/src/store/slices/tabs.test.ts | 40 +++++++ src/renderer/src/store/slices/tabs.ts | 29 +++++ 31 files changed, 593 insertions(+), 96 deletions(-) create mode 100644 mobile/src/session/mobile-bulk-close-sheet-actions.ts create mode 100644 mobile/src/session/mobile-tab-close-selection.test.ts create mode 100644 mobile/src/session/mobile-tab-close-selection.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8b236bfeaff..5eba95a619c 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -72,6 +72,10 @@ import { shouldShowSessionHeaderChecksAction, panelRouteDescriptor } from '../../../../src/session/session-panel-host' +import { + createBulkCloseSheetActions, + createCloseWithBulkActions +} from '../../../../src/session/mobile-bulk-close-sheet-actions' import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' @@ -4092,7 +4096,10 @@ export default function SessionScreen() { setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) - if (activeSessionTabId === tab.id) { + // Why: bulk close re-activates the anchor before awaiting each close; + // the render-synced ref sees that switch while this closure would not, + // so comparing against the ref keeps the anchor from being nulled out. + if (activeSessionTabIdRef.current === tab.id) { activeSessionTabTypeRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null @@ -4104,6 +4111,15 @@ export default function SessionScreen() { } } + const bulkCloseActions = createBulkCloseSheetActions({ + sessionTabsRef, + markdownDocs, + activeSessionTabIdRef, + switchSessionTab, + closeSessionTab: handleCloseSessionTab + }) + const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions) + const isPhoneMode = (handle: string | null): boolean => { if (!handle) { return false @@ -5157,7 +5173,8 @@ export default function SessionScreen() { onToggleDisplayMode: (handle) => void toggleDisplayMode(handle), onRename: setRenameTarget, onClear: (target) => void handleClearTerminal(target), - onClose: (target) => void handleCloseTerminal(target) + onClose: (target) => void handleCloseTerminal(target), + bulkCloseActions })} onClose={() => setActionTarget(null)} /> @@ -5190,17 +5207,7 @@ export default function SessionScreen() { } } }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = markdownActionTarget - setMarkdownActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } + ...closeWithBulkActions(markdownActionTarget, () => setMarkdownActionTarget(null)) ]} onClose={() => setMarkdownActionTarget(null)} /> @@ -5219,17 +5226,7 @@ export default function SessionScreen() { } } }, - { - label: 'Close', - destructive: true, - onPress: () => { - const target = fileActionTarget - setFileActionTarget(null) - if (target) { - void handleCloseSessionTab(target) - } - } - } + ...closeWithBulkActions(fileActionTarget, () => setFileActionTarget(null)) ]} onClose={() => setFileActionTarget(null)} /> @@ -5238,6 +5235,7 @@ export default function SessionScreen() { onClose={() => setBrowserActionTarget(null)} onNavigate={handleBrowserNavigationCommand} onCloseTab={handleCloseSessionTab} + bulkCloseActions={bulkCloseActions} /> @@ -13,8 +13,11 @@ export function MobileBrowserTabActionSheet(props: { onClose: () => void onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void onCloseTab: (target: BrowserTab) => void + /** Rendered after Close — receives the open tab's id so the session route's + * bulk-close builder can resolve the anchor itself. */ + bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[] }): React.JSX.Element { - const { target, onClose, onNavigate, onCloseTab } = props + const { target, onClose, onNavigate, onCloseTab, bulkCloseActions } = props return ( diff --git a/mobile/src/session/mobile-bulk-close-sheet-actions.ts b/mobile/src/session/mobile-bulk-close-sheet-actions.ts new file mode 100644 index 00000000000..8565575fe08 --- /dev/null +++ b/mobile/src/session/mobile-bulk-close-sheet-actions.ts @@ -0,0 +1,96 @@ +import type { + MarkdownDocState, + MobileSessionTab +} from '../../app/h/[hostId]/session/mobile-session-route-types' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { + BULK_TAB_CLOSE_ACTIONS, + selectBulkCloseTabs, + type BulkTabCloseMode +} from './mobile-tab-close-selection' + +/** Session-route state the bulk close orchestration reads and drives. */ +type BulkCloseSheetDeps = { + sessionTabsRef: { readonly current: readonly MobileSessionTab[] } + markdownDocs: ReadonlyMap + activeSessionTabIdRef: { readonly current: string | null } + switchSessionTab: (tab: MobileSessionTab) => void + closeSessionTab: (tab: MobileSessionTab) => Promise +} + +/** + * Builds the long-press bulk-close entries (Close Others / Left / Right) shared + * by every session tab sheet. Lives outside the session route to keep the + * orchestration out of its max-lines budget; anchors are passed by tab id so + * sheets never need the full tab object. + */ +export function createBulkCloseSheetActions(deps: BulkCloseSheetDeps) { + const selectClosable = (anchorTabId: string, mode: BulkTabCloseMode) => + selectBulkCloseTabs(deps.sessionTabsRef.current, anchorTabId, mode).filter((candidate) => { + if (candidate.type !== 'markdown') { + return true + } + // Why: the tab list's isDirty can lag behind a phone draft; the local + // markdown doc state is the authority on unsaved edits. + const doc = deps.markdownDocs.get(candidate.id) + return !(doc?.status === 'ready' && doc.isDirty) + }) + + const bulkClose = async (anchor: MobileSessionTab, mode: BulkTabCloseMode) => { + const targets = selectClosable(anchor.id, mode) + const activeWasTargeted = targets.some( + (candidate) => candidate.id === deps.activeSessionTabIdRef.current + ) + // Why: activate the anchor before the per-tab close round-trips so the user + // never sits on a dying tab or an empty pane while the loop runs. + if (activeWasTargeted) { + deps.switchSessionTab(anchor) + } + for (const target of targets) { + await deps.closeSessionTab(target) + } + } + + return (anchorTabId: string | null | undefined, dismiss: () => void): ActionSheetAction[] => { + const anchor = + anchorTabId == null + ? undefined + : deps.sessionTabsRef.current.find((candidate) => candidate.id === anchorTabId) + if (!anchor) { + return [] + } + return BULK_TAB_CLOSE_ACTIONS.filter( + ({ mode }) => selectClosable(anchor.id, mode).length > 0 + ).map(({ mode, label }) => ({ + label, + destructive: true, + onPress: () => { + dismiss() + void bulkClose(anchor, mode) + } + })) + } +} + +/** + * Builds the destructive Close entry followed by the bulk-close entries, so + * per-tab-type sheets in the session route stay at one spread per call site. + */ +export function createCloseWithBulkActions( + closeSessionTab: (tab: MobileSessionTab) => Promise, + bulkActions: ReturnType +) { + return (target: MobileSessionTab | null, dismiss: () => void): ActionSheetAction[] => [ + { + label: 'Close', + destructive: true, + onPress: () => { + dismiss() + if (target) { + void closeSessionTab(target) + } + } + }, + ...bulkActions(target?.id, dismiss) + ] +} diff --git a/mobile/src/session/mobile-tab-close-selection.test.ts b/mobile/src/session/mobile-tab-close-selection.test.ts new file mode 100644 index 00000000000..df47a6d6513 --- /dev/null +++ b/mobile/src/session/mobile-tab-close-selection.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { selectBulkCloseTabs } from './mobile-tab-close-selection' + +const tab = (id: string, isDirty?: boolean, isPinned?: boolean) => ({ + id, + ...(isDirty === undefined ? {} : { isDirty }), + ...(isPinned === undefined ? {} : { isPinned }) +}) + +describe('selectBulkCloseTabs', () => { + const tabs = [tab('a'), tab('b'), tab('c'), tab('d')] + + it('selects every tab except the anchor for mode "others"', () => { + expect(selectBulkCloseTabs(tabs, 'b', 'others').map((t) => t.id)).toEqual(['a', 'c', 'd']) + }) + + it('selects tabs before the anchor for mode "left"', () => { + expect(selectBulkCloseTabs(tabs, 'c', 'left').map((t) => t.id)).toEqual(['a', 'b']) + }) + + it('selects tabs after the anchor for mode "right"', () => { + expect(selectBulkCloseTabs(tabs, 'b', 'right').map((t) => t.id)).toEqual(['c', 'd']) + }) + + it('returns empty when the anchor is at the edge', () => { + expect(selectBulkCloseTabs(tabs, 'a', 'left')).toEqual([]) + expect(selectBulkCloseTabs(tabs, 'd', 'right')).toEqual([]) + }) + + it('returns empty when the anchor is not in the list', () => { + expect(selectBulkCloseTabs(tabs, 'missing', 'others')).toEqual([]) + }) + + it('skips dirty tabs so unsaved edits survive a bulk close', () => { + const withDirty = [tab('a', true), tab('b'), tab('c', false), tab('d')] + expect(selectBulkCloseTabs(withDirty, 'd', 'left').map((t) => t.id)).toEqual(['b', 'c']) + expect(selectBulkCloseTabs(withDirty, 'b', 'others').map((t) => t.id)).toEqual(['c', 'd']) + }) + + it('skips pinned tabs', () => { + const withPinned = [tab('a', undefined, true), tab('b'), tab('c', undefined, true), tab('d')] + expect(selectBulkCloseTabs(withPinned, 'd', 'left').map((t) => t.id)).toEqual(['b']) + expect(selectBulkCloseTabs(withPinned, 'b', 'others').map((t) => t.id)).toEqual(['d']) + }) +}) diff --git a/mobile/src/session/mobile-tab-close-selection.ts b/mobile/src/session/mobile-tab-close-selection.ts new file mode 100644 index 00000000000..1362173beef --- /dev/null +++ b/mobile/src/session/mobile-tab-close-selection.ts @@ -0,0 +1,38 @@ +export type BulkTabCloseMode = 'others' | 'left' | 'right' + +/** Long-press sheet entries, in display order. */ +export const BULK_TAB_CLOSE_ACTIONS: { mode: BulkTabCloseMode; label: string }[] = [ + { mode: 'others', label: 'Close Other Tabs' }, + { mode: 'left', label: 'Close Tabs to the Left' }, + { mode: 'right', label: 'Close Tabs to the Right' } +] + +type BulkClosableTab = { + id: string + isDirty?: boolean + isPinned?: boolean +} + +/** + * Pick the tabs a long-press bulk close ("Close Other Tabs" / "Close Tabs to + * the Left/Right") should target, in strip order relative to the pressed tab. + * Dirty documents are skipped — mobile has no save prompt on close, so bulk + * closing must never silently discard unsaved edits. + */ +export function selectBulkCloseTabs( + tabs: readonly T[], + anchorTabId: string, + mode: BulkTabCloseMode +): T[] { + const anchorIndex = tabs.findIndex((tab) => tab.id === anchorTabId) + if (anchorIndex === -1) { + return [] + } + const candidates = + mode === 'others' + ? tabs.filter((_, index) => index !== anchorIndex) + : mode === 'left' + ? tabs.slice(0, anchorIndex) + : tabs.slice(anchorIndex + 1) + return candidates.filter((tab) => tab.isDirty !== true && tab.isPinned !== true) +} diff --git a/mobile/src/session/mobile-terminal-action-sheet-actions.ts b/mobile/src/session/mobile-terminal-action-sheet-actions.ts index f21468ff449..fd5ef5a5583 100644 --- a/mobile/src/session/mobile-terminal-action-sheet-actions.ts +++ b/mobile/src/session/mobile-terminal-action-sheet-actions.ts @@ -19,6 +19,9 @@ export function getMobileTerminalActionSheetActions void onClear: (target: Target) => void onClose: (target: Target) => void + /** Appended after Close; receives the pressed tab's id so the session route's + * bulk-close builder can resolve the anchor itself. */ + bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[] }): ActionSheetAction[] { const { target } = args if (!target) { @@ -64,6 +67,10 @@ export function getMobileTerminalActionSheetActions tab.terminal === target.handle)?.id, + args.onDismiss + ) ?? []) ] } diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index ad71a216b01..b25d74b5b1b 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -1414,18 +1414,17 @@ function Terminal(): React.JSX.Element | null { [consumeSuppressedPtyExit] ) - const handleCloseOthers = useCallback( - (tabId: string) => { + // Bulk-close for the tab bar: unlike closeUnifiedTab it must route each id to + // its backend (web-runtime sessions, terminals, editor files, browser tabs), + // skip pinned tabs, and defer dirty editor files to the confirm flow. + const closeTabBarTabs = useCallback( + (tabIds: string[]) => { if (!activeWorktreeId) { return } const state = useAppStore.getState() - const order = state.tabBarOrderByWorktree[activeWorktreeId] ?? [] const dirtyFileIds: string[] = [] - for (const id of order) { - if (id === tabId) { - continue - } + for (const id of tabIds) { const unifiedTab = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).find( (candidate) => candidate.id === id || candidate.entityId === id ) @@ -1468,6 +1467,10 @@ function Terminal(): React.JSX.Element | null { ) { destroyWorkspaceWebviews(state.browserPagesByWorkspace, id) closeBrowserTab(id) + } else if (unifiedTab?.contentType === 'simulator') { + // Why: simulator tabs live only in the unified-tab store, so the + // entity-store checks above never match them. + state.closeUnifiedTab(unifiedTab.id) } } if (dirtyFileIds.length > 0) { @@ -1477,69 +1480,45 @@ function Terminal(): React.JSX.Element | null { [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] ) + const handleCloseOthers = useCallback( + (tabId: string) => { + if (!activeWorktreeId) { + return + } + const order = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? [] + closeTabBarTabs(order.filter((id) => id !== tabId)) + }, + [activeWorktreeId, closeTabBarTabs] + ) + const handleCloseTabsToRight = useCallback( (tabId: string) => { if (!activeWorktreeId) { return } - const state = useAppStore.getState() - const currentOrder = state.tabBarOrderByWorktree[activeWorktreeId] ?? [] + const currentOrder = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? [] const index = currentOrder.indexOf(tabId) if (index === -1) { return } - const rightIds = currentOrder.slice(index + 1) - const dirtyFileIds: string[] = [] - for (const id of rightIds) { - const unifiedTab = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).find( - (candidate) => candidate.id === id || candidate.entityId === id - ) - if (unifiedTab?.isPinned) { - continue - } - const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId) - if ( - isWebRuntimeSessionActive(runtimeEnvironmentId) && - (unifiedTab?.contentType === 'terminal' || - (unifiedTab?.contentType === 'browser' && - browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId))) - ) { - if (unifiedTab.contentType === 'terminal') { - // Why: route terminal close through the destructive local lifecycle boundary before the paired-host RPC. - closeTerminalTab(unifiedTab.entityId) - } else { - void closeWebRuntimeSessionTab({ - worktreeId: activeWorktreeId, - tabId: unifiedTab.id, - environmentId: runtimeEnvironmentId, - reason: 'user' - }) - } - continue - } - if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) { - closeTab(id) - } else if ( - state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id) - ) { - const file = state.openFiles.find((candidate) => candidate.id === id) - if (file?.isDirty) { - dirtyFileIds.push(id) - continue - } - closeFile(id) - } else if ( - (state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id) - ) { - destroyWorkspaceWebviews(state.browserPagesByWorkspace, id) - closeBrowserTab(id) - } - } - if (dirtyFileIds.length > 0) { - queueEditorCloseRequests(dirtyFileIds) - } + closeTabBarTabs(currentOrder.slice(index + 1)) }, - [activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests] + [activeWorktreeId, closeTabBarTabs] + ) + + const handleCloseTabsToLeft = useCallback( + (tabId: string) => { + if (!activeWorktreeId) { + return + } + const currentOrder = useAppStore.getState().tabBarOrderByWorktree[activeWorktreeId] ?? [] + const index = currentOrder.indexOf(tabId) + if (index === -1) { + return + } + closeTabBarTabs(currentOrder.slice(0, index)) + }, + [activeWorktreeId, closeTabBarTabs] ) const handleCloseAllFiles = useCallback(() => { @@ -2035,6 +2014,7 @@ function Terminal(): React.JSX.Element | null { onClose={handleCloseTab} onCloseOthers={handleCloseOthers} onCloseToRight={handleCloseTabsToRight} + onCloseToLeft={handleCloseTabsToLeft} onNewTerminalTab={() => handleNewTab()} onNewTerminalWithShell={handleNewTab} onNewBrowserTab={handleNewBrowserTab} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index b391bf4a48c..fcb72202175 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -805,8 +805,8 @@ export function FloatingTerminalPanel({ [activeGroup, closeFloatingItems] ) - const closeToRight = useCallback( - (visibleId: string) => { + const closeToSide = useCallback( + (visibleId: string, side: 'left' | 'right') => { const state = useAppStore.getState() const currentGroup = activeGroup ? state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find( @@ -826,9 +826,13 @@ export function FloatingTerminalPanel({ if (index === -1) { return } + const sideIds = + side === 'right' + ? currentGroup.tabOrder.slice(index + 1) + : currentGroup.tabOrder.slice(0, index) const tabById = new Map(currentGroupTabs.map((tab) => [tab.id, tab])) closeFloatingItems( - currentGroup.tabOrder.slice(index + 1).filter((tabId) => { + sideIds.filter((tabId) => { const tab = tabById.get(tabId) return tab ? !tab.isPinned : false }) @@ -837,6 +841,16 @@ export function FloatingTerminalPanel({ [activeGroup, closeFloatingItems] ) + const closeToRight = useCallback( + (visibleId: string) => closeToSide(visibleId, 'right'), + [closeToSide] + ) + + const closeToLeft = useCallback( + (visibleId: string) => closeToSide(visibleId, 'left'), + [closeToSide] + ) + const closeAllFiles = useCallback(() => { const state = useAppStore.getState() const currentGroupTabs = activeGroup @@ -1509,6 +1523,7 @@ export function FloatingTerminalPanel({ onClose={closeFloatingItem} onCloseOthers={closeOthers} onCloseToRight={closeToRight} + onCloseToLeft={closeToLeft} onNewTerminalTab={() => createFloatingTerminalTab()} onNewTerminalWithShell={createFloatingTerminalTab} onNewBrowserTab={createFloatingBrowserTab} diff --git a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx index 2dfd84d87e5..6c8a4dc052a 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.test.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.test.tsx @@ -165,9 +165,13 @@ async function renderBrowserTab(tab: BrowserTabState): Promise { isActive: true, isPinned: false, hasTabsToRight: false, + hasTabsToLeft: false, + tabCount: 1, onActivate: () => {}, onClose: () => {}, + onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onDuplicate: () => {}, onTogglePin: () => {}, dragData: { diff --git a/src/renderer/src/components/tab-bar/BrowserTab.tsx b/src/renderer/src/components/tab-bar/BrowserTab.tsx index 45bc82e7989..185057c5d67 100644 --- a/src/renderer/src/components/tab-bar/BrowserTab.tsx +++ b/src/renderer/src/components/tab-bar/BrowserTab.tsx @@ -106,9 +106,13 @@ export default function BrowserTab({ isActive, isPinned, hasTabsToRight, + hasTabsToLeft, + tabCount, onActivate, onClose, + onCloseOthers, onCloseToRight, + onCloseToLeft, onDuplicate, onTogglePin, dragData, @@ -119,9 +123,13 @@ export default function BrowserTab({ isActive: boolean isPinned: boolean hasTabsToRight: boolean + hasTabsToLeft: boolean + tabCount: number onActivate: () => void onClose: () => void + onCloseOthers: () => void onCloseToRight: () => void + onCloseToLeft: () => void onDuplicate: () => void onTogglePin: () => void dragData: TabDragItemData @@ -300,10 +308,16 @@ export default function BrowserTab({ {translate('auto.components.tab.bar.BrowserTab.1611a1324b', 'Close')} + + {translate('components.tab.bar.BrowserTab.closeOthers', 'Close Others')} + {translate('auto.components.tab.bar.BrowserTab.9dd880bd56', 'Close Tabs To The Right')} + + {translate('components.tab.bar.BrowserTab.closeTabsToLeft', 'Close Tabs To The Left')} + void window.api.shell.openUrl(openInBrowserUrl)} disabled={!isHttpUrl} diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 66071a7da1d..0a3d266e1ff 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -248,10 +248,14 @@ async function renderEditorFileTab( isActive: true, isPinned: false, hasTabsToRight: false, + hasTabsToLeft: false, + tabCount: 1, statusByRelativePath: new Map(), onActivate, onClose: () => {}, + onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onCloseAll: () => {}, onMakePermanent, onTogglePin: () => {}, diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 13efe982db6..4af4e4fcfc4 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -37,10 +37,14 @@ export default function EditorFileTab({ isActive, isPinned, hasTabsToRight, + hasTabsToLeft, + tabCount, statusByRelativePath, onActivate, onClose, + onCloseOthers, onCloseToRight, + onCloseToLeft, onCloseAll, onMakePermanent, onTogglePin, @@ -52,10 +56,14 @@ export default function EditorFileTab({ isActive: boolean isPinned: boolean hasTabsToRight: boolean + hasTabsToLeft: boolean + tabCount: number statusByRelativePath: Map onActivate: () => void onClose: () => void + onCloseOthers: () => void onCloseToRight: () => void + onCloseToLeft: () => void onCloseAll: () => void onMakePermanent?: () => void onTogglePin: () => void @@ -404,6 +412,8 @@ export default function EditorFileTab({ isPinned={isPinned} isRenaming={isRenaming} hasTabsToRight={hasTabsToRight} + hasTabsToLeft={hasTabsToLeft} + tabCount={tabCount} canRename={canRename} canShowMarkdownPreview={canShowMarkdownPreview} resolvedLanguage={resolvedLanguage} @@ -414,8 +424,10 @@ export default function EditorFileTab({ onOpenRenameInput={openRenameInput} onTogglePin={onTogglePin} onClose={onClose} + onCloseOthers={onCloseOthers} onCloseAll={onCloseAll} onCloseToRight={onCloseToRight} + onCloseToLeft={onCloseToLeft} onOpenMarkdownPreview={openMarkdownPreview} /> diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 09257498698..eaff4b93bdb 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -216,6 +216,8 @@ async function renderMenu(): Promise { isPinned: false, isRenaming: false, hasTabsToRight: false, + hasTabsToLeft: false, + tabCount: 1, canRename: true, canShowMarkdownPreview: false, resolvedLanguage: 'typescript', @@ -226,8 +228,10 @@ async function renderMenu(): Promise { onOpenRenameInput: vi.fn(), onTogglePin: vi.fn(), onClose: vi.fn(), + onCloseOthers: vi.fn(), onCloseAll: vi.fn(), onCloseToRight: vi.fn(), + onCloseToLeft: vi.fn(), onOpenMarkdownPreview: vi.fn() }) } @@ -285,6 +289,17 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(3) }) + it('renders Close Others and both directional close items', async () => { + const tree = expandNode(await renderMenu()) + const labels = findElementsByType(tree, 'DropdownMenuItem').map((item) => + extractText(item.props.children) + ) + + expect(labels).toContain('Close Others') + expect(labels.some((label) => label.includes('Close Tabs To The Right'))).toBe(true) + expect(labels.some((label) => label.includes('Close Tabs To The Left'))).toBe(true) + }) + it('hides the shortcut chip when close-all is unassigned', async () => { shortcutLabelMock.mockReturnValue(null) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index bbf9a75d74a..1e386f4c062 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -44,6 +44,8 @@ type EditorFileTabContextMenuProps = { isPinned: boolean isRenaming: boolean hasTabsToRight: boolean + hasTabsToLeft: boolean + tabCount: number canRename: boolean canShowMarkdownPreview: boolean resolvedLanguage: string @@ -54,8 +56,10 @@ type EditorFileTabContextMenuProps = { onOpenRenameInput: () => void onTogglePin: () => void onClose: () => void + onCloseOthers: () => void onCloseAll: () => void onCloseToRight: () => void + onCloseToLeft: () => void onOpenMarkdownPreview: ( file: { filePath: string @@ -77,6 +81,8 @@ export function EditorFileTabContextMenu({ isPinned, isRenaming, hasTabsToRight, + hasTabsToLeft, + tabCount, canRename, canShowMarkdownPreview, resolvedLanguage, @@ -87,8 +93,10 @@ export function EditorFileTabContextMenu({ onOpenRenameInput, onTogglePin, onClose, + onCloseOthers, onCloseAll, onCloseToRight, + onCloseToLeft, onOpenMarkdownPreview }: EditorFileTabContextMenuProps): React.JSX.Element { const renameShortcut = useOptionalShortcutLabel('tab.rename') @@ -147,6 +155,9 @@ export function EditorFileTabContextMenu({ {translate('auto.components.tab.bar.EditorFileTabContextMenu.1ba8492c5b', 'Close')} {closeShortcut ? {closeShortcut} : null} + + {translate('components.tab.bar.EditorFileTabContextMenu.closeOthers', 'Close Others')} + {translate( @@ -164,6 +175,12 @@ export function EditorFileTabContextMenu({ 'Close Tabs To The Right' )} + + {translate( + 'components.tab.bar.EditorFileTabContextMenu.closeTabsToLeft', + 'Close Tabs To The Left' + )} + {canShowMarkdownPreview ? ( <> diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index d183891b6de..eca3a27c502 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -245,6 +245,7 @@ async function renderSortableTab({ groupId: 'group-1', tabCount: 1, hasTabsToRight: false, + hasTabsToLeft: false, isActive: true, isPinned: false, isExpanded: false, @@ -252,6 +253,7 @@ async function renderSortableTab({ onClose: vi.fn(), onCloseOthers: vi.fn(), onCloseToRight: vi.fn(), + onCloseToLeft: vi.fn(), onSetCustomTitle, onSetTabColor: vi.fn(), onTogglePin: vi.fn(), diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 075bfcbff89..b60473193f0 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -35,6 +35,7 @@ type SortableTabProps = { groupId: string tabCount: number hasTabsToRight: boolean + hasTabsToLeft: boolean isActive: boolean isPinned: boolean isExpanded: boolean @@ -42,6 +43,7 @@ type SortableTabProps = { onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseToRight: (tabId: string) => void + onCloseToLeft: (tabId: string) => void onSetCustomTitle: (tabId: string, title: string | null) => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void @@ -65,6 +67,7 @@ export default function SortableTab({ groupId, tabCount, hasTabsToRight, + hasTabsToLeft, isActive, isPinned, isExpanded, @@ -72,6 +75,7 @@ export default function SortableTab({ onClose, onCloseOthers, onCloseToRight, + onCloseToLeft, onSetCustomTitle, onSetTabColor, onTogglePin, @@ -418,12 +422,14 @@ export default function SortableTab({ point={menuPoint} tabCount={tabCount} hasTabsToRight={hasTabsToRight} + hasTabsToLeft={hasTabsToLeft} isPinned={isPinned} onOpenChange={setMenuOpen} onActivate={onActivate} onClose={onClose} onCloseOthers={onCloseOthers} onCloseToRight={onCloseToRight} + onCloseToLeft={onCloseToLeft} onRenameOpen={handleRenameOpen} onSetTabColor={onSetTabColor} onTogglePin={onTogglePin} diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index 6fb14821294..1022ccc7d86 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -110,12 +110,14 @@ function renderMenu(overrides: Partial { }) }) + it('routes the directional close actions to their handlers with the tab id', () => { + const onCloseOthers = vi.fn() + const onCloseToRight = vi.fn() + const onCloseToLeft = vi.fn() + const { container } = renderMenu({ onCloseOthers, onCloseToRight, onCloseToLeft }) + + act(() => getButton(container, 'Close Others').click()) + expect(onCloseOthers).toHaveBeenCalledWith('term-1') + + act(() => getButton(container, 'Close Tabs To The Right').click()) + expect(onCloseToRight).toHaveBeenCalledWith('term-1') + + act(() => getButton(container, 'Close Tabs To The Left').click()) + expect(onCloseToLeft).toHaveBeenCalledWith('term-1') + }) + + it('disables directional closes when no tabs exist on that side', () => { + const { container } = renderMenu({ hasTabsToLeft: false, hasTabsToRight: false }) + + expect(getButton(container, 'Close Tabs To The Left').disabled).toBe(true) + expect(getButton(container, 'Close Tabs To The Right').disabled).toBe(true) + }) + it('hides move-tab split actions for a single-tab group', () => { storeMock.state = { ...storeMock.state, diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 469c4f7159a..40f250285eb 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -94,12 +94,14 @@ type SortableTabContextMenuProps = { point: { x: number; y: number } tabCount: number hasTabsToRight: boolean + hasTabsToLeft: boolean isPinned: boolean onOpenChange: (open: boolean) => void onActivate: (tabId: string) => void onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseToRight: (tabId: string) => void + onCloseToLeft: (tabId: string) => void onRenameOpen: () => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void @@ -122,12 +124,14 @@ export function SortableTabContextMenu({ point, tabCount, hasTabsToRight, + hasTabsToLeft, isPinned, onOpenChange, onActivate, onClose, onCloseOthers, onCloseToRight, + onCloseToLeft, onRenameOpen, onSetTabColor, onTogglePin, @@ -211,6 +215,12 @@ export function SortableTabContextMenu({ 'Close Tabs To The Right' )} + onCloseToLeft(tab.id)} disabled={!hasTabsToLeft}> + {translate( + 'components.tab.bar.SortableTabContextMenu.closeTabsToLeft', + 'Close Tabs To The Left' + )} + diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 70dabed9b15..ecc0af5554d 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -273,6 +273,7 @@ async function renderTabBar(props: Record): Promise { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewBrowserTab: () => {}, onSetCustomTitle: () => {}, @@ -399,6 +400,33 @@ describe('TabBar context menu wiring', () => { expect(onCloseToRight).toHaveBeenCalledWith('unified-editor-1') }) + it('wires onCloseToLeft/onCloseOthers and hasTabsToLeft by strip position', async () => { + const onCloseToLeft = vi.fn() + const onCloseOthers = vi.fn() + const element = await renderTabBar({ + tabs: [TERMINAL_TAB], + editorFiles: [EDITOR_FILE], + browserTabs: [], + tabBarOrder: ['term-1', 'unified-editor-1'], + onCloseToLeft, + onCloseOthers + }) + + const sortable = findChildrenByType(element, 'SortableTab') + expect(sortable).toHaveLength(1) + // First tab in the strip: nothing to its left. + expect(sortable[0].props.hasTabsToLeft).toBe(false) + + const editorTabs = findChildrenByType(element, 'EditorFileTab') + expect(editorTabs).toHaveLength(1) + expect(editorTabs[0].props.hasTabsToLeft).toBe(true) + expect(editorTabs[0].props.tabCount).toBe(2) + ;(editorTabs[0].props.onCloseToLeft as () => void)() + expect(onCloseToLeft).toHaveBeenCalledWith('unified-editor-1') + ;(editorTabs[0].props.onCloseOthers as () => void)() + expect(onCloseOthers).toHaveBeenCalledWith('unified-editor-1') + }) + it('passes pinned state and toggles unpin through the unified tab id', async () => { appStoreSnapshot.unifiedTabsByWorktree = { 'wt-1': [ diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 95de615d426..e9eb7a96f30 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -112,6 +112,7 @@ type TabBarProps = { onClose: (tabId: string) => void onCloseOthers: (tabId: string) => void onCloseToRight: (tabId: string) => void + onCloseToLeft: (tabId: string) => void onNewTerminalTab: () => void /** On Windows, opens a new terminal with a specific shell instead of the default. */ onNewTerminalWithShell?: (shell: string) => void @@ -238,6 +239,7 @@ function TabBarInner({ onClose, onCloseOthers, onCloseToRight, + onCloseToLeft, onNewTerminalTab, onNewTerminalWithShell, onNewBrowserTab, @@ -1071,6 +1073,7 @@ function TabBarInner({ unifiedTabForItem ? () => toggleTabViewMode(unifiedTabForItem.id) : undefined } hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} isActive={ (activeTabType === 'terminal' || activeTabType === 'simulator') && item.id === activeTabId @@ -1081,6 +1084,7 @@ function TabBarInner({ onClose={onClose} onCloseOthers={onCloseOthers} onCloseToRight={onCloseToRight} + onCloseToLeft={onCloseToLeft} onSetCustomTitle={onSetCustomTitle} onSetTabColor={onSetTabColor} onTogglePin={() => togglePinned(item)} @@ -1099,9 +1103,13 @@ function TabBarInner({ isActive={activeTabType === 'browser' && activeBrowserTabId === item.id} isPinned={item.isPinned} hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} + tabCount={orderedItems.length} onActivate={() => onActivateBrowserTab?.(item.id)} onClose={() => onCloseBrowserTab?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} onDuplicate={() => onDuplicateBrowserTab?.(item.id)} onTogglePin={() => togglePinned(item)} dragData={dragData} @@ -1130,10 +1138,14 @@ function TabBarInner({ isActive={activeTabType === 'simulator' && item.id === activeSimulatorTabId} isPinned={item.isPinned} hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} + tabCount={orderedItems.length} statusByRelativePath={statusByRelativePath} onActivate={() => onActivateFile?.(item.id)} onClose={() => onCloseFile?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} onCloseAll={() => onCloseAllFiles?.()} onMakePermanent={() => {}} onTogglePin={() => togglePinned(item)} @@ -1153,10 +1165,14 @@ function TabBarInner({ } isPinned={item.isPinned} hasTabsToRight={index < orderedItems.length - 1} + hasTabsToLeft={index > 0} + tabCount={orderedItems.length} statusByRelativePath={statusByRelativePath} onActivate={() => onActivateFile?.(item.id)} onClose={() => onCloseFile?.(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} onCloseAll={() => onCloseAllFiles?.()} onMakePermanent={() => onMakePreviewFilePermanent?.(item.data.id, item.data.tabId) diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 58cd811e427..5e439f4ddcb 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -403,6 +403,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell, onNewBrowserTab: () => {}, @@ -474,6 +475,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -541,6 +543,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -604,6 +607,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -671,6 +675,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: () => {}, onNewBrowserTab: () => {}, @@ -723,6 +728,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell, onNewBrowserTab: () => {}, @@ -786,6 +792,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell, onNewBrowserTab: () => {}, @@ -855,6 +862,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: vi.fn(), onNewBrowserTab: () => {}, @@ -926,6 +934,7 @@ describe('TabBar PowerShell launch wiring', () => { onClose: () => {}, onCloseOthers: () => {}, onCloseToRight: () => {}, + onCloseToLeft: () => {}, onNewTerminalTab: () => {}, onNewTerminalWithShell: vi.fn(), onNewBrowserTab: () => {}, diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 975adc0e5d1..e69e9ea18a3 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -262,6 +262,7 @@ describe('tab title tooltips', () => { groupId="group-1" tabCount={1} hasTabsToRight={false} + hasTabsToLeft={false} isActive={true} isPinned={false} isExpanded={false} @@ -269,6 +270,7 @@ describe('tab title tooltips', () => { onClose={vi.fn()} onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onSetCustomTitle={vi.fn()} onSetTabColor={vi.fn()} onTogglePin={vi.fn()} @@ -295,6 +297,7 @@ describe('tab title tooltips', () => { groupId="group-1" tabCount={1} hasTabsToRight={false} + hasTabsToLeft={false} isActive={true} isPinned={false} isExpanded={false} @@ -302,6 +305,7 @@ describe('tab title tooltips', () => { onClose={vi.fn()} onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onSetCustomTitle={vi.fn()} onSetTabColor={vi.fn()} onTogglePin={vi.fn()} @@ -325,9 +329,13 @@ describe('tab title tooltips', () => { isActive={false} isPinned={false} hasTabsToRight={false} + hasTabsToLeft={false} + tabCount={1} onActivate={vi.fn()} onClose={vi.fn()} + onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onDuplicate={vi.fn()} onTogglePin={vi.fn()} dragData={makeDragData('browser', 'browser-1')} @@ -351,10 +359,14 @@ describe('tab title tooltips', () => { isActive={false} isPinned={false} hasTabsToRight={false} + hasTabsToLeft={false} + tabCount={1} statusByRelativePath={new Map()} onActivate={vi.fn()} onClose={vi.fn()} + onCloseOthers={vi.fn()} onCloseToRight={vi.fn()} + onCloseToLeft={vi.fn()} onCloseAll={vi.fn()} onMakePermanent={vi.fn()} onTogglePin={vi.fn()} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index a9f7f2ac507..07d8a86ccaf 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -105,6 +105,12 @@ export default function TabGroupPanel({ commands.closeToRight(item.id) } }} + onCloseToLeft={(visibleId) => { + const item = resolveGroupTabFromVisibleId(model.groupTabs, visibleId) + if (item) { + commands.closeToLeft(item.id) + } + }} onNewTerminalTab={commands.newTerminalTab} onNewTerminalWithShell={commands.newTerminalWithShell} onNewBrowserTab={commands.newBrowserTab} diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index c3e4cfcc69a..4bb234b9764 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -525,6 +525,25 @@ export function useTabGroupWorkspaceModel({ [closeMany, group, groupTabs] ) + const closeToLeft = useCallback( + (itemId: string) => { + // Why: see closeToRight — walk tabOrder locally and route through the + // dirty-aware closeMany path instead of the store helper. + const order = group?.tabOrder ?? [] + const index = order.indexOf(itemId) + if (index === -1) { + return + } + const tabById = new Map(groupTabs.map((candidate) => [candidate.id, candidate])) + const leftIds = order.slice(0, index).filter((id) => { + const candidate = tabById.get(id) + return candidate ? !candidate.isPinned : false + }) + closeMany(leftIds) + }, + [closeMany, group, groupTabs] + ) + const tabBarOrder = useMemo( () => (group?.tabOrder ?? []).map((itemId) => { @@ -560,6 +579,7 @@ export function useTabGroupWorkspaceModel({ closeItem, closeOthers, closeToRight, + closeToLeft, createSplitGroup, newBrowserTab: () => { void openNewBrowserTabInActiveWorkspace(groupId) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a904c79bbe7..c0f5c14463f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13767,7 +13767,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Switch to terminal view", - "switchToChatView": "Switch to chat view" + "switchToChatView": "Switch to chat view", + "closeTabsToLeft": "Close Tabs To The Left" + }, + "BrowserTab": { + "closeOthers": "Close Others", + "closeTabsToLeft": "Close Tabs To The Left" + }, + "EditorFileTabContextMenu": { + "closeOthers": "Close Others", + "closeTabsToLeft": "Close Tabs To The Left" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ed53674bbc2..01f938d1508 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "Cambiar a la vista de terminal", - "switchToChatView": "Cambiar a vista de chat" + "switchToChatView": "Cambiar a vista de chat", + "closeTabsToLeft": "Cerrar pestañas a la izquierda" + }, + "BrowserTab": { + "closeOthers": "Cerrar otras", + "closeTabsToLeft": "Cerrar pestañas a la izquierda" + }, + "EditorFileTabContextMenu": { + "closeOthers": "Cerrar otras", + "closeTabsToLeft": "Cerrar pestañas a la izquierda" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index b151276ec20..8c6cf107384 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "terminal ビューに切り替える", - "switchToChatView": "チャットビューに切り替える" + "switchToChatView": "チャットビューに切り替える", + "closeTabsToLeft": "左側のタブを閉じる" + }, + "BrowserTab": { + "closeOthers": "その他を閉じる", + "closeTabsToLeft": "左側のタブを閉じる" + }, + "EditorFileTabContextMenu": { + "closeOthers": "その他を閉じる", + "closeTabsToLeft": "左側のタブを閉じる" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 65d811b84be..dd754de9453 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "terminal 보기로 전환", - "switchToChatView": "채팅 보기로 전환" + "switchToChatView": "채팅 보기로 전환", + "closeTabsToLeft": "왼쪽으로 탭 닫기" + }, + "BrowserTab": { + "closeOthers": "다른 탭 닫기", + "closeTabsToLeft": "왼쪽으로 탭 닫기" + }, + "EditorFileTabContextMenu": { + "closeOthers": "다른 탭 닫기", + "closeTabsToLeft": "왼쪽으로 탭 닫기" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index deb949c95a5..11b6e0fe77e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13744,7 +13744,16 @@ "bar": { "SortableTabContextMenu": { "switchToTerminalView": "切换到终端视图", - "switchToChatView": "切换到聊天视图" + "switchToChatView": "切换到聊天视图", + "closeTabsToLeft": "关闭左侧的选项卡" + }, + "BrowserTab": { + "closeOthers": "关闭其他", + "closeTabsToLeft": "关闭左侧的选项卡" + }, + "EditorFileTabContextMenu": { + "closeOthers": "关闭其他", + "closeTabsToLeft": "关闭左侧的选项卡" } } }, diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index fce096e989e..2d521a43b1e 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -1401,6 +1401,46 @@ describe('TabsSlice', () => { }) }) + // ─── closeTabsToLeft ────────────────────────────────────────────── + + describe('closeTabsToLeft', () => { + it('closes unpinned tabs to the left of target', () => { + const t1 = store.getState().createUnifiedTab(WT, 'terminal') + const t2 = store.getState().createUnifiedTab(WT, 'terminal') + const t3 = store.getState().createUnifiedTab(WT, 'terminal') + const t4 = store.getState().createUnifiedTab(WT, 'terminal') + + store.getState().pinTab(t2.id) + + const closed = store.getState().closeTabsToLeft(t4.id) + + expect(closed).toEqual([t1.id, t3.id]) + const tabs = store.getState().unifiedTabsByWorktree[WT] + expect(tabs.map((t) => t.id)).toEqual([t2.id, t4.id]) + }) + + it('returns empty when target is the leftmost tab', () => { + const t1 = store.getState().createUnifiedTab(WT, 'terminal') + store.getState().createUnifiedTab(WT, 'terminal') + + const closed = store.getState().closeTabsToLeft(t1.id) + + expect(closed).toEqual([]) + expect(store.getState().unifiedTabsByWorktree[WT]).toHaveLength(2) + }) + + it('activates target if active tab was closed', () => { + store.getState().createUnifiedTab(WT, 'terminal') + const t2 = store.getState().createUnifiedTab(WT, 'terminal') + const t3 = store.getState().createUnifiedTab(WT, 'terminal') + store.getState().activateTab(t2.id) + + store.getState().closeTabsToLeft(t3.id) + + expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBe(t3.id) + }) + }) + // ─── getActiveTab / getTab ──────────────────────────────────────── describe('getActiveTab / getTab', () => { diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index fefbecd5363..3c070f10711 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -135,6 +135,7 @@ export type TabsSlice = { unpinTab: (tabId: string) => void closeOtherTabs: (tabId: string) => string[] closeTabsToRight: (tabId: string) => string[] + closeTabsToLeft: (tabId: string) => string[] ensureWorktreeRootGroup: (worktreeId: string) => string focusGroup: (worktreeId: string, groupId: string) => void closeEmptyGroup: (worktreeId: string, groupId: string) => boolean @@ -1231,6 +1232,34 @@ export const createTabsSlice: StateCreator = (set, return closableIds }, + closeTabsToLeft: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + const index = group.tabOrder.indexOf(tabId) + if (index === -1) { + return [] + } + const closableIds = group.tabOrder + .slice(0, index) + .filter( + (id) => + !(state.unifiedTabsByWorktree[worktreeId] ?? []).find((candidate) => candidate.id === id) + ?.isPinned + ) + for (const id of closableIds) { + get().closeUnifiedTab(id) + } + return closableIds + }, + ensureWorktreeRootGroup: (worktreeId) => { const existingGroups = get().groupsByWorktree[worktreeId] ?? [] if (existingGroups.length > 0) { From 18295c27e886f83856d3be9d4466d3bf47a166dc Mon Sep 17 00:00:00 2001 From: Andres Van Reepingen Date: Sun, 26 Jul 2026 11:39:30 +0200 Subject: [PATCH 086/150] feat: implement Cmd+Enter as commit shortcut in Source Control (#9773) * feat: implement Cmd+Enter as commit shortcut in Source Control * test: add unit tests for commit shortcut and tooltip formatting * fix: address review feedback on modifier keys and test coverage * test: split mac and windows/linux shortcut and keydown tests --------- Co-authored-by: Andres Van Reepingen Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../right-sidebar/CommitArea.test.tsx | 125 +++++++++++++++++- .../right-sidebar/SourceControl.tsx | 36 ++++- 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 11dc858ac4c..58b98d58a12 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -1,6 +1,14 @@ -import { describe, expect, it, vi } from 'vitest' +// @vitest-environment happy-dom +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' -import { CommitArea, ConflictSummaryCard, OperationBanner } from './SourceControl' +import { fireEvent, render } from '@testing-library/react' +import { + CommitArea, + ConflictSummaryCard, + handleSourceControlCommitShortcut, + OperationBanner +} from './SourceControl' import { resolveCommitAreaPrimaryAction, type PrimaryActionInputs @@ -9,6 +17,23 @@ import { resolveDropdownItems, type DropdownActionKind } from './source-control- import { TooltipProvider } from '@/components/ui/tooltip' import { deriveSourceControlPushRecovery } from './source-control-push-recovery' +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children, className }: { children: React.ReactNode; className?: string }) => ( +
{children}
+ ), + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function setUserAgent(userAgent: string): void { + vi.stubGlobal('navigator', { userAgent }) +} + function buildInputs(overrides: Partial = {}): PrimaryActionInputs { return { stagedCount: 1, @@ -146,6 +171,102 @@ describe('CommitArea', () => { expect(hasDisabledAttribute(firstButton(renderCommitArea(baseProps())))).toBe(false) }) + it('renders the Commit shortcut key indicator (⌘Enter) in primary button tooltip on macOS', () => { + const props = baseProps() + setUserAgent('Macintosh') + const markupMac = renderCommitArea({ + ...props, + primaryAction: { kind: 'commit', disabled: false, label: 'Commit', title: 'Commit changes' } + }) + expect(markupMac).toContain('Commit changes') + expect(markupMac).toContain('⌘') + expect(markupMac).toContain('Enter') + }) + + it('renders the Commit shortcut key indicator (Ctrl+Enter) in primary button tooltip on Windows/Linux', () => { + const props = baseProps() + setUserAgent('Windows NT') + const markupWin = renderCommitArea({ + ...props, + primaryAction: { kind: 'commit', disabled: false, label: 'Commit', title: 'Commit changes' } + }) + expect(markupWin).toContain('Commit changes') + expect(markupWin).toContain('Ctrl') + expect(markupWin).toContain('+') + expect(markupWin).toContain('Enter') + }) + + it('only handles Cmd+Enter when focus is within the Source Control sidebar', () => { + setUserAgent('Macintosh') + const onPrimaryAction = vi.fn() + const primaryAction = { + kind: 'commit' as const, + disabled: false + } + const { getByTestId } = render( + <> +
+ handleSourceControlCommitShortcut(event, primaryAction, onPrimaryAction) + } + > + +
+ + + ) + + const outside = getByTestId('outside-sidebar') + outside.focus() + fireEvent.keyDown(outside, { key: 'Enter', metaKey: true }) + expect(onPrimaryAction).not.toHaveBeenCalled() + + const inside = getByTestId('inside-sidebar') + inside.focus() + fireEvent.keyDown(inside, { key: 'Enter', metaKey: true }) + expect(onPrimaryAction).toHaveBeenCalledTimes(1) + }) + + it('handles Ctrl+Enter, but not Cmd+Enter, inside the sidebar on Windows/Linux', () => { + setUserAgent('Linux') + const onPrimaryAction = vi.fn() + const primaryAction = { + kind: 'commit' as const, + disabled: false + } + const { getByRole } = render( + + ) + const target = getByRole('button', { name: 'Commit scope' }) + + fireEvent.keyDown(target, { key: 'Enter', metaKey: true }) + expect(onPrimaryAction).not.toHaveBeenCalled() + + fireEvent.keyDown(target, { key: 'Enter', ctrlKey: true }) + expect(onPrimaryAction).toHaveBeenCalledTimes(1) + }) + + it('does not render the Commit shortcut keys inside the primary button tooltip when the action is not commit', () => { + const props = baseProps() + const markup = renderCommitArea({ + ...props, + primaryAction: { kind: 'push', disabled: false, label: 'Push', title: 'Push changes' } + }) + expect(markup).not.toContain('Enter') + }) + it('disables the textarea while the commit is in flight', () => { const markup = renderCommitArea({ ...baseProps({ isCommitting: true }), diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 315c45d948a..525c96a3203 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -45,6 +45,8 @@ import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' import { isFolderRepo } from '../../../../shared/repo-kind' import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import { Button } from '@/components/ui/button' +import { ShortcutKeyCombo } from '@/components/ShortcutKeyCombo' +import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { DropdownMenu, DropdownMenuContent, @@ -4708,6 +4710,13 @@ function SourceControlInner(): React.JSX.Element { runCreatePrIntent ]) + const handleSourceControlKeyDown = useCallback( + (event: React.KeyboardEvent): void => { + handleSourceControlCommitShortcut(event, primaryAction, handlePrimaryClick) + }, + [handlePrimaryClick, primaryAction] + ) + const handleCreatePrHeaderClick = useCallback((): void => { if (!createPrHeaderAction || createPrHeaderAction.disabled) { return @@ -5448,7 +5457,11 @@ function SourceControlInner(): React.JSX.Element { return ( <> -
+
, + primaryAction: Pick, + onCommit: () => void +): void { + if (primaryAction.disabled || primaryAction.kind !== 'commit' || !isScreenSubmitShortcut(event)) { + return + } + // Why: the handler lives on the Source Control root, so the shortcut cannot fire from the editor, terminal, or another sidebar tab. + event.preventDefault() + event.stopPropagation() + onCommit() +} + type CommitAreaProps = { worktreeId: string | null groupId: string | null @@ -6690,8 +6717,11 @@ export function CommitArea({ - - {primaryAction.title} + + {primaryAction.title} + {primaryAction.kind === 'commit' ? ( + + ) : null} From d8af1d21961c29d3265991db363f1d9750cd9489 Mon Sep 17 00:00:00 2001 From: xsacdw <251566507+xsacdw@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:43:56 +0900 Subject: [PATCH 087/150] Add SenseVoice speech-to-text model (Korean/Japanese support) (#7436) * Add SenseVoice speech-to-text model (Korean/Japanese support) SenseVoice (zh/en/ja/ko/yue) is the only bundled local STT model with Korean and Japanese support. The existing local models cover only English and Chinese (Parakeet, Zipformer, Paraformer); Whisper Tiny is multilingual but trades accuracy for breadth. - Add 'senseVoice' to SpeechModelType - Register the sherpa-onnx SenseVoice archive in the model catalog (pinned SHA-256, single-file model.int8.onnx + tokens.txt layout) - Handle the senseVoice type in the STT worker via createOfflineRecognizer with the senseVoice model config (auto language detection + ITN) - Add model-catalog regression tests for the new entry Co-Authored-By: Claude Opus 4.8 (1M context) * fix(speech): use int8-only SenseVoice archive * fix(speech): refresh SenseVoice catalog metadata --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: LauraGPT Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/main/speech/model-catalog.test.ts | 29 +++++++++++++++++++++++++++ src/main/speech/model-catalog.ts | 17 ++++++++++++++++ src/main/speech/stt-worker.ts | 19 ++++++++++++++++++ src/shared/speech-types.ts | 2 +- 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 src/main/speech/model-catalog.test.ts diff --git a/src/main/speech/model-catalog.test.ts b/src/main/speech/model-catalog.test.ts new file mode 100644 index 00000000000..0568c42bdc9 --- /dev/null +++ b/src/main/speech/model-catalog.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { getCatalogModel } from './model-catalog' + +describe('SPEECH_MODEL_CATALOG SenseVoice entry', () => { + it('registers SenseVoice as a non-streaming local model', () => { + const model = getCatalogModel('sense-voice-zh-en-ja-ko-yue') + expect(model).toBeDefined() + expect(model?.type).toBe('senseVoice') + expect(model?.provider).toBe('local') + expect(model?.language).toBe('multilingual') + expect(model?.streaming).toBe(false) + }) + + it('ships the single-file SenseVoice model layout the loader resolves', () => { + const model = getCatalogModel('sense-voice-zh-en-ja-ko-yue') + expect(model?.files).toEqual(['model.int8.onnx', 'tokens.txt']) + }) + + it('downloads the upstream int8-only SenseVoice archive', () => { + const model = getCatalogModel('sense-voice-zh-en-ja-ko-yue') + expect(model?.sizeBytes).toBe(163_002_883) + expect(model?.downloadUrl).toBe( + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2' + ) + expect(model?.archiveSha256).toBe( + '7d1efa2138a65b0b488df37f8b89e3d91a60676e416f515b952358d83dfd347e' + ) + }) +}) diff --git a/src/main/speech/model-catalog.ts b/src/main/speech/model-catalog.ts index 10855210fe1..12e34f9447c 100644 --- a/src/main/speech/model-catalog.ts +++ b/src/main/speech/model-catalog.ts @@ -161,6 +161,23 @@ export const SPEECH_MODEL_CATALOG: SpeechModelManifest[] = [ sampleRate: 16000, streaming: false }, + { + id: 'sense-voice-zh-en-ja-ko-yue', + label: 'SenseVoice', + description: + 'Chinese, English, Japanese, Korean, and Cantonese with automatic language detection.', + type: 'senseVoice', + provider: 'local', + language: 'multilingual', + sizeBytes: 163_002_883, + downloadUrl: + 'https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2', + archiveSha256: '7d1efa2138a65b0b488df37f8b89e3d91a60676e416f515b952358d83dfd347e', + archiveFormat: 'tar.bz2', + files: ['model.int8.onnx', 'tokens.txt'], + sampleRate: 16000, + streaming: false + }, { id: 'openai-gpt-4o-mini-transcribe', label: 'GPT-4o mini Transcribe', diff --git a/src/main/speech/stt-worker.ts b/src/main/speech/stt-worker.ts index 1e6cd8bb75c..8d2f0ba0a9d 100644 --- a/src/main/speech/stt-worker.ts +++ b/src/main/speech/stt-worker.ts @@ -111,6 +111,25 @@ function handleInit(msg: Extract): void { } recognizer = sherpa.createOfflineRecognizer(config) stream = sherpa.createOfflineStream(recognizer) + } else if (modelType === 'senseVoice') { + const config = { + featConfig: { sampleRate, featureDim: 80 }, + modelConfig: { + senseVoice: { + model: resolveFile(files, 'model', modelDir), + // Empty string = auto-detect language (supports zh/en/ja/ko/yue). + language: '', + useInverseTextNormalization: 1 + }, + tokens, + numThreads: 2, + provider: 'cpu', + debug: 0 + }, + decodingMethod: 'greedy_search' + } + recognizer = sherpa.createOfflineRecognizer(config) + stream = sherpa.createOfflineStream(recognizer) } else { const config = { featConfig: { sampleRate, featureDim: 80 }, diff --git a/src/shared/speech-types.ts b/src/shared/speech-types.ts index 9881e9b4e96..8aeea4d9649 100644 --- a/src/shared/speech-types.ts +++ b/src/shared/speech-types.ts @@ -1,4 +1,4 @@ -export type SpeechModelType = 'transducer' | 'paraformer' | 'whisper' | 'openai' +export type SpeechModelType = 'transducer' | 'paraformer' | 'whisper' | 'senseVoice' | 'openai' export type SpeechModelProvider = 'local' | 'openai' export type ModelingUnit = 'bpe' | 'cjkchar' | 'cjkchar+bpe' From ca8481ec7358636660973f4eed4b2c841f19839e Mon Sep 17 00:00:00 2001 From: BingZ Date: Sun, 26 Jul 2026 17:51:11 +0800 Subject: [PATCH 088/150] fix(settings): show a way back to local accounts when a remote server owns provider-account scope (#8188) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../components/settings/AccountsPane.test.tsx | 34 ++++++++++++++++++- .../src/components/settings/AccountsPane.tsx | 29 ++++++++++++++-- .../settings/provider-account-scope.test.ts | 27 ++++++++++++++- .../settings/provider-account-scope.ts | 24 +++++++++++++ src/renderer/src/i18n/locales/en.json | 3 ++ src/renderer/src/i18n/locales/es.json | 3 ++ src/renderer/src/i18n/locales/ja.json | 3 ++ src/renderer/src/i18n/locales/ko.json | 3 ++ src/renderer/src/i18n/locales/zh.json | 3 ++ 9 files changed, 125 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/settings/AccountsPane.test.tsx b/src/renderer/src/components/settings/AccountsPane.test.tsx index a069bc44f0f..d9b5cd182c0 100644 --- a/src/renderer/src/components/settings/AccountsPane.test.tsx +++ b/src/renderer/src/components/settings/AccountsPane.test.tsx @@ -23,7 +23,7 @@ function renderPane( describe('AccountsPane', () => { beforeEach(async () => { await i18n.changeLanguage('en') - useAppStore.setState({ settingsSearchQuery: '' }) + useAppStore.setState({ settingsSearchQuery: '', runtimeEnvironments: [] }) }) it('hides the WSL account location controls on platforms without WSL support', () => { @@ -116,6 +116,14 @@ describe('AccountsPane', () => { expect(markup).toContain( 'Showing accounts managed by the remote server. Add or re-authenticate accounts on that server.' ) + // Both the Claude and Codex sections must say local accounts are intact and + // link the default-runtime control, so the scoped list never reads as loss. + expect(markup.split('Accounts managed on this desktop are unchanged').length - 1).toBe(2) + expect(markup.split('Open Remote Servers').length - 1).toBe(2) + // Before the saved-server list loads there is no name to interpolate, so the + // scope label must stay bare instead of stuttering the prose fallback. + expect(markup).toContain('Account scope: Remote server<') + expect(markup).not.toContain('Remote server: the remote server') // The WSL account-location toggle is a local concern; a remote owner hides it. expect(markup).not.toContain('aria-label="Account location"') const addAccountIndex = markup.indexOf('Add Account') @@ -125,10 +133,34 @@ describe('AccountsPane', () => { ) }) + it('omits the scope control on the web client, which cannot select Local desktop', () => { + const webGlobal = globalThis as { window?: { __ORCA_WEB_CLIENT__?: boolean } } + const hadWindow = 'window' in webGlobal + webGlobal.window = { ...webGlobal.window, __ORCA_WEB_CLIENT__: true } + try { + const markup = renderPane({ + ...getDefaultSettings('/tmp'), + activeRuntimeEnvironmentId: 'env-1' + }) + + // The web client has no desktop-managed accounts to switch back to, so + // this copy would promise a move it cannot make. + expect(markup).not.toContain('Accounts managed on this desktop are unchanged') + expect(markup).not.toContain('Open Remote Servers') + // The server-scope copy itself still applies. + expect(markup).toContain('Showing accounts managed by') + } finally { + if (!hadWindow) { + delete webGlobal.window + } + } + }) + it('keeps local copy and enabled sign-in actions when no remote server is active', () => { const markup = renderPane(getDefaultSettings('/tmp')) expect(markup).toContain('Showing accounts for this device. New accounts are added there.') + expect(markup).not.toContain('Open Remote Servers') const addAccountIndex = markup.indexOf('Add Account') expect(addAccountIndex).toBeGreaterThan(0) expect( diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index e9a1c0efc58..6f96cd2e1f4 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -52,6 +52,8 @@ import { getAccountsPaneSearchEntries } from './accounts-search' import { GrokAccountsSection } from './GrokAccountsSection' +import { getRemoteAccountsPaneScope } from './provider-account-scope' +import { ProviderHostScopeControl } from './ProviderHostScopeControl' import { SearchableSetting } from './SearchableSetting' import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' import { matchesSettingsSearch } from './settings-search' @@ -77,6 +79,7 @@ import { } from './provider-account-visibility' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' +import { isWebClientLocation } from '@/lib/web-client-location' import { emptyClaudeAccountsState, emptyCodexAccountsState, @@ -350,9 +353,14 @@ export function AccountsPane({ // (see #7973); every list/select/remove below must scope to it, not host/WSL. const isRemoteAccountScope = hasRemoteProviderAccountOwner(settings) const activeRuntimeEnvironmentId = settings.activeRuntimeEnvironmentId?.trim() || null - const remoteServerLabel = isRemoteAccountScope + // Why: keep the real name separate from the prose fallback below; the scope + // label must not interpolate the fallback. + const remoteServerName = isRemoteAccountScope ? (runtimeEnvironments.find((environment) => environment.id === activeRuntimeEnvironmentId) - ?.name ?? + ?.name ?? null) + : null + const remoteServerLabel = isRemoteAccountScope + ? (remoteServerName ?? translate('auto.components.settings.AccountsPane.remoteServerFallback', 'the remote server')) : null const accountRuntime: LocalAccountRuntime = isRemoteAccountScope @@ -369,6 +377,21 @@ export function AccountsPane({ localAccountRuntime.runtime === 'host' && !navigator.userAgent.includes('Windows') ? `${localAccountRuntime.label.charAt(0).toLocaleLowerCase()}${localAccountRuntime.label.slice(1)}` : localAccountRuntime.label + // Why: users read the remote-scoped list as their desktop accounts being + // deleted (#8186); say they are intact and link the default-runtime control. + // The web client has no desktop-owned accounts and cannot select Local + // desktop, so promising a switch back would be a dead end there. + const remoteAccountScopeNotice = + isRemoteAccountScope && !isWebClientLocation() ? ( + + ) : null const [codexAccounts, setCodexAccounts] = useState(emptyCodexAccountsState) @@ -886,6 +909,7 @@ export function AccountsPane({ ) : null}
+ {remoteAccountScopeNotice}
+ {remoteAccountScopeNotice}
@@ -1439,10 +1418,7 @@ export function ResourceUsageStatusSegment({ )} aria-pressed={sortOption === 'memory'} > - {translate( - 'auto.components.status.bar.ResourceUsageStatusSegment.1b24a32d3a', - 'Memory' - )} + {memoryMetricCopy.columnLabel}
{/* Why: empty trailing gutter keeps CPU/Memory header cells aligned with rows that reserve this width for the kill-X. */} diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index 831d4ad90e3..030eace034c 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -27,11 +27,14 @@ function makeSnapshot(worktrees: WorktreeMemory[]): MemorySnapshot { host: { totalMemory: 16e9, freeMemory: 8e9, + availableMemory: 8e9, + availableMemorySource: 'free-memory', usedMemory: 8e9, memoryUsagePercent: 50, cpuCoreCount: 8, loadAverage1m: 0 }, + processMemoryMetric: 'rss', totalCpu: worktrees.reduce((s, w) => s + w.cpu, 0), totalMemory: worktrees.reduce((s, w) => s + w.memory, 0), collectedAt: 0 diff --git a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts index 2a34e5e3dff..42b42d492b6 100644 --- a/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts +++ b/src/renderer/src/components/status-bar/resource-manager-terminal-copy.test.ts @@ -14,12 +14,12 @@ describe('resource manager terminal copy', () => { it('points users from the status-bar count back to workspace terminals', () => { expect( getResourceManagerTooltipLines({ - memoryLabel: '512 MB', + memoryLabel: '512 MB · Σ RSS', sessionCount: 2, spaceScanReady: false }) ).toEqual([ - 'Resource Manager - 512 MB - 2 terminal sessions', + 'Resource Manager - 512 MB · Σ RSS - 2 terminal sessions', 'Terminal sessions are grouped by workspace.' ]) }) diff --git a/src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts b/src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts new file mode 100644 index 00000000000..c4d65bdb151 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-memory-metric-copy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { getResourceMemoryMetricCopy } from './resource-memory-metric-copy' + +describe('resource memory metric copy', () => { + it('discloses that Unix RSS sums can repeat shared and aliased pages', () => { + expect(getResourceMemoryMetricCopy('rss')).toEqual({ + columnLabel: 'RSS', + summaryLabel: 'Σ RSS', + description: + 'Summed resident set size (RSS). Shared or aliased pages can appear in more than one process.' + }) + }) + + it('uses working-set terminology for Windows snapshots', () => { + expect(getResourceMemoryMetricCopy('working-set')).toEqual({ + columnLabel: 'WS', + summaryLabel: 'Σ WS', + description: 'Summed working set (WS). Shared pages can appear in more than one process.' + }) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts b/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts new file mode 100644 index 00000000000..a847f7d53a9 --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-memory-metric-copy.ts @@ -0,0 +1,29 @@ +import type { ProcessMemoryMetric } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type ResourceMemoryMetricCopy = { + columnLabel: string + summaryLabel: string + description: string +} + +export function getResourceMemoryMetricCopy(metric: ProcessMemoryMetric): ResourceMemoryMetricCopy { + if (metric === 'working-set') { + return { + columnLabel: 'WS', + summaryLabel: 'Σ WS', + description: translate( + 'auto.components.status.bar.resource.memory.metric.workingSetDescription', + 'Summed working set (WS). Shared pages can appear in more than one process.' + ) + } + } + return { + columnLabel: 'RSS', + summaryLabel: 'Σ RSS', + description: translate( + 'auto.components.status.bar.resource.memory.metric.rssDescription', + 'Summed resident set size (RSS). Shared or aliased pages can appear in more than one process.' + ) + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d617a59168c..f1a8649e2d1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3359,6 +3359,14 @@ "updatedTooltip": "Remote Orca Server updates completed", "failedOne": "1 server update failed", "updatedOne": "1 server updated" + }, + "resource": { + "memory": { + "metric": { + "workingSetDescription": "Summed working set (WS). Shared pages can appear in more than one process.", + "rssDescription": "Summed resident set size (RSS). Shared or aliased pages can appear in more than one process." + } + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index a92e2d06a08..0af2565aa6a 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3336,6 +3336,14 @@ "updatedTooltip": "Remote Orca Server updates completed", "failedOne": "1 server update failed", "updatedOne": "1 server updated" + }, + "resource": { + "memory": { + "metric": { + "workingSetDescription": "Suma del conjunto de trabajo (WS). Las páginas compartidas pueden aparecer en más de un proceso.", + "rssDescription": "Suma del tamaño del conjunto residente (RSS). Las páginas compartidas o con alias pueden aparecer en más de un proceso." + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e519062d530..687ef61b746 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3336,6 +3336,14 @@ "updatedTooltip": "Remote Orca Server updates completed", "failedOne": "1 server update failed", "updatedOne": "1 server updated" + }, + "resource": { + "memory": { + "metric": { + "workingSetDescription": "ワーキングセット(WS)の合計です。共有ページは複数のプロセスに含まれる場合があります。", + "rssDescription": "常駐セットサイズ(RSS)の合計です。共有ページやエイリアスされたページは複数のプロセスに含まれる場合があります。" + } + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4e0d9aabee9..ba9b3574f0f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3336,6 +3336,14 @@ "updatedTooltip": "Remote Orca Server updates completed", "failedOne": "1 server update failed", "updatedOne": "1 server updated" + }, + "resource": { + "memory": { + "metric": { + "workingSetDescription": "작업 집합(WS)의 합계입니다. 공유 페이지가 여러 프로세스에 포함될 수 있습니다.", + "rssDescription": "상주 집합 크기(RSS)의 합계입니다. 공유되거나 별칭으로 매핑된 페이지가 여러 프로세스에 포함될 수 있습니다." + } + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 66d232343a0..456b7aa4b7e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3336,6 +3336,14 @@ "updatedTooltip": "Remote Orca Server updates completed", "failedOne": "1 server update failed", "updatedOne": "1 server updated" + }, + "resource": { + "memory": { + "metric": { + "workingSetDescription": "工作集 (WS) 的总和。共享页可能会出现在多个进程中。", + "rssDescription": "驻留集大小 (RSS) 的总和。共享页或别名映射页可能会出现在多个进程中。" + } + } } } }, diff --git a/src/renderer/src/store/slices/memory.test.ts b/src/renderer/src/store/slices/memory.test.ts index 750ef373d47..a63b5d92288 100644 --- a/src/renderer/src/store/slices/memory.test.ts +++ b/src/renderer/src/store/slices/memory.test.ts @@ -18,11 +18,14 @@ function makeMemorySnapshot(overrides: Partial = {}): MemorySnap host: { totalMemory: 8192, freeMemory: 4096, + availableMemory: 4096, + availableMemorySource: 'free-memory', usedMemory: 4096, memoryUsagePercent: 50, cpuCoreCount: 8, loadAverage1m: 1 }, + processMemoryMetric: 'rss', totalCpu: 1, totalMemory: 1024, collectedAt: 1, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index bd454183ecc..950e42f8a6e 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -3950,11 +3950,14 @@ function createEmptyMemorySnapshot(): MemorySnapshot { host: { totalMemory: 0, freeMemory: 0, + availableMemory: 0, + availableMemorySource: 'free-memory', usedMemory: 0, memoryUsagePercent: 0, cpuCoreCount: navigator.hardwareConcurrency || 1, loadAverage1m: 0 }, + processMemoryMetric: getBrowserPlatform() === 'win32' ? 'working-set' : 'rss', totalCpu: 0, totalMemory: 0, collectedAt: Date.now() diff --git a/src/shared/types.ts b/src/shared/types.ts index 5838526733d..6f2b1c1d5c1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3667,6 +3667,10 @@ export type UsageValues = { memory: number } +export type ProcessMemoryMetric = 'rss' | 'working-set' + +export type HostAvailableMemorySource = 'memory-pressure' | 'proc-meminfo' | 'free-memory' + /** The top-level cpu/memory are the sum of main + renderer + other. */ export type AppMemory = UsageValues & { main: UsageValues @@ -3695,7 +3699,12 @@ export type WorktreeMemory = UsageValues & { export type HostMemory = { totalMemory: number + /** Immediately free memory reported by Node's host API. */ freeMemory: number + /** Memory available without material pressure, or freeMemory when unavailable. */ + availableMemory: number + availableMemorySource: HostAvailableMemorySource + /** totalMemory - availableMemory. */ usedMemory: number memoryUsagePercent: number cpuCoreCount: number @@ -3706,9 +3715,11 @@ export type MemorySnapshot = { app: AppMemory worktrees: WorktreeMemory[] host: HostMemory + /** Per-process byte metric used by app, session, worktree, history, and totalMemory values. */ + processMemoryMetric: ProcessMemoryMetric /** Sum of app + all tracked worktree sessions. Percent of a single core, so may exceed 100 on multi-core machines. */ totalCpu: number - /** Sum of app + all tracked worktree sessions in bytes. NOT the same as host.totalMemory, which is physical RAM. */ + /** Sum of per-process samples. Shared pages may repeat, so this can exceed host.totalMemory. */ totalMemory: number collectedAt: number } From 4109f4eec57915409406909b28b6f99b5f55d2cf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:20:12 -0700 Subject: [PATCH 112/150] perf(agent-status): scan Command Code transcripts backward from EOF (#10742) Co-authored-by: Orca --- ...command-code-transcript-scan-benchmark.mjs | 427 ++++++++++++++++++ src/shared/agent-hook-listener.test.ts | 286 ++++++++++++ src/shared/agent-hook-listener.ts | 147 +++--- 3 files changed, 807 insertions(+), 53 deletions(-) create mode 100644 config/scripts/command-code-transcript-scan-benchmark.mjs diff --git a/config/scripts/command-code-transcript-scan-benchmark.mjs b/config/scripts/command-code-transcript-scan-benchmark.mjs new file mode 100644 index 00000000000..02ea0d09e78 --- /dev/null +++ b/config/scripts/command-code-transcript-scan-benchmark.mjs @@ -0,0 +1,427 @@ +#!/usr/bin/env node +// Benchmark: cost of resolving a Command Code turn prompt from the transcript, +// paid on EVERY command-code hook event (PreToolUse/PostToolUse fire once per +// tool call, so many per second during an active agent turn). +// +// Before the fix, readLastCommandCodeUserPromptEntryFromTranscript() read up to +// TRANSCRIPT_MAX_SCAN_BYTES (4 MB) synchronously, decoded it all to a JS string, +// and JSON-parsed EVERY line to the end of the buffer to find the LAST user +// entry — so cost grew with the transcript, which only grows as a session runs. +// +// The fix scans backward from EOF in TRANSCRIPT_CHUNK_BYTES blocks and returns +// on the first user line, the shape the sibling readLastTextFromTranscriptOnce +// already used. The answer sits near EOF in a real session (the current turn's +// prompt precedes only this turn's output), so the scan reads one or two blocks +// instead of the whole file. +// +// Both implementations are mirrored here: node cannot import the .ts source, +// matching the other benchmarks in this directory. Constants are re-read from +// the real module so a drifted cap fails loudly instead of measuring dead code. +import { + closeSync, + mkdtempSync, + openSync, + readFileSync, + readSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const LISTENER_SOURCE = readFileSync( + fileURLToPath(new URL('../../src/shared/agent-hook-listener.ts', import.meta.url)), + 'utf8' +) + +function readMirroredConstant(name) { + const match = LISTENER_SOURCE.match(new RegExp(`const ${name} = ([^\\n]+)`)) + if (!match) { + throw new Error(`agent-hook-listener.ts no longer defines ${name}; re-sync this benchmark.`) + } + const value = Number(new Function(`return (${match[1]})`)()) + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} did not resolve to a positive integer`) + } + return value +} + +const TRANSCRIPT_CHUNK_BYTES = readMirroredConstant('TRANSCRIPT_CHUNK_BYTES') +const TRANSCRIPT_MAX_SCAN_BYTES = readMirroredConstant('TRANSCRIPT_MAX_SCAN_BYTES') +const EMPTY_REGION = Buffer.alloc(0) +const ITERATIONS = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_ITERATIONS ?? '150', 10) +const WARMUP = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_WARMUP ?? '20', 10) + +for (const [name, value] of [ + ['ORCA_CC_SCAN_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_CC_SCAN_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +// Mirror of parseAgentHookJson: the real reader scans a line's structure before +// parsing it, on BOTH sides of this comparison. Omitting it made the pre-fix +// column ~9x too fast and invented a regression that does not exist. +const HOOK_STRUCTURAL_TOKENS = 128 * 1024 +const HOOK_NESTING_DEPTH = 64 + +function assertJsonStructure(content) { + let structuralTokens = 0 + let depth = 0 + let inString = false + let escaped = false + for (let index = 0; index < content.length; index += 1) { + const character = content[index] + if (inString) { + if (escaped) { + escaped = false + } else if (character === '\\') { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + if (character === '"') { + inString = true + continue + } + if ( + character !== '{' && + character !== '}' && + character !== '[' && + character !== ']' && + character !== ',' && + character !== ':' + ) { + continue + } + structuralTokens += 1 + if (structuralTokens > HOOK_STRUCTURAL_TOKENS) { + throw new Error('structuralTokens') + } + if (character === '{' || character === '[') { + depth += 1 + if (depth > HOOK_NESTING_DEPTH) { + throw new Error('nestingDepth') + } + } else if (character === '}' || character === ']') { + depth = Math.max(0, depth - 1) + } + } +} + +function extractUserPrompt(line) { + let entry + try { + assertJsonStructure(line) + entry = JSON.parse(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null || entry.role !== 'user') { + return undefined + } + const content = entry.content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = part.text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} + +// Pre-fix: read the capped window, then parse every line to the end. +function readForward(path) { + const size = statSync(path).size + if (size <= 0) { + return undefined + } + const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES) + const position = size - bytesToRead + const fd = openSync(path, 'r') + try { + const buffer = Buffer.alloc(bytesToRead) + let filled = 0 + while (filled < bytesToRead) { + const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + let text = buffer.subarray(0, filled).toString('utf8') + if (position > 0) { + const firstNewline = text.indexOf('\n') + text = firstNewline === -1 ? '' : text.slice(firstNewline + 1) + } + let last + for (const line of text.split('\n')) { + const prompt = extractUserPrompt(line.trim()) + if (prompt !== undefined) { + last = prompt + } + } + return last + } finally { + closeSync(fd) + } +} + +function findLastPromptInRegion(region) { + let lineEnd = region.length + for (let index = region.length - 1; index >= -1; index--) { + if (index >= 0 && region[index] !== 0x0a) { + continue + } + const lineStart = index + 1 + if (lineEnd > lineStart) { + const prompt = extractUserPrompt(region.subarray(lineStart, lineEnd).toString('utf8').trim()) + if (prompt !== undefined) { + return prompt + } + } + lineEnd = index + } + return undefined +} + +// Post-fix: walk backward from EOF, return on the first user line. The carry is +// a chunk list, not a re-joined buffer, so one oversized line stays linear. +function readBackward(path) { + const size = statSync(path).size + if (size <= 0) { + return undefined + } + const fd = openSync(path, 'r') + try { + let carryChunks = [] + let bytesRead = 0 + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min( + scanEnd, + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_MAX_SCAN_BYTES - bytesRead + ) + const position = scanEnd - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + if (filled < chunkSize) { + break + } + bytesRead += filled + scanEnd = position + const firstNewline = buffer.indexOf(0x0a) + const atStart = position === 0 + let completeRegion + if (atStart) { + completeRegion = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [] + } else if (firstNewline === -1) { + completeRegion = EMPTY_REGION + carryChunks.unshift(buffer) + } else { + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + } + if (completeRegion.length > 0) { + const found = findLastPromptInRegion(completeRegion) + if (found !== undefined) { + return found + } + } + } + return undefined + } finally { + closeSync(fd) + } +} + +// A real session: many completed turns, then THIS turn's prompt, then the tool +// output produced since. The prompt therefore sits near EOF. +function writeTranscript(path, priorTurns) { + const lines = [] + for (let index = 0; index < priorTurns; index += 1) { + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] }) + ) + lines.push( + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }] + }) + ) + } + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }) + ) + for (let index = 0; index < 40; index += 1) { + lines.push( + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }] + }) + ) + } + writeFileSync(path, `${lines.join('\n')}\n`) +} + +// A turn already in progress: `trailingBytes` of tool output sits between the +// prompt and EOF, which is what the backward scan has to read past. +function writeTranscriptWithTrailing(path, priorTurns, trailingBytes) { + const lines = [] + for (let index = 0; index < priorTurns; index += 1) { + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] }) + ) + lines.push( + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }] + }) + ) + } + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }) + ) + let written = 0 + let index = 0 + while (written < trailingBytes) { + const line = JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }] + }) + lines.push(line) + written += line.length + 1 + index += 1 + } + writeFileSync(path, `${lines.join('\n')}\n`) +} + +// One tool result larger than many read blocks — the shape with no newline for +// the backward scan to stop on. +function writeTranscriptWithHugeLine(path, lineBytes) { + const lines = [ + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }), + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] }) + ] + writeFileSync(path, `${lines.join('\n')}\n`) +} + +function measure(fn, path) { + for (let index = 0; index < WARMUP; index += 1) { + fn(path) + } + const samples = [] + for (let round = 0; round < 3; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + fn(path) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[1] +} + +const dir = mkdtempSync(join(tmpdir(), 'orca-cc-transcript-bench-')) +try { + const rows = [] + for (const priorTurns of [250, 1000, 3000, 6000]) { + const path = join(dir, `transcript-${priorTurns}.jsonl`) + writeTranscript(path, priorTurns) + const forward = readForward(path) + const backward = readBackward(path) + if (forward !== backward) { + throw new Error(`prompt mismatch at ${priorTurns} prior turns: ${forward} vs ${backward}`) + } + if (backward !== 'the current prompt') { + throw new Error(`benchmark fixture resolved the wrong prompt: ${backward}`) + } + rows.push({ + sizeMb: statSync(path).size / (1024 * 1024), + beforeMs: measure(readForward, path), + afterMs: measure(readBackward, path) + }) + } + + const pad = (value, width) => String(value).padStart(width) + console.log('Command Code transcript prompt read, per hook event') + console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 3 rounds)`) + console.log( + `${pad('size', 9)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}` + ) + for (const row of rows) { + console.log( + `${pad(`${row.sizeMb.toFixed(2)} MB`, 9)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}` + ) + } + console.log( + '\nThe old cost grows with the transcript; the new cost is flat because the\ncurrent turn’s prompt sits near EOF and the scan stops at the first hit.' + ) + + // Worst cases, reported even where the ratio is below 1x. The new cost scales + // with bytes-AFTER the prompt, so a long turn (many tool calls since the ask) + // and a single oversized tool result are where the win decays or inverts. + const worst = [] + for (const trailingKb of [32, 256, 1024, 3072]) { + const path = join(dir, `trailing-${trailingKb}.jsonl`) + writeTranscriptWithTrailing(path, 1500, trailingKb * 1024) + if (readForward(path) !== readBackward(path)) { + throw new Error(`prompt mismatch at trailing ${trailingKb} KB`) + } + worst.push({ + label: `${(trailingKb / 1024).toFixed(2)} MB after prompt`, + beforeMs: measure(readForward, path), + afterMs: measure(readBackward, path) + }) + } + const hugePath = join(dir, 'huge-line.jsonl') + writeTranscriptWithHugeLine(hugePath, 3 * 1024 * 1024) + if (readForward(hugePath) !== readBackward(hugePath)) { + throw new Error('prompt mismatch on the oversized-line fixture') + } + worst.push({ + label: '3 MB single line', + beforeMs: measure(readForward, hugePath), + afterMs: measure(readBackward, hugePath) + }) + + console.log('\nWorst cases (win decays as a turn progresses; <1x means slower):') + console.log( + `${pad('case', 22)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('ratio', 9)}` + ) + for (const row of worst) { + console.log( + `${pad(row.label, 22)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(2)}x`, 9)}` + ) + } + console.log( + '\nThe win shrinks toward parity as output accumulates after the prompt, since\nthe backward scan has to read past all of it. The single-line row is the floor:\nno newline to stop on, so the scan reads the line in blocks and joins once where\nthe old code issued one flat read. Both sides pay the same per-line structure\nscan, and the carry is a chunk list, so cost stays linear either way.' + ) +} finally { + rmSync(dir, { recursive: true, force: true }) +} diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 1047c828218..f5a3e470c87 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -971,6 +971,292 @@ describe('shared agent-hook-listener', () => { } }) + // Why these three: the prompt read scans backward from EOF and stops at the + // first user line, so the cases that can break are a prompt spanning a chunk + // boundary, a later prompt that must win over an earlier one, and the byte + // offset in interactionKey, which the old forward pass computed absolutely. + it('reads a Command Code prompt that straddles the backward-scan chunk boundary', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-chunk-straddle-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + try { + const promptLine = JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'straddling prompt' }] + }) + // Place the prompt so it spans the 64 KiB read boundary counted back from + // EOF: the scan must stitch the two reads together to see the whole line. + const chunkBytes = 64 * 1024 + const bytesAfterPrompt = chunkBytes - Math.floor(Buffer.byteLength(promptLine) / 2) + const tail = Array.from({ length: 271 }, (_value, index) => + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'t'.repeat(180)}${index}` }] + }) + ) + let tailText = `${tail.join('\n')}\n` + const padBytes = bytesAfterPrompt - Buffer.byteLength(tailText) + expect(padBytes).toBeGreaterThan(0) + tailText = `${'x'.repeat(padBytes - 1)}\n${tailText}` + expect(Buffer.byteLength(tailText)).toBe(bytesAfterPrompt) + const head = Array.from({ length: 200 }, (_value, index) => + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'h'.repeat(180)}${index}` }] + }) + ) + writeFileSync(transcriptPath, `${head.join('\n')}\n${promptLine}\n${tailText}`) + + const tool = normalizeHookPayload( + state, + 'command-code', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'PreToolUse', + transcript_path: transcriptPath, + tool_name: 'shell_command', + tool_input: { command: 'pwd' } + } + }, + 'production' + ) + expect(tool?.payload.prompt).toBe('straddling prompt') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('reads a prompt behind one oversized line without quadratic carry copying', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-huge-line-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + const originalConcat = Buffer.concat + let concatenatedBytes = 0 + try { + // A single tool result spanning many 64 KiB read blocks. Re-joining the + // accumulated carry per block copies O(line^2) bytes; the chunk list defers + // to one join, so total copied bytes stay proportional to the line. + const lineBytes = 2 * 1024 * 1024 + const hugeLine = JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] + }) + const promptLine = JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'prompt behind a huge tool result' }] + }) + writeFileSync(transcriptPath, `${promptLine}\n${hugeLine}\n`) + + Buffer.concat = ((list: readonly Uint8Array[], totalLength?: number) => { + const joined = originalConcat(list as Uint8Array[], totalLength) + concatenatedBytes += joined.length + return joined + }) as typeof Buffer.concat + + const tool = normalizeHookPayload( + state, + 'command-code', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'PreToolUse', + transcript_path: transcriptPath, + tool_name: 'shell_command', + tool_input: { command: 'pwd' } + } + }, + 'production' + ) + + expect(tool?.payload.prompt).toBe('prompt behind a huge tool result') + // Linear copies once (~lineBytes). The quadratic form copied ~16x that at + // this size and grows with the square, so 4x separates them decisively. + expect(concatenatedBytes).toBeLessThan(lineBytes * 4) + } finally { + Buffer.concat = originalConcat + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('reads a Command Code prompt line that spans several read blocks', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-long-line-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + try { + // A prompt longer than one 64 KiB block: the scan sees consecutive blocks + // with no newline at all and must stitch them before parsing. + const promptText = `pasted prompt ${'W'.repeat(150 * 1024)}` + writeFileSync( + transcriptPath, + `${JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'earlier' }] })}\n${JSON.stringify( + { role: 'user', content: [{ type: 'text', text: promptText }] } + )}\n${JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'tail' }] })}\n` + ) + + const tool = normalizeHookPayload( + state, + 'command-code', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'PreToolUse', + transcript_path: transcriptPath, + tool_name: 'shell_command', + tool_input: { command: 'pwd' } + } + }, + 'production' + ) + + expect(tool?.payload.prompt.startsWith('pasted prompt WWW')).toBe(true) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('ignores a Command Code prompt older than the transcript scan cap', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-over-cap-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + try { + // The only user line sits beyond the 4 MB cap, so the bounded scan must not + // reach it — dropping the cap would restore the unbounded read this avoids. + const filler = JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: 'f'.repeat(64 * 1024) }] + }) + const lines = [ + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'ancient prompt' }] }) + ] + for (let index = 0; index < 80; index += 1) { + lines.push(filler) + } + writeFileSync(transcriptPath, `${lines.join('\n')}\n`) + expect(statSync(transcriptPath).size).toBeGreaterThan(4 * 1024 * 1024) + + const tool = normalizeHookPayload( + state, + 'command-code', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'PreToolUse', + transcript_path: transcriptPath, + tool_name: 'shell_command', + tool_input: { command: 'pwd' } + } + }, + 'production' + ) + + expect(tool?.payload.prompt ?? '').toBe('') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('resolves the last Command Code prompt, not an earlier one', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-last-prompt-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + try { + writeFileSync( + transcriptPath, + `${[ + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'first ask' }] }), + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'first answer' }] }), + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'second ask' }] }), + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'second answer' }] }) + ].join('\n')}\n` + ) + + const tool = normalizeHookPayload( + state, + 'command-code', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'PreToolUse', + transcript_path: transcriptPath, + tool_name: 'shell_command', + tool_input: { command: 'pwd' } + } + }, + 'production' + ) + expect(tool?.payload.prompt).toBe('second ask') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('keys the Command Code interaction by the absolute prompt line offset', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-command-code-offset-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + try { + const prompt = JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'same text' }] + }) + const answer = JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'a' }] }) + // Why past one chunk: the offset is absolute over the whole file, so the + // prompt must sit beyond a single backward-scan read for a chunk-relative + // offset to be distinguishable from the correct one. + const filler = Array.from({ length: 900 }, (_value, index) => + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'f'.repeat(200)}${index}` }] + }) + ) + const head = `${filler.join('\n')}\n` + writeFileSync(transcriptPath, `${head}${prompt}\n${answer}\n`) + const promptOffset = Buffer.byteLength(head) + expect(promptOffset).toBeGreaterThan(64 * 1024) + + const key = normalizeHookPayload( + createHookListenerState(), + 'command-code', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'PreToolUse', + transcript_path: transcriptPath, + tool_name: 'shell_command', + tool_input: { command: 'pwd' } + } + }, + 'production' + )?.promptInteractionKey + + // The offset segment must be the prompt line's real position in the file; + // a chunk-relative value would make two turns collide across reads. + // Key shape: command-code-transcript---. + expect(key?.split('-')[4]).toBe(String(promptOffset)) + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + it('trims surrounding whitespace from extracted prompt text', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 4e6432a7458..e2413f8fb02 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -835,6 +835,7 @@ function extractToolResponseText(toolResponse: unknown): string | undefined { const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 +const EMPTY_TRANSCRIPT_REGION = Buffer.alloc(0) const AMP_THREAD_ID_MAX_LENGTH = 256 const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 const GROK_SESSION_CWD_MAX_LENGTH = 4096 @@ -959,7 +960,32 @@ function hashInteractionKeyPart(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 12) } -function readLastCommandCodeUserPromptEntryFromTranscript( +// Why byte offsets: the caller's interactionKey embeds the prompt's absolute +// position, so the backward scan has to report the same offset the old +// read-everything-then-take-the-last-match pass produced. +function findLastCommandCodePromptInRegion( + region: Buffer +): { prompt: string; byteOffset: number } | undefined { + let lineEnd = region.length + for (let index = region.length - 1; index >= -1; index--) { + if (index >= 0 && region[index] !== 0x0a) { + continue + } + const lineStart = index + 1 + if (lineEnd > lineStart) { + const prompt = extractCommandCodeUserPromptFromLine( + region.subarray(lineStart, lineEnd).toString('utf8').trim() + ) + if (prompt !== undefined) { + return { prompt, byteOffset: lineStart } + } + } + lineEnd = index + } + return undefined +} + +export function readLastCommandCodeUserPromptEntryFromTranscript( transcriptPath: unknown ): { text: string; interactionKey: string } | undefined { if (typeof transcriptPath !== 'string' || transcriptPath.length === 0) { @@ -971,46 +997,79 @@ function readLastCommandCodeUserPromptEntryFromTranscript( if (size <= 0) { return undefined } - const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES) - const position = size - bytesToRead const fd = openSync(transcriptPath, 'r') try { - const buffer = Buffer.alloc(bytesToRead) - let filled = 0 - while (filled < bytesToRead) { - const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled) - if (n === 0) { + // Why scan backward: the answer is the LAST user line, so walking up from + // EOF returns on the first hit instead of parsing every line of a + // multi-megabyte transcript on every hook event. + // Why a chunk list: carry holds a partial line, and re-concatenating it per + // block made one oversized line (a big tool result) cost O(line^2). + let carryChunks: Buffer[] = [] + let bytesRead = 0 + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min( + scanEnd, + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_MAX_SCAN_BYTES - bytesRead + ) + const position = scanEnd - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + // Why bail on a short read: the file shrank under us, so the bytes above + // this block no longer line up and any stitched offset would be wrong. + if (filled < chunkSize) { break } - filled += n - } - let text = buffer.subarray(0, filled).toString('utf8') - let textBasePosition = position - if (position > 0) { - const firstNewline = text.indexOf('\n') - textBasePosition += firstNewline + 1 - text = firstNewline === -1 ? '' : text.slice(firstNewline + 1) - } - let lastPrompt: string | undefined - let lastPromptOffset = 0 - for (const { line, byteOffset } of iterateTranscriptLinesWithByteOffsets(text)) { - const prompt = extractCommandCodeUserPromptFromLine(line.trim()) - if (prompt !== undefined) { - lastPrompt = prompt - lastPromptOffset = textBasePosition + byteOffset + bytesRead += filled + scanEnd = position + // Why search only the new block: carry is always the run before a newline, + // so it holds none of its own. + const firstNewline = buffer.indexOf(0x0a) + // Why only at a true file start: a scan that stops on the size cap must + // discard its leading partial line, exactly as the capped read did. + const atStart = position === 0 + let completeRegion: Buffer + let regionPosition: number + if (atStart) { + completeRegion = + carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + regionPosition = position + carryChunks = [] + } else if (firstNewline === -1) { + completeRegion = EMPTY_TRANSCRIPT_REGION + regionPosition = position + carryChunks.unshift(buffer) + } else { + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + regionPosition = position + firstNewline + 1 + carryChunks = [buffer.subarray(0, firstNewline)] + } + if (completeRegion.length > 0) { + const found = findLastCommandCodePromptInRegion(completeRegion) + if (found) { + return { + text: found.prompt, + interactionKey: [ + 'command-code-transcript', + hashInteractionKeyPart(transcriptPath), + String(regionPosition + found.byteOffset), + hashInteractionKeyPart(found.prompt) + ].join('-') + } + } } } - return lastPrompt - ? { - text: lastPrompt, - interactionKey: [ - 'command-code-transcript', - hashInteractionKeyPart(transcriptPath), - String(lastPromptOffset), - hashInteractionKeyPart(lastPrompt) - ].join('-') - } - : undefined + return undefined } finally { closeSync(fd) } @@ -1019,24 +1078,6 @@ function readLastCommandCodeUserPromptEntryFromTranscript( } } -function* iterateTranscriptLinesWithByteOffsets( - text: string -): Generator<{ line: string; byteOffset: number }> { - let lineStart = 0 - let byteOffset = 0 - - for (let index = 0; index <= text.length; index++) { - if (index < text.length && text.charCodeAt(index) !== 10) { - continue - } - - const line = text.slice(lineStart, index) - yield { line, byteOffset } - byteOffset += Buffer.byteLength(line, 'utf8') + (index < text.length ? 1 : 0) - lineStart = index + 1 - } -} - function extractCommandCodeAssistantTextFromLine(line: string): string | undefined { let entry: unknown try { From 8db61248ec9f60bf0476832b5f3c546164d86fb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:22:47 -0700 Subject: [PATCH 113/150] fix(terminal): quarantine input after a replaced PTY endpoint (STA-2373) (#10811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #10065, which merged with this failure mode known and deferred. Recovery from a dead daemon socket reattaches to a fresh shell. Keystrokes in flight during the ~1.1s window are dropped, but everything typed after reattach lands on the new shell, so the surviving tail of a half-sent line is submitted by the user's own Enter: `echo hi; rm -rf x` arrives as `cho hi; rm -rf x` — zsh fails `cho` and still runs `rm -rf x`. Before #10065 the whole line was lost, so the executing tail is new. Quarantine the remainder of the interrupted line instead. Keyed by tab, not pane: recovery destroys the xterm being typed into and the successor pane receives the tail. Armed only from the onWriteUnavailable path; a stalled-pipeline remount keeps the same live shell, where quarantining would eat a real command. Disarms on the line terminator (CR/LF/Ctrl-C, dropped too since that is the byte that would submit the mangled line), a 700ms idle gap, or a 5s cap. The cap must not be shortened: the tail itself takes ~2.5s to type, so a shorter cap fires mid-tail and delivers the dangerous remainder to the fresh shell. The onData check sits after the query-reply branch so CPR/DSR replies still reach the shell. Live A/B QA on macOS: the bug reproduces verbatim without the fix (marker file created by the surviving tail) and is suppressed with it, with the next command still working — proving suppression rather than a dead pane. --- .../terminal-pane/pty-connection.test.ts | 36 ++++++ .../terminal-pane/pty-connection.ts | 16 ++- .../terminal-input-quarantine.test.ts | 112 ++++++++++++++++++ .../terminal-input-quarantine.ts | 88 ++++++++++++++ .../terminal-pane-recovery.test.ts | 52 ++++++++ .../terminal-pane/terminal-pane-recovery.ts | 16 +++ 6 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 3b8c434c961..ee0880d26b1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -4803,6 +4803,42 @@ describe('connectPanePty', () => { _resetTerminalPaneRecoveryForTests() }) + it('quarantines the interrupted line after a write-unavailable remount, but never device replies', async () => { + const { connectPanePty } = await import('./pty-connection') + const { _resetTerminalPaneRecoveryForTests } = await import('./terminal-pane-recovery') + _resetTerminalPaneRecoveryForTests() + const remountTerminalTabForRecovery = vi.fn<(tabId: string) => boolean>(() => true) + mockStoreState = { ...mockStoreState, remountTerminalTabForRecovery } as StoreState + const transport = createMockTransport('daemon-pty') + let writeUnavailable: (() => void) | undefined + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + writeUnavailable = callbacks.onWriteUnavailable + return { id: 'daemon-pty' } + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + writeUnavailable?.() + await flushAsyncTicks(6) + expect(remountTerminalTabForRecovery).toHaveBeenCalledWith('tab-1') + + // The surviving tail of `echo hi; rm -rf x`: reaching the fresh shell would + // let the user's own Enter run `rm -rf x` (#10065 follow-up). + sendTerminalInputThroughPane(pane, 'cho hi; rm -rf x') + expect(transport.sendInput).not.toHaveBeenCalledWith('cho hi; rm -rf x') + // A program that queries during reattach hangs if its reply is dropped. + sendTerminalInputThroughPane(pane, '\x1b[3;1R') + expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[3;1R') + sendTerminalInputThroughPane(pane, '\r') + expect(transport.sendInput).not.toHaveBeenCalledWith('\r') + // The terminator disarmed it, so the next real command reaches the shell. + sendTerminalInputThroughPane(pane, 'ls\r') + expect(transport.sendInput).toHaveBeenCalledWith('ls\r') + _resetTerminalPaneRecoveryForTests() + }) + it('recovers a wedged write pipeline after accepted input without renderer output', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b5ec43b2e69..fdeccfd7b65 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -87,6 +87,7 @@ import { registerTerminalPaneRecoveryInstance, requestTerminalPaneRecovery } from './terminal-pane-recovery' +import { shouldDropQuarantinedTerminalInput } from './terminal-input-quarantine' import { isDocumentVisibilityProvenStale, registerStaleDocumentVisibilityRecovery @@ -3662,7 +3663,11 @@ export function connectPanePty( // and a disconnected remote pane would otherwise remount-churn on every // cooldown window while typing. Local panes keep the lenient gate. requireAuthoritativeLiveness: - Boolean(transport.getConnectionId?.()) || isRemoteRuntimePtyId(undeliverablePtyId) + Boolean(transport.getConnectionId?.()) || isRemoteRuntimePtyId(undeliverablePtyId), + // Why only the rejected path: it is the only one whose remount can land on + // a fresh shell. A stalled-pipeline remount always reattaches to the same + // shell, so its half-typed line is still on screen and intact. + endpointReplaced: providerRejected }) } // Why: the write-pipeline health watch (scheduler stall probe, replay-guard @@ -3743,6 +3748,15 @@ export function connectPanePty( sendDesktopQueryReplyImmediate(data) return } + // Why after the query-reply branch: device replies are not user input and + // must always reach the shell, or a program querying during reattach hangs. + // Why at all: a replaced endpoint reattaches to a fresh shell, so the tail + // of the interrupted line would be submitted by the user's own Enter and a + // compound command could run its surviving half (#10065 follow-up). + if (shouldDropQuarantinedTerminalInput(deps.tabId, data)) { + clearPendingTerminalInputIntent() + return + } const intent = pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to diff --git a/src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts new file mode 100644 index 00000000000..63679bec8f1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + _resetTerminalInputQuarantineForTests, + armTerminalInputQuarantine, + isTerminalInputQuarantined, + shouldDropQuarantinedTerminalInput +} from './terminal-input-quarantine' + +const TAB = 'tab-1' +// Re-attach measured at ~1.1s in STA-2373 live QA; the tail lands just after. +const REATTACH_MS = 1_100 + +beforeEach(() => { + _resetTerminalInputQuarantineForTests() +}) + +describe('terminal input quarantine', () => { + it('passes input through when nothing is armed', () => { + expect(shouldDropQuarantinedTerminalInput(TAB, 'e', 0)).toBe(false) + expect(isTerminalInputQuarantined(TAB)).toBe(false) + }) + + it('drops the surviving tail of an interrupted line and its Enter', () => { + armTerminalInputQuarantine(TAB, 0) + // The tail of `echo hi; rm -rf x` after the head was eaten by recovery. + let at = REATTACH_MS + for (const char of 'cho hi; rm -rf x') { + expect(shouldDropQuarantinedTerminalInput(TAB, char, at)).toBe(true) + at += 30 + } + // The user's own Enter would have submitted the mangled line. + expect(shouldDropQuarantinedTerminalInput(TAB, '\r', at)).toBe(true) + expect(isTerminalInputQuarantined(TAB)).toBe(false) + }) + + it('lets the next command through once the terminator disarmed it', () => { + armTerminalInputQuarantine(TAB, 0) + expect(shouldDropQuarantinedTerminalInput(TAB, 'x', REATTACH_MS)).toBe(true) + expect(shouldDropQuarantinedTerminalInput(TAB, '\r', REATTACH_MS + 30)).toBe(true) + expect(shouldDropQuarantinedTerminalInput(TAB, 'l', REATTACH_MS + 60)).toBe(false) + }) + + it.each([ + ['carriage return', '\r'], + ['newline', '\n'], + ['ctrl-c', '\x03'] + ])('treats %s as the line terminator', (_label, terminator) => { + armTerminalInputQuarantine(TAB, 0) + expect(shouldDropQuarantinedTerminalInput(TAB, terminator, REATTACH_MS)).toBe(true) + expect(isTerminalInputQuarantined(TAB)).toBe(false) + }) + + it('drops a pasted tail that carries its terminator mid-chunk', () => { + armTerminalInputQuarantine(TAB, 0) + expect(shouldDropQuarantinedTerminalInput(TAB, 'cho hi; rm -rf x\r', REATTACH_MS)).toBe(true) + expect(isTerminalInputQuarantined(TAB)).toBe(false) + }) + + it('releases on an idle gap once a quarantined byte has been seen', () => { + armTerminalInputQuarantine(TAB, 0) + // Burst tail, no Enter — the user was still mid-line when the endpoint died. + expect(shouldDropQuarantinedTerminalInput(TAB, 'c', REATTACH_MS)).toBe(true) + expect(shouldDropQuarantinedTerminalInput(TAB, 'h', REATTACH_MS + 40)).toBe(true) + // Then they notice, pause, and type a real command. + expect(shouldDropQuarantinedTerminalInput(TAB, 'l', REATTACH_MS + 40 + 700)).toBe(false) + expect(isTerminalInputQuarantined(TAB)).toBe(false) + }) + + // The accepted cost of the cap, pinned so it is not "fixed" by shortening it: + // over-quarantine is visible (nothing echoes) and recoverable by retyping, + // while under-quarantine executes half a command. A tail takes seconds to + // type, so a cap short enough to spare this command would cut that tail + // mid-line and deliver its remainder to the fresh shell. + it('eats a fresh command typed inside the cap, terminator included', () => { + armTerminalInputQuarantine(TAB, 0) + // No byte yet, so the idle gate cannot release the first keystroke however + // long the user waited; from there normal typing never opens a 700ms gap. + let at = 1_500 + for (const char of 'ls -la\r') { + expect(shouldDropQuarantinedTerminalInput(TAB, char, at)).toBe(true) + at += 150 + } + }) + + it('does not let the idle gate fire on the re-attach delay itself', () => { + armTerminalInputQuarantine(TAB, 0) + // First quarantined byte arrives well past the idle window, because the + // remount itself took that long. It is still the interrupted line. + expect(shouldDropQuarantinedTerminalInput(TAB, 'c', REATTACH_MS)).toBe(true) + }) + + it('releases at the absolute cap so input can never wedge', () => { + armTerminalInputQuarantine(TAB, 0) + expect(shouldDropQuarantinedTerminalInput(TAB, 'a', 4_999)).toBe(true) + armTerminalInputQuarantine(TAB, 0) + expect(shouldDropQuarantinedTerminalInput(TAB, 'a', 5_000)).toBe(false) + expect(isTerminalInputQuarantined(TAB)).toBe(false) + }) + + it('keeps quarantine per tab', () => { + armTerminalInputQuarantine(TAB, 0) + expect(shouldDropQuarantinedTerminalInput('tab-2', 'a', REATTACH_MS)).toBe(false) + expect(shouldDropQuarantinedTerminalInput(TAB, 'a', REATTACH_MS)).toBe(true) + }) + + it('prunes expired entries for tabs that closed mid-quarantine', () => { + armTerminalInputQuarantine('closed-tab', 0) + armTerminalInputQuarantine(TAB, 5_000) + expect(isTerminalInputQuarantined('closed-tab')).toBe(false) + expect(isTerminalInputQuarantined(TAB)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts new file mode 100644 index 00000000000..416227702c1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-input-quarantine.ts @@ -0,0 +1,88 @@ +// Why this module exists: when a dead daemon endpoint is replaced (STA-2373, +// #10065), the pane remounts and re-attaches to a *fresh* shell. Keystrokes in +// flight during that window are dropped, but everything typed after re-attach +// lands on the new shell — so the surviving tail of a half-sent line is +// submitted by the user's own Enter. `echo hi; rm -rf x` can arrive as +// `cho hi; rm -rf x`: zsh fails `cho` and still runs `rm -rf x`. Before #10065 +// the whole line was silently lost, so the executing tail is a new risk. +// +// Quarantine drops the remainder of the interrupted line so a mangled command +// can never be submitted. It must be keyed by tab, not by pane: recovery +// destroys the xterm that was being typed into, and the successor pane is the +// one that receives the tail. + +/** Absolute cap from arming. A safety valve only — input must never wedge, + * even if the terminator never arrives and the pane keeps receiving data. */ +const QUARANTINE_MAX_MS = 5_000 +/** A gap this long proves the interrupted burst ended. Re-attach takes ~1.1s, + * so the tail arrives back-to-back (<100ms apart) while a human reacting to a + * recovered pane takes far longer — that gap is what separates the two. */ +const QUARANTINE_IDLE_MS = 700 + +/** CR/LF submit the line; Ctrl-C abandons it. Dropping the terminator itself is + * the point — it is the byte that would have submitted the mangled line. */ +const LINE_TERMINATORS = ['\r', '\n', '\x03'] + +type QuarantineEntry = { + armedAt: number + /** null until the first quarantined byte, so the idle gate cannot fire on the + * re-attach delay itself. */ + lastInputAt: number | null +} + +const quarantineByTabId = new Map() + +function containsLineTerminator(data: string): boolean { + return LINE_TERMINATORS.some((terminator) => data.includes(terminator)) +} + +/** Arm only when the endpoint stopped accepting writes and may have been + * replaced. A recovery that always keeps the same live shell has no mangled + * line to suppress, and quarantining there would eat a legitimate command. */ +export function armTerminalInputQuarantine(tabId: string, now: number = Date.now()): void { + // Prune first: entries are only meaningful for QUARANTINE_MAX_MS, and a tab + // closed mid-quarantine would otherwise leave one behind forever. + for (const [otherTabId, entry] of quarantineByTabId) { + if (now - entry.armedAt >= QUARANTINE_MAX_MS) { + quarantineByTabId.delete(otherTabId) + } + } + quarantineByTabId.set(tabId, { armedAt: now, lastInputAt: null }) +} + +export function isTerminalInputQuarantined(tabId: string): boolean { + return quarantineByTabId.has(tabId) +} + +/** + * True when `data` belongs to the interrupted line and must not reach the fresh + * shell. Disarms on the line terminator, on an idle gap that proves the burst + * ended, and on the absolute cap. + */ +export function shouldDropQuarantinedTerminalInput( + tabId: string, + data: string, + now: number = Date.now() +): boolean { + const entry = quarantineByTabId.get(tabId) + if (!entry) { + return false + } + if (now - entry.armedAt >= QUARANTINE_MAX_MS) { + quarantineByTabId.delete(tabId) + return false + } + if (entry.lastInputAt !== null && now - entry.lastInputAt >= QUARANTINE_IDLE_MS) { + quarantineByTabId.delete(tabId) + return false + } + entry.lastInputAt = now + if (containsLineTerminator(data)) { + quarantineByTabId.delete(tabId) + } + return true +} + +export function _resetTerminalInputQuarantineForTests(): void { + quarantineByTabId.clear() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index 7e0486523ef..51ccff40901 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -5,6 +5,7 @@ import { registerTerminalPaneRecoveryInstance, requestTerminalPaneRecovery } from './terminal-pane-recovery' +import { isTerminalInputQuarantined } from './terminal-input-quarantine' const mocks = vi.hoisted(() => ({ remountTerminalTabForRecovery: vi.fn<(tabId: string) => boolean>(() => true), @@ -478,4 +479,55 @@ describe('requestTerminalPaneRecovery', () => { expect.anything() ) }) + + // Why: quarantine suppresses real keystrokes, so arming it on a recovery that + // kept the same shell would eat a legitimate command (#10065 follow-up). + describe('input quarantine arming', () => { + it('arms after a replaced endpoint so the mangled line cannot be submitted', async () => { + const result = await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'input-undeliverable', + endpointReplaced: true + }) + + expect(result).toBe(true) + expect(isTerminalInputQuarantined('tab-1')).toBe(true) + }) + + it('does not arm when the same live shell is reattached', async () => { + const result = await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'input-undeliverable' + }) + + expect(result).toBe(true) + expect(isTerminalInputQuarantined('tab-1')).toBe(false) + }) + + it('does not arm for a stalled write pipeline', async () => { + await requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'write-stalled' + }) + + expect(isTerminalInputQuarantined('tab-1')).toBe(false) + }) + + it('does not arm when the remount never happened', async () => { + mocks.remountTerminalTabForRecovery.mockReturnValue(false) + + const result = await requestTerminalPaneRecovery({ + tabId: 'tab-gone', + ptyId: 'pty-1', + reason: 'input-undeliverable', + endpointReplaced: true + }) + + expect(result).toBe(false) + expect(isTerminalInputQuarantined('tab-gone')).toBe(false) + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 791234b232b..ca3d23daede 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -1,5 +1,9 @@ import { useAppStore } from '@/store' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + _resetTerminalInputQuarantineForTests, + armTerminalInputQuarantine +} from './terminal-input-quarantine' // Why this module exists: a terminal pane can die renderer-side while its PTY // stays alive — a wedged xterm WriteBuffer (issue #2836), a disposed xterm @@ -34,6 +38,12 @@ type RecoveryRequest = { * registry doesn't own, and treating null as "proceed" would let a * disconnected remote pane churn reconnects on every cooldown window. */ requireAuthoritativeLiveness?: boolean + /** The provider rejected the write because its endpoint stopped accepting + * writes, so re-attach MAY land on a *fresh* shell (a respawn; a transient + * socket drop reconnects to the same sessions). Only this path can mangle the + * in-flight line, and only it may quarantine input — a recovery that always + * keeps the same live shell would have a legitimate command eaten. */ + endpointReplaced?: boolean } // Why a cap exists: recovery must never loop. If the remounted pane wedges @@ -261,6 +271,11 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro // A remount replaces every pane xterm in the tab; a previously scheduled // retry would only re-remount the fresh, healthy panes. cancelPendingRecoveryRetry(request.tabId) + if (request.endpointReplaced) { + // Why here and not at request time: arming before the remount is certain + // would suppress input on a pane that never recovered. + armTerminalInputQuarantine(request.tabId) + } console.error( `[terminal] recovering pane tab ${request.tabId} — ${request.reason} with a live PTY (${request.ptyId ?? 'unbound'}); remounting to rebuild the renderer` ) @@ -280,4 +295,5 @@ export function _resetTerminalPaneRecoveryForTests(): void { clearTimeout(pendingRetry.timer) } pendingRetryByTabId.clear() + _resetTerminalInputQuarantineForTests() } From a8a2e6cb1fd20a890ee62e890773306da05ca8e3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:31:46 -0700 Subject: [PATCH 114/150] perf(agent-status): keep the shared transcript reader's carry linear (#10777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readLastTextFromTranscriptOnce re-joined its carry buffer on every block that held no newline, so a transcript whose tail is one oversized line copied O(line^2). It backs three readers — the Claude/Codex user prompt, the Command Code assistant message, and the shared assistant-text reader — so every agent that resolves turn text from a transcript paid it. Same chunk-list carry the Command Code prompt reader already uses. Measured on a transcript whose tail is one big line: 15.24 ms -> 8.87 ms at 3.9 MB, and the gap widens with the line, which is the quadratic signature. --- src/shared/agent-hook-listener.test.ts | 50 ++++++++++++++++++++++++++ src/shared/agent-hook-listener.ts | 43 ++++++++++++---------- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index f5a3e470c87..f6bcc0c383f 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -971,6 +971,56 @@ describe('shared agent-hook-listener', () => { } }) + it('reads the last assistant message behind an oversized line without quadratic copying', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-assistant-huge-line-')) + const transcriptPath = join(tmpDir, 'transcript.jsonl') + const originalConcat = Buffer.concat + let concatenatedBytes = 0 + try { + // The shared backward reader (readLastTextFromTranscriptOnce) stitches a + // line spanning many read blocks. Re-joining the carry per block copies + // O(line^2); the chunk list defers to one join. + const lineBytes = 2 * 1024 * 1024 + writeFileSync( + transcriptPath, + `${JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: 'answer behind a huge line' }] + })}\n${JSON.stringify({ + role: 'user', + content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] + })}\n` + ) + + Buffer.concat = ((list: readonly Uint8Array[], totalLength?: number) => { + const joined = originalConcat(list as Uint8Array[], totalLength) + concatenatedBytes += joined.length + return joined + }) as typeof Buffer.concat + + const done = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { hook_event_name: 'Stop', transcript_path: transcriptPath } + }, + 'production' + ) + + expect(done?.payload.lastAssistantMessage).toBe('answer behind a huge line') + // Linear copies once (~lineBytes); the quadratic form copied many times that. + expect(concatenatedBytes).toBeLessThan(lineBytes * 4) + } finally { + Buffer.concat = originalConcat + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + // Why these three: the prompt read scans backward from EOF and stops at the // first user line, so the cases that can break are a prompt spanning a chunk // boundary, a later prompt that must win over an earlier one, and the byte diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index e2413f8fb02..7701ea6b1fe 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1337,11 +1337,14 @@ function readLastTextFromTranscriptOnce( } const fd = openSync(transcriptPath, 'r') try { - let carryBytes: Buffer = Buffer.alloc(0) + // Why a chunk list: carry holds a partial line, and re-joining it per block + // made one oversized line (a big tool result or pasted prompt) cost O(line^2). + let carryChunks: Buffer[] = [] let bytesRead = 0 - while (bytesRead < size && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { - const chunkSize = Math.min(size - bytesRead, TRANSCRIPT_CHUNK_BYTES) - const position = size - bytesRead - chunkSize + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min(scanEnd, TRANSCRIPT_CHUNK_BYTES) + const position = scanEnd - chunkSize const buffer = Buffer.alloc(chunkSize) let filled = 0 while (filled < chunkSize) { @@ -1351,25 +1354,30 @@ function readLastTextFromTranscriptOnce( } filled += n } - const n = filled - bytesRead += n - if (n === 0) { + // Why bail on a short read: the file shrank under us, so the bytes above + // this block no longer line up with what the earlier ones assumed. + if (filled < chunkSize) { break } - const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) - const atStart = bytesRead >= size - const firstNewline = combined.indexOf(0x0a) + bytesRead += filled + scanEnd = position + // Why search only the new block: carry is always the run before a newline, + // so it holds none of its own. + const firstNewline = buffer.indexOf(0x0a) + const atStart = position === 0 let completeRegion: Buffer - let nextCarry: Buffer if (atStart) { - completeRegion = combined - nextCarry = Buffer.alloc(0) + completeRegion = + carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [] } else if (firstNewline === -1) { - completeRegion = Buffer.alloc(0) - nextCarry = combined + completeRegion = EMPTY_TRANSCRIPT_REGION + carryChunks.unshift(buffer) } else { - nextCarry = combined.subarray(0, firstNewline) - completeRegion = combined.subarray(firstNewline + 1) + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] } if (completeRegion.length > 0) { const extracted = findLastExtractedTranscriptLineText( @@ -1380,7 +1388,6 @@ function readLastTextFromTranscriptOnce( return extracted } } - carryBytes = nextCarry } return undefined } finally { From 8518cf47af51ab49331a54bd448e0c91c925aeb1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:31:48 -0700 Subject: [PATCH 115/150] perf(ai-vault): keep the JSONL line carry linear (#10783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consumeCompleteJsonlLines re-joined its held-over partial line with every stream chunk, so one oversized record — a large tool result — cost O(record^2). It backs the incremental parse for every resumable agent transcript, so the whole AI Vault corpus paid it. Hold the pieces in a list and join once, when a newline finally arrives. Measured on a transcript with a single oversized record: 2.13 ms -> 1.17 ms at 1 MB and 68.22 ms -> 4.35 ms at 8 MB, with byte-identical output. A transcript of ordinary records never reaches the branch. --- .../session-scanner-parse-cache.test.ts | 39 +++++++++++++++++++ .../ai-vault/session-scanner-parse-cache.ts | 32 ++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/main/ai-vault/session-scanner-parse-cache.test.ts b/src/main/ai-vault/session-scanner-parse-cache.test.ts index 6ba5e3266d6..8434f26bac1 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.test.ts @@ -128,6 +128,45 @@ describe('parseAgentSessionFileCached', () => { expect(incremental?.totalTokens).toBe(420) }) + it('parses an oversized record without quadratic carry copying', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + // One tool result far larger than a stream chunk. Re-joining the held-over + // partial line per chunk copies O(record^2); the piece list joins once. + const recordBytes = 4 * 1024 * 1024 + await writeFile( + path, + `${[ + userRecord(0, 'question'), + assistantRecord(1, 'x'.repeat(recordBytes)), + assistantRecord(2, 'tail answer') + ].join('\n')}\n` + ) + + const originalConcat = Buffer.concat + let concatenatedBytes = 0 + Buffer.concat = ((list: readonly Uint8Array[], totalLength?: number) => { + const joined = originalConcat(list as Uint8Array[], totalLength) + concatenatedBytes += joined.length + return joined + }) as typeof Buffer.concat + try { + const stats = createSessionParseStats() + const parsed = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(parsed).not.toBeNull() + } finally { + Buffer.concat = originalConcat + } + + // Linear joins the record about once; the quadratic form copied many times + // that, growing with the square of the record size. + expect(concatenatedBytes).toBeLessThan(recordBytes * 4) + }) + it('shows a trailing unterminated line without double-counting it later', async () => { const root = await makeTempDir() const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index b7aab770432..b3e4448a410 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -311,12 +311,28 @@ async function consumeCompleteJsonlLines(args: { }): Promise { let consumedThrough = args.start let bytesRead = 0 - let remainder: Buffer | null = null + // Why a piece list: re-joining the partial line with every chunk made one + // oversized record (a big tool result) cost O(record^2). Joining once, when a + // newline finally arrives, keeps it linear. + let remainderParts: Buffer[] = [] + let remainderLength = 0 const stream = createReadStream(args.path, { start: args.start }) for await (const chunk of stream as AsyncIterable) { bytesRead += chunk.length - const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + // Why check the chunk alone: the pieces held over are all mid-line, so none + // of them contains a newline. + if (!chunk.includes(NEWLINE_BYTE)) { + remainderParts.push(chunk) + remainderLength += chunk.length + continue + } + const data = + remainderLength > 0 + ? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length) + : chunk + remainderParts = [] + remainderLength = 0 let lineStart = 0 let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) while (newlineIndex !== -1) { @@ -329,13 +345,19 @@ async function consumeCompleteJsonlLines(args: { newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) } consumedThrough += lineStart - // Copy the tail so retaining it doesn't pin the whole chunk buffer. - remainder = lineStart < data.length ? Buffer.from(data.subarray(lineStart)) : null + if (lineStart < data.length) { + // Copy the tail so retaining it doesn't pin the whole chunk buffer. + remainderParts = [Buffer.from(data.subarray(lineStart))] + remainderLength = data.length - lineStart + } } + const trailingPartialLine = + remainderLength > 0 ? Buffer.concat(remainderParts, remainderLength).toString('utf-8') : null + return { consumedThrough, - trailingPartialLine: remainder && remainder.length > 0 ? remainder.toString('utf-8') : null, + trailingPartialLine, bytesRead } } From 5a30c5c2ed2b4a756c3f004cb44e67880f6d5ccd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:31:52 -0700 Subject: [PATCH 116/150] perf(git): read both diff blobs concurrently (#10781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(git): read both diff blobs concurrently The diff loaders awaited their two sides in series, so the second `git show` could not start until the first had returned. The reads are independent, so that was pure added latency on every diff the review panel opens: ~47 ms sequential vs ~24 ms concurrent, a saving of ~23 ms per diff. Covers the merge-base, commit, and staged loaders, plus the unstaged path where the working-tree read is independent of the index->HEAD chain. The unstaged left chain itself stays sequential because its second step depends on the first. The staged coalescing test asserted the sequential shape (one spawn, then the next); it now pins the contract that actually matters — eight identical reads still collapse to two spawns, one per side. * test(perf): interleave the diff-blob benchmark arms Running one strategy's whole batch before the other's lets cache warming, CPU frequency drift, and background load correlate with the strategy being measured. Alternate the arms per iteration, alternate which goes first, and report medians so that drift stays common to both. Also reject malformed env settings rather than truncating them — Number.parseInt accepts "10foo" and 3.5. Interleaved result confirms the original: 1.90x-2.03x, ~24 ms saved per diff. --- .../git-diff-blob-concurrency-benchmark.mjs | 128 ++++++++++++++++++ src/main/git/status.test.ts | 7 +- src/main/git/status.ts | 53 +++++--- 3 files changed, 164 insertions(+), 24 deletions(-) create mode 100644 config/scripts/git-diff-blob-concurrency-benchmark.mjs diff --git a/config/scripts/git-diff-blob-concurrency-benchmark.mjs b/config/scripts/git-diff-blob-concurrency-benchmark.mjs new file mode 100644 index 00000000000..602efd00e1b --- /dev/null +++ b/config/scripts/git-diff-blob-concurrency-benchmark.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Benchmark: latency of loading one file diff, which reads two git blobs. +// +// The diff loaders in src/main/git/status.ts awaited their two sides in series, +// so the second `git show` could not start until the first had fully returned. +// The two reads are independent, so that serialization was pure added latency on +// every diff the review panel opens. +// +// This spawns the real `git` binary against this repo, so it measures actual +// process-launch and read cost rather than a model of it. Over SSH each diff is +// one relay RPC and the two spawns run host-local inside the relay, so the same +// relative saving applies to remote-host spawn time, not to network round trips. +import { execFile } from 'node:child_process' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const ITERATIONS = Number(process.env.ORCA_DIFF_BLOB_BENCH_ITERATIONS ?? '10') +const WARMUP = Number(process.env.ORCA_DIFF_BLOB_BENCH_WARMUP ?? '3') + +for (const [name, value] of [ + ['ORCA_DIFF_BLOB_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_DIFF_BLOB_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function git(args) { + return new Promise((resolve, reject) => { + execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 256 * 1024 * 1024 }, (error, stdout) => + error ? reject(error) : resolve(stdout) + ) + }) +} + +// Pre-fix: await one side, then the other. +async function readSequential(leftRef, rightRef, filePath) { + const left = await git(['show', '--end-of-options', `${leftRef}:${filePath}`]) + const right = await git(['show', '--end-of-options', `${rightRef}:${filePath}`]) + return left.length + right.length +} + +// Post-fix: issue both, await together. +async function readConcurrent(leftRef, rightRef, filePath) { + const [left, right] = await Promise.all([ + git(['show', '--end-of-options', `${leftRef}:${filePath}`]), + git(['show', '--end-of-options', `${rightRef}:${filePath}`]) + ]) + return left.length + right.length +} + +// Why interleaved: running one strategy's whole batch before the other's lets +// cache warming, CPU-frequency drift, and background load correlate with the +// strategy being measured. Alternating per iteration and taking medians keeps +// that drift common to both arms. +async function measureInterleaved(leftRef, rightRef, filePath) { + for (let index = 0; index < WARMUP; index += 1) { + await readSequential(leftRef, rightRef, filePath) + await readConcurrent(leftRef, rightRef, filePath) + } + const sequentialSamples = [] + const concurrentSamples = [] + for (let index = 0; index < ITERATIONS; index += 1) { + // Alternate which arm goes first so neither systematically pays a cold cache. + const sequentialFirst = index % 2 === 0 + for (const runSequential of sequentialFirst ? [true, false] : [false, true]) { + const start = performance.now() + await (runSequential ? readSequential : readConcurrent)(leftRef, rightRef, filePath) + ;(runSequential ? sequentialSamples : concurrentSamples).push(performance.now() - start) + } + } + const median = (samples) => { + const sorted = [...samples].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] + } + return { sequential: median(sequentialSamples), concurrent: median(concurrentSamples) } +} + +const head = (await git(['rev-parse', 'HEAD'])).trim() +const parent = `${head}~1` + +// Files that exist on both sides, spanning small to large so the fixed spawn +// cost and the size-dependent read cost are both represented. +const CANDIDATES = [ + 'src/main/git/status.ts', + 'src/shared/agent-hook-listener.ts', + 'src/renderer/src/components/TaskPage.tsx' +] + +const files = [] +for (const filePath of CANDIDATES) { + try { + await git(['cat-file', '-e', `${parent}:${filePath}`]) + await git(['cat-file', '-e', `${head}:${filePath}`]) + files.push(filePath) + } catch { + // Skip a path that does not exist on both sides in this checkout. + } +} +if (files.length === 0) { + throw new Error('no benchmark file exists at both HEAD and HEAD~1 in this checkout') +} + +const pad = (value, width) => String(value).padStart(width) +console.log('One file diff = two git blob reads. Lower is better.') +console.log( + `iterations=${ITERATIONS} warmup=${WARMUP} (interleaved, medians) head=${head.slice(0, 9)}` +) +console.log( + `${pad('file', 26)} ${pad('sequential', 12)} ${pad('concurrent', 12)} ${pad('speedup', 9)} ${pad('saved', 10)}` +) +for (const filePath of files) { + const sequentialBytes = await readSequential(parent, head, filePath) + const concurrentBytes = await readConcurrent(parent, head, filePath) + if (sequentialBytes !== concurrentBytes) { + throw new Error(`byte mismatch for ${filePath}`) + } + const { sequential, concurrent } = await measureInterleaved(parent, head, filePath) + console.log( + `${pad(filePath.split('/').pop(), 26)} ${pad(`${sequential.toFixed(1)} ms`, 12)} ${pad(`${concurrent.toFixed(1)} ms`, 12)} ${pad(`${(sequential / concurrent).toFixed(2)}x`, 9)} ${pad(`${(sequential - concurrent).toFixed(1)} ms`, 10)}` + ) +} +console.log( + '\nThe saving is per diff opened, and is dominated by process launch rather than\nfile size — which is why it holds roughly constant across these files.' +) diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 2cdc41dc367..c39a4097825 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -543,11 +543,12 @@ describe('getDiff', () => { const reads = Array.from({ length: 8 }, () => getDiff('/repo', 'src/file.ts', true)) - await waitForMockCalls(gitExecFileAsyncBufferMock, 1) - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(1) + // Why both up front: the two sides are independent spawns issued concurrently, + // so 8 identical reads still collapse to exactly 2 — one per side, not per read. + await waitForMockCalls(gitExecFileAsyncBufferMock, 2) + expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) leftBlob.resolve() - await waitForMockCalls(gitExecFileAsyncBufferMock, 2) rightBlob.resolve() const results = await Promise.all(reads) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 34c5d750476..1375e4dca87 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1244,21 +1244,29 @@ async function loadDiff( let modifiedDeleted = false try { - const leftBlob = staged - ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) - : compareAgainstHead - ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) - : await readUnstagedLeftBlob(worktreePath, filePath, options) - originalContent = leftBlob.content - originalIsBinary = leftBlob.isBinary - if (staged) { - const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) + // Why concurrent: HEAD and the index are independent `git show` spawns. + // Only this branch qualifies — the unstaged left read chains index→HEAD. + const [leftBlob, rightBlob] = await Promise.all([ + readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options), + readGitBlobAtIndexPath(worktreePath, filePath, options) + ]) + originalContent = leftBlob.content + originalIsBinary = leftBlob.isBinary modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary modifiedDeleted = !rightBlob.exists } else { - const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath)) + // The left chain (index→HEAD) is sequential within itself, but the working + // tree read is a plain fs read that does not depend on it. + const [leftBlob, workingTreeBlob] = await Promise.all([ + compareAgainstHead + ? readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) + : readUnstagedLeftBlob(worktreePath, filePath, options), + readWorkingTreeFile(path.join(worktreePath, filePath)) + ]) + originalContent = leftBlob.content + originalIsBinary = leftBlob.isBinary modifiedContent = workingTreeBlob.content modifiedIsBinary = workingTreeBlob.isBinary modifiedDeleted = !workingTreeBlob.exists @@ -1397,8 +1405,12 @@ async function loadBranchDiff( ): Promise { try { const leftPath = args.oldPath ?? args.filePath - const leftBlob = await readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options) - const rightBlob = await readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) + // Why concurrent: the two sides are independent `git show` spawns, so awaiting + // them in series doubles the latency of every diff the review panel opens. + const [leftBlob, rightBlob] = await Promise.all([ + readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options), + readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) + ]) return buildDiffResult( leftBlob.content, @@ -1510,15 +1522,14 @@ async function loadCommitDiff( ): Promise { try { const leftPath = args.oldPath ?? args.filePath - const leftBlob = args.parentOid - ? await readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) - : { content: '', isBinary: false } - const rightBlob = await readGitBlobAtOidPath( - worktreePath, - args.commitOid, - args.filePath, - options - ) + // Why concurrent: the two sides are independent `git show` spawns. A root + // commit has no parent to read, so that side resolves without a spawn. + const [leftBlob, rightBlob] = await Promise.all([ + args.parentOid + ? readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) + : Promise.resolve({ content: '', isBinary: false }), + readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath, options) + ]) return buildDiffResult( leftBlob.content, From 62158570e0117261a45d6f1195e58c7253e12784 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:31:55 -0700 Subject: [PATCH 117/150] perf(mobile-sync): memoize the agent-status projection per entry (#10787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(mobile-sync): memoize the agent-status projection per entry buildRuntimeMobileAgentStatusProjection re-serialized every live agent on every status ping. setAgentStatus replaces one entry and re-spreads the map, which defeats the reference-equality skip gate, so each ping paid for every other agent's prompt, 20-entry history, and 8 KB assistant message to discover they had not changed. Memoize each row's JSON by entry identity, the cachedTabsProjection pattern already used a few functions above. Per ping: 0.18 ms -> 0.014 ms at 8 agents, 0.88 ms -> 0.063 ms at 40. The output is byte-identical — joining pre-serialized rows matches whole-array stringify, which the new test pins against a verbatim copy of the old implementation. * test(perf): stop inflating the projection benchmark baseline The pre-fix arm stringified each row and parsed it back before stringifying the array, a per-row roundtrip the original never paid. That made the baseline artificially slow: the reported 5.9x-14.0x is really 2.1x-5.0x. Share one row builder between both arms, and check equivalence after a ping as well as on the cold call — a stale-row bug can only surface once the cache is actually exercised, which the cold-path check could never catch. --- ...bile-agent-status-projection-benchmark.mjs | 192 ++++++++++++++++++ ...time-graph-agent-status-projection.test.ts | 120 +++++++++++ .../src/runtime/sync-runtime-graph.ts | 103 +++++++--- 3 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 config/scripts/mobile-agent-status-projection-benchmark.mjs create mode 100644 src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts diff --git a/config/scripts/mobile-agent-status-projection-benchmark.mjs b/config/scripts/mobile-agent-status-projection-benchmark.mjs new file mode 100644 index 00000000000..8d5d58a2dee --- /dev/null +++ b/config/scripts/mobile-agent-status-projection-benchmark.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// Benchmark: cost of the mobile agent-status projection per store mutation. +// +// buildRuntimeMobileAgentStatusProjection runs on the App.tsx global store +// subscriber. setAgentStatus replaces one entry and re-spreads +// agentStatusByPaneKey, which defeats the reference-equality skip gate, so before +// the fix EVERY live agent was re-serialized on EVERY status ping — each carrying +// a prompt, a 20-entry stateHistory, toolInput, and an 8 KB-capped +// lastAssistantMessage. +// +// The fix memoizes each row's JSON by entry identity, mirroring the +// cachedTabsProjection pattern already in the same file, so a ping re-serializes +// only the agent that actually changed. +// +// The bucket width is re-read from the real module so a drifted constant fails +// loudly here instead of quietly changing what this benchmark measures. +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const GRAPH_SOURCE = readFileSync( + fileURLToPath(new URL('../../src/renderer/src/runtime/sync-runtime-graph.ts', import.meta.url)), + 'utf8' +) + +const bucketMatch = GRAPH_SOURCE.match(/AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = ([0-9_]+)/) +if (!bucketMatch) { + throw new Error( + 'sync-runtime-graph.ts no longer defines the updatedAt bucket; re-sync this benchmark.' + ) +} +const BUCKET_MS = Number(bucketMatch[1].replaceAll('_', '')) + +const ITERATIONS = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_ITERATIONS ?? '400', 10) +const WARMUP = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_WARMUP ?? '60', 10) + +for (const [name, value] of [ + ['ORCA_AGENT_PROJECTION_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_AGENT_PROJECTION_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function toRow(paneKey, entry) { + return { + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + } +} + +function serializeEntry(paneKey, entry) { + return JSON.stringify(toRow(paneKey, entry)) +} + +// Pre-fix: build plain rows and stringify the array once — no per-row roundtrip, +// which the original never paid and which would inflate the reported speedup. +function buildFull(map) { + return JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey, entry]) => toRow(paneKey, entry)) + ) +} + +// Post-fix: reuse each row's JSON while its entry object is unchanged. +function makeCachedBuilder() { + let cache = null + return (map) => { + if (cache?.source === map) { + return cache.projection + } + const previous = cache?.entries + const entries = new Map() + const parts = [] + for (const [paneKey, entry] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) { + const prior = previous?.get(paneKey) + const row = + prior?.entry === entry ? prior : { entry, projection: serializeEntry(paneKey, entry) } + entries.set(paneKey, row) + parts.push(row.projection) + } + const projection = `[${parts.join(',')}]` + cache = { source: map, entries, projection } + return projection + } +} + +// A live agent as the store actually holds it. +function makeEntry(index, updatedAt) { + return { + paneKey: `tab-${index}:leaf-0`, + state: 'working', + prompt: 'implement the feature and run the tests '.repeat(4), + updatedAt, + stateStartedAt: 1740000000000, + agentType: 'claude', + terminalTitle: `agent ${index}`, + stateHistory: Array.from({ length: 20 }, (_value, step) => ({ + state: 'working', + prompt: `step ${step} of the current turn`, + startedAt: 1740000000000 + step, + interrupted: null + })), + toolName: 'shell_command', + toolInput: 'rg --line-number "pattern" src/ '.repeat(8), + interactivePrompt: null, + // The cap the store applies to assistant text. + lastAssistantMessage: 'x'.repeat(8000), + interrupted: null + } +} + +function makeMap(agents) { + const map = {} + for (let index = 0; index < agents; index += 1) { + map[`tab-${index}:leaf-0`] = makeEntry(index, 1740000000000 + index * BUCKET_MS) + } + return map +} + +// One status ping: one entry replaced, the map re-spread, every other entry +// reference-identical — exactly what setAgentStatus produces. +function ping(map, round) { + return { + ...map, + 'tab-0:leaf-0': makeEntry(0, 1740000000000 + BUCKET_MS * (round + 1)) + } +} + +function measure(build, map) { + let current = map + for (let index = 0; index < WARMUP; index += 1) { + current = ping(current, index) + build(current) + } + const samples = [] + for (let round = 0; round < 5; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + current = ping(current, index) + build(current) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[2] +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Mobile agent-status projection, per status ping (one agent changed)') +console.log(`bucket=${BUCKET_MS}ms iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log(`${pad('agents', 8)} ${pad('full', 11)} ${pad('cached', 11)} ${pad('speedup', 9)}`) +for (const agents of [3, 8, 20, 40]) { + const map = makeMap(agents) + const cachedBuilder = makeCachedBuilder() + if (buildFull(map) !== cachedBuilder(map)) { + throw new Error(`projection mismatch at ${agents} agents`) + } + // Why also after a ping: the cold call reuses nothing, so a stale-row bug would + // only surface once the cache is actually exercised. + const pinged = ping(map, 0) + if (buildFull(pinged) !== cachedBuilder(pinged)) { + throw new Error(`projection mismatch after a ping at ${agents} agents`) + } + const full = measure(buildFull, map) + const cached = measure(makeCachedBuilder(), map) + console.log( + `${pad(agents, 8)} ${pad(`${full.toFixed(4)} ms`, 11)} ${pad(`${cached.toFixed(4)} ms`, 11)} ${pad(`${(full / cached).toFixed(1)}x`, 9)}` + ) +} +console.log( + '\nThis runs on the global store subscriber, so the cost is paid per status ping\nand scales with the number of agents running in parallel — the workload this\napp exists for.' +) diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts new file mode 100644 index 00000000000..551e584decd --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, + buildRuntimeMobileAgentStatusProjectionForTests, + resetRuntimeMobileAgentStatusProjectionCacheForTests +} from './sync-runtime-graph' + +// Reference: the pre-change whole-array serialization, kept verbatim. The bucket +// width is read from the module under test so a drifted constant cannot make this +// reference silently disagree for a reason unrelated to the change. +const BUCKET_MS = AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS +function referenceProjection(map: AppState['agentStatusByPaneKey']): string { + return JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey, entry]) => ({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + })) + ) +} + +function makeEntry(index: number, overrides: Record = {}): never { + return { + paneKey: `tab-${index}:leaf-0`, + state: 'working', + prompt: `prompt ${index} with "quotes" and \\ backslash`, + updatedAt: 1740000000000 + index * 17, + stateStartedAt: 1740000000000, + agentType: 'claude', + terminalTitle: `agent ${index} 日本 \u{1f389}`, + stateHistory: Array.from({ length: 3 }, (_value, h) => ({ + state: 'working', + prompt: `step ${h}`, + startedAt: 1740000000000 + h + })), + toolName: 'shell_command', + toolInput: 'ls -la', + lastAssistantMessage: 'answer', + ...overrides + } as never +} + +describe('mobile agent-status projection equivalence', () => { + it('matches the whole-array serialization across shapes and cache reuse', () => { + resetRuntimeMobileAgentStatusProjectionCacheForTests() + const shapes: AppState['agentStatusByPaneKey'][] = [] + shapes.push({}) + shapes.push({ 'tab-0:leaf-0': makeEntry(0) }) + const many: AppState['agentStatusByPaneKey'] = {} + for (let index = 0; index < 12; index += 1) { + many[`tab-${index}:leaf-0`] = makeEntry(index) + } + shapes.push(many) + // Optional fields absent entirely, which the ?? null fallbacks must cover. + shapes.push({ + 'tab-9:leaf-1': makeEntry(9, { + agentType: undefined, + terminalTitle: undefined, + toolName: undefined, + toolInput: undefined, + interactivePrompt: undefined, + lastAssistantMessage: undefined, + interrupted: undefined + }) + }) + // Keys deliberately out of insertion order to pin the sort. + shapes.push({ + 'tab-z:leaf-0': makeEntry(2), + 'tab-a:leaf-0': makeEntry(1), + 'tab-m:leaf-0': makeEntry(3) + }) + + for (const [index, shape] of shapes.entries()) { + expect({ + index, + projection: buildRuntimeMobileAgentStatusProjectionForTests(shape) + }).toEqual({ index, projection: referenceProjection(shape) }) + } + + // Now exercise the cache: replace one entry the way setAgentStatus does and + // confirm the reused rows still produce the reference output. The changes must + // be observable in the projection — a sub-bucket updatedAt nudge is not, so a + // stale-entry reuse bug would slip through. + let current = many + for (let round = 0; round < 4; round += 1) { + current = { + ...current, + 'tab-0:leaf-0': makeEntry(0, { + updatedAt: 1740000000000 + BUCKET_MS * 3 * (round + 1), + state: round % 2 === 0 ? 'done' : 'working', + prompt: `changed prompt round ${round}`, + lastAssistantMessage: `answer round ${round}` + }) + } + expect({ + round, + projection: buildRuntimeMobileAgentStatusProjectionForTests(current) + }).toEqual({ round, projection: referenceProjection(current) }) + } + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 340af3c3d59..7d90d25d107 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -68,6 +68,15 @@ type TabsProjectionCache = { entries: Map projection: string } +type AgentStatusProjectionCacheEntry = { + entry: AppState['agentStatusByPaneKey'][string] + projection: string +} +type AgentStatusProjectionCache = { + source: AppState['agentStatusByPaneKey'] + entries: Map + projection: string +} const registeredTabs = new Map() // Why: registration time suppresses the "no live transport" warning during the async PTY-connect window; after the grace period it's a real stuck state. @@ -129,6 +138,7 @@ function jsonContentEquals(a: unknown, b: unknown): boolean { return true } let cachedTabsProjection: TabsProjectionCache | null = null +let cachedAgentStatusProjection: AgentStatusProjectionCache | null = null let cachedOpenFileIndexesSource: AppState['openFiles'] | null = null let cachedOpenFileIndexes: OpenFileIndexes | null = null let cachedEditorDraftsSource: AppState['editorDrafts'] | null = null @@ -491,35 +501,76 @@ function buildRuntimeMobileEditorDraftsProjection(editorDrafts: AppState['editor ) } +function serializeRuntimeMobileAgentStatusEntry( + paneKey: string, + entry: AppState['agentStatusByPaneKey'][string] +): string { + return JSON.stringify({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + // Why: include so a newly-captured AskUserQuestion prompt re-fires the mobile republish even when no other field changed. + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + }) +} + function buildRuntimeMobileAgentStatusProjection( agentStatusByPaneKey: AppState['agentStatusByPaneKey'] ): string { - return JSON.stringify( - Object.entries(agentStatusByPaneKey) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([paneKey, entry]) => ({ - paneKey, - entryPaneKey: entry.paneKey, - state: entry.state, - prompt: entry.prompt, - updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), - stateStartedAt: entry.stateStartedAt, - agentType: entry.agentType ?? null, - terminalTitle: entry.terminalTitle ?? null, - stateHistory: entry.stateHistory.map((history) => ({ - state: history.state, - prompt: history.prompt, - startedAt: history.startedAt, - interrupted: history.interrupted ?? null - })), - toolName: entry.toolName ?? null, - toolInput: entry.toolInput ?? null, - // Why: include so a newly-captured AskUserQuestion prompt re-fires the mobile republish even when no other field changed. - interactivePrompt: entry.interactivePrompt ?? null, - lastAssistantMessage: entry.lastAssistantMessage ?? null, - interrupted: entry.interrupted ?? null - })) - ) + if (cachedAgentStatusProjection?.source === agentStatusByPaneKey) { + return cachedAgentStatusProjection.projection + } + + // Why per-entry: a status ping replaces one entry and re-spreads the map, so + // without this every other live agent — each carrying a 20-entry history and an + // 8 KB message — is re-serialized to discover it did not change. + const previousEntries = cachedAgentStatusProjection?.entries + const entries = new Map() + const parts: string[] = [] + + for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => + a.localeCompare(b) + )) { + const previous = previousEntries?.get(paneKey) + const cached = + previous?.entry === entry + ? previous + : { entry, projection: serializeRuntimeMobileAgentStatusEntry(paneKey, entry) } + entries.set(paneKey, cached) + parts.push(cached.projection) + } + + const projection = `[${parts.join(',')}]` + cachedAgentStatusProjection = { source: agentStatusByPaneKey, entries, projection } + return projection +} + +export function buildRuntimeMobileAgentStatusProjectionForTests( + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] +): string { + return buildRuntimeMobileAgentStatusProjection(agentStatusByPaneKey) +} + +export const AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS = + AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS + +export function resetRuntimeMobileAgentStatusProjectionCacheForTests(): void { + cachedAgentStatusProjection = null } export function runtimeMobileSessionSyncKeysEqual( From 3200f7251c562c61e65cc09f904dd76064eae8f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:31:58 -0700 Subject: [PATCH 118/150] perf(startup): stop parsing qrcode and @linear/sdk at launch (#10788) Both are reachable only from features most users never touch, but both were in the main bundle's eager top-level require block. @linear/sdk is the sharper case: linear-sdk.ts exists solely to load that ~2.6 MB CJS bundle lazily, and a single value import in issue-relation-write.ts defeated it for everyone. That file now imports the type and goes through the loader. qrcode is only reachable from mobile pairing, and both call sites were already async, so they take a dynamic import. --- src/main/index.ts | 4 +++- src/main/ipc/mobile.ts | 4 +++- src/main/linear/issue-relation-write.ts | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ec2dbdc1b63..b81810f3d87 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,6 @@ import { isAbsolute, join } from 'node:path' import os from 'node:os' import { app, BrowserWindow, dialog, ipcMain, nativeTheme, type Tray } from 'electron' import { electronApp, is } from '@electron-toolkit/utils' -import * as QRCode from 'qrcode' import { Store, initDataPath, @@ -1559,6 +1558,9 @@ function getBundledWebClientRoot(): string | undefined { } async function renderTerminalPairingQr(pairingUrl: string): Promise { + // Why dynamic: qrcode is only reachable from mobile pairing, so launch should + // not parse it for the majority who never pair a device. + const QRCode = await import('qrcode') try { return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) } catch { diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 3482553bde1..fd46171a032 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -1,6 +1,5 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' -import QRCode from 'qrcode' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import { isTailnetIPv4Address } from '../../shared/tailnet-address' @@ -137,6 +136,9 @@ export function registerMobileHandlers( return { available: false as const } } + // Why dynamic: pairing is the only consumer, so launch should not parse + // the qrcode bundle for users who never pair a device. + const { default: QRCode } = await import('qrcode') const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, { errorCorrectionLevel: 'M', margin: 2, diff --git a/src/main/linear/issue-relation-write.ts b/src/main/linear/issue-relation-write.ts index af3f95e9b63..6f9ce71c634 100644 --- a/src/main/linear/issue-relation-write.ts +++ b/src/main/linear/issue-relation-write.ts @@ -1,4 +1,5 @@ -import { LinearClient } from '@linear/sdk' +import type { LinearClient } from '@linear/sdk' +import { loadLinearSdk } from './linear-sdk' import type { LinearIssueRelationship, LinearIssueRelationWriteResult @@ -34,7 +35,7 @@ export async function writeIssueRelation(params: { await acquire() try { const client = params.signal - ? new LinearClient({ apiKey: entry.apiKey, signal: params.signal }) + ? new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, signal: params.signal }) : entry.client const existing = await findExistingRelation(client, params) if (params.operation === 'add' && existing) { From dad3e3c9ef11009781c9dad0030d6b7dc28ad940 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:51:26 -0700 Subject: [PATCH 119/150] fix(codex): rank the Codex resume rescan by selected account, not settings order (#10801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy Codex session-id rescan (used when a persisted record has no transcriptPath) returned the FIRST trusted home holding a rollout with that id. That home becomes the pane's CODEX_HOME — i.e. it picks the ACCOUNT — and the list ended in per-account homes ordered by settings INSERTION order, so the account was decided by whichever one the user happened to add first. Ranks instead: selected account -> real system home -> shared runtime mirror -> everything else by normalized path. Both ranking inputs are required (an optional one would silently degrade to pure path order), and the selection arrives as a thunk so the common provenance-present resume never stats the ownership marker for a ranking it never runs. The mirror needs its own tier: prepareLegacySharedCodexSessionResume's guard only fires when the resolved home IS the mirror, and that guard is what migrates the rollout into ~/.codex. Without it a system-default selection silently resumed under an arbitrary account and stayed pinned there permanently once the hook stamped a transcript path. Reviewed over two independent rounds; every tier individually mutation-proved. Live-validated in a real Orca dev build by reading the spawned PTY's actual CODEX_HOME across three builds (head, base, and head-minus-the-mirror-tier). Note: fixes none of #10757's user-visible symptoms on its own — it is a correctness precondition. Verified on macOS only; Windows coverage is fixture-only. --- .../codex-accounts/runtime-home-service.ts | 16 ++ .../codex/codex-session-resume-home.test.ts | 199 +++++++++++++++++- src/main/codex/codex-session-resume-home.ts | 70 +++++- src/main/index.ts | 12 +- 4 files changed, 291 insertions(+), 6 deletions(-) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 9b1340b3e70..ee172a81e20 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -362,6 +362,22 @@ export class CodexRuntimeHomeService { return homes.filter((home, index) => homes.indexOf(home) === index) } + /** + * The account-owned CODEX_HOME the current HOST selection runs against, or + * null when the selection is not routed to one (system default, or the + * flag-OFF shared mirror, which every account hot-swaps and so names no + * account). + * + * Read-only on purpose: session discovery ranks homes with this before any + * launch prep, so it must create no directories and sync no auth. + */ + getSelectedHostAccountCodexHomePath(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + return selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null + } + // Why: the real-home hook installer flips this gate off when the trust-grant // client reports the host incapable, keeping that host byte-identical to the // managed lane instead of shipping status-blind panes. diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index f2bf77107a6..94a7a89f3fe 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -10,6 +10,13 @@ import { const tempRoots: string[] = [] +// Why: the ranking inputs are required by design; cases below that never reach the rescan stay neutral. +const withoutHomeRanking = { + getSelectedAccountCodexHome: (): string | null => null, + systemCodexHomePath: null, + sharedRuntimeCodexHomePath: null +} + afterEach(() => { for (const root of tempRoots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -116,7 +123,8 @@ describe('resolveTrustedCodexSessionResumeHome', () => { findTrustedCodexSessionResume({ sessionId: 'session-a', transcriptPath: plainPath, - trustedCodexHomes: [homePath] + trustedCodexHomes: [homePath], + ...withoutHomeRanking }) ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) @@ -125,7 +133,8 @@ describe('resolveTrustedCodexSessionResumeHome', () => { findTrustedCodexSessionResume({ sessionId: 'session-a', transcriptPath: compressedPath, - trustedCodexHomes: [homePath] + trustedCodexHomes: [homePath], + ...withoutHomeRanking }) ).resolves.toEqual({ homePath, transcriptPath: plainPath }) }) @@ -149,7 +158,8 @@ describe('resolveTrustedCodexSessionResumeHome', () => { findTrustedCodexSessionResume({ sessionId, transcriptPath: undefined, - trustedCodexHomes: [homePath] + trustedCodexHomes: [homePath], + ...withoutHomeRanking }) ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) }) @@ -169,6 +179,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId, transcriptPath: undefined, trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'], + ...withoutHomeRanking, listSessionFiles }) ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath }) @@ -186,6 +197,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId: 'session-a', transcriptPath, trustedCodexHomes: ['/managed/account/home'], + ...withoutHomeRanking, fileIsRegular: () => true, listSessionFiles }) @@ -204,6 +216,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId, transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`, trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'], + ...withoutHomeRanking, fileIsRegular: () => false, listSessionFiles }) @@ -220,12 +233,192 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId: '../session', transcriptPath: undefined, trustedCodexHomes: ['/Users/example/.codex'], + ...withoutHomeRanking, listSessionFiles }) ).resolves.toBeNull() }) }) +describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const systemHome = join('/Users', 'example', '.codex') + const sharedMirror = join('/userData', 'codex-runtime-home', 'home') + const accountAHome = join('/userData', 'codex-accounts', 'account-a', 'home') + const accountBHome = join('/userData', 'codex-accounts', 'account-b', 'home') + + const rolloutIn = (homePath: string): string => + join(homePath, 'sessions', '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`) + + // Why: one id already lives in several homes on main — the one-shot migrateLegacySessions copies + // each per-account rollout into the shared mirror and leaves the original. #10770 widens this to + // every managed home. Either way the id alone stops naming an account. + const listRolloutInEveryHome = async function* (sessionsRoot: string): AsyncIterable { + yield join(sessionsRoot, '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`) + } + + const listRolloutIn = (...homePaths: string[]) => + async function* (sessionsRoot: string): AsyncIterable { + if (homePaths.some((homePath) => sessionsRoot === join(homePath, 'sessions'))) { + yield* listRolloutInEveryHome(sessionsRoot) + } + } + + it('resumes into the selected account home whatever order the homes arrive in', async () => { + for (const trustedCodexHomes of [ + [systemHome, sharedMirror, accountAHome, accountBHome], + [systemHome, sharedMirror, accountBHome, accountAHome], + [accountBHome, accountAHome, sharedMirror, systemHome] + ]) { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes, + getSelectedAccountCodexHome: () => accountBHome, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) }) + } + }) + + it('falls back to the real system home when no account home is selected', async () => { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [sharedMirror, accountAHome, systemHome], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ homePath: systemHome, transcriptPath: rolloutIn(systemHome) }) + }) + + // Why: `/Users/…` already wins the tier-3 byte order, so the case above cannot tell the + // system-home tier apart from the path tie-break. Pin it with a home that sorts last. + it('ranks the real system home above the others even when its path sorts last', async () => { + const lateSortingSystemHome = join('/var', 'lib', 'orca', '.codex') + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [sharedMirror, accountAHome, lateSortingSystemHome], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: lateSortingSystemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ + homePath: lateSortingSystemHome, + transcriptPath: rolloutIn(lateSortingSystemHome) + }) + }) + + it('orders the remaining homes by path so insertion order never decides', async () => { + for (const trustedCodexHomes of [ + [accountBHome, accountAHome], + [accountAHome, accountBHome] + ]) { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes, + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ homePath: accountAHome, transcriptPath: rolloutIn(accountAHome) }) + } + }) + + // Why: the mirror is the only home whose win migrates the rollout into ~/.codex + // (prepareLegacySharedCodexSessionResume), which is how a system-default selection resumes on the + // real home. 'codex-accounts' sorts before 'codex-runtime-home', so path order alone would hand + // that selection to an arbitrary account instead. One-shot legacy migration already copies + // per-account rollouts into the mirror, so both really do hold the id. + it('prefers the shared mirror over a per-account home when the system home lacks the id', async () => { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [systemHome, accountAHome, sharedMirror], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutIn(sharedMirror, accountAHome) + }) + ).resolves.toEqual({ homePath: sharedMirror, transcriptPath: rolloutIn(sharedMirror) }) + }) + + it('ranks Windows homes case-insensitively and keeps the caller path spelling', async () => { + const windowsRoot = 'C:\\Users\\Example' + const windowsSystemHome = `${windowsRoot}\\.codex` + const windowsAccountAHome = `${windowsRoot}\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home` + const windowsAccountBHome = `${windowsRoot}\\AppData\\Roaming\\Orca\\codex-accounts\\b\\home` + const windowsRolloutIn = (homePath: string): string => + `${join(homePath, 'sessions')}\\2026\\07\\20\\rollout-2026-07-20T15-50-19-${sessionId}.jsonl` + const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable { + yield `${sessionsRoot}\\2026\\07\\20\\rollout-2026-07-20T15-50-19-${sessionId}.jsonl` + } + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [windowsSystemHome, windowsAccountAHome, windowsAccountBHome], + // Why: settings and discovery can disagree on drive/segment case; the selection must still match. + getSelectedAccountCodexHome: () => windowsAccountBHome.toLowerCase(), + systemCodexHomePath: windowsSystemHome, + sharedRuntimeCodexHomePath: null, + listSessionFiles + }) + ).resolves.toEqual({ + homePath: windowsAccountBHome, + transcriptPath: windowsRolloutIn(windowsAccountBHome) + }) + }) + + it('still resumes the only home holding the id, selected or not', async () => { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [systemHome, sharedMirror, accountAHome, accountBHome], + getSelectedAccountCodexHome: () => accountAHome, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutIn(accountBHome) + }) + ).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) }) + }) + + it('does not rescan into the selected account home when transcript provenance was rejected', async () => { + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: rolloutIn(accountBHome), + trustedCodexHomes: [systemHome, accountAHome, accountBHome], + getSelectedAccountCodexHome: () => accountAHome, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + fileIsRegular: () => false, + listSessionFiles + }) + ).resolves.toBeNull() + expect(listSessionFiles).not.toHaveBeenCalled() + }) +}) + describe('claimsCodexRolloutLayout', () => { it('is true for a rollout path even if the file is missing', () => { expect( diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index 639d2620eb7..e702eda6278 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -93,10 +93,78 @@ export function claimsCodexRolloutLayout(transcriptPath: string | undefined): bo return CODEX_ROLLOUT_LAYOUT_PATH.test(persistedPath.replace(/\\/g, '/')) } +/** + * Orders trusted homes for the legacy id rescan, lowest wins. + * + * The winning home becomes the resumed pane's CODEX_HOME, so it picks the + * account. Rank the currently selected account's own home first — once the same + * rollout sits in several homes the id alone no longer names an account, and + * resuming under the account the user has selected is what + * they asked for. The real system home ranks next because codex refreshes it + * directly. Everything else is ordered by normalized path so no winner ever + * depends on the order accounts happen to sit in settings. + * + * The shared runtime mirror ranks above the remaining homes because winning is + * not inert for it: with no account selected it is the only home that triggers + * the legacy migration into the real system home + * (prepareLegacySharedCodexSessionResume, which also requires the system-default + * selection), and that migration is how such a resume lands on ~/.codex instead + * of some account's home. Letting a per-account home outrank it by mere path + * order would silently route that selection to an account the user did not pick. + * With an account selected the migration cannot fire either way, so this tier + * just preserves the pre-ranking order rather than inventing a new winner. + * + * All inputs are required, not optional: a caller that forgot one would + * silently degrade to pure path order, which is the accident this exists to + * remove. The selection arrives as a thunk because resolving it stats the + * account's ownership marker, and the far more common provenance-present + * resume never reaches the ranking at all. + */ +function rankTrustedCodexHomesForRescan(args: { + trustedCodexHomes: readonly string[] + getSelectedAccountCodexHome: () => string | null + systemCodexHomePath: string | null + sharedRuntimeCodexHomePath: string | null +}): string[] { + const toComparisonHome = (value: string | null | undefined): string | null => { + const trimmed = value?.trim() + return trimmed ? normalizeRuntimePathForComparison(trimmed) : null + } + const selectedComparison = toComparisonHome(args.getSelectedAccountCodexHome()) + const systemComparison = toComparisonHome(args.systemCodexHomePath) + const sharedRuntimeComparison = toComparisonHome(args.sharedRuntimeCodexHomePath) + const rankOf = (comparisonHome: string): number => { + if (selectedComparison && comparisonHome === selectedComparison) { + return 0 + } + if (systemComparison && comparisonHome === systemComparison) { + return 1 + } + return sharedRuntimeComparison && comparisonHome === sharedRuntimeComparison ? 2 : 3 + } + return args.trustedCodexHomes + .map((homePath) => ({ homePath, comparisonHome: normalizeRuntimePathForComparison(homePath) })) + .sort((left, right) => { + const rankDelta = rankOf(left.comparisonHome) - rankOf(right.comparisonHome) + if (rankDelta !== 0) { + return rankDelta + } + return left.comparisonHome < right.comparisonHome + ? -1 + : left.comparisonHome > right.comparisonHome + ? 1 + : 0 + }) + .map((entry) => entry.homePath) +} + export async function findTrustedCodexSessionResume(args: { sessionId: string transcriptPath: string | undefined trustedCodexHomes: readonly string[] + getSelectedAccountCodexHome: () => string | null + systemCodexHomePath: string | null + sharedRuntimeCodexHomePath: string | null fileIsRegular?: (filePath: string) => boolean listSessionFiles?: (sessionsRoot: string) => AsyncIterable }): Promise<{ homePath: string; transcriptPath: string } | null> { @@ -118,7 +186,7 @@ export async function findTrustedCodexSessionResume(args: { listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 })) const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase() const seenHomes = new Set() - for (const homePath of args.trustedCodexHomes) { + for (const homePath of rankTrustedCodexHomesForRescan(args)) { const comparisonHome = normalizeRuntimePathForComparison(homePath) if (seenHomes.has(comparisonHome)) { continue diff --git a/src/main/index.ts b/src/main/index.ts index b81810f3d87..f512d7bf2a6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -175,7 +175,7 @@ import { claimsCodexRolloutLayout, findTrustedCodexSessionResume } from './codex/codex-session-resume-home' -import { getSystemCodexHomePath } from './codex/codex-home-paths' +import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/codex-home-paths' import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' import { getDefaultWslDistro } from './wsl' @@ -851,7 +851,15 @@ async function prepareCodexSessionResumeForLaunch(args: { const sessionSource = await findTrustedCodexSessionResume({ sessionId: args.providerSession.id, transcriptPath: args.providerSession.transcriptPath, - trustedCodexHomes: trustedHomes + trustedCodexHomes: trustedHomes, + // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; + // rank it by the current selection so settings insertion order can never decide the account. + // Lazy: only the legacy branch ranks, so a provenance-present resume never stats the marker. + getSelectedAccountCodexHome: () => codexRuntimeHome!.getSelectedHostAccountCodexHomePath(), + systemCodexHomePath: systemHomePath, + // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must + // outrank the path-sorted account homes or a system-default selection resumes as an account. + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath() }) if (!sessionSource) { // Why: an unverifiable Codex rollout still blocks; only paths that never claimed Codex From c3a9a5e8a637ac76457dee15a7537b7aa1573741 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:21:29 -0700 Subject: [PATCH 120/150] fix(sidebar): make worktree drag reorder follow the card, not the pointer (#10845) --- .../src/components/sidebar/WorktreeList.tsx | 103 +++++--- .../sidebar/worktree-drag-preview-offsets.ts | 19 +- .../sidebar/worktree-manual-order.test.ts | 16 +- .../worktree-sidebar-drag-autoscroll.test.ts | 9 +- .../worktree-sidebar-drag-autoscroll.ts | 20 +- .../worktree-sidebar-drag-geometry.test.ts | 239 +++++++++++++----- .../sidebar/worktree-sidebar-drag-geometry.ts | 128 ++++++++-- .../worktree-sidebar-drop-preview.test.ts | 71 +++++- .../sidebar/worktree-sidebar-drop-preview.ts | 129 ++++++++-- .../worktree-sidebar-pointer-drag-dom.ts | 4 +- 10 files changed, 569 insertions(+), 169 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 9cbb151f09c..211b4968d76 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -194,7 +194,12 @@ import { type WorktreeSidebarDragSession, type WorktreeSidebarDragPoint } from './worktree-sidebar-drag-autoscroll' -import { holdWorktreeSidebarDragRects } from './worktree-sidebar-drag-geometry' +import { + getWorktreeSidebarDragGrab, + shouldReevaluateWorktreeSidebarDropAnchor, + type WorktreeSidebarDragGrab, + type WorktreeSidebarDropAnchor +} from './worktree-sidebar-drag-geometry' import { computeWorktreeSidebarDropPreview, resolveWorktreeSidebarStatusDropCommitTarget, @@ -1405,7 +1410,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp [worktreeLineageById, worktreeMap] ) const worktreeDragSessionRef = useRef(null) - const heldStatusDropRectsRef = useRef>(new Map()) + // Why: cross-group hovers hit-test a group the session never captured, so hold + // that group's drop decision separately or a card expanding in the target group + // moves the insertion line under a still pointer. + const statusDropAnchorsRef = useRef>(new Map()) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) const worktreePointerAutoscrollLastFrameTimeRef = useRef(null) @@ -1595,9 +1603,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp pointerY: number groupKey: string rects: readonly WorktreeSidebarDragRect[] - liveRects?: readonly WorktreeSidebarDragRect[] draggedIds: readonly string[] draggingWorktreeId?: string | null + grab?: WorktreeSidebarDragGrab | null + anchor?: WorktreeSidebarDropAnchor | null }): WorktreeSidebarDropPreview | null => { const container = scrollRef.current if (!container) { @@ -1613,10 +1622,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp containerTop: containerRect.top, scrollTop: container.scrollTop, rects: args.rects, - liveRects: args.liveRects, groupIds: group.worktreeIds, draggedIds: args.draggedIds, - draggingWorktreeId: args.draggingWorktreeId + draggingWorktreeId: args.draggingWorktreeId, + grab: args.grab, + anchor: args.anchor }) }, [worktreeDragUnitGroups] @@ -1624,17 +1634,34 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const computeWorktreeDrop = useCallback( (pointerY: number): WorktreeSidebarDropPreview | null => { const session = worktreeDragSessionRef.current - if (!session) { + const container = scrollRef.current + if (!session || !container) { return null } - return computeWorktreeDropForGroup({ + const scrollTop = container.scrollTop + // Why: only real pointer or scroll movement should re-decide the slot; a + // card growing under a still pointer must not move it. + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: session.anchor, + pointerY, + scrollTop + }) + ? null + : session.anchor + const preview = computeWorktreeDropForGroup({ pointerY, groupKey: session.sourceGroupKey, rects: session.rects, - liveRects: session.liveRects, draggedIds: session.reorderUnitDraggedIds, - draggingWorktreeId: session.draggingWorktreeId + draggingWorktreeId: session.draggingWorktreeId, + grab: session.grab, + anchor }) + worktreeDragSessionRef.current = { + ...session, + anchor: preview ? { beforeWorktreeId: preview.dropAnchorId, pointerY, scrollTop } : null + } + return preview }, [computeWorktreeDropForGroup] ) @@ -1649,23 +1676,35 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return null } const groupKey = getWorkspaceStatusGroupKey(args.status) - // Why: cross-group hovers re-measure a group the drag session never - // captured, so hold its geometry here too or a card expanding in the - // target group jumps the insertion line under a still pointer. - const liveRects = getWorktreeSidebarDragRectsForGroup(container, groupKey) - const rects = holdWorktreeSidebarDragRects({ - held: heldStatusDropRectsRef.current.get(groupKey), - measured: liveRects + const session = worktreeDragSessionRef.current + const scrollTop = container.scrollTop + const heldAnchor = statusDropAnchorsRef.current.get(groupKey) ?? null + const anchor = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor: heldAnchor, + pointerY: args.pointerY, + scrollTop }) - heldStatusDropRectsRef.current.set(groupKey, rects) - return computeWorktreeDropForGroup({ + ? null + : heldAnchor + const preview = computeWorktreeDropForGroup({ pointerY: args.pointerY, groupKey, - rects, - liveRects, + rects: getWorktreeSidebarDragRectsForGroup(container, groupKey), draggedIds: args.draggedIds, - draggingWorktreeId: worktreeDragSessionRef.current?.draggingWorktreeId ?? null + draggingWorktreeId: session?.draggingWorktreeId ?? null, + grab: session?.grab ?? null, + anchor }) + if (preview) { + statusDropAnchorsRef.current.set(groupKey, { + beforeWorktreeId: preview.dropAnchorId, + pointerY: args.pointerY, + scrollTop + }) + } else { + statusDropAnchorsRef.current.delete(groupKey) + } + return preview }, [computeWorktreeDropForGroup] ) @@ -2672,7 +2711,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp cleanupWorktreePointerDrag() cancelWorktreeNativeAutoscroll() worktreeDragSessionRef.current = null - heldStatusDropRectsRef.current.clear() + statusDropAnchorsRef.current.clear() setWorktreeDragState(WORKTREE_ROW_DRAG_INITIAL_STATE) }, [cancelWorktreeNativeAutoscroll, cleanupWorktreePointerDrag]) @@ -3076,7 +3115,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const beginWorktreePointerDrag = useCallback( (drag: WorktreePointerDrag) => { - const { preview, offsetX, offsetY } = createSidebarDragPreview({ + const { preview, offsetX, offsetY, height } = createSidebarDragPreview({ sourceRow: drag.sourceRow, pointerX: drag.currentX, pointerY: drag.currentY, @@ -3095,7 +3134,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp reorderDraggedIds: drag.reorderDraggedIds, reorderUnitDraggedIds: drag.reorderUnitDraggedIds, rects: drag.rects, - liveRects: drag.rects + // Why: reuse the floating preview's own offset so the hit test tracks the + // card the user sees, not the raw pointer. + grab: getWorktreeSidebarDragGrab({ offsetY, height }), + anchor: null } setWorktreeDragState({ draggingWorktreeId: drag.worktreeId, @@ -3498,11 +3540,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [runWorktreeNativeAutoscrollFrame]) const handleWorktreeCardDragStart = useCallback( - ( - _event: React.DragEvent, - worktreeId: string, - draggedIds: readonly string[] - ) => { + (event: React.DragEvent, worktreeId: string, draggedIds: readonly string[]) => { const sourceGroupKey = worktreeDragGroups.find((group) => group.worktreeIds.includes(worktreeId))?.key ?? null if (!sourceGroupKey) { @@ -3513,6 +3551,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const rects = scrollRef.current ? getWorktreeSidebarDragRectsForGroup(scrollRef.current, sourceGroupKey) : [] + const sourceRect = event.currentTarget.getBoundingClientRect() worktreeDragSessionRef.current = { draggingWorktreeId: worktreeId, sourceGroupKey, @@ -3520,7 +3559,11 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp reorderDraggedIds, reorderUnitDraggedIds, rects, - liveRects: rects + grab: getWorktreeSidebarDragGrab({ + offsetY: event.clientY - sourceRect.top, + height: sourceRect.height + }), + anchor: null } setWorktreeDragState({ draggingWorktreeId: worktreeId, diff --git a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts index 678cee74421..cf97cad8958 100644 --- a/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts +++ b/src/renderer/src/components/sidebar/worktree-drag-preview-offsets.ts @@ -58,20 +58,28 @@ function getPreviewLayoutDraggedIds( return firstVisibleDraggedId ? [firstVisibleDraggedId] : draggedIds.slice(0, 1) } +export type WorktreeDragPreviewLayout = { + offsets: Map + // Top of the slot the dragged card lands in, in the same coordinate space as + // `rects`. Null when the drop is a no-op. The drop indicator draws here so the + // line marks the gap the offsets actually open. + placeholderTop: number | null +} + export function buildWorktreeDragPreviewOffsets(args: { groupIds: readonly string[] draggedIds: readonly string[] draggingWorktreeId?: string | null dropIndex: number rects: readonly WorktreeDragPreviewRect[] -}): Map { +}): WorktreeDragPreviewLayout { const committedNextIds = moveWorktreeIdsWithinGroup( args.groupIds, args.draggedIds, args.dropIndex ) if (arraysEqual(committedNextIds, args.groupIds)) { - return new Map() + return { offsets: new Map(), placeholderTop: null } } // Why: dragging a large multi-select batch should advertise the insertion @@ -83,7 +91,7 @@ export function buildWorktreeDragPreviewOffsets(args: { ) const nextIds = moveWorktreeIdsWithinGroup(args.groupIds, layoutDraggedIds, args.dropIndex) if (arraysEqual(nextIds, args.groupIds)) { - return new Map() + return { offsets: new Map(), placeholderTop: null } } const draggedSet = new Set(layoutDraggedIds) @@ -142,5 +150,8 @@ export function buildWorktreeDragPreviewOffsets(args: { offsets.set(rect.worktreeId, offset) } } - return offsets + return { + offsets, + placeholderTop: targetTopById.get(layoutDraggedIds[0] ?? '') ?? null + } } diff --git a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts index 3847c2168b6..47e728e1cb6 100644 --- a/src/renderer/src/components/sidebar/worktree-manual-order.test.ts +++ b/src/renderer/src/components/sidebar/worktree-manual-order.test.ts @@ -69,7 +69,7 @@ describe('moveWorktreeIdsWithinGroup', () => { describe('buildWorktreeDragPreviewOffsets', () => { it('slides intervening rows up while dragging a row down', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b', 'c', 'd'], draggedIds: ['b'], dropIndex: 4, @@ -88,7 +88,7 @@ describe('buildWorktreeDragPreviewOffsets', () => { }) it('slides intervening rows down while dragging a row up', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b', 'c'], draggedIds: ['c'], dropIndex: 0, @@ -106,7 +106,7 @@ describe('buildWorktreeDragPreviewOffsets', () => { }) it('returns no preview offsets for a no-op hover', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b'], draggedIds: ['a'], dropIndex: 1, @@ -120,7 +120,7 @@ describe('buildWorktreeDragPreviewOffsets', () => { }) it('uses the dragged unit height when previewing variable-height rows', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['parent', 'sibling'], draggedIds: ['parent'], dropIndex: 2, @@ -134,7 +134,7 @@ describe('buildWorktreeDragPreviewOffsets', () => { }) it('uses the dragged unit height when previewing a short row above a tall row', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['parent', 'sibling'], draggedIds: ['sibling'], dropIndex: 0, @@ -148,7 +148,7 @@ describe('buildWorktreeDragPreviewOffsets', () => { }) it('reserves one card-height slot while previewing a multi-select batch', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b', 'c', 'd', 'e'], draggedIds: ['b', 'c', 'd'], draggingWorktreeId: 'b', @@ -170,7 +170,7 @@ describe('buildWorktreeDragPreviewOffsets', () => { }) it('uses the grabbed selected card as the one preview placeholder', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b', 'c', 'd', 'e'], draggedIds: ['b', 'c', 'd'], draggingWorktreeId: 'd', @@ -188,7 +188,7 @@ describe('buildWorktreeDragPreviewOffsets', () => { }) it('returns no preview offsets for a no-op multi-select hover', () => { - const offsets = buildWorktreeDragPreviewOffsets({ + const { offsets } = buildWorktreeDragPreviewOffsets({ groupIds: ['a', 'b', 'c', 'd', 'e'], draggedIds: ['b', 'c', 'd'], draggingWorktreeId: 'b', diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts index f26d5ef6e60..bf0bd66b95d 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.test.ts @@ -22,7 +22,8 @@ const SESSION: WorktreeSidebarDragSession = { reorderDraggedIds: ['b'], reorderUnitDraggedIds: ['b'], rects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }], - liveRects: [{ worktreeId: 'b', groupIndex: 1, top: 48, bottom: 88 }] + grab: null, + anchor: null } describe('getWorktreeSidebarDragAutoscroll', () => { @@ -160,7 +161,7 @@ describe('refreshWorktreeSidebarDragSession', () => { rects }) // Why: the row set changed ('a' mounted), so the fresh measurement is adopted. - ).toEqual({ ...SESSION, rects, liveRects: rects }) + ).toEqual({ ...SESSION, rects }) }) it('clears when the source group is missing', () => { @@ -201,7 +202,7 @@ describe('refreshWorktreeSidebarDragSession', () => { unitGroups: [{ key: 'repo:one', worktreeIds: ['a', 'b'], units: [] }], rects: [] }) - ).toEqual({ ...SESSION, rects: [], liveRects: [] }) + ).toEqual({ ...SESSION, rects: [] }) }) it('keeps child-card reorder drags even when the child is not a top-level unit', () => { @@ -234,7 +235,7 @@ describe('refreshWorktreeSidebarDragSession', () => { ], rects }) - ).toEqual({ ...childSession, rects, liveRects: rects }) + ).toEqual({ ...childSession, rects }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts index 1dd0b5d6989..83bfbb29209 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-autoscroll.ts @@ -1,6 +1,9 @@ import type { WorktreeDragGroup } from './worktree-manual-order' import type { WorktreeDragUnitGroup } from './worktree-drag-units' -import { holdWorktreeSidebarDragRects } from './worktree-sidebar-drag-geometry' +import type { + WorktreeSidebarDragGrab, + WorktreeSidebarDropAnchor +} from './worktree-sidebar-drag-geometry' const EDGE_ZONE_PX = 56 const MAX_OUTSIDE_EDGE_PX = 48 @@ -26,11 +29,12 @@ export type WorktreeSidebarDragSession = { draggedIds: readonly string[] reorderDraggedIds: readonly string[] reorderUnitDraggedIds: readonly string[] - // Why: `rects` are held stable for hit testing so resizing cards cannot move - // the drop target; `liveRects` keep the rendered indicator and row previews - // anchored to where the cards actually are right now. + // Why: one live coordinate space for both hit testing and rendering. Stability + // against mid-drag card resizes comes from holding the drop *decision* + // (`anchor`), not from freezing this geometry. rects: readonly WorktreeSidebarDragRect[] - liveRects: readonly WorktreeSidebarDragRect[] + grab: WorktreeSidebarDragGrab | null + anchor: WorktreeSidebarDropAnchor | null } export type WorktreeSidebarAutoscrollResult = { @@ -195,11 +199,7 @@ export function refreshWorktreeSidebarDragSession(args: { return null } - return { - ...args.session, - rects: holdWorktreeSidebarDragRects({ held: args.session.rects, measured: args.rects }), - liveRects: args.rects - } + return { ...args.session, rects: args.rects } } function getVerticalEdgeIntensity( diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts index c783d53259f..1f44a94f7fa 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import { computeWorktreeSidebarDropPreview } from './worktree-sidebar-drop-preview' -import { holdWorktreeSidebarDragRects } from './worktree-sidebar-drag-geometry' +import { + getWorktreeSidebarDragGrab, + getWorktreeSidebarDragReferenceY, + resolveWorktreeSidebarDropAnchorIndex, + shouldReevaluateWorktreeSidebarDropAnchor, + type WorktreeSidebarDropAnchor +} from './worktree-sidebar-drag-geometry' import { refreshWorktreeSidebarDragSession, type WorktreeSidebarDragRect @@ -23,97 +29,153 @@ function layout(heightByWorktreeId: Readonly>): WorktreeS } const COLLAPSED = layout({}) +const GRAB = { offsetY: CARD_HEIGHT / 2, height: CARD_HEIGHT } function previewAt(args: { pointerY: number rects: readonly WorktreeSidebarDragRect[] - liveRects?: readonly WorktreeSidebarDragRect[] -}): { dropIndex: number; dropIndicatorY: number } | null { - const preview = computeWorktreeSidebarDropPreview({ + anchor?: WorktreeSidebarDropAnchor | null + draggingWorktreeId?: string + grab?: { offsetY: number; height: number } | null +}) { + return computeWorktreeSidebarDropPreview({ pointerY: args.pointerY, containerTop: 0, scrollTop: 0, rects: args.rects, - liveRects: args.liveRects, groupIds: GROUP_IDS, - draggedIds: ['a'], - draggingWorktreeId: 'a' + draggedIds: [args.draggingWorktreeId ?? 'a'], + draggingWorktreeId: args.draggingWorktreeId ?? 'a', + grab: args.grab === undefined ? GRAB : args.grab, + anchor: args.anchor }) - return preview ? { dropIndex: preview.dropIndex, dropIndicatorY: preview.dropIndicatorY } : null +} + +/** + * Replay a drag where the pointer never moves while a card animates open, holding + * the drop decision across frames exactly as the live drag loop does. + */ +function replayStillPointer(args: { + pointerY: number + frames: readonly (readonly WorktreeSidebarDragRect[])[] +}): { dropIndexes: number[]; indicatorYs: number[] } { + let anchor: WorktreeSidebarDropAnchor | null = null + const dropIndexes: number[] = [] + const indicatorYs: number[] = [] + for (const rects of args.frames) { + const held = shouldReevaluateWorktreeSidebarDropAnchor({ + anchor, + pointerY: args.pointerY, + scrollTop: 0 + }) + ? null + : anchor + const preview = previewAt({ pointerY: args.pointerY, rects, anchor: held })! + anchor = { beforeWorktreeId: preview.dropAnchorId, pointerY: args.pointerY, scrollTop: 0 } + dropIndexes.push(preview.dropIndex) + indicatorYs.push(preview.dropIndicatorY) + } + return { dropIndexes, indicatorYs } } describe('worktree sidebar drag geometry under mid-drag card growth', () => { it('keeps the drop target fixed while a card expands under a still pointer', () => { const pointerY = 250 - const before = previewAt({ pointerY, rects: COLLAPSED }) - - // Card 'b' expands its agent list while the pointer does not move at all. const grown = layout({ b: EXPANDED_CARD_HEIGHT }) - const liveDropIndex = previewAt({ pointerY, rects: grown })?.dropIndex - const held = holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: grown }) - const after = previewAt({ pointerY, rects: held, liveRects: grown }) - // Re-measuring live would move the drop target with zero pointer movement. - expect(liveDropIndex).not.toBe(before?.dropIndex) - expect(after?.dropIndex).toBe(before?.dropIndex) + const unheld = previewAt({ pointerY, rects: grown })!.dropIndex + const { dropIndexes } = replayStillPointer({ pointerY, frames: [COLLAPSED, grown] }) + + // Re-deciding from the grown layout would move the target with zero input. + expect(unheld).not.toBe(dropIndexes[0]) + expect(dropIndexes[1]).toBe(dropIndexes[0]) }) - it('never lets a growing card change the drop target across a whole expansion animation', () => { - const pointerY = 250 + it('never lets a growing card change the drop target across a whole expansion', () => { const frames = Array.from({ length: 12 }, (_, frame) => layout({ b: CARD_HEIGHT + ((EXPANDED_CARD_HEIGHT - CARD_HEIGHT) * frame) / 11 }) ) - const live = frames.map((rects) => previewAt({ pointerY, rects })?.dropIndex) - const stabilized = frames.map( - (rects) => - previewAt({ - pointerY, - rects: holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: rects }), - liveRects: rects - })?.dropIndex - ) + for (const pointerY of [150, 250, 350, 450, 550]) { + const unheld = frames.map((rects) => previewAt({ pointerY, rects })!.dropIndex) + const { dropIndexes } = replayStillPointer({ pointerY, frames }) - expect(new Set(live).size).toBeGreaterThan(1) - expect(new Set(stabilized)).toEqual( - new Set([previewAt({ pointerY, rects: COLLAPSED })?.dropIndex]) - ) + expect(new Set(dropIndexes).size).toBe(1) + // The scenario has to be one that actually moves without the hold. + if (pointerY !== 150) { + expect(new Set(unheld).size).toBeGreaterThan(1) + } + } }) - it('still tracks the pointer normally while geometry is held', () => { - const held = holdWorktreeSidebarDragRects({ - held: COLLAPSED, - measured: layout({ b: EXPANDED_CARD_HEIGHT }) - }) - - expect(previewAt({ pointerY: 100, rects: held })?.dropIndex).toBeLessThan( - previewAt({ pointerY: 500, rects: held })?.dropIndex ?? -1 + it('slides the indicator with the gap it marks while geometry is held', () => { + const frames = Array.from({ length: 12 }, (_, frame) => + layout({ b: CARD_HEIGHT + ((EXPANDED_CARD_HEIGHT - CARD_HEIGHT) * frame) / 11 }) ) + const { dropIndexes, indicatorYs } = replayStillPointer({ pointerY: 350, frames }) + + expect(new Set(dropIndexes).size).toBe(1) + // Held decision, live rendering: the line tracks the growing card, never freezes. + expect(indicatorYs.at(-1)!).toBeGreaterThan(indicatorYs[0]!) + expect(new Set(indicatorYs).size).toBe(frames.length) }) - it('draws the indicator at the live position so a grown card does not strand it', () => { + it('still tracks the pointer normally once it moves again', () => { const grown = layout({ b: EXPANDED_CARD_HEIGHT }) - const held = holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: grown }) - const preview = previewAt({ pointerY: 250, rects: held, liveRects: grown }) - const dropIndex = preview?.dropIndex ?? -1 - expect(preview?.dropIndicatorY).toBe(grown[dropIndex]!.top - 3) - // Held geometry alone would have parked the line ~288px above the real gap. - expect(preview?.dropIndicatorY).not.toBe(COLLAPSED[dropIndex]!.top - 3) + expect(previewAt({ pointerY: 100, rects: grown })!.dropIndex).toBeLessThan( + previewAt({ pointerY: 800, rects: grown })!.dropIndex + ) }) - it('adopts fresh geometry when rows mount or change slot mid-drag', () => { - const reordered = COLLAPSED.map((rect, index) => ({ - ...rect, - worktreeId: GROUP_IDS[(index + 1) % GROUP_IDS.length]! - })) + it('re-evaluates on real pointer or scroll movement but not on jitter', () => { + const anchor: WorktreeSidebarDropAnchor = { + beforeWorktreeId: 'c', + pointerY: 250, + scrollTop: 40 + } - expect(holdWorktreeSidebarDragRects({ held: COLLAPSED, measured: reordered })).toBe(reordered) - expect(holdWorktreeSidebarDragRects({ held: undefined, measured: COLLAPSED })).toBe(COLLAPSED) - expect(holdWorktreeSidebarDragRects({ held: [], measured: COLLAPSED })).toBe(COLLAPSED) + expect( + shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250, scrollTop: 40 }) + ).toBe(false) + expect( + shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250.2, scrollTop: 40 }) + ).toBe(false) + expect( + shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 254, scrollTop: 40 }) + ).toBe(true) + expect( + shouldReevaluateWorktreeSidebarDropAnchor({ anchor, pointerY: 250, scrollTop: 88 }) + ).toBe(true) + expect( + shouldReevaluateWorktreeSidebarDropAnchor({ anchor: null, pointerY: 250, scrollTop: 40 }) + ).toBe(true) }) - it('holds hit-test geometry across a session refresh while liveRects stay current', () => { + it('falls back to a fresh decision when the anchored card disappears mid-drag', () => { + const anchor: WorktreeSidebarDropAnchor = { + beforeWorktreeId: 'gone', + pointerY: 250, + scrollTop: 0 + } + + expect(resolveWorktreeSidebarDropAnchorIndex({ anchor, rects: COLLAPSED })).toBeNull() + expect( + resolveWorktreeSidebarDropAnchorIndex({ + anchor: { beforeWorktreeId: 'c', pointerY: 0, scrollTop: 0 }, + rects: COLLAPSED + }) + ).toBe(2) + // A null anchor id means end-of-group, which survives any row count change. + expect( + resolveWorktreeSidebarDropAnchorIndex({ + anchor: { beforeWorktreeId: null, pointerY: 0, scrollTop: 0 }, + rects: COLLAPSED + }) + ).toBe(COLLAPSED.length) + }) + + it('keeps one live coordinate space across a session refresh', () => { const grown = layout({ b: EXPANDED_CARD_HEIGHT }) const refreshed = refreshWorktreeSidebarDragSession({ session: { @@ -123,7 +185,8 @@ describe('worktree sidebar drag geometry under mid-drag card growth', () => { reorderDraggedIds: ['a'], reorderUnitDraggedIds: ['a'], rects: COLLAPSED, - liveRects: COLLAPSED + grab: GRAB, + anchor: null }, groups: [{ key: 'repo:one', worktreeIds: GROUP_IDS }], unitGroups: [ @@ -136,7 +199,65 @@ describe('worktree sidebar drag geometry under mid-drag card growth', () => { rects: grown }) - expect(refreshed?.rects).toBe(COLLAPSED) - expect(refreshed?.liveRects).toBe(grown) + expect(refreshed?.rects).toBe(grown) + expect(refreshed?.grab).toBe(GRAB) + }) +}) + +describe('grab-relative hit testing', () => { + it('projects the dragged card from the pointer instead of using the bare pointer', () => { + const activeRect = { worktreeId: 'a', groupIndex: 0, top: 0, bottom: CARD_HEIGHT } + + // Grabbed at the very top edge: the card sits below the pointer. + expect( + getWorktreeSidebarDragReferenceY({ + localY: 300, + grab: { offsetY: 0, height: CARD_HEIGHT }, + activeRect + }) + ).toBe(300 + CARD_HEIGHT / 2) + // Grabbed at the bottom edge: the card sits above the pointer. + expect( + getWorktreeSidebarDragReferenceY({ + localY: 300, + grab: { offsetY: CARD_HEIGHT, height: CARD_HEIGHT }, + activeRect + }) + ).toBe(300 - CARD_HEIGHT / 2) + // No grab (native HTML5 drag) degrades to the raw pointer. + expect(getWorktreeSidebarDragReferenceY({ localY: 300, grab: null, activeRect })).toBe(300) + }) + + it('resolves the same slot wherever a tall card was grabbed', () => { + const rects = layout({ c: EXPANDED_CARD_HEIGHT }) + const tall = rects.find((rect) => rect.worktreeId === 'c')! + const height = tall.bottom - tall.top + // Park the card so it visually occupies b's slot, varying only the grab point. + const slotTop = rects[1]!.top + + const dropIndexes = [0.05, 0.25, 0.5, 0.75, 0.95].map((fraction) => { + const offsetY = height * fraction + return previewAt({ + pointerY: slotTop + offsetY, + rects, + draggingWorktreeId: 'c', + grab: { offsetY, height } + })!.dropIndex + }) + + expect(new Set(dropIndexes).size).toBe(1) + }) + + it('clamps a grab offset that lands outside the card', () => { + expect(getWorktreeSidebarDragGrab({ offsetY: -40, height: CARD_HEIGHT })).toEqual({ + offsetY: 0, + height: CARD_HEIGHT + }) + expect(getWorktreeSidebarDragGrab({ offsetY: 900, height: CARD_HEIGHT })).toEqual({ + offsetY: CARD_HEIGHT, + height: CARD_HEIGHT + }) + expect(getWorktreeSidebarDragGrab({ offsetY: 10, height: 0 })).toBeNull() + expect(getWorktreeSidebarDragGrab({ offsetY: Number.NaN, height: CARD_HEIGHT })).toBeNull() }) }) diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts index 77bd680bcd4..c903868b7ca 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drag-geometry.ts @@ -1,34 +1,112 @@ import type { WorktreeSidebarDragRect } from './worktree-sidebar-drag-autoscroll' -function getDragRowSignature(rects: readonly WorktreeSidebarDragRect[]): string { - return rects.map((rect) => `${rect.worktreeId}@${rect.groupIndex}`).join('|') +export type WorktreeSidebarDragGrab = { + // Distance from the dragged unit's top to the grab point, captured at drag start. + offsetY: number + height: number +} + +export type WorktreeSidebarDropAnchor = { + // Identity of the unit the dragged card inserts before; null means end-of-group. + beforeWorktreeId: string | null + pointerY: number + scrollTop: number +} + +// Why: sub-pixel pointer jitter and scroll rounding must not count as intent. +const ANCHOR_REEVALUATE_EPSILON_PX = 0.5 + +/** + * Why: the pointer is not what the user is placing — the card is. Hit-testing the + * bare pointer makes the same visual placement resolve differently depending on + * where the card was grabbed, which is the "unnatural" part with tall expanded + * agent cards: grab one near its bottom and it drops a slot late. + * + * Project the dragged card from the pointer and compare its center instead, so + * the drop follows where the card actually sits. + */ +export function getWorktreeSidebarDragReferenceY(args: { + localY: number + grab: WorktreeSidebarDragGrab | null + activeRect: WorktreeSidebarDragRect | null +}): number { + if (!args.grab) { + return args.localY + } + const height = + args.grab.height > 0 + ? args.grab.height + : args.activeRect + ? args.activeRect.bottom - args.activeRect.top + : 0 + return args.localY - args.grab.offsetY + height / 2 } /** - * Why: the drop index comes from comparing the pointer against row midpoints, - * and sidebar cards keep resizing mid-drag — agent statuses stream in and - * expansion panels animate open underneath the pointer. Measuring afresh every - * frame lets a card that grows while the pointer barely moves shove those - * midpoints past it, so one nudge teleports the insertion line several slots. + * Why: cards resize constantly mid-drag — agent statuses stream in and expansion + * panels animate open — so re-deciding the slot from geometry every frame lets a + * card growing under a still pointer move the drop target with zero input. * - * Hold the geometry captured when the drag reached this row set, so only pointer - * movement can change the drop target. The set is held whole rather than merged - * per row: mixing held tops with freshly measured ones would describe two - * different layouts at once. When the rows themselves change — mounting during - * autoscroll, or genuinely changing slot — the fresh measurement is adopted - * wholesale so the coordinate space stays consistent. - * - * Only hit testing uses this; the indicator and row previews still render from - * live geometry, so a card growing mid-drag never leaves them stale. + * Hold the *decision* (insert before this card) rather than the *geometry* it was + * made from. Re-deriving that identity against live rects each frame keeps the + * indicator and row previews on one honest coordinate space, while only real + * pointer or scroll movement can pick a different neighbour. Freezing the rects + * instead — the previous approach — kept the target stable but let hit testing + * and rendering describe two different layouts at once. */ -export function holdWorktreeSidebarDragRects(args: { - held: readonly WorktreeSidebarDragRect[] | undefined - measured: readonly WorktreeSidebarDragRect[] -}): readonly WorktreeSidebarDragRect[] { - if (!args.held || args.held.length === 0) { - return args.measured +export function shouldReevaluateWorktreeSidebarDropAnchor(args: { + anchor: WorktreeSidebarDropAnchor | null + pointerY: number + scrollTop: number +}): boolean { + if (!args.anchor) { + return true + } + return ( + Math.abs(args.anchor.pointerY - args.pointerY) > ANCHOR_REEVALUATE_EPSILON_PX || + Math.abs(args.anchor.scrollTop - args.scrollTop) > ANCHOR_REEVALUATE_EPSILON_PX + ) +} + +/** + * Resolve a held anchor back to a drop index in the current layout. Returns null + * when the anchored card is gone (deleted, filtered, or unmounted by + * virtualization), so the caller falls back to a fresh geometric decision. + */ +export function resolveWorktreeSidebarDropAnchorIndex(args: { + anchor: WorktreeSidebarDropAnchor + rects: readonly WorktreeSidebarDragRect[] +}): number | null { + if (args.anchor.beforeWorktreeId === null) { + return args.rects.length + } + const target = args.rects.find((rect) => rect.worktreeId === args.anchor.beforeWorktreeId) + return target ? target.groupIndex : null +} + +export function getWorktreeSidebarDropAnchorId(args: { + rects: readonly WorktreeSidebarDragRect[] + dropIndex: number +}): string | null { + return args.rects.find((rect) => rect.groupIndex === args.dropIndex)?.worktreeId ?? null +} + +/** + * Where inside the dragged card the pointer grabbed it. The floating drag preview + * is a fixed-size clone of the source row, so these are exactly the numbers that + * place it on screen — reusing them keeps hit testing agreeing with what the user + * sees. Returns null for an unmeasured row, degrading to bare-pointer hit testing + * rather than to a wrong offset. + */ +export function getWorktreeSidebarDragGrab(args: { + offsetY: number + height: number +}): WorktreeSidebarDragGrab | null { + if (!Number.isFinite(args.offsetY) || !Number.isFinite(args.height) || args.height <= 0) { + return null + } + return { + offsetY: Math.min(Math.max(args.offsetY, 0), args.height), + height: args.height } - return getDragRowSignature(args.held) === getDragRowSignature(args.measured) - ? args.held - : args.measured } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts index 9fb1e630276..ef6147458a1 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.test.ts @@ -54,9 +54,12 @@ describe('computeWorktreeSidebarDropPreview', () => { draggedIds: ['parent'] }) + // Sibling slides up to 0 and the 282px-tall parent unit lands right after it, + // so the line marks 106 - 3. The old rule pointed at the sibling's stale + // bottom (391), a full lineage-height below where the card actually lands. expect(preview).toMatchObject({ dropIndex: 2, - dropIndicatorY: 391 + dropIndicatorY: 103 }) expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['sibling', -288]]) }) @@ -78,9 +81,11 @@ describe('computeWorktreeSidebarDropPreview', () => { draggingWorktreeId: 'b' }) + // c/d/e slide up one slot, so the placeholder opens at 224 and the line marks + // 221 — not 277, which was one whole card height below the real gap. expect(preview).toMatchObject({ dropIndex: 5, - dropIndicatorY: 277 + dropIndicatorY: 221 }) expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([ ['c', -56], @@ -108,17 +113,75 @@ describe('computeWorktreeSidebarDropPreview', () => { expect(preview).toMatchObject({ dropIndex: 5, - dropIndicatorY: 277 + dropIndicatorY: 221 }) expect(Array.from(preview?.previewOffsetsByWorktreeId ?? [])).toEqual([['e', -56]]) }) + + it('marks the gap the row previews open, not the displaced card top', () => { + // One expanded agent card (404px) among collapsed ones: dragging 'a' below it + // is exactly the case where the old rule put the line a card-height off. + const rects = [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 116 }, + { worktreeId: 'b', groupIndex: 1, top: 122, bottom: 238 }, + { worktreeId: 'expanded', groupIndex: 2, top: 244, bottom: 648 }, + { worktreeId: 'd', groupIndex: 3, top: 654, bottom: 770 } + ] + const groupIds = ['a', 'b', 'expanded', 'd'] + const preview = computeWorktreeSidebarDropPreview({ + pointerY: 700, + containerTop: 0, + scrollTop: 0, + rects, + groupIds, + draggedIds: ['a'], + draggingWorktreeId: 'a', + grab: { offsetY: 58, height: 116 } + })! + + // 'a' lands last, so every other card slides up by its 116px + 6px gap. + const offsets = preview.previewOffsetsByWorktreeId + expect(offsets.get('b')).toBe(-122) + expect(offsets.get('expanded')).toBe(-122) + expect(offsets.get('d')).toBe(-122) + // d ends at 532..648, so a's slot opens at 654 and the line marks 651. + expect(preview.dropIndicatorY).toBe(651) + // The old rule had no rect at this index and fell back to the pre-drag list + // bottom (773) — 122px below the gap the user could see opening. + }) + + it('resolves the same slot wherever a tall card is grabbed', () => { + const rects = [ + { worktreeId: 'a', groupIndex: 0, top: 0, bottom: 116 }, + { worktreeId: 'b', groupIndex: 1, top: 122, bottom: 238 }, + { worktreeId: 'expanded', groupIndex: 2, top: 244, bottom: 648 } + ] + const groupIds = ['a', 'b', 'expanded'] + const height = 404 + const dropIndexes = [0.05, 0.5, 0.95].map((fraction) => { + const offsetY = height * fraction + return computeWorktreeSidebarDropPreview({ + pointerY: 122 + offsetY, + containerTop: 0, + scrollTop: 0, + rects, + groupIds, + draggedIds: ['expanded'], + draggingWorktreeId: 'expanded', + grab: { offsetY, height } + })!.dropIndex + }) + + expect(new Set(dropIndexes).size).toBe(1) + }) }) describe('resolveWorktreeSidebarStatusDropCommitTarget', () => { const preview = { dropIndex: 1, dropIndicatorY: 129, - previewOffsetsByWorktreeId: new Map() + previewOffsetsByWorktreeId: new Map(), + dropAnchorId: null } it('uses the current status target when pointerup hit-testing succeeds', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts index dfb7a825aec..e25f2dd2b37 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-drop-preview.ts @@ -3,11 +3,21 @@ import { getWorktreeSidebarBoundaryDrop, type WorktreeSidebarDragRect } from './worktree-sidebar-drag-autoscroll' +import { + getWorktreeSidebarDragReferenceY, + getWorktreeSidebarDropAnchorId, + resolveWorktreeSidebarDropAnchorIndex, + type WorktreeSidebarDragGrab, + type WorktreeSidebarDropAnchor +} from './worktree-sidebar-drag-geometry' export type WorktreeSidebarDropPreview = { dropIndex: number dropIndicatorY: number previewOffsetsByWorktreeId: ReadonlyMap + // Identity of the unit the drop inserts before, so the next frame can hold this + // decision through a card resize instead of re-deciding from moved geometry. + dropAnchorId: string | null lineageParentId?: string } @@ -92,10 +102,28 @@ export function resolveWorktreeSidebarStatusDropCommitTarget(args: { : { target: args.currentTarget, preview: args.currentPreview } } +/** + * Why: the line must mark the gap the row previews actually open, not the old top + * of whatever card happens to sit at `dropIndex`. Those differ by a full card + * height whenever the drop shifts that card, and by the tall card's height when + * an expanded agent session is involved — which is the line landing "in the wrong + * spot". `placeholderTop` is the replayed layout's slot for the dragged card, so + * prefer it and fall back only when the drop is a no-op. + */ function getWorktreeSidebarDropIndicatorY(args: { rects: readonly WorktreeSidebarDragRect[] dropIndex: number + placeholderTop: number | null + activeRect: WorktreeSidebarDragRect | null }): number { + if (args.placeholderTop !== null) { + return Math.max(0, args.placeholderTop - 3) + } + // A no-op drop leaves the card where it is; park the line on its own top edge + // rather than jumping to a neighbour that never moves. + if (args.activeRect) { + return Math.max(0, args.activeRect.top - 3) + } const target = args.rects.find((rect) => rect.groupIndex === args.dropIndex) if (target) { return Math.max(0, target.top - 3) @@ -104,18 +132,58 @@ function getWorktreeSidebarDropIndicatorY(args: { return last ? last.bottom + 3 : 0 } +/** + * Why: with uniform rows, "first midpoint below the pointer" and "closest center + * to the dragged card" agree. With a 116px card next to a 404px expanded one they + * do not: the tall card's midpoint sits ~200px from its own top edge, so crossing + * it requires dragging far past where the card visually lands. Closest-center + * against the dragged card's projected rect is the model sortable lists use, and + * it keeps every slot reachable regardless of neighbour heights. + */ +function getWorktreeSidebarClosestCenterDropIndex(args: { + referenceY: number + rects: readonly WorktreeSidebarDragRect[] + activeIndex: number +}): number { + let overIndex = args.rects[0]!.groupIndex + let bestDistance = Number.POSITIVE_INFINITY + for (const rect of args.rects) { + const distance = Math.abs((rect.top + rect.bottom) / 2 - args.referenceY) + if (distance < bestDistance) { + bestDistance = distance + overIndex = rect.groupIndex + } + } + // Dropping onto a slot below the dragged card means landing after it. + return overIndex > args.activeIndex ? overIndex + 1 : overIndex +} + +function getWorktreeSidebarPointerDropIndex(args: { + referenceY: number + rects: readonly WorktreeSidebarDragRect[] +}): number { + for (const rect of args.rects) { + if (args.referenceY < (rect.top + rect.bottom) / 2) { + return rect.groupIndex + } + } + return args.rects.at(-1)!.groupIndex + 1 +} + export function computeWorktreeSidebarDropPreview(args: { pointerY: number containerTop: number scrollTop: number rects: readonly WorktreeSidebarDragRect[] - // Why: `rects` are held stable so resizing cards cannot move the drop target - // under a still pointer. The indicator and row previews still draw from live - // geometry, so a card growing mid-drag does not strand them at stale tops. - liveRects?: readonly WorktreeSidebarDragRect[] groupIds: readonly string[] draggedIds: readonly string[] draggingWorktreeId?: string | null + // Where the card was grabbed, so the drop follows the card rather than the bare + // pointer. Omitted for native HTML5 drags, which have no reliable grab offset. + grab?: WorktreeSidebarDragGrab | null + // A held decision from the previous frame; honoured while the pointer is still + // so a resizing card cannot move the target under it. + anchor?: WorktreeSidebarDropAnchor | null }): WorktreeSidebarDropPreview | null { const rects = getWorktreeSidebarDragUnitRects({ rects: args.rects, @@ -124,14 +192,18 @@ export function computeWorktreeSidebarDropPreview(args: { if (rects.length === 0 || args.groupIds.length === 0) { return null } - const liveUnitRects = args.liveRects - ? getWorktreeSidebarDragUnitRects({ rects: args.liveRects, groupIds: args.groupIds }) - : rects - // Why: an empty live measurement (rows unmounted by virtualization) would - // collapse the indicator to the top of the list; keep the held geometry then. - const renderRects = liveUnitRects.length === rects.length ? liveUnitRects : rects const localY = args.pointerY - args.containerTop + args.scrollTop + const activeIndex = args.draggingWorktreeId + ? rects.findIndex((rect) => rect.worktreeId === args.draggingWorktreeId) + : -1 + const activeRect = activeIndex >= 0 ? rects[activeIndex]! : null + const referenceY = getWorktreeSidebarDragReferenceY({ + localY, + grab: args.grab ?? null, + activeRect + }) + const first = rects[0]! const last = rects.at(-1)! const boundaryDrop = getWorktreeSidebarBoundaryDrop({ @@ -144,25 +216,36 @@ export function computeWorktreeSidebarDropPreview(args: { return null } - let dropIndex = last.groupIndex + 1 - if (boundaryDrop.kind === 'drop') { + const heldIndex = args.anchor + ? resolveWorktreeSidebarDropAnchorIndex({ anchor: args.anchor, rects }) + : null + let dropIndex: number + if (heldIndex !== null) { + dropIndex = heldIndex + } else if (boundaryDrop.kind === 'drop') { dropIndex = boundaryDrop.dropIndex + } else if (activeRect) { + dropIndex = getWorktreeSidebarClosestCenterDropIndex({ referenceY, rects, activeIndex }) } else { - for (const rect of rects) { - const mid = (rect.top + rect.bottom) / 2 - if (localY < mid) { - dropIndex = rect.groupIndex - break - } - } + dropIndex = getWorktreeSidebarPointerDropIndex({ referenceY, rects }) } - const indicatorY = getWorktreeSidebarDropIndicatorY({ rects: renderRects, dropIndex }) - const previewOffsetsByWorktreeId = buildWorktreeDragPreviewOffsets({ + + const { offsets, placeholderTop } = buildWorktreeDragPreviewOffsets({ groupIds: args.groupIds, draggedIds: args.draggedIds, draggingWorktreeId: args.draggingWorktreeId, dropIndex, - rects: renderRects + rects }) - return { dropIndex, dropIndicatorY: indicatorY, previewOffsetsByWorktreeId } + return { + dropIndex, + dropIndicatorY: getWorktreeSidebarDropIndicatorY({ + rects, + dropIndex, + placeholderTop, + activeRect + }), + previewOffsetsByWorktreeId: offsets, + dropAnchorId: getWorktreeSidebarDropAnchorId({ rects, dropIndex }) + } } diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts index 15d102ac3b5..54f302f872b 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-pointer-drag-dom.ts @@ -69,7 +69,7 @@ export function createSidebarDragPreview(args: { pointerX: number pointerY: number draggedCount: number -}): { preview: HTMLElement; offsetX: number; offsetY: number } { +}): { preview: HTMLElement; offsetX: number; offsetY: number; height: number } { const rect = args.sourceRow.getBoundingClientRect() const preview = document.createElement('div') const clone = args.sourceRow.cloneNode(true) as HTMLElement @@ -103,5 +103,5 @@ export function createSidebarDragPreview(args: { offsetY }) document.body.appendChild(preview) - return { preview, offsetX, offsetY } + return { preview, offsetX, offsetY, height: rect.height } } From 28b395ced2499cab340da7c0b4a21af174b4a164 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:08:38 -0700 Subject: [PATCH 121/150] fix(mobile): harden native chat send budgets, streams, and stop (#10814) Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- mobile/README.md | 13 + .../app/h/[hostId]/session/[worktreeId].tsx | 135 ++++--- mobile/scripts/mock-server-key-pair.ts | 167 +++++++++ .../mock-server-native-chat-scenario.ts | 275 ++++++++++++++ mobile/scripts/mock-server-rpc-handlers.ts | 15 +- mobile/scripts/mock-server.ts | 6 +- .../start-emulator-pairing-runtime.mjs | 6 +- mobile/src/mock-server-key-pair.test.ts | 209 +++++++++++ .../session/MobileNativeChatComposer.test.ts | 25 ++ .../src/session/MobileNativeChatComposer.tsx | 4 +- .../src/session/MobileNativeChatOverlay.tsx | 8 + .../src/session/MobileNativeChatView.test.ts | 164 +++++++++ mobile/src/session/MobileNativeChatView.tsx | 42 +-- .../mobile-native-chat-image-send.test.ts | 32 ++ .../session/mobile-native-chat-image-send.ts | 39 +- ...mobile-native-chat-permission-send.test.ts | 17 +- .../session/mobile-native-chat-send.test.ts | 103 +++++- mobile/src/session/mobile-native-chat-send.ts | 41 ++- .../session/mobile-native-chat-stale-input.ts | 6 +- ...mobile-native-chat-terminal-stream.test.ts | 23 +- .../mobile-native-chat-terminal-stream.ts | 10 +- .../mobile-terminal-prune-decision.test.ts | 80 +++++ .../session/mobile-terminal-prune-decision.ts | 64 ++++ ...use-mobile-native-chat-answer-send.test.ts | 57 +++ .../use-mobile-native-chat-answer-send.ts | 39 +- .../use-mobile-native-chat-cancel-ask.ts | 45 +++ .../use-mobile-native-chat-controller.test.ts | 47 ++- .../use-mobile-native-chat-controller.ts | 82 ++--- ...bile-native-chat-image-attachments.test.ts | 134 +++++-- ...se-mobile-native-chat-image-attachments.ts | 59 ++- ...use-mobile-native-chat-input-lease.test.ts | 32 ++ .../use-mobile-native-chat-input-lease.ts | 51 ++- .../use-mobile-native-chat-message-send.ts | 32 +- .../use-mobile-native-chat-send-error.test.ts | 223 ++++++++++++ .../use-mobile-native-chat-send-error.ts | 80 +++++ .../use-mobile-native-chat-stop.test.ts | 105 +++++- .../session/use-mobile-native-chat-stop.ts | 133 +++++-- ...mobile-native-chat-terminal-stream.test.ts | 335 +++++++++++++++++- .../use-mobile-native-chat-terminal-stream.ts | 146 +++++++- .../use-mobile-session-image-attachments.ts | 10 +- ...use-mobile-session-tabs-fetch-reporting.ts | 28 ++ .../use-native-chat-action-outcomes.ts | 37 ++ .../src/transport/mobile-relay-rpc-session.ts | 6 +- .../rpc-client-request-deadline.test.ts | 174 +++++++++ mobile/src/transport/rpc-client.ts | 19 +- mobile/src/transport/rpc-request-budget.ts | 38 ++ .../agent-hooks/agent-status-pane-index.ts | 22 ++ ...hook-provider-session-invalidation.test.ts | 68 ++++ .../hook-provider-session-invalidation.ts | 43 +++ src/main/agent-hooks/server.test.ts | 33 ++ src/main/agent-hooks/server.ts | 79 ++++- .../session-scanner-source-discovery.ts | 5 +- src/main/index.ts | 35 +- src/main/runtime/orca-runtime.test.ts | 327 +++++++++++++++++ src/main/runtime/orca-runtime.ts | 130 ++++++- 55 files changed, 3839 insertions(+), 299 deletions(-) create mode 100644 mobile/scripts/mock-server-key-pair.ts create mode 100644 mobile/scripts/mock-server-native-chat-scenario.ts create mode 100644 mobile/src/mock-server-key-pair.test.ts create mode 100644 mobile/src/session/MobileNativeChatView.test.ts create mode 100644 mobile/src/session/mobile-terminal-prune-decision.test.ts create mode 100644 mobile/src/session/mobile-terminal-prune-decision.ts create mode 100644 mobile/src/session/use-mobile-native-chat-cancel-ask.ts create mode 100644 mobile/src/session/use-mobile-native-chat-send-error.test.ts create mode 100644 mobile/src/session/use-mobile-native-chat-send-error.ts create mode 100644 mobile/src/session/use-mobile-session-tabs-fetch-reporting.ts create mode 100644 mobile/src/session/use-native-chat-action-outcomes.ts create mode 100644 mobile/src/transport/rpc-client-request-deadline.test.ts create mode 100644 mobile/src/transport/rpc-request-budget.ts create mode 100644 src/main/agent-hooks/agent-status-pane-index.ts create mode 100644 src/main/agent-hooks/hook-provider-session-invalidation.test.ts create mode 100644 src/main/agent-hooks/hook-provider-session-invalidation.ts diff --git a/mobile/README.md b/mobile/README.md index 576e1efe9be..64f1081b73c 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -181,6 +181,19 @@ pnpm mock-server # starts mock WebSocket server on port 6768 Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`. +### Environment variables + +- `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures. +- `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair. + +### Scenario control files + +Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default. + +- `MOCK_SEND_MODE_FILE` (default `orca-mock-send-mode` in the system temporary directory) — `accept` (default) accepts the send, `error` fails it with `mobile_input_floor_unavailable`, anything else reports the send as rejected. +- `MOCK_TERMINAL_LIST_MODE_FILE` (default `orca-mock-terminal-list-mode` in the system temporary directory) — `omit` returns an empty terminal list, `other` returns a list that omits the chat handle, anything else lists it. +- `MOCK_TERMINAL_STREAM_MODE_FILE` (default `orca-mock-terminal-stream-mode` in the system temporary directory) — `dead` answers a subscribe with `subscribed` then `end` (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally. + ## Connecting to Real Orca 1. Start Orca desktop with WebSocket transport enabled diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5eba95a619c..0b4b11b4449 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -211,9 +211,15 @@ import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrows import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller' import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability' import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease' +import { useMobileNativeChatSendError } from '../../../../src/session/use-mobile-native-chat-send-error' import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions' import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key' +import { + createTerminalPrunePredicate, + pruneTerminalKeyboardMetrics, + resolveRetainedTerminalHandles +} from '../../../../src/session/mobile-terminal-prune-decision' import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation' @@ -223,6 +229,7 @@ import type { SessionTabsApplyOutcome, SessionTabsStreamSource } from '../../../../src/session/mobile-session-tabs-stream-health' +import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting' import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation' import { getRepoIdFromMobileWorktreeId, @@ -898,6 +905,8 @@ export default function SessionScreen() { toggleTerminalLiveInput } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) + // Reactive teardown signal for the native-chat covered stream; see unsubscribeTerminal. + const [coveredStreamRevision, setCoveredStreamRevision] = useState(0) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) // Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll. @@ -1139,10 +1148,11 @@ export default function SessionScreen() { }, [clearToastHideTimer] ) - const showNativeChatSendError = useCallback( - (message: string) => showToast(message, 1600), - [showToast] - ) + const nativeChatScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId) + const nativeChatSendError = useMobileNativeChatSendError({ + scopeKey: nativeChatScopeKey, + showToast + }) const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId) const { ready: nativeChatInputLeaseReady, @@ -1164,9 +1174,12 @@ export default function SessionScreen() { deviceTokenRef, nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, - onSendError: showNativeChatSendError + connState, + onSendError: nativeChatSendError.show, + onSendResolved: nativeChatSendError.clear }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController + nativeChatSendError.bannerMountedRef.current = showNativeChat const dictation = useMobileDictation({ client, @@ -1303,9 +1316,25 @@ export default function SessionScreen() { subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) // Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale. layoutSeqRef.current.delete(handle) - clearNativeChatInputLease(handle) + // Why compare against the RENDERED lease: `clear` reports the drop from its + // synchronous mirror, so a `subscribed`+`end` pair applied in one render batch + // reports "dropped" while React only ever sees false → the effect never re-runs + // and the composer stays locked (#10681). A dead PTY can also emit `end` with no + // preceding `subscribed`, where the clear is a no-op for the same reason. Either + // way the flip carries no signal, so bump. When the lease really was up on + // screen, `leaseReady` already re-runs the effect and bumping too would + // double-render this whole route on every chat open. + const leaseWasOnScreen = nativeChatInputLeaseReadyRef.current + const leaseDropped = clearNativeChatInputLease(handle) + if ( + (!leaseDropped || !leaseWasOnScreen) && + showNativeChatRef.current && + handle === activeHandleRef.current + ) { + setCoveredStreamRevision((revision) => revision + 1) + } }, - [clearNativeChatInputLease] + [clearNativeChatInputLease, nativeChatInputLeaseReadyRef, showNativeChatRef] ) const unsubscribeTerminalRef = useRef(unsubscribeTerminal) unsubscribeTerminalRef.current = unsubscribeTerminal @@ -1563,10 +1592,12 @@ export default function SessionScreen() { [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] ) - const notifyTerminalWebReady = useMobileNativeChatTerminalStream({ + const nativeChatStream = useMobileNativeChatTerminalStream({ showNativeChat, activeHandle, activeTabType: activeSessionTab?.type ?? null, + leaseReady: nativeChatInputLeaseReady, + streamRevision: coveredStreamRevision, subscriptionsRef: terminalUnsubsRef, subscribingRef: subscribingHandlesRef, webReadyRef: webReadyHandlesRef, @@ -1630,7 +1661,6 @@ export default function SessionScreen() { }) if (response.ok) { const result = (response as RpcSuccess).result as { terminals: Terminal[] } - if (result.terminals.length === 0 && !allowEmptyLoaded) { return } @@ -1641,25 +1671,31 @@ export default function SessionScreen() { } const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) - // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. - pruneTerminalHandlesFromLiveInput(liveHandles) - defaultTerminalHandlesToLiveInput([...liveHandles]) - for (const handle of Array.from(terminalUnsubsRef.current.keys())) { - if (!liveHandles.has(handle)) { - unsubscribeTerminal(handle) - terminalRefs.current.delete(handle) - initializedHandlesRef.current.delete(handle) - clearTerminalLiveInputDefault(handle) - setTerminalKeyboardMetrics((prev) => { - if (!prev.has(handle)) { - return prev - } - const next = new Map(prev) - next.delete(handle) - return next - }) - } + const pruneContext = { + liveHandles, + showNativeChat: showNativeChatRef.current, + activeHandle: activeHandleRef.current } + // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. + // Sweep against the retained set, not the raw list: a chat-covered handle + // keeps its subscription across a graph reload, so erasing its live-input + // preference on the same refresh is the erasure this guard exists to stop. + pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext)) + defaultTerminalHandlesToLiveInput([...liveHandles]) + const shouldPrune = createTerminalPrunePredicate(pruneContext) + for (const handle of Array.from(terminalUnsubsRef.current.keys())) { + if (!shouldPrune(handle)) { + continue + } + unsubscribeTerminal(handle) + terminalRefs.current.delete(handle) + initializedHandlesRef.current.delete(handle) + clearTerminalLiveInputDefault(handle) + } + setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) + // Why: a chat-covered handle the host reports again refills its rearm budget, + // so an exhausted rearm can't lock the composer until leave-chat. + nativeChatStream.notifyListedHandles(liveHandles) lastKnownTerminalCountRef.current = result.terminals.length // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. const seen = new Set() @@ -1694,6 +1730,7 @@ export default function SessionScreen() { worktreeId, clearTerminalLiveInputDefault, defaultTerminalHandlesToLiveInput, + nativeChatStream, pruneTerminalHandlesFromLiveInput, subscribeToTerminal, unsubscribeTerminal @@ -1807,6 +1844,10 @@ export default function SessionScreen() { activeSessionTabIdRef.current = nextActiveTabId setActiveSessionTabId(nextActiveTabId) activeSessionTabTypeRef.current = 'terminal' + // Why: every other active-handle branch assigns the ref alongside the + // state. Leaving it stale here makes `covered` resolve against the wrong + // handle, so a native-chat rearm silently no-ops on the webview gates. + activeHandleRef.current = pendingActiveTerminalHandle setActiveHandle(pendingActiveTerminalHandle) subscribeToTerminal(pendingActiveTerminalHandle) return outcome @@ -2301,25 +2342,23 @@ export default function SessionScreen() { [] ) const hasSessionTabsRecoveryNeed = useCallback( - () => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null, - [] + () => + closedTabTombstonesRef.current.size > 0 || + pendingBrowserFocusPageIdRef.current !== null || + // Why: a chat-covered handle that ran out of rearms and left `terminal.list` + // was reminted by a desktop graph reload. Only a fresh tab snapshot carries + // the replacement handle, so force one instead of holding the composer locked. + nativeChatStream.hasTabsRecoveryNeed(), + [nativeChatStream] ) const getSessionTabsApplicationRevision = useCallback( () => appliedSessionTabsRevisionRef.current, [] ) - const reportSessionTabsFetchStarted = useCallback(() => { - terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId) - }, [worktreeId]) - const reportSessionTabsFetchSucceeded = useCallback((result: SessionTabsResult) => { - terminalDiagnosticsRef.current.tabsFetchSucceeded(result) - }, []) - const reportSessionTabsFetchFailed = useCallback((code: string) => { - terminalDiagnosticsRef.current.tabsFetchFailed(code) - }, []) - const reportSessionTabsFetchErrored = useCallback((error: unknown) => { - terminalDiagnosticsRef.current.tabsFetchErrored(error) - }, []) + const sessionTabsFetchReporting = useMobileSessionTabsFetchReporting({ + worktreeId, + diagnosticsRef: terminalDiagnosticsRef + }) const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } = useMobileSessionTabsReconciliation({ client, @@ -2330,10 +2369,7 @@ export default function SessionScreen() { fetchTerminals, hasRecoveryNeed: hasSessionTabsRecoveryNeed, getApplicationRevision: getSessionTabsApplicationRevision, - onFetchStarted: reportSessionTabsFetchStarted, - onFetchSucceeded: reportSessionTabsFetchSucceeded, - onFetchFailed: reportSessionTabsFetchFailed, - onFetchErrored: reportSessionTabsFetchErrored + ...sessionTabsFetchReporting }) useEffect(() => { @@ -2900,7 +2936,7 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) - notifyTerminalWebReady(handle, wasAlreadyReady) + nativeChatStream.notifyWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( handle, wasAlreadyReady, @@ -2928,7 +2964,7 @@ export default function SessionScreen() { })() } }, - [measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal] + [measureViewportOnce, nativeChatStream, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -3631,12 +3667,13 @@ export default function SessionScreen() { canSend, connState, deviceTokenRef, - nativeChatScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), + nativeChatScopeKey, nativeChatInputLeaseReady, getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, showToast, + onNativeChatSendError: nativeChatSendError.show, onSuccess: triggerSelection, onError: triggerError }) @@ -4720,6 +4757,8 @@ export default function SessionScreen() { onMicPressIn={handleDictationPressIn} onMicPressOut={handleDictationPressOut} inputLockReason={nativeChatInputLockReason} + sendErrorMessage={nativeChatSendError.message} + onClearSendError={nativeChatSendError.clear} keyboardInset={keyboardLift} /> {toastMessage && ( diff --git a/mobile/scripts/mock-server-key-pair.ts b/mobile/scripts/mock-server-key-pair.ts new file mode 100644 index 00000000000..915ba1eaf75 --- /dev/null +++ b/mobile/scripts/mock-server-key-pair.ts @@ -0,0 +1,167 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + closeSync, + openSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import nacl from 'tweetnacl' + +const LOCK_ATTEMPTS = 50 +const LOCK_WAIT_MS = 10 +const RENAME_ATTEMPTS = 5 +const RENAME_WAIT_MS = 25 +const lockWaitSignal = new Int32Array(new SharedArrayBuffer(4)) + +type KeyReadResult = + | { keyPair: nacl.BoxKeyPair; reason?: never } + | { keyPair?: never; reason: string } + +type KeyLockResult = + | { fd: number; lockFile: string; keyPair?: never } + | { fd?: never; lockFile?: never; keyPair: nacl.BoxKeyPair } + +type KeyWarningLogger = Pick + +function errnoCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code +} + +function readKeyPair(keyFile: string): KeyReadResult { + try { + const encoded = readFileSync(keyFile, 'utf-8').trim() + if (!encoded) { + return { reason: 'empty' } + } + const decoded = Buffer.from(encoded, 'base64') + if (decoded.toString('base64') !== encoded) { + return { reason: 'invalid base64' } + } + if (decoded.length !== nacl.box.secretKeyLength) { + return { reason: `wrong length (${decoded.length} bytes)` } + } + return { keyPair: nacl.box.keyPair.fromSecretKey(Uint8Array.from(decoded)) } + } catch (error) { + return { reason: errnoCode(error) === 'ENOENT' ? 'missing' : 'unreadable' } + } +} + +function acquireKeyLock(keyFile: string): KeyLockResult { + const lockFile = `${keyFile}.lock` + for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) { + try { + return { fd: openSync(lockFile, 'wx', 0o600), lockFile } + } catch (error) { + if (errnoCode(error) !== 'EEXIST') { + throw error + } + const concurrent = readKeyPair(keyFile) + if (concurrent.keyPair) { + return { keyPair: concurrent.keyPair } + } + if (attempt < LOCK_ATTEMPTS - 1) { + Atomics.wait(lockWaitSignal, 0, 0, LOCK_WAIT_MS) + } + } + } + const winner = readKeyPair(keyFile) + if (winner.keyPair) { + return { keyPair: winner.keyPair } + } + try { + return { fd: openSync(lockFile, 'wx', 0o600), lockFile } + } catch (error) { + if (errnoCode(error) !== 'EEXIST') { + throw error + } + const lateWinner = readKeyPair(keyFile) + if (lateWinner.keyPair) { + return { keyPair: lateWinner.keyPair } + } + } + throw new Error( + `[mock] Key file lock ${lockFile} remained busy; remove it if no mock server is running` + ) +} + +function renameKeyFile(temporaryFile: string, keyFile: string): void { + for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt += 1) { + try { + renameSync(temporaryFile, keyFile) + return + } catch (error) { + if ( + !['EACCES', 'EBUSY', 'EPERM'].includes(errnoCode(error) ?? '') || + attempt === RENAME_ATTEMPTS - 1 + ) { + throw error + } + Atomics.wait(lockWaitSignal, 0, 0, RENAME_WAIT_MS) + } + } +} + +function persistKeyPair(keyFile: string, keyPair: nacl.BoxKeyPair): void { + const temporaryFile = `${keyFile}.${process.pid}.${randomUUID()}.tmp` + try { + writeFileSync(temporaryFile, Buffer.from(keyPair.secretKey).toString('base64'), { + flag: 'wx', + mode: 0o600 + }) + if (process.platform !== 'win32') { + chmodSync(temporaryFile, 0o600) + } + renameKeyFile(temporaryFile, keyFile) + } finally { + try { + rmSync(temporaryFile, { force: true }) + } catch {} + } +} + +function releaseKeyLock(lock: { fd: number; lockFile: string }): void { + try { + closeSync(lock.fd) + } catch {} + try { + unlinkSync(lock.lockFile) + } catch {} +} + +export function loadOrCreateMockServerKeyPair( + keyFile: string | undefined, + logger: KeyWarningLogger = console +): nacl.BoxKeyPair { + if (!keyFile) { + return nacl.box.keyPair() + } + const existing = readKeyPair(keyFile) + if (existing.keyPair) { + return existing.keyPair + } + + const lock = acquireKeyLock(keyFile) + if (lock.keyPair) { + return lock.keyPair + } + let selected: nacl.BoxKeyPair + try { + const current = readKeyPair(keyFile) + if (current.keyPair) { + selected = current.keyPair + } else { + logger.warn( + `[mock] Key file ${keyFile} is ${current.reason} — minting a fresh key; paired devices must re-pair` + ) + selected = nacl.box.keyPair() + persistKeyPair(keyFile, selected) + } + } finally { + releaseKeyLock(lock) + } + return selected +} diff --git a/mobile/scripts/mock-server-native-chat-scenario.ts b/mobile/scripts/mock-server-native-chat-scenario.ts new file mode 100644 index 00000000000..d2f65e0b635 --- /dev/null +++ b/mobile/scripts/mock-server-native-chat-scenario.ts @@ -0,0 +1,275 @@ +import { readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { WebSocket } from 'ws' +import type { AgentStatusEntry } from '../../src/shared/agent-status-types' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTerminalClientTab +} from '../../src/shared/runtime-types' +import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers' + +// Why: native chat needs a live agent tab, an empty-but-subscribed transcript, +// and a terminal send path whose acceptance can be flipped mid-session. +// Restarting the server re-keys E2EE (forcing a re-pair), so send behaviour is +// read from a control file on every request instead of an env var. +const SEND_MODE_FILE = process.env.MOCK_SEND_MODE_FILE ?? join(tmpdir(), 'orca-mock-send-mode') +const TERMINAL_LIST_MODE_FILE = + process.env.MOCK_TERMINAL_LIST_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-list-mode') +// Write `dead` here to reproduce a gone PTY: the host answers with `subscribed` +// then `end`, which is the shape the rearm bound and terminal prune react to. +const TERMINAL_STREAM_MODE_FILE = + process.env.MOCK_TERMINAL_STREAM_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-stream-mode') +const TERMINAL_HANDLE = 'chat-term-1' +const TAB_ID = 'chat-tab-1' +const SESSION_ID = 'mock-chat-session' +const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl') +const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png') + +function readControl(file: string): string { + try { + return readFileSync(file, 'utf-8').trim() + } catch { + // Default to the happy path when the control file is absent. + return '' + } +} + +const tabsSnapshots = new Map() +const agentStatus: AgentStatusEntry = { + state: 'done', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey: `${TAB_ID}:leaf-1`, + terminalHandle: TERMINAL_HANDLE, + stateHistory: [], + providerSession: { + key: 'session_id', + id: SESSION_ID, + transcriptPath: TRANSCRIPT_PATH + } +} + +function buildTab(): RuntimeMobileSessionTerminalClientTab { + return { + type: 'terminal', + id: TAB_ID, + title: 'Claude Code', + parentTabId: TAB_ID, + leafId: 'leaf-1', + ptyId: 'pty-1', + status: 'ready', + terminal: TERMINAL_HANDLE, + launchAgent: 'claude', + agentStatus, + viewMode: 'chat', + isActive: true + } +} + +// Versions are per-worktree and only advance on real content change, so the +// mock can't fake a re-render heartbeat the runtime would never send. +function buildTabsResult(worktree: string): RuntimeMobileSessionTabsResult { + const result: RuntimeMobileSessionTabsResult = { + worktree, + publicationEpoch: 'mock-epoch-1', + snapshotVersion: 0, + activeGroupId: 'group-1', + activeTabId: TAB_ID, + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: TAB_ID, tabOrder: [TAB_ID] }], + tabs: [buildTab()] + } + const signature = JSON.stringify(result) + const previous = tabsSnapshots.get(worktree) + const current = + previous?.signature === signature + ? previous + : { version: (previous?.version ?? 0) + 1, signature } + tabsSnapshots.set(worktree, current) + result.snapshotVersion = current.version + return result +} + +function tabsResultIfChanged(worktree: string): RuntimeMobileSessionTabsResult | null { + const before = tabsSnapshots.get(worktree)?.version + const result = buildTabsResult(worktree) + return result.snapshotVersion === before ? null : result +} + +function worktreeOf(request: RpcRequest): string { + const raw = request.params?.worktree + return typeof raw === 'string' ? raw : 'id:mock-worktree' +} + +// Why: unsubscribe correlates by worktree, not request id, and a socket that +// navigates A->B->A would otherwise stack one push loop per subscribe. +const tabsPushLoops = new Map>>() + +function stopTabsPushLoop(ws: WebSocket, worktree: string): void { + const loops = tabsPushLoops.get(ws) + const existing = loops?.get(worktree) + if (existing !== undefined) { + clearInterval(existing) + loops?.delete(worktree) + } +} + +function startTabsPushLoop(ws: WebSocket, worktree: string, push: () => void): void { + stopTabsPushLoop(ws, worktree) + const interval = setInterval(() => { + if (ws.readyState !== ws.OPEN) { + stopTabsPushLoop(ws, worktree) + return + } + push() + }, 3000) + let loops = tabsPushLoops.get(ws) + if (!loops) { + loops = new Map() + tabsPushLoops.set(ws, loops) + ws.once('close', () => { + for (const timer of tabsPushLoops.get(ws)?.values() ?? []) { + clearInterval(timer) + } + tabsPushLoops.delete(ws) + }) + } + loops.set(worktree, interval) +} + +type Respond = (response: RpcResponse) => void +type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse +type Failure = (id: string, code: string, message: string) => RpcResponse + +/** Mock backend for the native-chat surface: session tabs, an empty transcript + * snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1 + * because it replaces the default terminal fixtures. No transcript or terminal + * output frames are pushed. Returns false for methods it does not own. */ +export function handleMockNativeChatRequest( + request: RpcRequest, + respond: Respond, + success: Success, + error: Failure, + ws: WebSocket +): boolean { + if (process.env.MOCK_NATIVE_CHAT !== '1') { + return false + } + switch (request.method) { + case 'session.tabs.list': + respond(success(request.id, buildTabsResult(worktreeOf(request)))) + return true + + case 'session.tabs.subscribe': { + const worktree = worktreeOf(request) + respond(success(request.id, buildTabsResult(worktree), true)) + startTabsPushLoop(ws, worktree, () => { + const changed = tabsResultIfChanged(worktree) + if (changed) { + respond(success(request.id, changed, true)) + } + }) + return true + } + + case 'session.tabs.unsubscribe': + stopTabsPushLoop(ws, worktreeOf(request)) + respond(success(request.id, { ok: true })) + return true + + case 'session.tabs.activate': + case 'nativeChat.unsubscribe': + respond(success(request.id, { ok: true })) + return true + + case 'terminal.list': { + // `omit` = empty list; `other` = a live list that just doesn't name the + // chat handle (what the runtime returns when the handle is scoped to a + // different worktree id than the one mobile queries). + const mode = readControl(TERMINAL_LIST_MODE_FILE) + const worktreeId = worktreeOf(request).replace(/^id:/, '') + const entry = (handle: string) => ({ + handle, + worktreeId, + title: 'Claude Code', + isActive: true, + hasRunningProcess: true + }) + const terminals = + mode === 'omit' + ? [] + : mode === 'other' + ? [entry('some-other-term')] + : [entry(TERMINAL_HANDLE)] + respond(success(request.id, { terminals, totalCount: terminals.length, truncated: false })) + return true + } + + case 'nativeChat.subscribe': + respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true)) + return true + + case 'nativeChat.readSession': + respond(success(request.id, { messages: [], hasMore: false })) + return true + + case 'terminal.subscribe': { + // The `subscribed` frame is what releases mobile's native-chat input lease. + respond(success(request.id, { type: 'subscribed', terminal: TERMINAL_HANDLE }, true)) + if (readControl(TERMINAL_STREAM_MODE_FILE) === 'dead') { + respond(success(request.id, { type: 'end' })) + return true + } + respond( + success( + request.id, + { type: 'scrollback', serialized: '', cols: 80, rows: 24, seq: 1 }, + true + ) + ) + return true + } + + case 'terminal.send': { + const mode = readControl(SEND_MODE_FILE) || 'accept' + console.log(`[mock] terminal.send mode=${mode} text=${JSON.stringify(request.params?.text)}`) + if (mode === 'error') { + respond(error(request.id, 'mobile_input_floor_unavailable', 'Mobile input floor is held')) + return true + } + respond( + success(request.id, { + send: { + handle: TERMINAL_HANDLE, + accepted: mode === 'accept', + bytesWritten: mode === 'accept' ? String(request.params?.text ?? '').length : 0 + } + }) + ) + return true + } + + case 'clipboard.startImageUpload': + respond(success(request.id, { uploadId: 'mock-upload-1' })) + return true + + case 'clipboard.appendImageUploadChunk': + respond(success(request.id, { ok: true })) + return true + + case 'clipboard.commitImageUpload': + case 'clipboard.saveImageAsTempFile': + respond(success(request.id, MOCK_IMAGE_PATH)) + return true + + case 'clipboard.abortImageUpload': + respond(success(request.id, { ok: true })) + return true + + default: + return false + } +} diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index ccb8c1c721f..d704c79e855 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -11,6 +11,7 @@ import type { TerminalQuickCommand } from '../../src/shared/types' import { handleMockFilePreviewRequest } from './mock-server-file-preview-data' import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' +import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario' import { createMockTerminals, FAKE_SCROLLBACK, @@ -118,13 +119,13 @@ export function handleRequest( send(response) } - if (handleMockGitRequest(request, respond, success)) { - return - } - if (handleMockFilePreviewRequest(request, respond, success, error)) { - return - } - if (handleMockAccountRequest(request, respond, success, error)) { + // Each returns false for methods it does not own; first owner wins. + if ( + handleMockGitRequest(request, respond, success) || + handleMockFilePreviewRequest(request, respond, success, error) || + handleMockAccountRequest(request, respond, success, error) || + handleMockNativeChatRequest(request, respond, success, error, ws) + ) { return } diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts index 26bc5be1db6..5298a014336 100644 --- a/mobile/scripts/mock-server.ts +++ b/mobile/scripts/mock-server.ts @@ -3,8 +3,8 @@ // a running Orca desktop instance. Responds to the same RPC methods the real // runtime exposes, with realistic fake data. Supports E2EE handshake. import { WebSocketServer, type WebSocket } from 'ws' -import nacl from 'tweetnacl' import { deriveSharedKey, e2eeDecrypt, e2eeEncrypt, type E2EEState } from './mock-server-encryption' +import { loadOrCreateMockServerKeyPair } from './mock-server-key-pair' import { error, handleRequest, @@ -17,7 +17,9 @@ const AUTH_TOKEN = 'mock-device-token' // Why: generate a persistent server keypair for this mock session. // The public key is printed at startup so it can be used in pairing QR data. -const serverKeyPair = nacl.box.keyPair() +// MOCK_SERVER_KEY_FILE reuses one across restarts so a paired device (which +// pins the public key) survives a server restart. +const serverKeyPair = loadOrCreateMockServerKeyPair(process.env.MOCK_SERVER_KEY_FILE) const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64') const wss = new WebSocketServer({ port: PORT }) diff --git a/mobile/scripts/start-emulator-pairing-runtime.mjs b/mobile/scripts/start-emulator-pairing-runtime.mjs index 02078825db4..40188efacbf 100644 --- a/mobile/scripts/start-emulator-pairing-runtime.mjs +++ b/mobile/scripts/start-emulator-pairing-runtime.mjs @@ -85,7 +85,11 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce process: child, env: { ...process.env, - ORCA_USER_DATA_PATH: userData + ORCA_USER_DATA_PATH: userData, + // Why: `orca-dev` derives its own profile and ignores ORCA_USER_DATA_PATH, so + // without this an ORCA_CLI=orca-dev run would address the dev profile instead + // of this disposable runtime. Plain `orca` ignores it. + ORCA_DEV_USER_DATA_PATH: userData }, stop }) diff --git a/mobile/src/mock-server-key-pair.test.ts b/mobile/src/mock-server-key-pair.test.ts new file mode 100644 index 00000000000..bf4f764dd3c --- /dev/null +++ b/mobile/src/mock-server-key-pair.test.ts @@ -0,0 +1,209 @@ +import { spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import nacl from 'tweetnacl' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { loadOrCreateMockServerKeyPair } from '../scripts/mock-server-key-pair' + +const temporaryDirectories: string[] = [] + +type ConcurrentCreator = { + ready: Promise + calling: Promise + start: () => void + stop: () => void + closed: Promise + result: Promise +} + +function keyFilePath(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-mock-key-')) + temporaryDirectories.push(directory) + return join(directory, 'server-key') +} + +function runConcurrentCreator(keyFile: string): ConcurrentCreator { + const moduleUrl = pathToFileURL( + join(import.meta.dirname, '../scripts/mock-server-key-pair.ts') + ).href + const script = ` + const { loadOrCreateMockServerKeyPair } = await import(process.argv[1]) + process.stdout.write('READY\\n') + await new Promise((resolve) => process.stdin.once('data', resolve)) + process.stdout.write('CALLING\\n') + const keyPair = loadOrCreateMockServerKeyPair(process.argv[2], { warn() {} }) + process.stdout.write('KEY:' + Buffer.from(keyPair.secretKey).toString('base64') + '\\n') + ` + const child = spawn( + process.execPath, + ['--import', 'tsx', '--input-type=module', '--eval', script, moduleUrl, keyFile], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + let stdout = '' + let stderr = '' + let resolveReady!: () => void + let rejectReady!: (error: Error) => void + let resolveCalling!: () => void + let rejectCalling!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const calling = new Promise((resolve, reject) => { + resolveCalling = resolve + rejectCalling = reject + }) + let resolveClosed!: () => void + const closed = new Promise((resolve) => { + resolveClosed = resolve + }) + const timeout = setTimeout(() => child.kill(), 5_000) + timeout.unref() + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + if (stdout.includes('READY\n')) { + resolveReady() + } + if (stdout.includes('CALLING\n')) { + resolveCalling() + } + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + const result = new Promise((resolve, reject) => { + child.on('error', (error) => { + rejectReady(error) + rejectCalling(error) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(timeout) + resolveClosed() + const error = new Error(stderr || `Concurrent key creator exited ${code}`) + if (!stdout.includes('READY\n')) { + rejectReady(error) + } + if (!stdout.includes('CALLING\n')) { + rejectCalling(error) + } + const key = stdout.match(/KEY:([A-Za-z0-9+/=]+)\n/)?.[1] + if (code === 0 && key) { + resolve(key) + } else { + reject(error) + } + }) + }) + void result.catch(() => {}) + return { + ready, + calling, + start: () => { + if (!child.stdin.destroyed && !child.stdin.writableEnded) { + child.stdin.end('go\n') + } + }, + stop: () => { + if (child.exitCode === null && child.signalCode === null) { + child.kill() + } + }, + closed, + result + } +} + +async function cleanupConcurrentCreators( + creators: ConcurrentCreator[], + lockFile: string, + removeLock: boolean +): Promise { + let lockRemovalError: unknown + if (removeLock) { + try { + rmSync(lockFile, { force: true }) + } catch (error) { + lockRemovalError = error + } + } + creators.forEach((creator) => { + creator.start() + creator.stop() + }) + await Promise.allSettled(creators.flatMap((creator) => [creator.result, creator.closed])) + if (lockRemovalError) { + throw lockRemovalError + } +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('mock server key persistence', () => { + it('persists one private key and reuses it after restart', () => { + const keyFile = keyFilePath() + const first = loadOrCreateMockServerKeyPair(keyFile, { warn: vi.fn() }) + const second = loadOrCreateMockServerKeyPair(keyFile) + + expect(second.secretKey).toEqual(first.secretKey) + expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(first.secretKey).toString('base64')) + expect(readdirSync(dirname(keyFile))).toEqual(['server-key']) + if (process.platform !== 'win32') { + expect(statSync(keyFile).mode & 0o777).toBe(0o600) + } + }) + + it('re-keys canonical-length content with malformed base64', () => { + const keyFile = keyFilePath() + const encoded = Buffer.from(nacl.box.keyPair().secretKey).toString('base64') + const malformed = `${encoded.slice(0, 4)}!${encoded.slice(4)}` + expect(Buffer.from(malformed, 'base64')).toHaveLength(nacl.box.secretKeyLength) + writeFileSync(keyFile, malformed) + const logger = { warn: vi.fn() } + + const loaded = loadOrCreateMockServerKeyPair(keyFile, logger) + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid base64')) + expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(loaded.secretKey).toString('base64')) + }) + + it('makes concurrent creators converge on the persisted winner', async () => { + const keyFile = keyFilePath() + const lockFile = `${keyFile}.lock` + writeFileSync(lockFile, '', { flag: 'wx', mode: 0o600 }) + const firstCreator = runConcurrentCreator(keyFile) + const secondCreator = runConcurrentCreator(keyFile) + const creators = [firstCreator, secondCreator] + let parentOwnsLock = true + try { + await Promise.all(creators.map((creator) => creator.ready)) + creators.forEach((creator) => creator.start()) + await Promise.all(creators.map((creator) => creator.calling)) + await new Promise((resolve) => setTimeout(resolve, 50)) + rmSync(lockFile) + parentOwnsLock = false + const [first, second] = await Promise.all(creators.map((creator) => creator.result)) + + expect(second).toBe(first) + expect(readFileSync(keyFile, 'utf-8')).toBe(first) + expect(readdirSync(dirname(keyFile))).toEqual(['server-key']) + } finally { + await cleanupConcurrentCreators(creators, lockFile, parentOwnsLock) + } + }) + + it('does not overwrite an invalid key owned by another creator', () => { + const keyFile = keyFilePath() + writeFileSync(keyFile, 'invalid') + writeFileSync(`${keyFile}.lock`, '', { flag: 'wx', mode: 0o600 }) + + expect(() => loadOrCreateMockServerKeyPair(keyFile)).toThrow('remained busy') + expect(readFileSync(keyFile, 'utf-8')).toBe('invalid') + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 36006d2f633..4cbc955a9f4 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -114,6 +114,31 @@ describe('MobileNativeChatComposer', () => { expect(onSend).not.toHaveBeenCalled() }) + it('keeps the text input editable while the send is locked', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'half-typed', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + disabled: true + }) + ) + }) + } finally { + restore() + } + // Revoking `editable` on a focused field resigns first responder on iOS and + // yanks the keyboard mid-typing (#10681) — the lock may only gate sending. + const input = renderer!.root.find((node) => node.type === 'TextInput') as { + props: { editable?: boolean } + } + expect(input.props.editable).not.toBe(false) + expect(sendButton().props).toMatchObject({ disabled: true }) + }) + it('renders a removable thumbnail for each pending image attachment', async () => { const onRemoveAttachment = vi.fn() const restore = suppressRendererWarning() diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 58508be77b5..202a0ae5398 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -215,7 +215,9 @@ export function MobileNativeChatComposer({ placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} multiline - editable={!disabled} + // Why: never revoke `editable` — iOS resigns first responder on a focused + // field, so a transient lock would yank the keyboard mid-typing (#10681). + // The lock gates sending; the draft survives and rides the next send. textAlignVertical="top" /> {onMicPress ? ( diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 6277ed1a69b..b8f9cd9277b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -14,6 +14,10 @@ type Props = { onMicPressIn: () => void onMicPressOut: () => void inputLockReason: MobileNativeChatInputLockReason | null + /** Latest send failure, rendered inline above the composer. */ + sendErrorMessage: string | null + /** Drops that failure once a later send succeeds. */ + onClearSendError: () => void keyboardInset: number } @@ -28,6 +32,8 @@ export function MobileNativeChatOverlay({ onMicPressIn, onMicPressOut, inputLockReason, + sendErrorMessage, + onClearSendError, keyboardInset }: Props): React.JSX.Element | null { if (!controller.showNativeChat) { @@ -69,6 +75,8 @@ export function MobileNativeChatOverlay({ onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} inputLockReason={inputLockReason} + sendErrorMessage={sendErrorMessage} + onClearSendError={onClearSendError} filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} keyboardInset={keyboardInset} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts new file mode 100644 index 00000000000..86ead424b90 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -0,0 +1,164 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatView } from './MobileNativeChatView' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + FlatList: 'FlatList', + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) + +vi.mock('react-native-gesture-handler', () => { + const chain = { + runOnJS: () => chain, + onStart: () => chain, + onUpdate: () => chain + } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) + +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +// Stand-in composer: exposes the view's `handleSend` through a pressable, which is +// the only composer behaviour these banner tests exercise. +vi.mock('./MobileNativeChatComposer', async () => { + const React = await import('react') + return { + MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + React.createElement('Composer', { + accessibilityLabel: 'Send message', + onPress: () => props.onSend('hi') + }) + } +}) + +type Overrides = { + sendErrorMessage?: string | null + onClearSendError?: () => void + inputLockReason?: 'disconnected' | 'waiting' | null + onSend?: (text: string) => Promise +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('MobileNativeChatView send-error banner', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render(overrides: Overrides = {}): Promise { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatView, { + messages: [], + status: 'ready', + onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true), + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) + ) + }) + } finally { + restore() + } + } + + function banners(): ReactTestInstance[] { + return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') + } + + function bannerText(): string { + const [alert, ...rest] = banners() + expect(rest).toHaveLength(0) + return alert + .findAll((node) => node.type === 'Text') + .map((node) => node.props.children) + .join('') + } + + async function pressSend(): Promise { + const composer = renderer!.root.find((node) => node.type === 'Composer') as { + props: { onPress: () => Promise } + } + await act(async () => { + await composer.props.onPress() + }) + } + + it('renders the route-reported failure verbatim', async () => { + await render({ sendErrorMessage: 'Permission reply failed' }) + + expect(banners()).toHaveLength(1) + expect(bannerText()).toContain('Permission reply failed') + }) + + it('does not duplicate the route banner when the composer rejects', async () => { + const onClearSendError = vi.fn() + await render({ + onSend: vi.fn().mockResolvedValue(false), + inputLockReason: 'disconnected', + sendErrorMessage: 'Stop failed', + onClearSendError + }) + await pressSend() + + expect(onClearSendError).not.toHaveBeenCalled() + expect(banners()).toHaveLength(1) + expect(bannerText()).toContain('Stop failed') + expect(bannerText()).toBe('Stop failed') + }) + + it('retires the route-owned banner once a send is accepted', async () => { + const onClearSendError = vi.fn() + await render({ sendErrorMessage: 'Stop failed', onClearSendError }) + + await pressSend() + + expect(onClearSendError).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f2a23910a26..f14efc1f25f 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -71,6 +71,13 @@ type Props = { onMicPressIn?: () => void onMicPressOut?: () => void inputLockReason?: MobileNativeChatInputLockReason | null + /** Route-reported send failure (answer cards, permission replies, stop). Shares the + * inline banner with a rejected composer send, so one failure paints once. The + * route routes these here only while this view is mounted, and falls back to its + * toast otherwise — a deferred failure must not land on an unmounted banner. */ + sendErrorMessage?: string | null + /** Clears `sendErrorMessage` once a later send is accepted. */ + onClearSendError?: () => void filePaths?: string[] onNeedFiles?: (query: string) => void /** A pending agent question/permission detected from live status, shown as a @@ -118,6 +125,8 @@ export function MobileNativeChatView({ onMicPressIn, onMicPressOut, inputLockReason, + sendErrorMessage, + onClearSendError, filePaths, onNeedFiles, ask, @@ -143,17 +152,6 @@ export function MobileNativeChatView({ const [atBottom, setAtBottom] = useState(true) const sendScrollTimerRef = useRef | null>(null) const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() - // Surface a rejected send inline above the composer — a bottom toast gets hidden - // behind the keyboard (the case that prompted this). Auto-dismisses after a beat. - const [sendFailed, setSendFailed] = useState(false) - useEffect(() => { - if (!sendFailed) { - return - } - const t = setTimeout(() => setSendFailed(false), 4000) - return () => clearTimeout(t) - }, [sendFailed]) - useEffect( () => () => { if (sendScrollTimerRef.current) { @@ -189,10 +187,11 @@ export function MobileNativeChatView({ async (text: string): Promise => { const accepted = await onSend(text) if (!accepted) { - setSendFailed(true) return false } - setSendFailed(false) + // The route-owned banner outlives this send; a success must retire it too, + // or a stale "Message not sent" sits above the delivered message. + onClearSendError?.() // Always jump to the newest message when the user sends. setAtBottom(true) if (sendScrollTimerRef.current) { @@ -204,7 +203,7 @@ export function MobileNativeChatView({ }, 60) return true }, - [onSend] + [onSend, onClearSendError] ) const onScroll = useCallback( @@ -397,13 +396,14 @@ export function MobileNativeChatView({ ) : null} - {sendFailed ? ( - - - {rawLockReason === 'disconnected' - ? 'Message not sent — reconnecting…' - : 'Message not sent'} - + {sendErrorMessage ? ( + // This banner is the only channel for a send failure — announce it. + + {sendErrorMessage} ) : null} { expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[0]?.params).not.toHaveProperty('client') }) + + it('aborts rather than scheduling a write past the shared paste deadline', async () => { + vi.useFakeTimers() + try { + const responses = [sendResult(true), sendResult(true), sendResult(true)] + const calls: { timeoutMs: unknown }[] = [] + // Each write burns 10s, so the 15s sequence budget is spent by the third. + const client = { + sendRequest: vi.fn(async (_method: string, _params?: unknown, options?: unknown) => { + calls.push({ timeoutMs: (options as { timeoutMs: number }).timeoutMs }) + vi.advanceTimersByTime(10_000) + return responses.shift()! + }) + } + + const ok = await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png', '/tmp/b.png'] + }) + + expect(ok).toBe(false) + // Clear + first image only; the second image is never written. + expect(calls).toHaveLength(2) + expect(calls[0]?.timeoutMs).toBe(15_000) + // Positive-but-small remainder still gets the floor. + expect(calls[1]?.timeoutMs).toBe(5_000) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index 6d163b41fc3..4d52c59b0bb 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -1,5 +1,9 @@ import type { RpcClient } from '../transport/rpc-client' import { buildMobileImagePastePayload } from './mobile-clipboard-image' +import { + MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS, + openMobileNativeChatSendBudget +} from './mobile-native-chat-send' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' // Give the agent TUI a beat to register each bracketed image paste before the @@ -19,6 +23,9 @@ type PasteImagesArgs = { readonly terminal: string readonly deviceToken: string | null readonly imagePaths: readonly string[] + /** Budget shared with the rest of the user action (the text body that follows, or + * the send this is healing for). Omit to open a fresh one for this paste alone. */ + readonly deadline?: number } /** Clears the agent's unsubmitted input line, then pastes each uploaded image @@ -30,22 +37,40 @@ export async function pasteMobileNativeChatImagePaths({ client, terminal, deviceToken, - imagePaths + imagePaths, + deadline: sharedDeadline }: PasteImagesArgs): Promise { const mobileClient: MobileTerminalClient | null = deviceToken ? { id: deviceToken, type: 'mobile' } : null const clientField = mobileClient ? { client: mobileClient } : {} + // Why: this is a sequential loop, so a per-write budget multiplies by the number + // of images — the composer stays `sending` the whole time. Budget the sequence + // once and let each write draw from what's left. + const deadline = sharedDeadline ?? openMobileNativeChatSendBudget() for (const text of [ MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, ...imagePaths.map(buildMobileImagePastePayload) ]) { - const response = await client.sendRequest('terminal.send', { - terminal, - text, - enter: false, - ...clientField - }) + const remainingMs = deadline - Date.now() + // Why: the budget is the whole sequence's — starting a write it can't fund would + // let a multi-image paste overrun before the text body even begins its own send. + // Abort instead; the caller reports the failure and can retry. + if (remainingMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { + return false + } + const response = await client.sendRequest( + 'terminal.send', + { + terminal, + text, + enter: false, + ...clientField + }, + // The remaining budget covers the reconnect wait too; a fresh post-connect + // clock here would let one write outlast the whole sequence's ceiling. + { timeoutMs: remainingMs, budgetSpansConnect: true } + ) if (!isTerminalSendRpcAccepted(response)) { return false } diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index b2db8580937..1b595683112 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS } from './mobile-native-chat-send' import { sendMobileNativeChatPermissionResponse, useMobileNativeChatPermissionSend @@ -28,12 +29,16 @@ describe('sendMobileNativeChatPermissionResponse', () => { text: '1' }) ).resolves.toBe('accepted') - expect(sendRequest).toHaveBeenCalledWith('terminal.send', { - terminal: 'terminal', - text: '1', - enter: false, - client: { id: 'phone', type: 'mobile' } - }) + expect(sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'terminal', + text: '1', + enter: false, + client: { id: 'phone', type: 'mobile' } + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) }) it('surfaces an ambiguous delivery as unknown instead of a definite failure', async () => { diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 5384436ac1f..2f4e5fb9b8d 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -3,6 +3,8 @@ import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, + openMobileNativeChatSendBudget, sendMobileNativeChatMessage, sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' @@ -30,12 +32,16 @@ describe('sendMobileNativeChatMessage', () => { mobileClient: { id: 'device', type: 'mobile' } }) ).resolves.toBe(true) - expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { - terminal: 'term', - text: 'hello', - enter: true, - client: { id: 'device', type: 'mobile' } - }) + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: 'hello', + enter: true, + client: { id: 'device', type: 'mobile' } + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) }) it('returns false when the terminal rejects the send', async () => { @@ -87,6 +93,34 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe(false) }) + it('reports an unknown outcome when the request timeout expires', async () => { + // The request-timeout path: the frame was written and only the ack is missing, + // so calling it "Message not sent" would hide a message the desktop received. + const client = { + sendRequest: vi + .fn() + .mockRejectedValue(markRpcDeliveryUnknown(new Error('Request timed out: terminal.send'))) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('unknown') + }) + + it('reports a definite rejection when the connect wait times out', async () => { + // The same timeout budget also covers the pre-connect wait, which is deliberately + // NOT marked delivery-unknown — no frame was ever written. + const client = { + sendRequest: vi + .fn() + .mockRejectedValue(new Error('Timed out while connecting to the remote Orca runtime.')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('rejected') + }) + it('reports an unknown outcome when a logical cutover interrupts the send', async () => { const client = { sendRequest: vi.fn().mockRejectedValue(new LogicalClientCutoverError()) @@ -149,10 +183,59 @@ describe('sendMobileNativeChatMessage', () => { text: String.fromCharCode(27), enter: false }) - expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { - terminal: 'term', - text: String.fromCharCode(27), - enter: false + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) + }) + + it('spends only what is left of a shared budget', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } }) + + await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: 'term', + text: 'hi', + deadline: Date.now() + 4_000 + }) + const options = vi.mocked(client.sendRequest).mock.calls[0]?.[2] as { timeoutMs: number } + expect(options.timeoutMs).toBeGreaterThan(3_000) + expect(options.timeoutMs).toBeLessThanOrEqual(4_000) + }) + + it('refuses a write whose shared budget cannot fund the final acknowledgement', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + // Nothing reaches the wire, so this is a definite non-send rather than ambiguous. + await expect( + sendMobileNativeChatMessageWithOutcome({ + client, + terminal: 'term', + text: 'hi', + deadline: Date.now() + 400 + }) + ).resolves.toBe('rejected') + expect(client.sendRequest).not.toHaveBeenCalled() + }) + + it('opens a budget bounded by the send timeout', () => { + const budget = openMobileNativeChatSendBudget() - Date.now() + expect(budget).toBeGreaterThan(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS - 1_000) + expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS) }) }) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 0e20931abcd..6bce66e9bfa 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -14,6 +14,9 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean mobileClient?: MobileTerminalClient + /** Shared budget for a whole user action (heal → paste → text, or one selector's + * keystroke sequence). Omit to give this write its own full budget. */ + deadline?: number } /** 'unknown' = the RPC failed without proof the request never reached the @@ -21,16 +24,42 @@ type MobileNativeChatSendArgs = { * frame was written) — callers must not present it as a definite send failure. */ export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' +/** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and + * the composer holds `sending` (send arrow dimmed, no error) for as long as it + * pends. Chat writes are interactive: fail them so the user can retry. */ +export const MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS = 15_000 +export const MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS = 2_000 + +/** Opens a budget for one user action. Multi-write actions (heal → paste → text, a + * paced selector answer) must share one so the composer's `sending` window stays + * bounded by MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS instead of multiplying by it. */ +export function openMobileNativeChatSendBudget(): number { + return Date.now() + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS +} + export async function sendMobileNativeChatMessageWithOutcome( args: MobileNativeChatSendArgs ): Promise { + const timeoutMs = + args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() + // Starting an underfunded final write risks delivery followed by a false timeout. + if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { + return 'rejected' + } try { - const response = await args.client.sendRequest('terminal.send', { - terminal: args.terminal, - text: args.text, - enter: args.enter ?? true, - ...(args.mobileClient ? { client: args.mobileClient } : {}) - }) + const response = await args.client.sendRequest( + 'terminal.send', + { + terminal: args.terminal, + text: args.text, + enter: args.enter ?? true, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }, + // The budget covers this whole write, reconnect wait included — a chat send + // that spends its ceiling waiting to connect and then starts a fresh clock + // pins the composer for twice as long. + { timeoutMs, budgetSpansConnect: true } + ) return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected' } catch (error) { // Why: a logical relay↔direct cutover rejects the in-flight send without diff --git a/mobile/src/session/mobile-native-chat-stale-input.ts b/mobile/src/session/mobile-native-chat-stale-input.ts index 8fcc0b9a514..18d84d2e503 100644 --- a/mobile/src/session/mobile-native-chat-stale-input.ts +++ b/mobile/src/session/mobile-native-chat-stale-input.ts @@ -41,6 +41,9 @@ export async function healMobileNativeChatStaleInput(args: { readonly client: Pick readonly terminal: string readonly deviceToken: string | null + /** Budget shared with the write this heal precedes, so a hung clear can't spend a + * full send timeout and leave the following write free to spend another. */ + readonly deadline?: number }): Promise { if (!isMobileNativeChatInputStale(args.terminal)) { return true @@ -51,7 +54,8 @@ export async function healMobileNativeChatStaleInput(args: { client: args.client, terminal: args.terminal, deviceToken: args.deviceToken, - imagePaths: [] + imagePaths: [], + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) }) } catch { // Leave marked for the next attempt. diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts index 9b751c5af75..df860efc6b1 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -80,13 +80,26 @@ describe('mobile native-chat terminal stream lifecycle', () => { ) }) + it('rearms a covered stream that lost its subscription', () => { + // The covered stream IS the input lease, and nothing else re-subscribes it — + // losing it while chat is open must not leave the composer locked (#10681). + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + streamCovered: true + }) + ).toBe('rearm') + }) + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') expect( resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true, - streamActive: false, + streamActive: true, streamCovered: true }) ).toBe('none') @@ -96,5 +109,13 @@ describe('mobile native-chat terminal stream lifecycle', () => { expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe( 'none' ) + // Leaving chat with the WebView not yet ready must wait, not resume blind. + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamCovered: true, + webViewReady: false + }) + ).toBe('none') }) }) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts index d96ced2a6f6..b9b58448113 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -1,4 +1,4 @@ -export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none' +export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'rearm' | 'none' /** Decides whether the active mobile terminal stream should run while native chat * covers its WebView. Resume is allowed only once the mounted WebView is ready. */ @@ -14,7 +14,13 @@ export function resolveMobileNativeChatTerminalStreamAction(args: { return 'none' } if (args.showNativeChat) { - return !args.streamCovered ? 'pause' : 'none' + if (!args.streamCovered) { + return 'pause' + } + // Why: the covered stream IS the input lease. Anything that tore it down + // (terminal.list churn, a client swap, an `end` frame) would otherwise leave + // the composer locked forever — nothing else re-subscribes a covered handle. + return args.streamActive ? 'none' : 'rearm' } return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' } diff --git a/mobile/src/session/mobile-terminal-prune-decision.test.ts b/mobile/src/session/mobile-terminal-prune-decision.test.ts new file mode 100644 index 00000000000..c4340279f23 --- /dev/null +++ b/mobile/src/session/mobile-terminal-prune-decision.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + resolveRetainedTerminalHandles, + shouldPruneTerminalHandle +} from './mobile-terminal-prune-decision' + +describe('shouldPruneTerminalHandle', () => { + it('prunes a handle the list no longer reports while chat is closed', () => { + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(['term-2']), + showNativeChat: false, + activeHandle: 'term-1' + }) + ).toBe(true) + }) + + it('retains the chat-covered handle when the list omits it (#10681)', () => { + // terminal.list drops every handle while the desktop graph reloads; the covered + // stream is the input lease and nothing else re-subscribes it. + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(), + showNativeChat: true, + activeHandle: 'term-1' + }) + ).toBe(false) + }) + + it('still prunes an absent handle that chat is not covering', () => { + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(['term-2']), + showNativeChat: true, + activeHandle: 'term-2' + }) + ).toBe(true) + }) + + it('keeps a handle the list still reports, whatever chat is doing', () => { + for (const showNativeChat of [true, false]) { + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(['term-1']), + showNativeChat, + activeHandle: null + }) + ).toBe(false) + } + }) +}) + +describe('resolveRetainedTerminalHandles', () => { + it('carries the chat-covered handle so its live-input preference survives', () => { + // Sweeping preferences against the raw list would erase the buffered-mode + // opt-out on the very refresh the subscription was retained through. + expect([ + ...resolveRetainedTerminalHandles({ + liveHandles: new Set(['term-2']), + showNativeChat: true, + activeHandle: 'term-1' + }) + ]).toEqual(['term-2', 'term-1']) + }) + + it('returns the list untouched when nothing is retained beyond it', () => { + const liveHandles = new Set(['term-1']) + expect( + resolveRetainedTerminalHandles({ + liveHandles, + showNativeChat: false, + activeHandle: 'term-2' + }) + ).toBe(liveHandles) + }) +}) diff --git a/mobile/src/session/mobile-terminal-prune-decision.ts b/mobile/src/session/mobile-terminal-prune-decision.ts new file mode 100644 index 00000000000..2ee18cfacf0 --- /dev/null +++ b/mobile/src/session/mobile-terminal-prune-decision.ts @@ -0,0 +1,64 @@ +/** Whether a known terminal handle should be dropped after a `terminal.list` refresh. + * + * A handle covered by native chat is retained even when the list omits it: the list + * drops every handle while the desktop graph reloads (it re-mints handle ids), and + * the covered stream IS the input lease — nothing else re-subscribes it, so dropping + * it there locks the composer for good (#10681). A genuinely dead PTY still arrives + * as an `end`/`error` stream frame, and the chat stream hook bounds its rearms — + * a later list that reports the handle again refills that rearm budget, so an + * exhausted rearm never locks the composer past the host's recovery. */ +export function shouldPruneTerminalHandle(args: { + handle: string + liveHandles: ReadonlySet + showNativeChat: boolean + activeHandle: string | null +}): boolean { + if (args.liveHandles.has(args.handle)) { + return false + } + return !(args.showNativeChat && args.handle === args.activeHandle) +} + +/** Binds one `terminal.list` refresh's context so callers can test many handles. */ +export function createTerminalPrunePredicate(context: { + liveHandles: ReadonlySet + showNativeChat: boolean + activeHandle: string | null +}): (handle: string) => boolean { + return (handle) => shouldPruneTerminalHandle({ handle, ...context }) +} + +/** The handles this refresh treats as alive: everything the list reported, plus + * whatever the chat retention keeps. Per-handle preferences must be swept with the + * same set the subscriptions are, or the refresh that retains a covered handle + * still erases its live-input opt-out. */ +export function resolveRetainedTerminalHandles(context: { + liveHandles: ReadonlySet + showNativeChat: boolean + activeHandle: string | null +}): ReadonlySet { + const { activeHandle } = context + if (!activeHandle || shouldPruneTerminalHandle({ handle: activeHandle, ...context })) { + return context.liveHandles + } + return new Set(context.liveHandles).add(activeHandle) +} + +/** Drops per-handle keyboard metrics for pruned terminals, returning `previous` + * untouched when nothing changed. Swept over the whole map rather than the handles + * the caller just tore down: a handle retained for chat leaves the subscription map + * without ever being revisited by that loop. */ +export function pruneTerminalKeyboardMetrics( + previous: Map, + shouldPrune: (handle: string) => boolean +): Map { + let next: Map | null = null + for (const handle of previous.keys()) { + if (!shouldPrune(handle)) { + continue + } + next ??= new Map(previous) + next.delete(handle) + } + return next ?? previous +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index a9871802b5e..8f22884145e 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -164,6 +164,35 @@ describe('useMobileNativeChatAnswerSend', () => { ]) }) + it('bounds a stepped answer with one shared budget, crediting back the pacing waits', async () => { + const timeouts: number[] = [] + const sendRequest = vi.fn(async (_method: string, _params?: unknown, options?: unknown) => { + timeouts.push((options as { timeoutMs: number }).timeoutMs) + // A slow write must eat into what the rest of the answer has left. + vi.advanceTimersByTime(6_000) + return acceptedResponse() + }) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + + await expect(result).resolves.toBe(true) + // 15s total transport, minus 6s per completed write; the 1s pacing steps are + // deliberate and are added back, so they never shrink the budget. + expect(timeouts).toEqual([15_000, 9_000, 3_000]) + }) + it('free text: opens "Type something", types the sanitized answer, then Enter', async () => { const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) await mount({ sendRequest } as unknown as RpcClient, vi.fn()) @@ -298,6 +327,34 @@ describe('useMobileNativeChatAnswerSend', () => { expect(onSendError).toHaveBeenCalledWith('Answer not sent') }) + it('does not call a budget-truncated multi-question answer a definite non-send', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn(async () => { + // A slow relay: the first group lands, then the shared budget is gone and the + // next write short-circuits to 'rejected' without reaching the wire. + vi.advanceTimersByTime(16_000) + return acceptedResponse() + }) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(false) + // The first group DID land, so the remote selector is half-stepped — telling the + // user nothing was sent invites a retry on top of the advanced state. + expect(onSendError).toHaveBeenCalledWith('Answer partly sent — check chat before retrying') + }) + it('reports an ambiguous write as unconfirmed instead of a definite failure', async () => { const onSendError = vi.fn() const sendRequest = vi diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 021a948a2a2..11a3a030788 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -9,7 +9,10 @@ import { type AskAnswerSelection, type AskPrompt } from './mobile-native-chat-ask' -import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' +import { + openMobileNativeChatSendBudget, + sendMobileNativeChatMessageWithOutcome +} from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { resolveNativeChatTranscriptAgent, @@ -104,6 +107,12 @@ export function useMobileNativeChatAnswerSend(args: { cancelPending() const generation = generationRef.current let sawUnknownOutcome = false + let sawAcceptedGroup = false + // One budget for the whole answer instead of a fresh timeout per keystroke + // group, which let an N-group selector hold the card for N × the send timeout. + // It bounds transport time only: each deliberate pacing wait is credited back + // below, so a long multi-question answer still gets a full budget to write in. + let deadline = openMobileNativeChatSendBudget() const sendTerminal = async (body: string, enter: boolean): Promise => { const activeRoute = activeRouteRef.current if ( @@ -120,6 +129,7 @@ export function useMobileNativeChatAnswerSend(args: { terminal: handle, text: body, enter, + deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) @@ -127,6 +137,9 @@ export function useMobileNativeChatAnswerSend(args: { if (outcome === 'unknown') { sawUnknownOutcome = true } + if (outcome === 'accepted') { + sawAcceptedGroup = true + } return outcome === 'accepted' } const wait = (ms: number): Promise => @@ -144,11 +157,16 @@ export function useMobileNativeChatAnswerSend(args: { if (generationRef.current === generation) { // Why: keystrokes that may have landed (ack lost / path cutover) must // not read as a definite failure — a blind resend could double-step - // the selector. + // the selector. An earlier group that WAS accepted is the same hazard + // in definite form: a multi-question answer whose shared budget ran out + // mid-sequence left the remote selector half-stepped, and telling the + // user nothing was sent invites a retry on top of the advanced state. onSendError( - sawUnknownOutcome - ? 'Answer unconfirmed — check chat before retrying' - : 'Answer not sent' + sawAcceptedGroup + ? 'Answer partly sent — check chat before retrying' + : sawUnknownOutcome + ? 'Answer unconfirmed — check chat before retrying' + : 'Answer not sent' ) } return false @@ -168,7 +186,8 @@ export function useMobileNativeChatAnswerSend(args: { !(await healMobileNativeChatStaleInput({ client, terminal: handle, - deviceToken: deviceTokenRef.current + deviceToken: deviceTokenRef.current, + deadline })) ) { if (generationRef.current === generation) { @@ -194,8 +213,12 @@ export function useMobileNativeChatAnswerSend(args: { if (!(await sendTerminal(body, false))) { return fail() } - if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { - return false + if (index < groups.length - 1) { + if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + // Pacing is deliberate, not transport latency — don't charge it to the budget. + deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } } return groups.length > 0 diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts new file mode 100644 index 00000000000..fb09f2d4aad --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts @@ -0,0 +1,45 @@ +import { useCallback, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' + +/** Sends the Escape that dismisses an ask/question card. Its own module for the + * same reason stop/permission/answer are: the controller owns composition, not + * the per-action write semantics. */ +export function useMobileNativeChatCancelAsk(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + /** Drops any in-flight paced answer writes before the Escape lands. */ + cancelPending: () => void + onSendError: (message: string) => void +}): () => Promise { + const { client, enabled, handleRef, deviceTokenRef, cancelPending, onSendError } = args + return useCallback(async (): Promise => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Cancel not sent (disconnected)') + return false + } + cancelPending() + // Escape never submits the composer, so no stale-input heal: it would consume + // the marker still protecting the next real message. + const outcome = await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: handle, + text: String.fromCharCode(27), + enter: false, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (outcome === 'unknown') { + // Why: the Escape may have landed (ack lost / path cutover) — a definite + // "not sent" would invite a second Escape into a changed prompt state. + onSendError('Cancel unconfirmed — check chat before retrying') + } else if (outcome === 'rejected') { + onSendError('Cancel not sent') + } + return outcome === 'accepted' + }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError]) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 37cbfdc0ae5..7118d23321b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' const acceptSend = vi.fn() const captureSendOrigin = vi.fn() @@ -44,7 +45,10 @@ vi.mock('./use-mobile-native-chat-stop', () => ({ vi.mock('./use-mobile-native-chat-file-search', () => ({ useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() }) })) -vi.mock('./mobile-native-chat-send', () => ({ +// Partial: the stale-input heal reaches the real transport through image-send, +// which must read the REAL timeout constant, not a copy that can silently drift. +vi.mock('./mobile-native-chat-send', async (importOriginal) => ({ + ...(await importOriginal()), sendMobileNativeChatMessageWithOutcome: vi.fn() })) @@ -73,13 +77,15 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { let renderer: ReactTestRenderer | null = null let controller: MobileNativeChatController | null = null const onSendError = vi.fn() + const onSendResolved = vi.fn() // Only the stale-input heal reaches the transport directly (the message send // itself is mocked above). const clientStub = { sendRequest: vi.fn() } - function Harness(): null { + function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null { controller = useMobileNativeChatController({ client: clientStub as unknown as RpcClient, + connState, hostId: 'h', worktreeId: 'w', activeSessionTab: null, @@ -88,7 +94,8 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { deviceTokenRef: { current: null }, nativeChatTranscriptIsLocalReadable: true, nativeChatInputLeaseReady: true, - onSendError + onSendError, + onSendResolved }) return null } @@ -176,6 +183,23 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(isMobileNativeChatInputStale('term-1')).toBe(true) }) + it('retires a held failure banner when a card action is accepted', async () => { + // The banner is route-owned and outlives the write that raised it, so an accepted + // answer or permission reply must clear it too — not just a composer send. + sendWithOutcome.mockResolvedValue('accepted') + await act(async () => { + await controller!.handleNativeChatCancelAsk() + }) + expect(onSendResolved).toHaveBeenCalled() + + onSendResolved.mockClear() + sendWithOutcome.mockResolvedValue('rejected') + await act(async () => { + await controller!.handleNativeChatCancelAsk() + }) + expect(onSendResolved).not.toHaveBeenCalled() + }) + it('threads the optimistic-echo image URIs into acceptSend on an accepted send', async () => { sendWithOutcome.mockResolvedValue('accepted') let accepted = false @@ -215,6 +239,23 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function)) }) + it('fails a send fast while the socket is down, before spending the heal budget', async () => { + // The lease collapses a render after connState, so a question-card answer could + // otherwise sit in `sending` for the whole 15s heal+send budget. + markMobileNativeChatInputStale('term-1') + await act(async () => { + renderer?.update(createElement(Harness, { connState: 'connecting' })) + }) + let accepted = true + await act(async () => { + accepted = await controller!.handleNativeChatSend('answer') + }) + expect(accepted).toBe(false) + expect(clientStub.sendRequest).not.toHaveBeenCalled() + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)') + }) + it('reports a rejected send and posts no echo', async () => { sendWithOutcome.mockResolvedValue('rejected') let accepted = true diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index e65ad2e242a..8ca4b0b6075 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -7,6 +7,7 @@ import { } from 'react' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' import { parseAskFromStatus, type AskAnswerSelection, @@ -17,11 +18,9 @@ import { detectAgentPermission } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' import { openMobileNativeChatFile } from './mobile-native-chat-open-file' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import { - sendMobileNativeChatMessageWithOutcome, - type MobileNativeChatSendOutcome -} from './mobile-native-chat-send' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' import { useMobileNativeChatDrafts, type MobileNativeChatPendingMessage @@ -31,6 +30,7 @@ import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 @@ -65,10 +65,13 @@ export type MobileNativeChatController = { handleNativeChatQuestionAnswer: (text: string) => Promise handleNativeChatSend: (text: string, images?: string[]) => Promise /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. */ + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ handleNativeChatSendWithOutcome: ( text: string, - images?: string[] + images?: string[], + deadline?: number ) => Promise } @@ -84,7 +87,12 @@ export function useMobileNativeChatController(args: { deviceTokenRef: MutableRefObject nativeChatTranscriptIsLocalReadable: boolean nativeChatInputLeaseReady: boolean + /** Live socket state; the lease collapses on disconnect but one render later. */ + connState: ConnectionState onSendError: (message: string) => void + /** Retires a held failure banner. Any accepted chat write clears it — a delivered + * answer or permission reply must not sit under a stale "not sent". */ + onSendResolved: () => void }): MobileNativeChatController { const { client, @@ -96,7 +104,9 @@ export function useMobileNativeChatController(args: { deviceTokenRef, nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, - onSendError + connState, + onSendError, + onSendResolved } = args const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) @@ -169,10 +179,14 @@ export function useMobileNativeChatController(args: { [activeHandleRef, client, worktreeId] ) + // Every chat write gates on both: the lease proves the input floor is ours, and + // `connState` collapses a render before the lease does on disconnect. + const inputSendable = nativeChatInputLeaseReady && connState === 'connected' + const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = useMobileNativeChatAnswerSend({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, agentRef: activeChatAgentRef, @@ -181,44 +195,18 @@ export function useMobileNativeChatController(args: { onSendError }) - const handleNativeChatCancelAsk = useCallback(async (): Promise => { - const handle = activeHandleRef.current - if (!client || !handle || !nativeChatInputLeaseReady) { - onSendError('Cancel not sent (disconnected)') - return false - } - cancelNativeChatAnswer() - // Escape never submits the composer, so no stale-input heal: it would consume - // the marker still protecting the next real message. - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text: String.fromCharCode(27), - enter: false, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) - if (outcome === 'unknown') { - // Why: the Escape may have landed (ack lost / path cutover) — a definite - // "not sent" would invite a second Escape into a changed prompt state. - onSendError('Cancel unconfirmed — check chat before retrying') - } else if (outcome === 'rejected') { - onSendError('Cancel not sent') - } - return outcome === 'accepted' - }, [ - activeHandleRef, - cancelNativeChatAnswer, + const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({ client, + enabled: inputSendable, + handleRef: activeHandleRef, deviceTokenRef, - nativeChatInputLeaseReady, + cancelPending: cancelNativeChatAnswer, onSendError - ]) + }) const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, onSendError @@ -226,7 +214,7 @@ export function useMobileNativeChatController(args: { const handleNativeChatStop = useMobileNativeChatStop({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, streamIdentity, @@ -245,7 +233,7 @@ export function useMobileNativeChatController(args: { answerQuestion: handleNativeChatQuestionAnswer } = useMobileNativeChatMessageSend({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, captureSendOrigin, @@ -255,6 +243,10 @@ export function useMobileNativeChatController(args: { holdUnconfirmedSend, onSendError }) + // Card actions retire the route's held failure banner too, not just sends. + const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) + const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) + const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) return { isTabChatView, @@ -272,9 +264,9 @@ export function useMobileNativeChatController(args: { nativeChatQuestion, nativeChatAsk, handleNativeChatOpenFile, - handleNativeChatAnswerAsk, - handleNativeChatCancelAsk, - handleNativeChatRespondPermission, + handleNativeChatAnswerAsk: answerAsk, + handleNativeChatCancelAsk: cancelAsk, + handleNativeChatRespondPermission: respond, handleNativeChatStop, nativeChatFilePaths, loadNativeChatFiles, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 8079a48d2b1..68ae3e2dc05 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -67,6 +67,7 @@ function baseArgs(overrides: Partial & Pick): Hook scopeKey: SCOPE_A, enabled: true, showToast: vi.fn(), + onSendError: vi.fn(), baseSend: vi.fn().mockResolvedValue('accepted'), sleep: async () => {}, ...overrides @@ -195,11 +196,51 @@ describe('useMobileNativeChatImageAttachments', () => { // Clear, then paste, then settle, then the text send — in that order. expect(order).toEqual(['clear', 'paste', 'settle', 'text:look at this']) // The local preview URI rides along so the sent bubble shows the photo. - expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg']) + expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg'], expect.any(Number)) // Chips clear once the send is accepted. expect(hook!.attachments).toEqual([]) }) + it('spends one budget across the image paste and the text body that follows', async () => { + vi.useFakeTimers() + try { + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), // Ctrl+U clear + sendResult(true) // image paste + ]) + const slowClient: Pick = { + sendRequest: async (method, params) => { + if (method === 'terminal.send') { + // A slow relay: each write burns 5s of the action's budget. + vi.setSystemTime(Date.now() + 5_000) + } + return client.sendRequest(method, params) + } + } + const baseSend = vi.fn().mockResolvedValue('accepted') + mount(baseArgs({ client: slowClient as RpcClient, baseSend })) + + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('look at this') + }) + + // The paste spent 10s of the 15s ceiling, so the text body inherits what is + // left (plus the credited settle). Opening a fresh budget here — which the + // caller used to do by omitting `deadline` — would hand it a full 15s and let + // one user action hold the composer `sending` for ~30s. + const deadline = baseSend.mock.calls[0]?.[2] as number + expect(deadline - Date.now()).toBeLessThanOrEqual(5_300) + } finally { + vi.useRealTimers() + } + }) + it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) const client = makeClient([ @@ -222,7 +263,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(accepted).toBe(true) // Empty text still goes through baseSend (which submits the bare Enter) so the // optimistic echo carries the preview URI. - expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg']) + expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'], expect.any(Number)) const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') // Only the clear + image paste hit the wire here; baseSend owns the submit. expect(sendCalls).toHaveLength(2) @@ -237,7 +278,7 @@ describe('useMobileNativeChatImageAttachments', () => { await act(async () => { await hook!.sendNativeChat('just text') }) - expect(baseSend).toHaveBeenCalledWith('just text') + expect(baseSend).toHaveBeenCalledWith('just text', undefined, expect.any(Number)) expect(client.calls).toHaveLength(0) }) @@ -250,8 +291,8 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(false) // image paste rejected ]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() - mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast })) + const onSendError = vi.fn() + mount(baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError })) await act(async () => { await hook!.attachImage('library') }) @@ -261,7 +302,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent') expect(hook!.attachments).toHaveLength(1) }) @@ -270,8 +311,8 @@ describe('useMobileNativeChatImageAttachments', () => { // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() - mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast })) + const onSendError = vi.fn() + mount(baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError })) await act(async () => { await hook!.attachImage('library') }) @@ -281,17 +322,19 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent') expect(hook!.attachments).toHaveLength(1) }) - it('surfaces a toast instead of a silent no-op when the input lease gate is closed', async () => { + it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() + const onSendError = vi.fn() // Attaching is allowed without the lease; only the send is gated on it. - mount(baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, showToast })) + mount( + baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, onSendError }) + ) await act(async () => { await hook!.attachImage('library') }) @@ -301,7 +344,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent (disconnected)', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)') expect(hook!.attachments).toHaveLength(1) }) @@ -322,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { await act(async () => { await hook!.sendNativeChat('hi') }) - expect(baseSend).toHaveBeenCalledWith('hi') + expect(baseSend).toHaveBeenCalledWith('hi', undefined, expect.any(Number)) expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false) // Back on the original tab the chip is still pending. @@ -425,7 +468,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) // Only the first (sent) image rode along; the mid-send chip survives. - expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg']) + expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg'], expect.any(Number)) expect(hook!.attachments.map((a) => a.previewUri)).toEqual(['file:///b.jpg']) }) @@ -438,14 +481,14 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(true) // image paste — into term-1 ]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() + const onSendError = vi.fn() const activeHandleRef = { current: 'term-1' } let releaseSettle: (() => void) | null = null const args = baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend, - showToast, + onSendError, sleep: () => new Promise((resolve) => { releaseSettle = resolve @@ -474,7 +517,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent') expect(hook!.attachments).toHaveLength(1) }) @@ -511,7 +554,7 @@ describe('useMobileNativeChatImageAttachments', () => { // Failed attempt's clear + rejected paste, then the healing clear. expect(sendCalls).toHaveLength(3) expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) - expect(baseSend).toHaveBeenCalledWith('hi again') + expect(baseSend).toHaveBeenCalledWith('hi again', undefined, expect.any(Number)) }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { @@ -548,8 +591,8 @@ describe('useMobileNativeChatImageAttachments', () => { const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') expect(sendCalls).toHaveLength(3) expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg']) - expect(baseSend).toHaveBeenNthCalledWith(2, 'later message') + expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg'], expect.any(Number)) + expect(baseSend).toHaveBeenNthCalledWith(2, 'later message', undefined, expect.any(Number)) }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { @@ -585,7 +628,7 @@ describe('useMobileNativeChatImageAttachments', () => { const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') expect(sendCalls).toHaveLength(3) expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(2, 'later message') + expect(baseSend).toHaveBeenNthCalledWith(2, 'later message', undefined, expect.any(Number)) }) it('does not heal after an unknown text-only send (nothing was pasted first)', async () => { @@ -644,8 +687,8 @@ describe('useMobileNativeChatImageAttachments', () => { expect(sendCalls).toHaveLength(4) expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) expect(sendCalls[3]?.params).toMatchObject({ text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg']) - expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again') + expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg'], expect.any(Number)) + expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again', undefined, expect.any(Number)) }) it('does not reroute text when the active terminal changes during a healing clear', async () => { @@ -692,6 +735,49 @@ describe('useMobileNativeChatImageAttachments', () => { expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false }) }) + it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), // Ctrl+U clear + sendResult(true), // image paste accepted + sendResult(true) // the heal, once the lease is back + ]) + const baseSend = vi.fn().mockResolvedValueOnce('rejected').mockResolvedValueOnce('accepted') + const onSendError = vi.fn() + const args = baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError }) + mount(args) + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('hi') + }) + await act(async () => { + hook!.removeAttachment('img-1') + }) + + // Lease lost: the heal is a terminal.send too, so it must not be attempted. + update({ ...args, enabled: false }) + let accepted = true + await act(async () => { + accepted = await hook!.sendNativeChat('hi again') + }) + expect(accepted).toBe(false) + expect(onSendError).toHaveBeenLastCalledWith('Message not sent (disconnected)') + expect(client.calls.filter((c) => c.method === 'terminal.send')).toHaveLength(2) + + update({ ...args, enabled: true }) + await act(async () => { + accepted = await hook!.sendNativeChat('hi again') + }) + expect(accepted).toBe(true) + const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') + expect(sendCalls).toHaveLength(3) + expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) + }) + it('heals rejected image submits independently across terminals', async () => { pick .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index f0ce66dd92b..7ce1ad8978b 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -15,7 +15,10 @@ import { MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { + openMobileNativeChatSendBudget, + type MobileNativeChatSendOutcome +} from './mobile-native-chat-send' import { clearMobileNativeChatInputStale, healMobileNativeChatStaleInput, @@ -39,13 +42,19 @@ type Args = { /** The native-chat input lease is ready — same gate `handleNativeChatSend` uses. */ readonly enabled: boolean readonly showToast: ShowToast + /** Send failures go to the composer's inline banner, not the toast — the same + * channel the controller's own rejections use, so one failure paints once. */ + readonly onSendError: (message: string) => void /** The plain text send (controller.handleNativeChatSendWithOutcome); wrapped so * images ride along. The optional URIs drive the optimistic echo's thumbnails. * Must preserve 'unknown': after a successful paste, an ambiguously-delivered - * text+Enter may have left the image on the input line, which needs healing. */ + * text+Enter may have left the image on the input line, which needs healing. + * Accepts this action's budget so the text body draws from what the paste left + * rather than opening a second one. */ readonly baseSend: ( text: string, - imagePreviewUris?: string[] + imagePreviewUris?: string[], + deadline?: number ) => Promise readonly onAttachSuccess?: () => void readonly onError?: () => void @@ -95,6 +104,7 @@ export function useMobileNativeChatImageAttachments({ scopeKey, enabled, showToast, + onSendError, baseSend, onAttachSuccess, onError, @@ -206,12 +216,16 @@ export function useMobileNativeChatImageAttachments({ const operationTerminal = activeHandleRef.current if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) { onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } if (operationTerminal) { sendInFlightTerminalsRef.current.add(operationTerminal) } + // One budget for the whole user action. The paste loop, the settle, and the + // text body that follows are a single send from the composer's point of view; + // opening a budget per leg let `sending` run to twice the stated ceiling. + const deadline = openMobileNativeChatSendBudget() try { const scope = scopeKey const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS @@ -220,28 +234,38 @@ export function useMobileNativeChatImageAttachments({ // otherwise glue the stale image paste onto this message. Best-effort — // on failure the marker stays set and the text must not be submitted. const staleTerminal = activeHandleRef.current - if (staleTerminal && isMobileNativeChatInputStale(staleTerminal) && client) { + if (staleTerminal && isMobileNativeChatInputStale(staleTerminal)) { + // Why: the heal is itself a terminal.send, so without the input lease it + // can only be rejected — which used to latch the marker and fail every + // later send with a bare "Message not sent" (#10681). Gate it like the + // image path; the heal retries once the lease is back. + if (!client || !enabled || connState !== 'connected') { + onError?.() + onSendError('Message not sent (disconnected)') + return false + } const healed = await healMobileNativeChatStaleInput({ client, terminal: staleTerminal, - deviceToken: deviceTokenRef.current + deviceToken: deviceTokenRef.current, + deadline }) // A tab switch during the clear would send this text to a terminal the // clear never touched, so abort rather than reroute it. if (!healed || activeHandleRef.current !== staleTerminal) { onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } } // Text-only sends paste nothing first, so 'unknown' leaves no stale input. - return (await baseSend(text)) !== 'rejected' + return (await baseSend(text, undefined, deadline)) !== 'rejected' } const handle = activeHandleRef.current if (!client || !handle || !enabled || connState !== 'connected') { onError?.() // Mirror the text path's failure surface (the base send is never reached). - showToast('Message not sent (disconnected)', 1500) + onSendError('Message not sent (disconnected)') return false } try { @@ -249,13 +273,14 @@ export function useMobileNativeChatImageAttachments({ client, terminal: handle, deviceToken: deviceTokenRef.current, - imagePaths: pendingImages.map((attachment) => attachment.path) + imagePaths: pendingImages.map((attachment) => attachment.path), + deadline }) if (!pasted) { // Keep the chips so the user can retry; the failed paste never submitted. markMobileNativeChatInputStale(handle) onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } // The paste's leading Ctrl+U cleared any earlier stale input in `handle`. @@ -264,18 +289,22 @@ export function useMobileNativeChatImageAttachments({ // preview URIs ride along to baseSend so the sent bubble shows the photo // immediately (empty text still submits a bare Enter through baseSend). await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS) + // The settle is deliberate pacing, not transport latency — credit it back + // so a shared budget doesn't charge the text body for the TUI's beat. + const textDeadline = deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS // The paste above targeted `handle`; a tab switch during the settle would // route the text + Enter to a different terminal than the images. Abort — // the chips keep their scope and a retry's Ctrl+U clears the stale paste. if (activeHandleRef.current !== handle) { markMobileNativeChatInputStale(handle) onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } const outcome = await baseSend( text, - pendingImages.map((attachment) => attachment.previewUri) + pendingImages.map((attachment) => attachment.previewUri), + textDeadline ) if (outcome !== 'accepted') { // 'rejected' leaves the pasted image path on this input line; 'unknown' @@ -303,7 +332,7 @@ export function useMobileNativeChatImageAttachments({ // attempt's leading Ctrl+U clears whatever fraction of the paste landed. markMobileNativeChatInputStale(handle) onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } } finally { @@ -321,8 +350,8 @@ export function useMobileNativeChatImageAttachments({ deviceTokenRef, enabled, onError, + onSendError, scopeKey, - showToast, sleep ] ) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts index bb90327c5b9..c7e60170f0a 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -54,4 +54,36 @@ describe('useMobileNativeChatInputLease', () => { consoleSpy.mockRestore() } }) + + it('reports whether a clear actually dropped a lease', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + // The route reads this to tell a real teardown from one React never sees. + expect(lease?.clear('terminal')).toBe(false) + expect(lease?.clear()).toBe(false) + + act(() => lease?.markReady('terminal')) + let dropped: boolean | undefined + act(() => { + dropped = lease?.clear('terminal') + }) + expect(dropped).toBe(true) + expect(lease?.ready).toBe(false) + expect(lease?.clear('terminal')).toBe(false) + + act(() => lease?.markReady('other')) + expect(lease?.clear()).toBe(true) + } finally { + consoleSpy.mockRestore() + } + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index 02dfeff84a8..fddf9274573 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -9,9 +9,13 @@ export function useMobileNativeChatInputLease(args: { readyRef: { readonly current: boolean } lockReason: MobileNativeChatInputLockReason | null markReady: (handle: string) => void - clear: (handle?: string) => void + /** Returns whether the clear actually dropped a lease — callers use a no-op clear + * to detect a teardown React would otherwise never see (#10681). */ + clear: (handle?: string) => boolean } { const [readyHandles, setReadyHandles] = useState>(new Set()) + // Mirrors the state so `clear` can report synchronously whether it changed anything. + const readyHandlesRef = useRef(readyHandles) const ready = args.activeHandle != null && readyHandles.has(args.activeHandle) // Why: absence of an acknowledgement proves only that setup is still pending; // the protocol does not report evidence that another client owns the floor. @@ -22,27 +26,44 @@ export function useMobileNativeChatInputLease(args: { : 'waiting' const readyRef = useRef(ready) readyRef.current = ready - useEffect(() => { - if (!args.connected) { - setReadyHandles(new Set()) - } - }, [args.connected]) - const markReady = useCallback((handle: string) => { - setReadyHandles((current) => new Set(current).add(handle)) + const replace = useCallback((next: Set) => { + readyHandlesRef.current = next + setReadyHandles(next) }, []) - const clear = useCallback((handle?: string) => { - setReadyHandles((current) => { + useEffect(() => { + if (!args.connected && readyHandlesRef.current.size > 0) { + replace(new Set()) + } + }, [args.connected, replace]) + const markReady = useCallback( + (handle: string) => { + if (readyHandlesRef.current.has(handle)) { + return + } + replace(new Set(readyHandlesRef.current).add(handle)) + }, + [replace] + ) + const clear = useCallback( + (handle?: string): boolean => { + const current = readyHandlesRef.current if (handle === undefined) { - return new Set() + if (current.size === 0) { + return false + } + replace(new Set()) + return true } if (!current.has(handle)) { - return current + return false } const next = new Set(current) next.delete(handle) - return next - }) - }, []) + replace(next) + return true + }, + [replace] + ) return { ready, readyRef, diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 939aee06fd8..559524f3c5d 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -1,6 +1,7 @@ import { useCallback, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import { + openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' @@ -11,8 +12,14 @@ export type MobileNativeChatMessageSend = { /** Composer send that syncs the draft (clear on send, restore on rejection). */ send: (text: string, images?: string[]) => Promise /** Outcome-preserving variant: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. */ - sendWithOutcome: (text: string, images?: string[]) => Promise + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number + ) => Promise /** Answer to an agent question — never touches the composer draft. */ answerQuestion: (text: string) => Promise } @@ -52,10 +59,14 @@ export function useMobileNativeChatMessageSend(args: { async ( text: string, images: string[] | undefined, - syncComposer: boolean + syncComposer: boolean, + sharedDeadline?: number ): Promise => { const handle = handleRef.current const origin = captureSendOrigin(text) + // Why: the lease collapses one render after `connState`, so a question-card + // answer (which reaches this send directly) would otherwise burn the whole + // 15s heal+send budget waiting on a socket that is already gone. if (!client || !handle || !origin || !enabled) { onSendError('Message not sent (disconnected)') return 'rejected' @@ -64,7 +75,16 @@ export function useMobileNativeChatMessageSend(args: { // send (#10228); submitting on top of it would glue the image onto this // message. Healed before the draft clear so a failed heal — which sends // nothing — leaves the composer exactly as the user left it. - const healArgs = { client, terminal: handle, deviceToken: deviceTokenRef.current } + // One budget for the whole action: a hung heal must eat into the text send's + // time, not hand it a fresh timeout and pin the composer for twice as long. + // An image send already opened one covering its paste — keep spending that. + const deadline = sharedDeadline ?? openMobileNativeChatSendBudget() + const healArgs = { + client, + terminal: handle, + deviceToken: deviceTokenRef.current, + deadline + } if (!(await healMobileNativeChatStaleInput(healArgs))) { onSendError('Message not sent') return 'rejected' @@ -79,6 +99,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, + deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) @@ -118,7 +139,8 @@ export function useMobileNativeChatMessageSend(args: { ) const sendWithOutcome = useCallback( - (text: string, images?: string[]) => sendMessage(text, images, true), + (text: string, images?: string[], deadline?: number) => + sendMessage(text, images, true, deadline), [sendMessage] ) diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts new file mode 100644 index 00000000000..a22eee3a0a4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts @@ -0,0 +1,223 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatSendError } from './use-mobile-native-chat-send-error' + +type HookApi = ReturnType + +describe('useMobileNativeChatSendError', () => { + let renderer: ReactTestRenderer | null = null + const apiRef = { current: null as HookApi | null } + + const showToast = vi.fn() + + function Harness({ + scopeKey, + bannerMounted = true + }: { + scopeKey: string | null + bannerMounted?: boolean + }): null { + const api = useMobileNativeChatSendError({ scopeKey, showToast }) + api.bannerMountedRef.current = bannerMounted + apiRef.current = api + return null + } + + function api(): HookApi { + if (!apiRef.current) { + throw new Error('Harness was not rendered') + } + return apiRef.current + } + + /** react-test-renderer logs a deprecation notice on every render; keep real errors. */ + function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() + } + + async function render(scopeKey: string | null = 'terminal-1'): Promise { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness, { scopeKey })) + }) + } finally { + restore() + } + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + apiRef.current = null + showToast.mockClear() + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('holds a failure for four seconds, then drops it', async () => { + await render() + await act(async () => api().show('a')) + expect(api().message).toBe('a') + + await act(async () => { + vi.advanceTimersByTime(4000) + }) + expect(api().message).toBeNull() + }) + + it('restarts the hold when a second failure lands mid-hold', async () => { + await render() + await act(async () => api().show('a')) + await act(async () => { + vi.advanceTimersByTime(3000) + }) + await act(async () => api().show('b')) + + // The first failure's timer must not survive to clear the second message. + await act(async () => { + vi.advanceTimersByTime(3000) + }) + expect(api().message).toBe('b') + + await act(async () => { + vi.advanceTimersByTime(1000) + }) + expect(api().message).toBeNull() + }) + + it('clears immediately and cancels the pending hold', async () => { + await render() + await act(async () => api().show('a')) + await act(async () => api().clear()) + expect(api().message).toBeNull() + + await act(async () => api().show('b')) + await act(async () => api().clear()) + await act(async () => { + vi.advanceTimersByTime(10_000) + }) + expect(api().message).toBeNull() + }) + + it('drops a held failure when the scope changes', async () => { + await render('terminal-1') + await act(async () => api().show('a')) + expect(api().message).toBe('a') + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) + } finally { + restore() + } + expect(api().message).toBeNull() + }) + + it('falls back to the toast when the banner is not mounted', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false })) + }) + } finally { + restore() + } + // A deferred failure landing after the user left chat must still be seen. + await act(async () => api().show('Delivery unconfirmed')) + + expect(showToast).toHaveBeenCalledWith('Delivery unconfirmed', 1600) + expect(api().message).toBeNull() + }) + + it('toasts a deferred failure that resolves after the user switched tabs', async () => { + await render('terminal-1') + // Captured while tab A was live; a 20s unconfirmed send resolves much later. + const showFromTabA = api().show + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) + } finally { + restore() + } + await act(async () => showFromTabA('Message not sent')) + + // The banner belongs to terminal-2 now, so A's failure must not paint there. + expect(api().message).toBeNull() + expect(showToast).toHaveBeenCalledWith('Message not sent', 1600) + }) + + it('does not let a stale scope clear the banner the live scope is showing', async () => { + await render('terminal-1') + const clearFromTabA = api().clear + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) + } finally { + restore() + } + await act(async () => api().show('b')) + // An accepted card action from tab A resolving late must not retire B's warning. + await act(async () => clearFromTabA()) + + expect(api().message).toBe('b') + }) + + it('toasts a failure that resolves after the route unmounted', async () => { + await render() + const showWhileMounted = api().show + + act(() => renderer?.unmount()) + renderer = null + // The route writes bannerMountedRef during render, so an unmount leaves it + // stuck true — the failure would target a banner that no longer exists. + await act(async () => showWhileMounted('Delivery unconfirmed')) + + expect(showToast).toHaveBeenCalledWith('Delivery unconfirmed', 1600) + }) + + it('does not fire the hold timer after unmount', async () => { + await render() + await act(async () => api().show('a')) + + const errors: unknown[] = [] + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + errors.push(args[0]) + original(...args) + }) + try { + act(() => renderer?.unmount()) + renderer = null + act(() => { + vi.advanceTimersByTime(4000) + }) + } finally { + spy.mockRestore() + } + expect(errors).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-send-error.ts b/mobile/src/session/use-mobile-native-chat-send-error.ts new file mode 100644 index 00000000000..359b94a8336 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-send-error.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' + +const NATIVE_CHAT_SEND_ERROR_HOLD_MS = 4000 +const NATIVE_CHAT_SEND_ERROR_TOAST_MS = 1600 + +/** Holds the newest native-chat send failure for the composer's inline banner. + * Why a banner and not the bottom toast: chat failures happen with the keyboard + * up, which covers the toast — the surface the user is looking at is the composer. + * Scoped like drafts and image chips: a failure belongs to the terminal it was + * raised on and must not follow the user to another tab. */ +export function useMobileNativeChatSendError(args: { + scopeKey: string | null + showToast: (message: string, durationMs?: number) => void +}): { + message: string | null + show: (message: string) => void + clear: () => void + /** Set by the route each render; gates banner vs toast. */ + bannerMountedRef: MutableRefObject +} { + const [message, setMessage] = useState(null) + const timerRef = useRef | null>(null) + const bannerMountedRef = useRef(false) + const showToastRef = useRef(args.showToast) + showToastRef.current = args.showToast + // Why: `show`/`clear` are handed to sends that resolve much later (a 20s + // unconfirmed send, a paced answer). Comparing the scope they were built for + // against the live one is what stops tab A's late outcome from painting — or + // wiping — tab B's banner. + const liveScopeRef = useRef(args.scopeKey) + liveScopeRef.current = args.scopeKey + const scopeKey = args.scopeKey + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + const clear = useCallback(() => { + if (liveScopeRef.current !== scopeKey) { + return + } + clearTimer() + setMessage(null) + }, [clearTimer, scopeKey]) + const show = useCallback( + (next: string) => { + // Why: deferred failures can land after the user left chat (banner unmounted) + // or moved to another tab, where the banner belongs to a different terminal — + // both must fall back to the toast instead of being swallowed or misattributed. + if (liveScopeRef.current !== scopeKey || !bannerMountedRef.current) { + showToastRef.current(next, NATIVE_CHAT_SEND_ERROR_TOAST_MS) + return + } + clearTimer() + setMessage(next) + timerRef.current = setTimeout(() => { + timerRef.current = null + setMessage(null) + }, NATIVE_CHAT_SEND_ERROR_HOLD_MS) + }, + [clearTimer, scopeKey] + ) + // A held failure describes the scope it was raised on; drop it when that changes. + useEffect(() => { + clearTimer() + setMessage(null) + }, [clearTimer, scopeKey]) + useEffect( + () => () => { + // Why: the route writes this ref during render, so an unmount leaves it stuck + // true and a pending send's late failure would target a banner that no longer + // exists — swallowing the one signal the toast fallback is here to carry. + bannerMountedRef.current = false + clearTimer() + }, + [clearTimer] + ) + return { message, show, clear, bannerMountedRef } +} diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts index 27385f8d33e..2a5460b67fa 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.test.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -2,6 +2,8 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS } from './mobile-native-chat-send' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' describe('useMobileNativeChatStop', () => { @@ -13,7 +15,10 @@ describe('useMobileNativeChatStop', () => { beforeEach(() => { vi.useFakeTimers() globalThis.IS_REACT_ACT_ENVIRONMENT = true - sendRequest.mockReset().mockResolvedValue({ ok: true }) + sendRequest.mockReset().mockResolvedValue({ + ok: true, + result: { send: { accepted: true } } + }) onSendError.mockReset() }) @@ -82,4 +87,102 @@ describe('useMobileNativeChatStop', () => { expect(onSendError).toHaveBeenCalledOnce() expect(onSendError).toHaveBeenCalledWith('Stop not sent') }) + + it.each([ + ['RPC failure', { ok: false, error: { code: 'stale', message: 'stale' } }], + ['non-accepted send', { ok: true, result: { send: { accepted: false } } }] + ])('reports Stop not sent after a resolved %s', async (_case, response) => { + sendRequest.mockResolvedValue(response) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => vi.runAllTimersAsync()) + + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop not sent') + }) + + it.each([ + [ + 'an ack lost after the frame was written', + () => markRpcDeliveryUnknown(new Error('rpc timeout')) + ], + ['a logical client cutover', () => new Error('RPC interrupted by connection migration')] + ])('reports Stop as unconfirmed after %s', async (_case, makeError) => { + sendRequest.mockRejectedValue(makeError()) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + // The Escape may have landed; a definite "not sent" invites a second Escape. + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop unconfirmed — check chat before retrying') + }) + + it.each([ + ['second', 0], + ['first', 1] + ])('stays quiet when the %s Escape fails after its sibling landed', async (_case, failIndex) => { + let call = 0 + sendRequest.mockImplementation(() => { + const index = call + call += 1 + return index === failIndex + ? Promise.reject(markRpcDeliveryUnknown(new Error('rpc timeout'))) + : Promise.resolve({ ok: true, result: { send: { accepted: true } } }) + }) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + // Two paced Escapes are one user action: either landing means the agent stopped, + // so a straggler's failure must not tell the user to press Stop again. + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('bounds the Escape on a reconnect wait instead of parking forever', async () => { + await render(true, 'stream-1') + + act(() => stop?.()) + + // The budget covers the reconnect wait too, so a stop can't outlast its ceiling. + expect(sendRequest).toHaveBeenCalledWith( + 'terminal.send', + expect.anything(), + expect.objectContaining({ + timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, + budgetSpansConnect: true + }) + ) + }) + + it('suppresses an older Stop verdict after a newer Stop succeeds', async () => { + let rejectFirst!: (error: Error) => void + const first = new Promise((_, reject) => { + rejectFirst = reject + }) + sendRequest + .mockReturnValueOnce(first) + .mockResolvedValue({ ok: true, result: { send: { accepted: true } } }) + await render(true, 'stream-1') + + act(() => stop?.()) + act(() => stop?.()) + await act(async () => vi.runAllTimersAsync()) + await act(async () => { + rejectFirst(new Error('late failure')) + await Promise.resolve() + }) + + expect(onSendError).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts index c6d36ea3436..871d221abb2 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' +import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { openMobileNativeChatSendBudget } from './mobile-native-chat-send' export function useMobileNativeChatStop(args: { client: RpcClient | null @@ -13,20 +17,29 @@ export function useMobileNativeChatStop(args: { const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = args const timerRef = useRef | null>(null) + const generationRef = useRef(0) + /** Settles the paced second Escape when it is cancelled rather than sent, so a + * first-Escape failure still reports instead of waiting on a write that will + * never happen. */ + const dropSecondEscapeRef = useRef<(() => void) | null>(null) const activeRouteRef = useRef({ client, enabled, streamIdentity }) activeRouteRef.current = { client, enabled, streamIdentity } - useEffect(() => { - if (!enabled && timerRef.current) { + const cancelSecondEscape = useCallback(() => { + if (timerRef.current) { clearTimeout(timerRef.current) timerRef.current = null } - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current) - timerRef.current = null - } - } - }, [client, enabled, streamIdentity]) + const drop = dropSecondEscapeRef.current + dropSecondEscapeRef.current = null + drop?.() + }, []) + useEffect( + () => () => { + generationRef.current += 1 + cancelSecondEscape() + }, + [cancelSecondEscape, client, enabled, streamIdentity] + ) return useCallback(() => { const handle = handleRef.current if (!client || !handle || !enabled) { @@ -34,11 +47,34 @@ export function useMobileNativeChatStop(args: { return } cancelPending() - if (timerRef.current) { - clearTimeout(timerRef.current) - } + generationRef.current += 1 + const generation = generationRef.current + cancelSecondEscape() const stopStreamIdentity = streamIdentity - let failureReported = false + const deadline = openMobileNativeChatSendBudget() + // Why: the two paced Escapes are one user action. Reporting the first one's + // failure the moment it lands told the user a stop failed that the second + // Escape then completed — and a second Stop press writes into changed prompt + // state. Hold the verdict until both have settled, then stay quiet if either + // was accepted. `pending` starts at 1 for the Escape still on its timer. + let pending = 1 + let sawAccepted = false + let sawUnknown = false + let sawRejected = false + const reportIfSettled = (): void => { + if ( + generationRef.current !== generation || + pending > 0 || + sawAccepted || + (!sawUnknown && !sawRejected) + ) { + return + } + // Why: an ack lost after the frame was written (or a logical cutover) may + // still have stopped the agent — a definite "not sent" would invite a second + // Escape into changed state. Mirrors the cancel/answer wording. + onSendError(sawUnknown ? 'Stop unconfirmed — check chat before retrying' : 'Stop not sent') + } const sendEscape = (): void => { const activeRoute = activeRouteRef.current if ( @@ -49,28 +85,71 @@ export function useMobileNativeChatStop(args: { ) { return } + pending += 1 + const timeoutMs = deadline - Date.now() + if (timeoutMs <= 0) { + sawRejected = true + pending -= 1 + reportIfSettled() + return + } void client - .sendRequest('terminal.send', { - terminal: handle, - text: String.fromCharCode(27), - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - .catch(() => { - // Why: disconnect can race either fire-and-forget Escape; surface one - // failure instead of leaking an unhandled RPC rejection. - if (!failureReported) { - failureReported = true - onSendError('Stop not sent') + .sendRequest( + 'terminal.send', + { + terminal: handle, + text: String.fromCharCode(27), + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }, + // Why: without this the call parks indefinitely on reconnect, so "Stop not + // sent" never appears and a stale Escape can land minutes later — into a + // composer that by then holds fresh text. + { timeoutMs, budgetSpansConnect: true } + ) + .then((response) => { + if (isTerminalSendRpcAccepted(response)) { + sawAccepted = true + } else { + sawRejected = true } }) + // Why: disconnect can race either fire-and-forget Escape; record one verdict + // instead of leaking an unhandled RPC rejection. + .catch((error: unknown) => { + if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) { + sawUnknown = true + } else { + sawRejected = true + } + }) + .finally(() => { + pending -= 1 + reportIfSettled() + }) } sendEscape() + dropSecondEscapeRef.current = () => { + pending -= 1 + reportIfSettled() + } // Why: two paced Escape bytes reliably stop TUIs without remote coalescing. timerRef.current = setTimeout(() => { timerRef.current = null + dropSecondEscapeRef.current = null sendEscape() + pending -= 1 + reportIfSettled() }, 80) - }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity]) + }, [ + cancelPending, + cancelSecondEscape, + client, + deviceTokenRef, + enabled, + handleRef, + onSendError, + streamIdentity + ]) } diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts index 92320b9ff86..fdfb5041402 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -13,6 +13,8 @@ describe('useMobileNativeChatTerminalStream', () => { const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } + const notifyListedHandlesRef = { current: (_liveHandles: ReadonlySet): void => {} } + const hasTabsRecoveryNeedRef = { current: (): boolean => false } beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -30,12 +32,24 @@ describe('useMobileNativeChatTerminalStream', () => { renderer = null }) - function Harness({ showNativeChat }: { showNativeChat: boolean }): null { + function Harness({ + showNativeChat, + activeHandle = 'terminal-1', + leaseReady = true, + streamRevision = 0 + }: { + showNativeChat: boolean + activeHandle?: string + leaseReady?: boolean + streamRevision?: number + }): null { harnessRenderCount += 1 - notifyWebReadyRef.current = useMobileNativeChatTerminalStream({ + const stream = useMobileNativeChatTerminalStream({ showNativeChat, - activeHandle: 'terminal-1', + activeHandle, activeTabType: 'terminal', + leaseReady, + streamRevision, subscriptionsRef, subscribingRef, webReadyRef, @@ -43,9 +57,33 @@ describe('useMobileNativeChatTerminalStream', () => { subscribe, unsubscribe }) + notifyWebReadyRef.current = stream.notifyWebReady + notifyListedHandlesRef.current = stream.notifyListedHandles + hasTabsRecoveryNeedRef.current = stream.hasTabsRecoveryNeed return null } + /** One dead-PTY round trip: `end` tears the stream down (subscription gone, lease + * cleared), then the host's `subscribed` answer to the rearm briefly brings both + * back. Rendering only the `end` half is what let the shipped bound look sound. */ + async function playDeadPtyRoundTrip(revision: number): Promise { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: revision }) + ) + }) + } + it('replaces output with a lease-only stream while covered, then restores output', async () => { const original = console.error const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { @@ -81,6 +119,297 @@ describe('useMobileNativeChatTerminalStream', () => { } }) + it('re-subscribes a covered stream torn down under chat (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + unsubscribe.mockClear() + // What a terminal.list prune / `end` frame does to the lease-only stream: + // the subscription is gone and the lease drops with it. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false })) + }) + + expect(subscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledWith('terminal-1') + // Pins the rearm branch specifically: without it the action falls through to + // the resume tail, which also subscribes — but drops coverage on the way. + expect(unsubscribe).not.toHaveBeenCalled() + } finally { + consoleSpy.mockRestore() + } + }) + + it('stops rearming a handle whose stream never comes back (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is + // gone again on each pass. Unbounded, that is a ~10s resubscribe loop. + for (let revision = 1; revision <= 6; revision += 1) { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + } + + expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) + } finally { + consoleSpy.mockRestore() + } + }) + + it('does not charge the rearm budget for a subscribe its own gates turned away', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // No client / no webview yet: the call returns without registering anything, so + // it never reached the host and must not spend one of the three real tries. + subscribe.mockImplementation(() => {}) + for (let revision = 1; revision <= 5; revision += 1) { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + } + + expect(subscribe).toHaveBeenCalledTimes(5) + } finally { + subscribe.mockImplementation((handle: string) => + subscriptionsRef.current.set(handle, () => {}) + ) + consoleSpy.mockRestore() + } + }) + + it('refills the rearm budget when terminal.list reports the handle again (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + for (let revision = 1; revision <= 5; revision += 1) { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + } + subscribe.mockClear() + // Budget is spent, so a further teardown signal alone changes nothing. + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 }) + ) + }) + expect(subscribe).not.toHaveBeenCalled() + + // A dead-but-listed handle must not buy a new budget on every list refresh, + // or the resubscribe loop this bound exists to stop comes straight back. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + expect(subscribe).not.toHaveBeenCalled() + + // The handle actually went away and came back: it may have a live PTY once more. + await act(async () => { + notifyListedHandlesRef.current(new Set()) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('rearms on a teardown the lease cannot report (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // `end` with no preceding `subscribed` leaves the lease untouched, so + // `leaseReady` holds its value and only the revision bump can re-run us. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 }) + ) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('does not let a dead PTY buy a new budget with its own `subscribed` ack', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // The `subscribed` half re-runs the effect with the stream momentarily up, which + // used to clear the attempt count — the loop refilled its own budget forever. + for (let revision = 1; revision <= 6; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + + expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) + }) + + it('refills the budget for a rearmed stream that outlives the teardown window', async () => { + vi.useFakeTimers() + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + for (let revision = 1; revision <= 3; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + expect(subscribe).toHaveBeenCalledTimes(3) + + // This rearm actually took: no `end` follows, so the stream is still up well + // past the window a dead PTY's teardown would have landed in. + subscribe.mockClear() + subscriptionsRef.current.set('terminal-1', () => {}) + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 4 }) + ) + }) + await act(async () => { + vi.advanceTimersByTime(5_000) + }) + await playDeadPtyRoundTrip(5) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + } finally { + vi.useRealTimers() + } + }) + + it('keeps an absence marker observed before the budget ran out', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + for (let revision = 1; revision <= 2; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + // The handle went away and came back with budget still left. Spending the marker + // on that check left nothing to trade once the budget did run out — and a handle + // the host keeps listing never goes absent again, so the composer locked for good. + await act(async () => { + notifyListedHandlesRef.current(new Set()) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + await playDeadPtyRoundTrip(3) + subscribe.mockClear() + await playDeadPtyRoundTrip(4) + expect(subscribe).not.toHaveBeenCalled() + + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + }) + + it('keeps asking until a tab snapshot replaces the exhausted handle', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + for (let revision = 1; revision <= 3; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + // Exhausted but still listed: the host may yet answer, so nothing to recover from. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + expect(hasTabsRecoveryNeedRef.current()).toBe(false) + + // Gone AND out of rearms: a graph reload reminted the id, so only a fresh tab + // snapshot carries a handle the composer can use. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-2'])) + }) + expect(hasTabsRecoveryNeedRef.current()).toBe(true) + // An equal cached tab snapshot must leave recovery pending. + expect(hasTabsRecoveryNeedRef.current()).toBe(true) + + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + activeHandle: 'terminal-2', + leaseReady: false, + streamRevision: 4 + }) + ) + }) + expect(hasTabsRecoveryNeedRef.current()).toBe(false) + }) + it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { const original = console.error const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts index c45980ae6e3..0f0a642a149 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -1,21 +1,60 @@ -import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' +/** Enough to ride out a transient teardown without spinning on a dead PTY. */ +const MAX_REARM_ATTEMPTS = 3 + +/** A dead PTY's `end` follows its `subscribed` within one host round trip, so a + * stream still up this long after the ack is one that actually recovered. */ +const HEALTHY_STREAM_PROOF_MS = 5_000 + /** Pauses the active terminal stream while native chat covers its mounted WebView, * then resumes from a fresh scrollback snapshot when terminal view returns. */ export function useMobileNativeChatTerminalStream(args: { showNativeChat: boolean activeHandle: string | null activeTabType: string | null + /** Reactive lease state — losing it must re-run this effect, since the refs it + * reads for stream liveness are invisible to React. */ + leaseReady: boolean + /** Bumped whenever a covered stream is torn down. The lease alone can't carry + * that signal: a dead PTY can emit `end` with no preceding `subscribed`, so + * clearing an already-absent lease is a no-op and nothing would re-run. */ + streamRevision: number subscriptionsRef: MutableRefObject void>> subscribingRef: MutableRefObject> webReadyRef: MutableRefObject> initializedRef: MutableRefObject> subscribe: (handle: string) => void unsubscribe: (handle: string) => void -}): (handle: string, wasAlreadyReady: boolean) => void { +}): { + notifyWebReady: (handle: string, wasAlreadyReady: boolean) => void + notifyListedHandles: (liveHandles: ReadonlySet) => void + /** True while an exhausted, absent handle still needs a replacement tab snapshot. */ + hasTabsRecoveryNeed: () => boolean +} { const coveredHandleRef = useRef(null) + const rearmAttemptsRef = useRef>(new Map()) + /** Handles `terminal.list` has omitted since we last saw them. Only a handle that + * actually went away and came back earns a fresh rearm budget. */ + const absentSinceExhaustionRef = useRef>(new Set()) + /** Pending proof that a rearmed stream outlived the dead-PTY teardown window. */ + const healthyStreamProofRef = useRef<{ + handle: string + timer: ReturnType + } | null>(null) const [webReadyRevision, setWebReadyRevision] = useState(0) + const [rearmBudgetRevision, setRearmBudgetRevision] = useState(0) + const forgetRearmState = useCallback((handle: string) => { + rearmAttemptsRef.current.delete(handle) + absentSinceExhaustionRef.current.delete(handle) + }, []) + const cancelHealthyStreamProof = useCallback(() => { + if (healthyStreamProofRef.current) { + clearTimeout(healthyStreamProofRef.current.timer) + healthyStreamProofRef.current = null + } + }, []) const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { // Why: ordinary WebView startups must not rerender the large session route; // only readiness that can release a native-chat lease needs reconciliation. @@ -23,9 +62,54 @@ export function useMobileNativeChatTerminalStream(args: { setWebReadyRevision((revision) => revision + 1) } }, []) + const notifyListedHandles = useCallback( + (liveHandles: ReadonlySet) => { + // Why: the rearm budget bounds one teardown, not the handle's lifetime. A covered + // handle the host drops and then reports again may have a live PTY once more, so + // spend a fresh budget instead of leaving the composer locked until leave-chat + // (#10681). + // + // The absence is required, not incidental: a dead PTY that stays listed answers + // every subscribe with `subscribed`+`end`, so refilling merely because the handle + // is present now would hand that loop a new budget on each list refresh and undo + // the bound this hook exists to enforce. + const handle = coveredHandleRef.current + if (handle == null) { + return + } + if (!liveHandles.has(handle)) { + absentSinceExhaustionRef.current.add(handle) + return + } + // Why: consume the absence marker only when it actually buys a refill. Spending + // it on a below-threshold check left the handle with nothing to trade once the + // budget did run out, and a still-listed handle never goes absent again — the + // permanent lock the marker exists to prevent. + if ((rearmAttemptsRef.current.get(handle) ?? 0) < MAX_REARM_ATTEMPTS) { + return + } + if (!absentSinceExhaustionRef.current.delete(handle)) { + return + } + forgetRearmState(handle) + setRearmBudgetRevision((revision) => revision + 1) + }, + [forgetRearmState] + ) + const hasTabsRecoveryNeed = useCallback((): boolean => { + // Keep polling until a replacement handle arrives; an equal cached snapshot + // must not consume recovery and strand the composer on the dead handle. + const handle = coveredHandleRef.current + return ( + handle != null && + absentSinceExhaustionRef.current.has(handle) && + (rearmAttemptsRef.current.get(handle) ?? 0) >= MAX_REARM_ATTEMPTS + ) + }, []) useEffect(() => { const handle = args.activeHandle if (coveredHandleRef.current && coveredHandleRef.current !== handle) { + forgetRearmState(coveredHandleRef.current) coveredHandleRef.current = null } const streamActive = @@ -39,11 +123,53 @@ export function useMobileNativeChatTerminalStream(args: { streamCovered: coveredHandleRef.current === handle, webViewReady: handle != null && args.webReadyRef.current.has(handle) }) - if (!handle || action === 'none') { + const streamHolding = action === 'none' && streamActive && coveredHandleRef.current === handle + // Any pass that isn't "this covered stream is still up" invalidates a pending + // proof — the stream it was vouching for is gone or being replaced. + if (!streamHolding || healthyStreamProofRef.current?.handle !== handle) { + cancelHealthyStreamProof() + } + if (!handle) { + return + } + if (action === 'none') { + // Why: `subscribed` is the FIRST half of a dead PTY's reply (`subscribed` then + // `end`), and it re-runs this effect with the stream momentarily up. Clearing + // the budget there handed every dead-PTY pass a fresh one, so the resubscribe + // loop this hook exists to bound ran forever. Only a stream still up after the + // `end` would have landed proves the last rearm took. + if (streamHolding && !healthyStreamProofRef.current && rearmAttemptsRef.current.has(handle)) { + healthyStreamProofRef.current = { + handle, + timer: setTimeout(() => { + healthyStreamProofRef.current = null + forgetRearmState(handle) + }, HEALTHY_STREAM_PROOF_MS) + } + } + return + } + if (action === 'rearm') { + // Why: the host answers a handle whose PTY is gone with `subscribed`+`end`, + // which drops the lease again — an unbounded resubscribe loop, each pass + // costing a 10s host wait and a desktop tab-mount request (#10681). Give up + // after a few tries and leave the composer honestly locked. + const attempts = rearmAttemptsRef.current.get(handle) ?? 0 + if (attempts >= MAX_REARM_ATTEMPTS) { + return + } + args.subscribe(handle) + // Why: a subscribe turned away by its own gates (no client, no webview yet) + // never reached the host, so charging it an attempt would spend the budget on + // nothing and could exhaust it before a single real rearm was tried. + if (args.subscribingRef.current.has(handle) || args.subscriptionsRef.current.has(handle)) { + rearmAttemptsRef.current.set(handle, attempts + 1) + } return } if (action === 'pause') { coveredHandleRef.current = handle + forgetRearmState(handle) // Why: returning to terminal must accept the fresh scrollback snapshot; // the stream was paused while chat covered output that xterm never saw. args.initializedRef.current.delete(handle) @@ -64,13 +190,25 @@ export function useMobileNativeChatTerminalStream(args: { args.activeHandle, args.activeTabType, args.initializedRef, + args.leaseReady, args.showNativeChat, + args.streamRevision, args.subscribe, args.subscribingRef, args.subscriptionsRef, args.unsubscribe, args.webReadyRef, + cancelHealthyStreamProof, + forgetRearmState, + rearmBudgetRevision, webReadyRevision ]) - return notifyWebReady + useEffect(() => cancelHealthyStreamProof, [cancelHealthyStreamProof]) + // Why memoized: the session route keeps this object in callback dep arrays, and a + // fresh literal per render would rebuild them on every keystroke. All three members + // are already stable, so this reference never changes. + return useMemo( + () => ({ notifyWebReady, notifyListedHandles, hasTabsRecoveryNeed }), + [hasTabsRecoveryNeed, notifyListedHandles, notifyWebReady] + ) } diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 7428bde224d..ad4343ac87d 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -24,12 +24,16 @@ type Args = { readonly getActiveWorktreeConnectionId: () => Promise readonly beforeTerminalSend: (terminal: string) => Promise /** Outcome-preserving so an ambiguous ('unknown') delivery after an image - * paste can mark the terminal input for healing (#10228). */ + * paste can mark the terminal input for healing (#10228). Takes the image + * send's budget so the paste and this text body share one `sending` window. */ readonly nativeChatBaseSend: ( text: string, - images?: string[] + images?: string[], + deadline?: number ) => Promise readonly showToast: (message: string, durationMs?: number) => void + /** Native-chat send failures — rendered in the composer's inline banner. */ + readonly onNativeChatSendError: (message: string) => void readonly onSuccess: () => void readonly onError: () => void } @@ -51,6 +55,7 @@ export function useMobileSessionImageAttachments({ beforeTerminalSend, nativeChatBaseSend, showToast, + onNativeChatSendError, onSuccess, onError }: Args): { @@ -79,6 +84,7 @@ export function useMobileSessionImageAttachments({ scopeKey: nativeChatScopeKey, enabled: nativeChatInputLeaseReady, showToast, + onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, onAttachSuccess: onSuccess, onError diff --git a/mobile/src/session/use-mobile-session-tabs-fetch-reporting.ts b/mobile/src/session/use-mobile-session-tabs-fetch-reporting.ts new file mode 100644 index 00000000000..13bbc7cb7a0 --- /dev/null +++ b/mobile/src/session/use-mobile-session-tabs-fetch-reporting.ts @@ -0,0 +1,28 @@ +import { useMemo, type MutableRefObject } from 'react' +import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' + +type DiagnosticTabsSnapshot = Parameters[0] + +/** Forwards session-tabs fetch outcomes to the screen's diagnostics recorder. + * Split out of the session route so the reconciliation wiring there stays a + * single call rather than five one-line callbacks. */ +export function useMobileSessionTabsFetchReporting(args: { + worktreeId: string + diagnosticsRef: MutableRefObject +}): { + onFetchStarted: () => void + onFetchSucceeded: (result: Result) => void + onFetchFailed: (code: string) => void + onFetchErrored: (error: unknown) => void +} { + const { worktreeId, diagnosticsRef } = args + return useMemo( + () => ({ + onFetchStarted: () => diagnosticsRef.current.tabsFetchStarted(worktreeId), + onFetchSucceeded: (result: Result) => diagnosticsRef.current.tabsFetchSucceeded(result), + onFetchFailed: (code: string) => diagnosticsRef.current.tabsFetchFailed(code), + onFetchErrored: (error: unknown) => diagnosticsRef.current.tabsFetchErrored(error) + }), + [diagnosticsRef, worktreeId] + ) +} diff --git a/mobile/src/session/use-native-chat-action-outcomes.ts b/mobile/src/session/use-native-chat-action-outcomes.ts new file mode 100644 index 00000000000..51a6f831b5a --- /dev/null +++ b/mobile/src/session/use-native-chat-action-outcomes.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' + +/** Wraps a chat card action so an accepted write also retires the route's held + * failure banner. + * + * The banner outlives the write that raised it, so every accepted action has to + * retire it — not just the composer send, which was the only one that did. A + * delivered answer or permission reply otherwise sits under a stale "not sent" + * until the hold timer happens to expire. */ +export function useNativeChatAcceptedAction( + action: (...params: Params) => Promise, + onAccepted: () => void +): (...params: Params) => Promise { + return useCallback( + async (...params: Params): Promise => { + const accepted = await action(...params) + if (accepted) { + onAccepted() + } + return accepted + }, + [action, onAccepted] + ) +} + +/** Boolean surface for callers with no pre-pasted input: 'unknown' stays true + * (the send usually landed; the optimistic echo is already held unconfirmed). */ +export function useNativeChatSentFlag( + send: (text: string, images?: string[]) => Promise +): (text: string, images?: string[]) => Promise { + return useCallback( + async (text: string, images?: string[]): Promise => + (await send(text, images)) !== 'rejected', + [send] + ) +} diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index a0e71a85c1b..a614e833387 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -7,6 +7,7 @@ import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' import { isRpcResponse } from './rpc-response-shape' import type { RpcClient } from './rpc-client' import type { ConnectionState, RpcResponse } from './types' @@ -75,8 +76,9 @@ export function connectMobileRelayRpcSession(args: { const client: MobileRelayRpcSession = { async sendRequest(method, params, options) { - await waitForConnected(options?.timeoutMs) - return sendRpc(method, params, options?.timeoutMs) + const budget = openRpcRequestBudget(options) + await waitForConnected(budget.timeoutMs) + return sendRpc(method, params, resolvePostConnectRequestTimeout(budget, requestTimeoutMs)) }, subscribe(method, params, listener, options) { diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts new file mode 100644 index 00000000000..2ed349f375a --- /dev/null +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) { + return + } + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + this.receive(JSON.stringify({ type: 'e2ee_ready' })) + this.receive('encrypted:{"type":"e2ee_authenticated"}') + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +/** Latest settled state of a request, so a timeout can be observed without + * awaiting a promise that would reject under fake timers. */ +function track(request: Promise): { read: () => string } { + let outcome = 'pending' + request.then( + () => { + outcome = 'resolved' + }, + (error: Error) => { + outcome = error.message + } + ) + return { read: () => outcome } +} + +describe('mobile rpc-client request deadline', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('spends one deadline across connect-wait and request, not one each', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + const request = client.sendRequest( + 'terminal.send', + { terminal: 't1', text: 'hi' }, + { timeoutMs: 5_000, budgetSpansConnect: true } + ) + const outcome = track(request) + + try { + // The first reconnect delay burns 500ms of the caller's 5s budget. + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(4_499) + expect(outcome.read()).toBe('pending') + + // Restarting the budget after connecting would still have 500ms to go here. + await vi.advanceTimersByTimeAsync(1) + expect(outcome.read()).toBe('Request timed out: terminal.send') + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('leaves a caller that did not opt in on the post-connect clock', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + // A pre-existing caller's budget was sized against the request phase alone; + // the connect wait must not eat into it. + const request = client.sendRequest( + 'speech.dictation.finish', + { dictationId: 'd1' }, + { timeoutMs: 5_000 } + ) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + + // A shared deadline would already have fired at 4_500 here. + await vi.advanceTimersByTimeAsync(4_999) + expect(outcome.read()).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + expect(outcome.read()).toBe('Request timed out: speech.dictation.finish') + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('never floors a sub-second request timeout above what the caller asked for', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + mockSockets[0]!.open() + + // Under the 1s minimum: the floor clamps to 400ms rather than stretching it. + const request = client.sendRequest( + 'terminal.send', + { terminal: 't1', text: 'hi' }, + { timeoutMs: 400, budgetSpansConnect: true } + ) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(399) + expect(outcome.read()).toBe('pending') + await vi.advanceTimersByTimeAsync(1) + expect(outcome.read()).toBe('Request timed out: terminal.send') + } finally { + client.close() + await request.catch(() => undefined) + } + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9b0b6246f1c..9cf08483a9d 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -29,6 +29,7 @@ import { } from './rpc-client-terminal-subscription' import { describeSocketEvent } from './socket-event-debug' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' import { isRpcResponse } from './rpc-response-shape' import { websocketPayloadToUint8 } from './websocket-payload-bytes' @@ -43,8 +44,16 @@ type ConnectWaiter = { timeout: ReturnType | null } -type SendRequestOptions = { +export type SendRequestOptions = { timeoutMs?: number + /** Spend `timeoutMs` across connect-wait AND the request instead of giving each + * phase its own. Interactive chat writes need it: they run as sequential loops + * under one shared budget, so a per-phase clock lets the composer sit `sending` + * for a multiple of the stated ceiling. Off by default — the long-running + * callers (worktree create, dictation finish, credit reset) sized their budgets + * against the post-connect clock, and squeezing them to the floor after a slow + * reconnect would fail sends that used to land. */ + budgetSpansConnect?: boolean } type SubscribeOptions = { @@ -101,6 +110,9 @@ const AUTH_RETRY_BUDGET = 3 // Why: a desktop that regenerated its E2EE keypair sends an e2ee_error we can't decrypt — the 4001 close code is the only surviving auth-failure signal. const UNAUTHORIZED_CLOSE_CODE = 4001 const REQUEST_TIMEOUT_MS = 30_000 +// Why: an explicit `timeoutMs` is one budget for the whole call. If the connect wait +// ate nearly all of it, still give the written frame a moment to be answered rather +// than arming a 1ms timer. const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 5_000 // Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes. @@ -985,7 +997,8 @@ export function connect( params?: unknown, options?: SendRequestOptions ): Promise { - const waitStart = Date.now() + const budget = openRpcRequestBudget(options) + const waitStart = budget.startedAt const wasConnected = state === 'connected' await waitForConnected(options?.timeoutMs) if (!wasConnected) { @@ -997,7 +1010,7 @@ export function connect( return new Promise((resolve, reject) => { const id = nextId() - const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS + const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) const timeout = setTimeout(() => { pending.delete(id) console.log('[net] sendRequest TIMEOUT', { diff --git a/mobile/src/transport/rpc-request-budget.ts b/mobile/src/transport/rpc-request-budget.ts new file mode 100644 index 00000000000..aecbe023cfd --- /dev/null +++ b/mobile/src/transport/rpc-request-budget.ts @@ -0,0 +1,38 @@ +type RpcRequestBudgetOptions = { + timeoutMs?: number + budgetSpansConnect?: boolean +} + +export type RpcRequestBudget = { + startedAt: number + timeoutMs?: number + deadline: number | null +} + +export const RPC_REQUEST_MIN_ACK_MS = 1_000 + +export function openRpcRequestBudget( + options?: RpcRequestBudgetOptions, + now = Date.now() +): RpcRequestBudget { + const timeoutMs = options?.timeoutMs + return { + startedAt: now, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + deadline: options?.budgetSpansConnect && timeoutMs !== undefined ? now + timeoutMs : null + } +} + +export function resolvePostConnectRequestTimeout( + budget: RpcRequestBudget, + fallbackMs: number, + now = Date.now() +): number { + if (budget.deadline === null) { + return budget.timeoutMs ?? fallbackMs + } + return Math.max( + Math.min(RPC_REQUEST_MIN_ACK_MS, budget.deadline - budget.startedAt), + budget.deadline - now + ) +} diff --git a/src/main/agent-hooks/agent-status-pane-index.ts b/src/main/agent-hooks/agent-status-pane-index.ts new file mode 100644 index 00000000000..3844ef42127 --- /dev/null +++ b/src/main/agent-hooks/agent-status-pane-index.ts @@ -0,0 +1,22 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +/** Groups one agent-status snapshot by pane key so a projection that resolves many + * panes reads the snapshot once instead of rescanning (and re-materializing) it per + * pane. Rows without a pane key are unaddressable here and dropped. */ +export function indexAgentStatusRowsByPaneKey( + rows: readonly AgentStatusIpcPayload[] +): Map { + const byPaneKey = new Map() + for (const row of rows) { + if (!row.paneKey) { + continue + } + const existing = byPaneKey.get(row.paneKey) + if (existing) { + existing.push(row) + continue + } + byPaneKey.set(row.paneKey, [row]) + } + return byPaneKey +} diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts b/src/main/agent-hooks/hook-provider-session-invalidation.test.ts new file mode 100644 index 00000000000..15338c20056 --- /dev/null +++ b/src/main/agent-hooks/hook-provider-session-invalidation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { createHookProviderSessionInvalidator } from './hook-provider-session-invalidation' + +describe('createHookProviderSessionInvalidator', () => { + it('names the worktree the first time a pane reports a provider session', () => { + const collect = createHookProviderSessionInvalidator() + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }])).toEqual(['w1']) + }) + + it('stays quiet while the same session keeps being reported', () => { + const collect = createHookProviderSessionInvalidator() + const rows = [{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }] + collect(rows) + + expect(collect(rows)).toEqual([]) + }) + + it('names the worktree when a pane relaunches under a new session', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2', worktreeId: 'w1' }])).toEqual(['w1']) + }) + + it('names the worktree when a pane loses its session entirely', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([])).toEqual(['w1']) + }) + + it('names both worktrees when a pane moves without changing session', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w2' }])).toEqual([ + 'w1', + 'w2' + ]) + }) + + it('invalidates when Pi keeps its session id but changes transcript path', () => { + const collect = createHookProviderSessionInvalidator() + collect([ + { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/a.jsonl', worktreeId: 'w1' } + ]) + + expect( + collect([ + { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/b.jsonl', worktreeId: 'w1' } + ]) + ).toEqual(['w1']) + }) + + it('retains the known worktree when a later hook omits it', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2' }])).toEqual(['w1']) + }) + + it('ignores a session with no worktree to invalidate', () => { + const collect = createHookProviderSessionInvalidator() + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1' }])).toEqual([]) + }) +}) diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.ts b/src/main/agent-hooks/hook-provider-session-invalidation.ts new file mode 100644 index 00000000000..6ef1e6f7d63 --- /dev/null +++ b/src/main/agent-hooks/hook-provider-session-invalidation.ts @@ -0,0 +1,43 @@ +import type { AgentHookProviderSessionIdentity } from './server' + +type KnownSession = { sessionId: string; transcriptPath?: string; worktreeId: string } + +/** Names worktrees whose hook-reported resume identity changed. */ +export function createHookProviderSessionInvalidator(): ( + identities: readonly AgentHookProviderSessionIdentity[] +) => string[] { + let known = new Map() + return (identities) => { + const next = new Map() + const changedWorktrees = new Set() + for (const identity of identities) { + const previous = known.get(identity.paneKey) + const worktreeId = identity.worktreeId ?? previous?.worktreeId + if (!worktreeId) { + continue + } + next.set(identity.paneKey, { + sessionId: identity.sessionId, + ...(identity.transcriptPath ? { transcriptPath: identity.transcriptPath } : {}), + worktreeId + }) + if ( + previous?.sessionId !== identity.sessionId || + previous?.transcriptPath !== identity.transcriptPath || + previous?.worktreeId !== worktreeId + ) { + if (previous?.worktreeId !== worktreeId) { + changedWorktrees.add(previous?.worktreeId ?? worktreeId) + } + changedWorktrees.add(worktreeId) + } + } + for (const [paneKey, previous] of known) { + if (!next.has(paneKey)) { + changedWorktrees.add(previous.worktreeId) + } + } + known = next + return [...changedWorktrees] + } +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 6b4e12042c5..351960dd79e 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1469,6 +1469,39 @@ describe('AgentHookServer listener replay', () => { ]) }) + it('notifies provider-session subscribers without changing status listener arguments', () => { + const server = new AgentHookServer() + const statuses = vi.fn() + const sessions = vi.fn() + server.subscribeStatusChanges(statuses) + server.subscribeProviderSessionChanges(sessions) + + server.ingestRemote( + { + paneKey: PANE, + worktreeId: 'wt-1', + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + providerSessionOnly: true, + payload: { state: 'done', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(statuses).toHaveBeenCalledWith([]) + expect(sessions).toHaveBeenCalledWith([ + { + paneKey: PANE, + sessionId: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl', + worktreeId: 'wt-1' + } + ]) + }) + it('keeps status-change subscribers when renderer fanout listener is cleared', () => { const server = new AgentHookServer() const statusChangeListener = vi.fn() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 06dfb151560..1aeb253a191 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -84,7 +84,15 @@ export type AgentHookStatusChangeEntry = { observedInCurrentRuntime: boolean } +export type AgentHookProviderSessionIdentity = { + paneKey: string + sessionId: string + transcriptPath?: string + worktreeId?: string +} + type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void type PaneKeyAliasEntry = { @@ -456,6 +464,7 @@ export class AgentHookServer { private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null private onPaneStatusCleared: PaneStatusClearListener | null = null private statusChangeListeners = new Set() + private providerSessionChangeListeners = new Set() // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). private endpointDir: string | null = null private endpointFilePathCache: string | null = null @@ -509,6 +518,13 @@ export class AgentHookServer { } } + subscribeProviderSessionChanges(listener: ProviderSessionChangeListener): () => void { + this.providerSessionChangeListeners.add(listener) + return () => { + this.providerSessionChangeListeners.delete(listener) + } + } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { this.onPaneStatusCleared = listener } @@ -521,6 +537,16 @@ export class AgentHookServer { ) } + /** Provider-session identities, including Pi's metadata-only rows. */ + getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { + return this.buildStatusChangeNotification().providerSessions + } + + getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { + const entry = this.state.lastStatusByPaneKey.get(paneKey) + return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] + } + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -652,32 +678,57 @@ export class AgentHookServer { } getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { - return Array.from(this.state.lastStatusByPaneKey.entries()).flatMap(([paneKey, entry]) => { + return this.buildStatusChangeNotification().statuses + } + + private buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } { + const statuses: AgentHookStatusChangeEntry[] = [] + const providerSessions: AgentHookProviderSessionIdentity[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { const enriched = entry as EnrichedAgentHookEventPayload - return enriched.providerSessionOnly - ? [] - : [ - { - state: enriched.payload.state, - receivedAt: enriched.receivedAt, - observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) - } - ] - }) + if (enriched.providerSession) { + providerSessions.push({ + paneKey, + sessionId: enriched.providerSession.id, + ...(enriched.providerSession.transcriptPath + ? { transcriptPath: enriched.providerSession.transcriptPath } + : {}), + ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) + }) + } + if (!enriched.providerSessionOnly) { + statuses.push({ + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) + }) + } + } + return { statuses, providerSessions } } private notifyStatusChangeListeners(): void { - if (this.statusChangeListeners.size === 0) { + if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { return } - const snapshot = this.getStatusChangeSnapshot() + const { statuses, providerSessions } = this.buildStatusChangeNotification() for (const listener of this.statusChangeListeners) { try { - listener(snapshot) + listener(statuses) } catch (err) { console.error('[agent-hooks] status-change listener threw', err) } } + for (const listener of this.providerSessionChangeListeners) { + try { + listener(providerSessions) + } catch (err) { + console.error('[agent-hooks] provider-session listener threw', err) + } + } } private markTabClosedForAgentStatus(tabId: string): void { diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index ee8f6dae2ef..cd369c41496 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -21,7 +21,6 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const GROK_SESSIONS_DIR = resolveGrokSessionsDir() const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') @@ -175,7 +174,9 @@ function grokDiscoveries( limit: number, issues: AiVaultScanIssue[] ): Promise[] { - return sessionRootDirs(options.grokSessionsDir ?? GROK_SESSIONS_DIR, wslHomeDirs, [ + // Resolved lazily: a module-scope call binds across chunks at init time, which + // breaks whenever bundle ordering puts this module before its import. + return sessionRootDirs(options.grokSessionsDir ?? resolveGrokSessionsDir(), wslHomeDirs, [ '.grok', 'sessions' ]).map((rootDir) => diff --git a/src/main/index.ts b/src/main/index.ts index f512d7bf2a6..ab72fd35232 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -187,7 +187,8 @@ import { seedLiveClaudePtysFromPersistence } from './claude-accounts/live-pty-gate' import { StarNagService } from './star-nag/service' -import { agentHookServer } from './agent-hooks/server' +import { agentHookServer, type AgentHookProviderSessionIdentity } from './agent-hooks/server' +import { createHookProviderSessionInvalidator } from './agent-hooks/hook-provider-session-invalidation' import { wslHookRelayManager } from './agent-hooks/wsl-hook-relay-manager' import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branch-rename' import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output' @@ -1912,9 +1913,32 @@ app.whenReady().then(async () => { agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. agentAwakeService.setStatuses([]) - unsubscribeAgentAwakeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { + const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() + const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { + const ownedIdentities = identities.map((identity) => ({ + ...identity, + worktreeId: + identity.worktreeId ?? + runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? + undefined + })) + for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { + runtime?.notifyMobileSessionTabsChanged(worktreeId) + } + } + const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { agentAwakeService?.setStatuses(statuses) }) + const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( + (sessions) => { + // Healthy session.tabs streams need a push when transcript identity changes. + publishProviderSessionChanges(sessions) + } + ) + unsubscribeAgentAwakeStatusChanges = () => { + unsubscribeStatusChanges() + unsubscribeProviderSessionChanges() + } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) // Why: the trust-grant module is bundled into plain-node CLI entries where @@ -2084,6 +2108,12 @@ app.whenReady().then(async () => { // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + // Why: the filter above hides resume-identity rows from the live-agent views, but + // those rows carry the provider session mobile native chat addresses transcripts + // by — Pi publishes identity that way and would otherwise be unreachable. + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], @@ -2096,6 +2126,7 @@ app.whenReady().then(async () => { isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) runtime = runtimeService + publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities()) browserManager.setBrowserGuestStateChangedListener((worktreeId) => { runtimeService.notifyMobileSessionTabsChanged(worktreeId) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6d815452211..1486207771f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -18904,6 +18904,333 @@ describe('OrcaRuntimeService', () => { expect(result.tabs[0]).not.toHaveProperty('launchAgent') }) + it('publishes the hook provider session on a headless mobile tab so native chat can address the transcript', async () => { + const paneKey = makePaneKey('claude-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: '7dd0c22c-0ff6-45bf-b88a-cea11c34d073', + transcriptPath: '/transcripts/7dd0c22c.jsonl' + } + const runtime = new OrcaRuntimeService(store, undefined, { + // Headless serve has no renderer, so the hook snapshot is the only carrier. + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'claude-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-claude' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'claude-tab', + leafId: HEADLESS_LEAF_ID, + launchAgent: 'claude', + title: 'Terminal' + }) + + runtime.onPtyData('pty-claude', '\x1b]0;✳ Claude Code\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'claude', providerSession }) + }) + ) + }) + + it('recovers the agent type from the hook row when the pane was launched without an agent hint', async () => { + // A user who types `claude` in a plain terminal leaves no launchAgent, and headless + // has no renderer to publish one; without the hook's agentType mobile treats the tab + // as a non-agent terminal and hides native chat even though the session is addressable. + const paneKey = makePaneKey('shell-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: 'ac1f6b90-2f77-4f0e-9c5e-1d2f6a4b8c31', + transcriptPath: '/transcripts/ac1f6b90.jsonl' + } + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'shell-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-shell' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'shell-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-shell', '\x1b]0;✳ Claude Code\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'claude', providerSession }) + }) + ) + }) + + it('reads one agent-status snapshot per projection, not one per terminal tab', async () => { + // The getter rebuilds every known pane's payload on each call, so reading it + // inside the per-tab loop made a projection O(tabs x panes) of pure garbage — + // worst in headless serve, where every terminal tab takes the hook fallback. + let snapshotReads = 0 + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentProviderSessionSnapshot: () => { + snapshotReads += 1 + return [] + } + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `pty-${snapshotReads}-${Math.random()}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + for (const tabId of ['fan-a', 'fan-b', 'fan-c']) { + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId, + leafId: HEADLESS_LEAF_ID, + launchAgent: 'claude', + title: 'Terminal' + }) + } + snapshotReads = 0 + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + // Guards the assertion below from passing vacuously on a one-tab projection. + expect(result.tabs.filter((tab) => tab.type === 'terminal').length).toBeGreaterThan(1) + expect(snapshotReads).toBe(1) + }) + + it('publishes hook-only identity for a pane that never emitted an agent title', async () => { + // The hook row is the whole evidence here: no launchAgent hint, no recognized OSC + // title, so `pty.lastAgentStatus` stays unset. Gating the hook read behind that + // made the headless carrier unreachable in exactly the case it exists for. + const paneKey = makePaneKey('quiet-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: 'b91c7e40-5a2d-4f19-9c33-2a7b6e5d4c88', + transcriptPath: '/transcripts/b91c7e40.jsonl' + } + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'quiet-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-quiet' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'quiet-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'claude', providerSession }) + }) + ) + }) + + it('reads a resume-identity-only row the live-agent snapshot filters out', async () => { + // Pi publishes its session separately from status, and the shared getter drops + // those rows so they can't read as running agents — leaving native chat with no + // transcript to address unless the unfiltered snapshot is consulted too. + const paneKey = makePaneKey('pi-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: '/sessions/pi-1.json', + transcriptPath: '/sessions/pi-1.json' + } + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentProviderSessionSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: '', + agentType: 'pi', + connectionId: null, + receivedAt: now + 1, + stateStartedAt: now + 1, + tabId: 'pi-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession, + providerSessionOnly: true + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-pi' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'pi', providerSession }) + }) + ) + }) + + it('does not let stale Pi resume metadata claim a plain terminal', async () => { + const paneKey = makePaneKey('stale-pi-tab', HEADLESS_LEAF_ID) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentProviderSessionSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: '', + agentType: 'pi', + connectionId: null, + receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1, + stateStartedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1, + tabId: 'stale-pi-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession: { + key: 'session_id', + id: '/sessions/stale-pi.json', + transcriptPath: '/sessions/stale-pi.json' + }, + providerSessionOnly: true + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-stale-pi' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'stale-pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.not.objectContaining({ agentType: 'pi' }) + }) + ) + }) + + it('does not claim a stale hook agent owns a pane whose agent has since exited', async () => { + // `pty.lastAgentStatus` outlives the agent, so an unbounded hook read would keep + // offering mobile native chat for what is now a plain shell — and point it at a + // dead transcript. The session id may stay; the ownership claim must not. + const paneKey = makePaneKey('exited-tab', HEADLESS_LEAF_ID) + const staleReceivedAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1_000 + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: staleReceivedAt, + stateStartedAt: staleReceivedAt, + tabId: 'exited-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession: { + key: 'session_id' as const, + id: 'd4c3b2a1-0000-4000-8000-000000000001', + transcriptPath: '/transcripts/d4c3b2a1.jsonl' + } + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-exited' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'exited-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-exited', '\x1b]0;✳ Claude Code\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + const tab = result.tabs[0] + expect(tab?.type).toBe('terminal') + const agentStatus = tab && 'agentStatus' in tab ? tab.agentStatus : null + expect(agentStatus?.agentType ?? null).toBeNull() + }) + it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => { const foregroundProcess = deferred() const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e66cda4e60..ede2767ea4f 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -35,6 +35,7 @@ import { type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' +import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, @@ -2906,6 +2907,10 @@ export class OrcaRuntimeService { private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentProviderSessionRowsForPaneFn: + | ((paneKey: string) => AgentStatusIpcPayload[]) + | null private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private readonly prepareAiVaultSessionResumeFn: @@ -2942,6 +2947,12 @@ export class OrcaRuntimeService { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + /** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot` + * filters out so they can't read as running agents. Mobile native chat needs + * them: for an agent that publishes identity separately (Pi), that row is the + * only carrier of the provider session a transcript is addressed by. */ + getAgentProviderSessionSnapshot?: () => AgentStatusIpcPayload[] + getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[] // Why: codex-home paths for the Agent Session History scan must be sourced // here, not via the window-only registerCoreHandlers path — that path never // runs under `orca serve`, so remote/SSH hosts would silently drop @@ -2969,6 +2980,9 @@ export class OrcaRuntimeService { this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + this.getAgentProviderSessionSnapshotFn = + deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null + this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions // even on headless `orca serve` hosts where registerCoreHandlers never runs. @@ -13461,6 +13475,12 @@ export class OrcaRuntimeService { return this.getPaneKeyForTerminalHandle(handle) } + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null + return leaf?.worktreeId ?? this.getPtyRecordForPaneKey(paneKey)?.worktreeId ?? null + } + resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { // Why: the renderer context menu only knows the stable pane key; main owns // the runtime terminal handle that agents and CLI commands can address. @@ -26083,6 +26103,27 @@ export class OrcaRuntimeService { ): RuntimeMobileSessionTabsResult { const tabs: RuntimeMobileSessionClientTab[] = [] const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) + // Production reads hook rows by pane; the snapshot fallback remains for tests + // and embedders that have not adopted the narrow getter. + let hookRowsByPaneKey: Map | null = null + const hookRowsForPane = new Map() + const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { + const cached = hookRowsForPane.get(paneKey) + if (cached) { + return cached + } + const direct = this.getAgentProviderSessionRowsForPaneFn?.(paneKey) + if (direct) { + hookRowsForPane.set(paneKey, direct) + return direct + } + hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey( + this.getAgentProviderSessionSnapshotFn?.() ?? [] + ) + const rows = hookRowsByPaneKey.get(paneKey) ?? [] + hookRowsForPane.set(paneKey, rows) + return rows + } // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) { @@ -26221,7 +26262,8 @@ export class OrcaRuntimeService { mobileStatusPty, tab, terminalHandle, - retainedAgentStatus + retainedAgentStatus, + getHookRowsForPane )), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), @@ -26278,12 +26320,25 @@ export class OrcaRuntimeService { pty: RuntimePtyWorktreeRecord | null, tab: RuntimeMobileSessionTerminalTab, terminalHandle: string | null, - retained: RuntimeAgentRowSnapshot | null + retained: RuntimeAgentRowSnapshot | null, + getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) - if (!pty?.lastAgentStatus && !retained) { + // Why: neither the OSC-retained row nor a title-derived status can carry a + // provider session — only the hook payload does, and headless serve has no + // renderer to publish `tab.agentStatus`. Without it mobile native chat has no + // transcript to address and sits on the empty state forever. + const hookRow = this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) + // Why: the hook row is evidence in its own right. Returning early on a missing + // PTY status/retained row put this check ahead of the only headless carrier, so + // an agent that reported its session but never emitted a recognized title got no + // `agentStatus` at all — exactly the hook-only case the fallback exists for. + if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { return {} } + const providerSession = hookRow.providerSession + ? { providerSession: hookRow.providerSession } + : {} const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null const ptyTitle = pty ? getLatestAgentCandidateTitle( @@ -26300,7 +26355,14 @@ export class OrcaRuntimeService { if (ptyTitle !== null && ptyTitleClassification !== 'agent') { // Why: non-agent title = shell reclaimed the pane; suppress to clear stuck spinners (#1437), though a live hook signal survives. const hasLiveHookSignal = - retained?.payload.interactivePrompt != null || retained?.payload.toolName != null + retained?.payload.interactivePrompt != null || + retained?.payload.toolName != null || + // Why: headless serve has no renderer to retain an OSC row, so a fresh hook + // agentType is the only live signal a hook-only pane can offer — and an agent + // that reports over HTTP need never set a title this gate would recognize. + // Scoped to panes with no PTY status at all, so it cannot revive a spinner: + // this branch publishes `done`. It only keeps the transcript addressable. + (!pty?.lastAgentStatus && (hookRow.agentType != null || hookRow.providerSession != null)) if (!hasLiveHookSignal) { return {} } @@ -26309,7 +26371,7 @@ export class OrcaRuntimeService { const ownerAgent = resolvePaneAgentOwner({ launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, - hookAgent: retained?.payload.agentType ?? null + hookAgent: retained?.payload.agentType ?? hookRow.agentType }) ?? pty?.foregroundAgent ?? null @@ -26332,20 +26394,23 @@ export class OrcaRuntimeService { ? { worktreeId: pty?.worktreeId ?? retained.worktreeId } : {}), tabId: tab.parentTabId, - terminalTitle + terminalTitle, + ...providerSession }, ownerAgent ) } } - const now = pty!.lastOutputAt ?? Date.now() + // A hook-only pane has no PTY status to date the row from; `done` with a + // now-stamp is the honest projection — the hook proves identity, not liveness. + const now = pty?.lastOutputAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { state: - pty!.lastAgentStatus === 'working' + pty?.lastAgentStatus === 'working' ? 'working' - : pty!.lastAgentStatus === 'permission' + : pty?.lastAgentStatus === 'permission' ? 'blocked' : 'done', prompt: '', @@ -26354,14 +26419,57 @@ export class OrcaRuntimeService { paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), - worktreeId: pty!.worktreeId, + ...(pty?.worktreeId ? { worktreeId: pty.worktreeId } : {}), tabId: tab.parentTabId, terminalTitle, - stateHistory: [] + stateHistory: [], + ...providerSession } } } + /** Hook-reported identity for this pane, newest wins per field. + * + * `providerSession` is deliberately unbounded: it is resume identity, not live + * state, it stays correct until the pane relaunches (which overwrites the row + * under the same paneKey), and it is only ever read once an agent is already + * established. Bounding it would blank mobile native chat on an idle session. + * + * `agentType` is bounded by the same staleness window the retained OSC path uses, + * because it is the signal that claims an agent owns the pane at all. A user who + * exits the agent leaves `pty.lastAgentStatus` behind forever, so an unbounded + * read would keep offering native chat for what is now a plain shell. */ + private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): { + providerSession: AgentProviderSessionMetadata | null + agentType: string | null + } { + let session: AgentStatusIpcPayload | null = null + let agent: AgentStatusIpcPayload | null = null + const agentTypeFreshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS + // Why pane key only: the sibling `terminalHandle` arm this used to carry never + // matched. `toAgentStatusIpcPayload` does not emit the field on the hook path + // (only the renderer's own store stamps it, and headless serve has no renderer), + // and because it is optional TypeScript could not flag the dead comparison. + for (const entry of rows) { + if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) { + session = entry + } + if ( + entry.agentType && + (entry.providerSessionOnly !== true || + (entry.agentType === 'pi' && entry.providerSession != null)) && + entry.receivedAt >= agentTypeFreshAfter && + (!agent || entry.receivedAt > agent.receivedAt) + ) { + agent = entry + } + } + return { + providerSession: session?.providerSession ?? null, + agentType: agent?.agentType ?? null + } + } + /** Retained OSC 9999 hook row for this mobile tab if still fresh; looked up by pane identity, then PTY ownership (legacy `pane:N` ids can drift). */ private getFreshRetainedAgentStatusForMobileTab( paneKey: string, From 8bee4bc62cc5b5668ca5dc401d751097f9fabc57 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:26:49 -0700 Subject: [PATCH 122/150] perf(source-control): share one path collator across the projection (#10850) --- .../source-control-path-sort-benchmark.mjs | 116 ++++++++++++++++++ .../right-sidebar/SourceControl.tsx | 18 +-- .../source-control-status-sort.test.ts | 77 ++++++++++++ .../source-control-status-sort.ts | 6 +- .../right-sidebar/source-control-tree.ts | 4 +- 5 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 config/scripts/source-control-path-sort-benchmark.mjs create mode 100644 src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts diff --git a/config/scripts/source-control-path-sort-benchmark.mjs b/config/scripts/source-control-path-sort-benchmark.mjs new file mode 100644 index 00000000000..039bb4d6f08 --- /dev/null +++ b/config/scripts/source-control-path-sort-benchmark.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Benchmark: cost of sorting the Source Control changed-file list. +// +// compareGitStatusEntries called `a.path.localeCompare(b.path, undefined, {numeric:true})`. +// Passing an options object makes each call resolve a fresh ICU collator, so a +// sort paid for one per O(n log n) comparison. The fix hoists a single +// Intl.Collator, which is the idiom TaskPage.tsx already uses for Jira labels. +// +// The sort runs in a useMemo keyed on the entry list, so it re-runs on every git +// refresh that changes the working tree. +// +// Both arms sort the same generated list and their outputs are compared before +// timing, so a comparator that changed the order cannot be reported as a win. +import { execFileSync } from 'node:child_process' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const ITERATIONS = Number(process.env.ORCA_SC_SORT_BENCH_ITERATIONS ?? '25') +const WARMUP = Number(process.env.ORCA_SC_SORT_BENCH_WARMUP ?? '5') + +for (const [name, value] of [ + ['ORCA_SC_SORT_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_SC_SORT_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function conflictRank(entry) { + if (entry.conflictStatus === 'unresolved') { + return 0 + } + if (entry.conflictStatus === 'resolved_locally') { + return 1 + } + return 2 +} + +// Pre-fix: resolves a collator per comparison. +function compareBefore(a, b) { + return ( + conflictRank(a) - conflictRank(b) || a.path.localeCompare(b.path, undefined, { numeric: true }) + ) +} + +// Post-fix: one hoisted collator, mirroring source-control-status-sort.ts. +const collator = new Intl.Collator(undefined, { numeric: true }) +function compareAfter(a, b) { + return conflictRank(a) - conflictRank(b) || collator.compare(a.path, b.path) +} + +// Why real repo paths in git's own order: `git status` emits byte-sorted paths, +// and a shuffled fixture inflates the win — a nearly-sorted array is the case +// this actually has to beat. +const REPO_PATHS = execFileSync('git', ['ls-files'], { + cwd: fileURLToPath(new URL('../..', import.meta.url)), + maxBuffer: 256 * 1024 * 1024 +}) + .toString() + .split('\n') + .filter(Boolean) + +function makeEntries(count) { + const step = Math.max(1, Math.floor(REPO_PATHS.length / count)) + const paths = [] + for (let index = 0; index < REPO_PATHS.length && paths.length < count; index += step) { + paths.push(REPO_PATHS[index]) + } + return paths.map((path, index) => ({ + path, + area: 'unstaged', + status: 'modified', + ...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {}) + })) +} + +function measure(compare, entries) { + for (let index = 0; index < WARMUP; index += 1) { + ;[...entries].sort(compare) + } + const samples = [] + for (let round = 0; round < 5; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + ;[...entries].sort(compare) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[2] +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Source Control changed-file sort, per git refresh. Lower is better.') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log(`${pad('files', 7)} ${pad('per-call', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`) + +// Sizes from the real distribution over 7,324 non-merge commits on this repo: +// p50 3, p75 7, p90 17, p95 26, p99 63, max 1626. +for (const count of [3, 17, 26, 63, 308, 1000]) { + const entries = makeEntries(count) + const before = [...entries].sort(compareBefore).map((entry) => entry.path) + const after = [...entries].sort(compareAfter).map((entry) => entry.path) + if (before.join('\n') !== after.join('\n')) { + throw new Error(`sort order differs at ${count} files`) + } + const beforeMs = measure(compareBefore, entries) + const afterMs = measure(compareAfter, entries) + console.log( + `${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} +console.log( + '\nSizes are the real changed-file distribution over 7,324 non-merge commits on\nthis repo (p50 3, p90 17, p95 26, p99 63), so the top rows are the common case.\nThis times the sort alone; the sort is roughly 85% of the Source Control\nprojection chain, so the end-to-end memo win is smaller than these ratios.' +) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 525c96a3203..d349d789aa0 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -97,6 +97,7 @@ import { namespaceSourceControlTreeDirectoryKeys, type SourceControlTreeNode } from './source-control-tree' +import { compareGitStatusEntries } from './source-control-status-sort' import { collectListSelectionEntries, getSubmoduleExpansionKey, @@ -8213,20 +8214,3 @@ export function ActionButton({ ) } - -function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number { - return ( - getConflictSortRank(a) - getConflictSortRank(b) || - a.path.localeCompare(b.path, undefined, { numeric: true }) - ) -} - -function getConflictSortRank(entry: GitStatusEntry): number { - if (entry.conflictStatus === 'unresolved') { - return 0 - } - if (entry.conflictStatus === 'resolved_locally') { - return 1 - } - return 2 -} diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts new file mode 100644 index 00000000000..506e49c2949 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { GitStatusEntry } from '../../../../shared/types' +import { compareGitStatusEntries } from './source-control-status-sort' + +function entry(path: string, conflictStatus?: GitStatusEntry['conflictStatus']): GitStatusEntry { + return { + path, + area: 'unstaged', + status: 'modified', + ...(conflictStatus ? { conflictStatus } : {}) + } as GitStatusEntry +} + +// Reference: the pre-change comparator, which resolved a collator per call. +function referenceCompare(a: GitStatusEntry, b: GitStatusEntry): number { + const rank = (value: GitStatusEntry): number => { + if (value.conflictStatus === 'unresolved') { + return 0 + } + if (value.conflictStatus === 'resolved_locally') { + return 1 + } + return 2 + } + return rank(a) - rank(b) || a.path.localeCompare(b.path, undefined, { numeric: true }) +} + +describe('compareGitStatusEntries', () => { + // Why pin the order: the shared collator replaced a per-call localeCompare, so + // the only thing that could regress is the ordering it produces. + it('orders identically to a per-call localeCompare', () => { + const paths = [ + 'src/a.ts', + 'src/A.ts', + 'src/file2.ts', + 'src/file10.ts', + 'src/file1.ts', + 'src/File3.ts', + 'src/nested/deep/z.ts', + 'src/nested/a.ts', + 'README.md', + 'package.json', + 'src/日本語.ts', + 'src/émoji.ts', + 'src/file-2.ts', + 'src/file_2.ts', + 'src/10.ts', + 'src/9.ts' + ] + const entries = paths.map((path) => entry(path)) + const sorted = [...entries].sort(compareGitStatusEntries).map((value) => value.path) + const reference = [...entries].sort(referenceCompare).map((value) => value.path) + expect(sorted).toEqual(reference) + }) + + it('keeps conflicts ahead of clean paths regardless of name', () => { + const entries = [ + entry('z-clean.ts'), + entry('a-resolved.ts', 'resolved_locally'), + entry('m-unresolved.ts', 'unresolved') + ] + expect([...entries].sort(compareGitStatusEntries).map((value) => value.path)).toEqual([ + 'm-unresolved.ts', + 'a-resolved.ts', + 'z-clean.ts' + ]) + }) + + it('sorts numeric path segments naturally', () => { + const entries = ['f10.ts', 'f9.ts', 'f1.ts'].map((path) => entry(path)) + expect([...entries].sort(compareGitStatusEntries).map((value) => value.path)).toEqual([ + 'f1.ts', + 'f9.ts', + 'f10.ts' + ]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts index a2582db74f1..f5ee065bc70 100644 --- a/src/renderer/src/components/right-sidebar/source-control-status-sort.ts +++ b/src/renderer/src/components/right-sidebar/source-control-status-sort.ts @@ -1,9 +1,13 @@ import type { GitStatusEntry } from '../../../../shared/types' +// Why hoisted: localeCompare with an options object resolves a fresh ICU collator +// on every comparison, so a changed-file sort paid for one per O(n log n) step. +export const sourceControlPathCollator = new Intl.Collator(undefined, { numeric: true }) + export function compareGitStatusEntries(a: GitStatusEntry, b: GitStatusEntry): number { return ( getConflictSortRank(a) - getConflictSortRank(b) || - a.path.localeCompare(b.path, undefined, { numeric: true }) + sourceControlPathCollator.compare(a.path, b.path) ) } diff --git a/src/renderer/src/components/right-sidebar/source-control-tree.ts b/src/renderer/src/components/right-sidebar/source-control-tree.ts index 5668e8d2023..6bf94893b07 100644 --- a/src/renderer/src/components/right-sidebar/source-control-tree.ts +++ b/src/renderer/src/components/right-sidebar/source-control-tree.ts @@ -1,7 +1,7 @@ import { normalizeRelativePath } from '@/lib/path' import type { GitStatusEntry, GitStagingArea } from '../../../../shared/types' import { splitPathSegments } from './path-tree' -import { compareGitStatusEntries } from './source-control-status-sort' +import { compareGitStatusEntries, sourceControlPathCollator } from './source-control-status-sort' export type SourceControlTreeArea = Extract // Why: committed branch rows share the same path tree but do not carry @@ -51,7 +51,7 @@ type MutableDirectoryNode( From 58ef46d2522da100f1b49cac25413f0b42290b46 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:28:19 -0700 Subject: [PATCH 123/150] lint: guard the two perf bug shapes we fixed repeatedly (#10851) --- .oxlintrc.json | 17 + .../scripts/check-quadratic-buffer-concat.mjs | 343 ++++++++++++++++++ .../check-quadratic-buffer-concat.test.mjs | 146 ++++++++ package.json | 3 +- 4 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 config/scripts/check-quadratic-buffer-concat.mjs create mode 100644 config/scripts/check-quadratic-buffer-concat.test.mjs diff --git a/.oxlintrc.json b/.oxlintrc.json index f053732b6d3..4b0cbed3e53 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -49,6 +49,23 @@ ], "curly": "error", "no-unneeded-ternary": "error", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@linear/sdk", + "allowTypeImports": true, + "message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts." + }, + { + "name": "qrcode", + "allowTypeImports": true, + "message": "qrcode is only reachable from mobile pairing; a static import parses it at launch for everyone. Use `import type` plus `await import('qrcode')` at the call site." + } + ] + } + ], "no-useless-return": "error", "prefer-template": "error", "unicorn/consistent-empty-array-spread": "error", diff --git a/config/scripts/check-quadratic-buffer-concat.mjs b/config/scripts/check-quadratic-buffer-concat.mjs new file mode 100644 index 00000000000..611157c3eaa --- /dev/null +++ b/config/scripts/check-quadratic-buffer-concat.mjs @@ -0,0 +1,343 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. +import ts from 'typescript-api' + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts']) +const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__']) +const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile'] + +const LOOP_KINDS = new Set([ + ts.SyntaxKind.ForStatement, + ts.SyntaxKind.ForInStatement, + ts.SyntaxKind.ForOfStatement, + ts.SyntaxKind.WhileStatement, + ts.SyntaxKind.DoStatement +]) + +const ASSIGNMENT_OPERATORS = new Set([ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.PlusEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken +]) + +function normalizeReferenceText(text) { + return text.replaceAll(/\s+/g, '') +} + +// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)` +// still resolves to `carry`. `this.x` stops at the property: the class field is +// the accumulator. +function rootReferenceText(node) { + if (ts.isIdentifier(node)) { + return node.text + } + if (ts.isPropertyAccessExpression(node)) { + return node.expression.kind === ts.SyntaxKind.ThisKeyword + ? normalizeReferenceText(node.getText()) + : rootReferenceText(node.expression) + } + if ( + ts.isElementAccessExpression(node) || + ts.isCallExpression(node) || + ts.isNonNullExpression(node) || + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) + ) { + return rootReferenceText(node.expression) + } + return undefined +} + +function isBufferConcatCall(node) { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'concat' && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'Buffer' && + node.arguments.length > 0 && + ts.isArrayLiteralExpression(node.arguments[0]) + ) +} + +function enclosingLoop(node) { + for (let current = node.parent; current; current = current.parent) { + if (LOOP_KINDS.has(current.kind)) { + return current + } + } + return undefined +} + +// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside +// the loop node, so the initializer does not count as loop-local. +function isDeclaredInsideLoop(declarationStart, loop) { + if (declarationStart < loop.getStart() || declarationStart >= loop.end) { + return false + } + const initializer = ts.isForStatement(loop) ? loop.initializer : undefined + if (!initializer) { + return true + } + return declarationStart < initializer.getStart() || declarationStart >= initializer.end +} + +function collectBindingNames(name, into) { + if (ts.isIdentifier(name)) { + into.push(name) + return + } + if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + for (const element of name.elements) { + if (ts.isBindingElement(element)) { + collectBindingNames(element.name, into) + } + } + } +} + +function collectDeclarations(sourceFile) { + const declarations = new Map() + const record = (identifier, node) => { + const existing = declarations.get(identifier.text) + const start = node.getStart(sourceFile) + if (existing) { + existing.push(start) + } else { + declarations.set(identifier.text, [start]) + } + } + + const visit = (node) => { + if (ts.isVariableDeclaration(node) || ts.isParameter(node)) { + const names = [] + collectBindingNames(node.name, names) + for (const identifier of names) { + record(identifier, node) + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return declarations +} + +function collectAssignedRoots(loop) { + const assigned = new Set() + const visit = (node) => { + if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) { + const root = rootReferenceText(node.left) + if (root) { + assigned.add(root) + } + } + ts.forEachChild(node, visit) + } + visit(loop.statement) + return assigned +} + +// Unwrap the wrappers a concat result passes through before it lands on the +// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts. +function assignmentTargetOf(call) { + let node = call + let parent = node.parent + while ( + parent && + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isNonNullExpression(parent) || + (ts.isConditionalExpression(parent) && parent.condition !== node)) + ) { + node = parent + parent = parent.parent + } + if ( + parent && + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.right === node + ) { + return { + text: normalizeReferenceText(parent.left.getText()), + root: rootReferenceText(parent.left) + } + } + return undefined +} + +function concatOperands(call) { + return call.arguments[0].elements.map((element) => { + const spread = ts.isSpreadElement(element) + const expression = spread ? element.expression : element + return { + spread, + text: normalizeReferenceText(expression.getText()), + root: rootReferenceText(expression) + } + }) +} + +// A binding reset on every iteration cannot accumulate; only one that outlives +// the iteration carries the cost forward. +function isLoopCarried(root, loop, declarations) { + const starts = declarations.get(root) + if (!starts) { + return true + } + return !starts.some((start) => isDeclaredInsideLoop(start, loop)) +} + +function quadraticAccumulator(call, loop, declarations, assignedRoots) { + const operands = concatOperands(call) + const target = assignmentTargetOf(call) + // The result feeds straight back into its own input: every iteration re-copies + // everything accumulated so far. + const selfOperand = target + ? operands.find((operand) => operand.text === target.text || operand.root === target.root) + : undefined + if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) { + return target.text + } + + // No direct self-assignment, so look for a loop-carried operand reassigned + // inside the loop: the concat result reaches it by some other path. Spread + // operands are the sanctioned chunk-list fix, not this bug, so skip them. + for (const operand of operands) { + if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) { + continue + } + if (isLoopCarried(operand.root, loop, declarations)) { + return operand.root + } + } + return undefined +} + +export function reportQuadraticBufferConcat(filePath, sourceText) { + if (!sourceText.includes('Buffer.concat')) { + return [] + } + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true) + const declarations = collectDeclarations(sourceFile) + const assignedRootsByLoop = new Map() + const reports = [] + const seen = new Set() + + const visit = (node) => { + if (isBufferConcatCall(node)) { + const loop = enclosingLoop(node) + if (loop) { + let assignedRoots = assignedRootsByLoop.get(loop) + if (!assignedRoots) { + assignedRoots = collectAssignedRoots(loop) + assignedRootsByLoop.set(loop, assignedRoots) + } + const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots) + if (accumulator) { + const start = node.getStart(sourceFile) + if (!seen.has(start)) { + seen.add(start) + const { line, character } = sourceFile.getLineAndCharacterOfPosition(start) + reports.push({ + filePath, + line: line + 1, + column: character + 1, + accumulator, + text: node.getText() + }) + } + } + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return reports +} + +export function normalizePath(root, filePath) { + return path.relative(root, filePath).split(path.sep).join('/') +} + +function isSkippedFile(root, filePath) { + const relative = normalizePath(root, filePath) + // Benchmarks keep the pre-fix shape on purpose so they can measure against it. + if ( + relative.includes('.test.') || + relative.includes('.spec.') || + relative.includes('-benchmark.') + ) { + return true + } + return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) +} + +async function collectSourceFiles(root, dir) { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return [] + } + const files = [] + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (!SKIP_PATH_PARTS.has(entry.name)) { + files.push(...(await collectSourceFiles(root, fullPath))) + } + } else if ( + entry.isFile() && + SOURCE_EXTENSIONS.has(path.extname(entry.name)) && + !isSkippedFile(root, fullPath) + ) { + files.push(fullPath) + } + } + + return files +} + +function formatReports(root, reports) { + return reports + .map( + (report) => + `${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}` + ) + .join('\n') +} + +export async function main(root = process.cwd()) { + const reports = [] + for (const scanRoot of SCAN_ROOTS) { + const files = await collectSourceFiles(root, path.join(root, scanRoot)) + for (const filePath of files) { + const sourceText = await fs.readFile(filePath, 'utf8') + reports.push(...reportQuadraticBufferConcat(filePath, sourceText)) + } + } + if (reports.length === 0) { + return 0 + } + + console.error('Buffer.concat must not rebuild a loop-carried accumulator.') + console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).') + console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.') + console.error('') + console.error(formatReports(root, reports)) + return 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(await main()) +} diff --git a/config/scripts/check-quadratic-buffer-concat.test.mjs b/config/scripts/check-quadratic-buffer-concat.test.mjs new file mode 100644 index 00000000000..bc17ce8c7d3 --- /dev/null +++ b/config/scripts/check-quadratic-buffer-concat.test.mjs @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' + +import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs' + +describe('check-quadratic-buffer-concat', () => { + it('reports an accumulator rebuilt from itself in a for-of loop', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }' + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('acc') + }) + + it('reports the accumulator when it trails the new chunk', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('reports a spread of the accumulator into its own concat', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('reports the real pre-fix ai-vault stream carry', () => { + const reports = reportQuadraticBufferConcat( + 'session-scanner-parse-cache.ts', + `async function read(stream) { + let remainder = null + for await (const chunk of stream) { + const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + remainder = Buffer.from(data.subarray(lineStart)) + } + }` + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('remainder') + }) + + it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => { + const reports = reportQuadraticBufferConcat( + 'agent-hook-listener.ts', + `function read(fd, size) { + let carryBytes = Buffer.alloc(0) + while (bytesRead < size) { + const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) + carryBytes = combined.subarray(0, firstNewline) + } + }` + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('carryBytes') + }) + + it('accepts the chunk-list fix that joins once after the loop', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)' + ) + + expect(reports).toHaveLength(0) + }) + + it('accepts spreading a carry chunk list, which is the sanctioned fix', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + `let carryChunks = [] + while (scanEnd > 0) { + const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + }` + ) + + expect(reports).toHaveLength(0) + }) + + it('accepts a concat whose result never outlives the iteration', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }' + ) + + expect(reports).toHaveLength(0) + }) + + it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }' + ) + + expect(reports).toHaveLength(0) + }) + + it('reports an accumulator declared in a classic for initializer', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('reports a class field accumulator rebuilt inside a loop', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }' + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('this.pending') + }) + + it('reports an accumulator guarded by an emptiness ternary', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('ignores a per-event accumulator with no enclosing loop', () => { + const reports = reportQuadraticBufferConcat( + 'scrcpy-stream-session.ts', + 'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }' + ) + + expect(reports).toHaveLength(0) + }) + + it('ignores files with no Buffer.concat at all', () => { + expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0) + }) +}) diff --git a/package.json b/package.json index af69111a1fc..5d83c96c466 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:quadratic-buffer-concat && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -19,6 +19,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", + "check:quadratic-buffer-concat": "node config/scripts/check-quadratic-buffer-concat.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", From a1a78da87830df2898c633d7dfb9e3c188d5c221 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:39:32 -0700 Subject: [PATCH 124/150] fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841) --- ...ssion-scanner-claude-unicode-scope.test.ts | 130 ++++++++++++++++++ .../session-scanner-scope-discovery.ts | 22 ++- src/main/ipc/filesystem-watcher-wsl.test.ts | 2 +- src/main/ipc/filesystem-watcher-wsl.ts | 12 +- src/main/ipc/filesystem-watcher.test.ts | 5 +- src/main/ipc/filesystem-watcher.ts | 22 +-- src/renderer/src/lib/path.test.ts | 9 ++ src/renderer/src/lib/path.ts | 48 +------ src/shared/cross-platform-path.test.ts | 59 ++++++++ src/shared/cross-platform-path.ts | 63 ++++++++- 10 files changed, 300 insertions(+), 72 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts diff --git a/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts b/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts new file mode 100644 index 00000000000..b36e7c588be --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { resetProjectDirCwdCacheForTests } from './session-scanner-scope-discovery' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] + resetProjectDirCwdCacheForTests() +}) + +// Claude's own rule, deliberately spelled out rather than imported from the +// production encoder: these tests exist to catch Orca drifting away from how +// Claude actually names its directories, which a shared helper would hide. +// Claude maps every non-alphanumeric character of the raw cwd to '-', keeping +// case, and records the cwd it was launched with (NFC on macOS). +function claudeProjectDirName(cwd: string): string { + return cwd.normalize('NFC').replace(/[^a-zA-Z0-9]/g, '-') +} + +describe('scanAiVaultSessions — non-ASCII scope paths', () => { + it('lists Claude sessions when the workspace path is NFD and the transcript is NFC', async () => { + // Regression for #10832: macOS hands Orca decomposed (NFD) workspace paths + // while Claude Code writes NFC in both the project dir name and the recorded + // cwd, so every workspace with non-ASCII path segments listed zero sessions. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nfc-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + const workspaceNfc = '/Users/ada/내 드라이브/한국농어촌공사' + const workspaceNfd = workspaceNfc.normalize('NFD') + expect(workspaceNfd).not.toBe(workspaceNfc) + + const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspaceNfc)) + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'korean-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'korean-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: workspaceNfc, + gitBranch: 'main', + message: { role: 'user', content: '안녕하세요' } + } + ]) + ) + + // A newer ASCII session plus limit:1 exhausts the recency cap, so the Korean + // session can only surface through scope discovery — the path under test. + await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true }) + await writeFile( + join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'recent-session', + timestamp: '2026-06-01T10:00:00.000Z', + cwd: '/Users/ada/other', + gitBranch: 'main', + message: { role: 'user', content: 'newer' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin', + limit: 1, + scopePaths: [workspaceNfd] + }) + + expect(result.sessions.map((session) => session.sessionId)).toContain('korean-session') + }) + + it('lists Claude sessions for a Windows workspace whose path has uppercase segments', async () => { + // Claude derives the dir name from the raw cwd, so it keeps 'C--Users-Ada-repo'. + // Encoding from the lowercased comparison key produced 'c--users-ada-repo', + // which never matched — scoped discovery was dead on Windows entirely. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-win-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + const workspace = 'C:\\Users\\Ada\\repo' + const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspace)) + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'windows-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'windows-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: workspace, + gitBranch: 'main', + message: { role: 'user', content: 'hello' } + } + ]) + ) + await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true }) + await writeFile( + join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'recent-session', + timestamp: '2026-06-01T10:00:00.000Z', + cwd: '/Users/ada/other', + gitBranch: 'main', + message: { role: 'user', content: 'newer' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'win32', + limit: 1, + scopePaths: [workspace] + }) + + expect(result.sessions.map((session) => session.sessionId)).toContain('windows-session') + }) +}) diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index 41aac5f2700..b0a4792ffd6 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -4,7 +4,7 @@ import { createInterface } from 'node:readline' import { extname, join } from 'node:path' import { isPathInsideOrEqual, - normalizeRuntimePathForComparison + normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -91,7 +91,9 @@ function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set const prefixes = new Set() for (const scopePath of scopePaths) { for (const candidate of scopePathCandidates(scopePath)) { - prefixes.add(encodeClaudeProjectPath(candidate)) + for (const prefix of encodeClaudeProjectPaths(candidate)) { + prefixes.add(prefix) + } } } return prefixes @@ -102,8 +104,22 @@ function scopePathCandidates(scopePath: string): string[] { return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath] } +/** + * Why: Claude derives the directory name from the raw cwd, so encoding from the + * comparison key would lowercase Windows paths and never match on disk. Encode + * the raw path, plus its NFC spelling, since macOS hands us NFD (#10832). + */ +function encodeClaudeProjectPaths(pathValue: string): string[] { + const raw = encodeClaudeProjectPath(pathValue) + const composed = encodeClaudeProjectPath(pathValue.normalize('NFC')) + return raw === composed ? [raw] : [raw, composed] +} + function encodeClaudeProjectPath(pathValue: string): string { - return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-') + const separated = normalizeRuntimePathSeparators(pathValue) + const trimmed = + separated === '/' || /^[A-Za-z]:\/$/.test(separated) ? separated : separated.replace(/\/+$/, '') + return trimmed.replace(/[^a-zA-Z0-9]/g, '-') } function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet) { diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 4c8333d508e..5b1d1347a15 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -39,7 +39,7 @@ function snapshotFrame(entries: [type: string, mtime: string, path: string][]): .join('')}${SNAPSHOT_END}` } -type ScheduleBatchFlush = (rootKey: string, root: WatchedRoot) => void +type ScheduleBatchFlush = (root: WatchedRoot) => void type ScheduleBatchFlushMock = ReturnType> function makeDeps( diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index b73f58d5122..49ff8f20c7d 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -29,12 +29,14 @@ export type WatchedRoot = { subscription: WatcherSubscription listeners: Map batch: DebouncedBatch - rootPath?: string + // Why: the real on-disk path. Never substitute the watcher's rootKey — that is + // a comparison key (case/Unicode folded) and would reach the renderer as a path. + rootPath: string } export type WslWatcherDeps = { ignoreDirs: string[] - scheduleBatchFlush: (rootKey: string, root: WatchedRoot) => void + scheduleBatchFlush: (root: WatchedRoot) => void watchedRoots: Map } @@ -192,7 +194,7 @@ export async function createWslWatcher( } stopped = true markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) deps.watchedRoots.delete(rootKey) } @@ -208,7 +210,7 @@ export async function createWslWatcher( if (events.length > 0) { queueWatcherEvents(root.batch, events) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) } } @@ -227,7 +229,7 @@ export async function createWslWatcher( if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) { streamBuffer = '' markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) } return } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 4687a6b71e1..b2f83baaf22 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -118,7 +118,7 @@ describe('registerFilesystemWatcherHandlers', () => { const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => reserveWatcherChild() ) - vi.mocked(createWslWatcher).mockImplementation(async () => { + vi.mocked(createWslWatcher).mockImplementation(async (_rootKey, worktreePath) => { const release = reserveWatcherChild() if (!release) { throw new WatcherChildCapacityError() @@ -126,7 +126,8 @@ describe('registerFilesystemWatcherHandlers', () => { return { subscription: { unsubscribe: vi.fn(async () => release()) }, listeners: new Map(), - batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 } + batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }, + rootPath: worktreePath } }) const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index b217f51e5b4..a4f13ff82ff 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -274,8 +274,8 @@ async function tryStatIsDirectory(filePath: string): Promise { +async function flushBatch(root: WatchedRoot): Promise { const overflowed = root.batch.overflowed const rawEvents = root.batch.events.splice(0) root.batch.overflowed = false @@ -300,7 +300,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) { // Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh. - emitOverflowPayload(rootKey, root) + emitOverflowPayload(root) return } @@ -320,7 +320,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { ) const payload: FsChangedPayload = { - worktreePath: root.rootPath ?? rootKey, + worktreePath: root.rootPath, events } @@ -331,7 +331,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { } } -function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { +function scheduleBatchFlush(root: WatchedRoot): void { const now = Date.now() if (root.batch.firstEventAt === 0) { @@ -343,7 +343,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - void flushBatch(rootKey, root) + void flushBatch(root) return } @@ -351,7 +351,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - root.batch.timer = setTimeout(() => void flushBatch(rootKey, root), DEBOUNCE_TRAILING_MS) + root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS) } // ── Watcher creation ───────────────────────────────────────────────── @@ -380,7 +380,7 @@ async function createWatcher( const markWatcherInterrupted = (): void => { root.batch.overflowed = true - scheduleBatchFlush(rootKey, root) + scheduleBatchFlush(root) } // Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap. @@ -390,7 +390,7 @@ async function createWatcher( if (err) { // Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3). console.error(`[filesystem-watcher] error for ${rootKey}:`, err) - emitOverflowPayload(rootKey, root) + emitOverflowPayload(root) // Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3). if (root.batch.timer) { clearTimeout(root.batch.timer) @@ -406,7 +406,7 @@ async function createWatcher( } queueWatcherEvents(root.batch, events) - scheduleBatchFlush(rootKey, root) + scheduleBatchFlush(root) }, watcherOptions, { diff --git a/src/renderer/src/lib/path.test.ts b/src/renderer/src/lib/path.test.ts index c40ce80d757..42f4d59581e 100644 --- a/src/renderer/src/lib/path.test.ts +++ b/src/renderer/src/lib/path.test.ts @@ -39,4 +39,13 @@ describe('getRelativePathInsideRoot', () => { it('keeps POSIX path checks case-sensitive', () => { expect(getRelativePathInsideRoot('/Repo/Docs/Plan.md', '/repo')).toBeNull() }) + + it('matches a decomposed root against a composed file path', () => { + // Regression for #10832: a local copy of this comparison folded case but not + // Unicode, so macOS NFD roots missed their own NFC files entirely. + const composed = '/Users/ada/프로젝트' + expect(getRelativePathInsideRoot(`${composed}/Docs/Plan.md`, composed.normalize('NFD'))).toBe( + 'Docs/Plan.md' + ) + }) }) diff --git a/src/renderer/src/lib/path.ts b/src/renderer/src/lib/path.ts index 8437f4032ca..284ee247ad2 100644 --- a/src/renderer/src/lib/path.ts +++ b/src/renderer/src/lib/path.ts @@ -1,3 +1,5 @@ +import { relativePathInsideRoot } from '../../../shared/cross-platform-path' + function stripTrailingSeparators(path: string): string { return path.replace(/[\\/]+$/, '') } @@ -14,26 +16,6 @@ export function normalizeRelativePath(path: string): string { return stripLeadingSeparators(path).replace(/[\\/]+/g, '/') } -function normalizeAbsolutePath(path: string): string { - const isUncPath = /^[\\/]{2}[^\\/]/.test(path) - const normalized = path.replace(/[\\/]+/g, '/') - return isUncPath && !normalized.startsWith('//') ? `/${normalized}` : normalized -} - -function stripTrailingAbsoluteSeparators(path: string): string { - if (path === '/') { - return path - } - if (/^[A-Za-z]:\/$/.test(path)) { - return path - } - return path.replace(/\/+$/, '') -} - -function isCaseInsensitiveAbsolutePath(path: string): boolean { - return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//') -} - export function getRelativePathInsideRoot( filePath: string, rootPath: string | null @@ -41,29 +23,9 @@ export function getRelativePathInsideRoot( if (!rootPath) { return null } - - const normalizedFilePath = normalizeAbsolutePath(filePath) - const normalizedRoot = stripTrailingAbsoluteSeparators(normalizeAbsolutePath(rootPath)) - const comparisonFilePath = isCaseInsensitiveAbsolutePath(normalizedFilePath) - ? normalizedFilePath.toLowerCase() - : normalizedFilePath - const comparisonRoot = isCaseInsensitiveAbsolutePath(normalizedRoot) - ? normalizedRoot.toLowerCase() - : normalizedRoot - - if (comparisonFilePath === comparisonRoot) { - return '' - } - - // Why: POSIX and Windows drive roots already include their trailing separator. - const isRootPath = normalizedRoot === '/' || /^[A-Za-z]:\/$/.test(normalizedRoot) - const rootPrefix = isRootPath ? comparisonRoot : `${comparisonRoot}/` - if (!comparisonFilePath.startsWith(rootPrefix)) { - return null - } - - const sliceStart = isRootPath ? normalizedRoot.length : normalizedRoot.length + 1 - return normalizeRelativePath(normalizedFilePath.slice(sliceStart)) + // Why: delegate so renderer containment folds Unicode the same way the rest of + // the app does — a local copy silently missed NFD-vs-NFC matches (#10832). + return relativePathInsideRoot(rootPath, filePath) } export function basename(path: string): string { diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index 70d313ba63c..5e3ce6dd4de 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -70,6 +70,65 @@ describe('cross-platform path containment', () => { ).toBe('line\nbreak') }) + it('matches macOS NFD paths against agent-recorded NFC paths', () => { + // Regression for #10832: macOS file pickers hand Orca decomposed (NFD) paths + // while Claude Code records cwd and names its project dirs in NFC, so a + // non-ASCII workspace never matched its own sessions. + const nfc = '/Users/ada/내 드라이브/프로젝트' + const nfd = nfc.normalize('NFD') + expect(nfd).not.toBe(nfc) + + expect(normalizeRuntimePathForComparison(nfd)).toBe(normalizeRuntimePathForComparison(nfc)) + expect(isPathInsideOrEqual(nfd, `${nfc}/src`)).toBe(true) + expect(isPathInsideOrEqual(nfc, `${nfd}/src`)).toBe(true) + + // WSL UNC keys return before the trailing fold, so they need NFC too. + expect( + normalizeRuntimePathForComparison( + `\\\\wsl$\\Ubuntu\\home\\ada\\${'프로젝트'.normalize('NFD')}` + ) + ).toBe(normalizeRuntimePathForComparison(`\\\\wsl.localhost\\Ubuntu\\home\\ada\\프로젝트`)) + }) + + it('returns a byte-exact suffix when comparison folding changes length', () => { + // Comparison folding (NFC, case) is not length-preserving, so slicing the raw + // candidate by the folded root's length would cut mid-character and fabricate + // a path — callers rejoin this suffix and hit the filesystem with it. + const nfc = '/Users/ada/프로젝트' + const nfd = nfc.normalize('NFD') + for (const root of [nfc, nfd]) { + for (const candidate of [nfc, nfd]) { + expect(relativePathInsideRoot(root, `${candidate}/src/index.ts`)).toBe('src/index.ts') + } + } + + // Pre-existing over-slice: toLowerCase expands U+0130 to two UTF-16 units. + expect(relativePathInsideRoot('C:\\İş', 'C:\\İş\\src\\a.ts')).toBe('src/a.ts') + + // U+212A KELVIN SIGN folds to 'K', so the root and candidate must agree on + // Windows-ness or their segment counts desync and the suffix comes back ''. + expect(relativePathInsideRoot('\u212A:/a\\b', '\u212A:/a\\b/c')).toBe( + relativePathInsideRoot('K:/a\\b', 'K:/a\\b/c') + ) + + // Astral characters must not be cut mid-surrogate-pair. + expect(relativePathInsideRoot('/repo/🚀app', '/repo/🚀app/src/🎉file.ts')).toBe('src/🎉file.ts') + + // A UNC-shaped candidate under POSIX root '/' used to yield a leading slash, + // which is not a relative path. + expect(relativePathInsideRoot('/', '//server/share/x')).toBe('server/share/x') + + // WSL suffixes must stay decomposed: they name files on a Linux filesystem, + // where NFD and NFC are distinct entries. + const decomposed = '프로젝트'.normalize('NFD') + expect( + relativePathInsideRoot( + '\\\\wsl$\\Ubuntu\\home\\ada\\repo', + `\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo\\${decomposed}\\a.ts` + ) + ).toBe(`${decomposed}/a.ts`) + }) + it('resolves POSIX relative paths without using the process cwd', () => { expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe( '/repos/app/worktrees/feature' diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index e329784719b..5e5b5432d6e 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -1,3 +1,5 @@ +const SLASH_CHAR_CODE = '/'.charCodeAt(0) + export function isWindowsAbsolutePathLike(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//') } @@ -10,7 +12,19 @@ export function normalizeRuntimePathSeparators(value: string): string { return normalized } -export function normalizeRuntimePathForComparison(value: string): string { +/** + * Comparison key only — never return this as, or splice it into, a real path. + * + * Why NFC: macOS file pickers and on-disk names yield NFD, while agents such as + * Claude Code record cwd and encode their project directory names in NFC. Both + * spell the same file, so a non-ASCII workspace otherwise never matches its own + * sessions (#10832). Folding here knowingly treats canonically equivalent names + * as one, which is exact on APFS but permissive on byte-exact Linux/SSH hosts — + * an acceptable trade, since only comparison keys are affected. + */ +export function normalizeRuntimePathForComparison(rawValue: string): string { + // Normalize before any folding so the WSL alias branch below is covered too. + const value = rawValue.normalize('NFC') const isWindowsPath = isWindowsAbsolutePathLike(value) // Why: backslash is a valid POSIX filename character; fold it only when the // path itself proves Windows drive/UNC semantics. @@ -68,8 +82,12 @@ export function isPathInsideOrEqual(rootPath: string, candidatePath: string): bo } export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null { + // Why: decide Windows-ness on the same NFC form the comparison key uses, or the + // two disagree (U+212A folds to 'K', making only one side a drive path) and the + // segment counts desync. Only the branch test sees NFC — the sliced string stays + // raw so the returned suffix remains byte-exact. const normalizedCandidate = trimRuntimePathTrailingSlash( - isWindowsAbsolutePathLike(candidatePath) + isWindowsAbsolutePathLike(candidatePath.normalize('NFC')) ? normalizeRuntimePathSeparators(candidatePath) : candidatePath.replace(/\/+/g, '/') ) @@ -84,11 +102,42 @@ export function relativePathInsideRoot(rootPath: string, candidatePath: string): if (!comparisonCandidate.startsWith(comparisonPrefix)) { return null } - // WSL comparison keys fold the UNC alias but preserve Linux path casing, so - // their suffix is both aligned across aliases and safe to return directly. - return comparisonRoot.startsWith('//wsl/') - ? comparisonCandidate.slice(comparisonPrefix.length) - : normalizedCandidate.slice(comparisonPrefix.length) + return sliceCandidatePastRootSegments(comparisonRoot, normalizedCandidate) +} + +/** + * Why: skip whole root segments rather than a character count. Comparison + * folding (NFC, case, UNC alias) changes length, so a folded-prefix length would + * cut the raw candidate mid-character and fabricate a path; segment positions + * survive every fold and keep the suffix byte-exact. Scanning rather than + * splitting keeps watcher event storms allocation-free. + */ +function sliceCandidatePastRootSegments(root: string, candidate: string): string { + let remainingRootSegments = 0 + let inRootSegment = false + for (let index = 0; index < root.length; index++) { + if (root.charCodeAt(index) === SLASH_CHAR_CODE) { + inRootSegment = false + } else if (!inRootSegment) { + inRootSegment = true + remainingRootSegments++ + } + } + + let inSegment = false + for (let index = 0; index < candidate.length; index++) { + if (candidate.charCodeAt(index) === SLASH_CHAR_CODE) { + inSegment = false + continue + } + if (!inSegment) { + inSegment = true + if (remainingRootSegments-- === 0) { + return candidate.slice(index) + } + } + } + return '' } function trimRuntimePathTrailingSlash(value: string): string { From 5a6a9e0b28d72856c995d7e6400a5364e5126321 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:46:39 -0700 Subject: [PATCH 125/150] fix(terminal): avoid flash while restoring parked terminals (#10871) --- src/renderer/src/components/Terminal.tsx | 85 +++++++++-- .../components/terminal-pane/TerminalPane.tsx | 9 +- ...TerminalPaneOverlayLayer.react185.test.tsx | 2 + .../TerminalPaneOverlayLayer.tsx | 124 ++++++++------- .../terminal-initial-render-settle.test.ts | 63 ++++++++ .../terminal-initial-render-settle.ts | 51 +++++++ .../terminal-overlay-assignments.ts | 23 +++ .../terminal-overlay-positioning.ts | 16 ++ ...-cold-parked-terminal-presentation.test.ts | 90 +++++++++++ .../use-cold-parked-terminal-presentation.ts | 141 ++++++++++++++++++ .../use-terminal-overlay-presentation.ts | 75 ++++++++++ .../use-terminal-pane-lifecycle.ts | 29 +++- .../use-terminal-tab-cold-parking.ts | 7 +- .../e2e/terminal-hidden-view-parking.spec.ts | 105 +++++++++++++ 14 files changed, 743 insertions(+), 77 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-initial-render-settle.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-initial-render-settle.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-overlay-assignments.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-overlay-positioning.ts create mode 100644 src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-overlay-presentation.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index b25d74b5b1b..0c2982043ff 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -84,6 +84,7 @@ import { type TerminalWorktreeColdParkCandidate } from './terminal-pane/terminal-hidden-view-parking' import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' +import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation' import { canWatcherCoverParkedTerminalTab, disposeAllParkedTerminalWatchers, @@ -1042,6 +1043,32 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) + const desiredPresentedWorktree = useMemo( + () => + new Map([['terminal-worktree', activeView === 'terminal' ? renderedActiveWorktreeId : null]]), + [activeView, renderedActiveWorktreeId] + ) + const availablePresentedWorktreeIds = useMemo( + () => new Set(workspaceSurfaces.map((workspace) => workspace.id)), + [workspaceSurfaces] + ) + const coldWorktreePresentationTargets = useMemo( + () => + activeView === 'terminal' && activeTabType === 'terminal' + ? parkedTerminalWorktreeIds + : new Set(), + [activeTabType, activeView, parkedTerminalWorktreeIds] + ) + const { + presentationByScope: worktreePresentationByScope, + settleTarget: settleWorktreePresentation + } = useColdParkedTerminalPresentation({ + desiredTargetByScope: desiredPresentedWorktree, + coldParkedTargetIds: coldWorktreePresentationTargets, + availableTargetIds: availablePresentedWorktreeIds + }) + const presentedWorktreeId = + worktreePresentationByScope.get('terminal-worktree')?.presentedTargetId ?? null // Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer. useEffect(() => { pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) @@ -2076,6 +2103,7 @@ function Terminal(): React.JSX.Element | null { // Why: strict '=== terminal' (not !== settings) so the terminal/browser surface hides on the tasks page too. const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const isPresented = workspace.id === presentedWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = @@ -2090,6 +2118,7 @@ function Terminal(): React.JSX.Element | null { layout={layout} focusedGroupId={activeGroupIdByWorktree[workspace.id]} isVisible={isVisible} + isPresented={isPresented} shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} shouldColdParkTerminalPanes={shouldColdParkTerminalPanes} activityTerminalPortals={activityTerminalPortals} @@ -2099,6 +2128,7 @@ function Terminal(): React.JSX.Element | null { activationDeferredMountTabIds={ activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null } + onInitialTerminalRenderSettled={() => settleWorktreePresentation(workspace.id)} /> ) })} @@ -2125,6 +2155,7 @@ function Terminal(): React.JSX.Element | null { // Why: strict '=== terminal' (not !== settings) so the terminal/browser surface hides on the tasks page too. const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const isPresented = workspace.id === presentedWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = @@ -2135,15 +2166,16 @@ function Terminal(): React.JSX.Element | null {
- + {(tabsByWorktree[workspace.id] ?? []) .filter((tab) => shouldMountBackgroundWorktreeTab( @@ -2159,6 +2191,9 @@ function Terminal(): React.JSX.Element | null { const isActivityPortalTab = activityTerminalPortal !== null const isActiveTerminalTab = isVisible && tab.id === activeTabId && activeTabType === 'terminal' + const isPresentedTerminalTab = + isPresented && + (isActiveTerminalTab || tab.id === activeTabIdByWorktree[workspace.id]) // Why: parking unmounts the view but keeps the PTY; an Activity portal stays mounted as a visible consumer. if (shouldColdParkTerminalPanes && !isActivityPortalTab) { return null @@ -2170,16 +2205,22 @@ function Terminal(): React.JSX.Element | null { worktreeId={workspace.id} cwd={tab.startupCwd ?? workspace.path} isActive={ - isActiveTerminalTab || activityTerminalPortal?.active === true + (isActiveTerminalTab && isPresented) || + activityTerminalPortal?.active === true } // Why: keep isVisible true for the portaled tab so xterm fits/streams while the workspace surface stays hidden. - isVisible={isActiveTerminalTab || isActivityPortalTab} + isVisible={isPresentedTerminalTab || isActivityPortalTab} // Why: inactive tabs here are tab-hidden (not worktree-hidden), so they need the same light resume path as split-group overlays. - isWorktreeActive={isVisible || isActivityPortalTab} + isWorktreeActive={isVisible || isPresented || isActivityPortalTab} // Why: isolate the portaled Activity leaf so split siblings stay hidden; workspace renders pass null. isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)} onCloseTab={() => handleCloseTab(tab.id)} + onInitialRenderSettled={ + isActiveTerminalTab + ? () => settleWorktreePresentation(workspace.id) + : undefined + } /> ) if (activityTerminalPortal) { @@ -2349,22 +2390,26 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ layout, focusedGroupId, isVisible, + isPresented, shouldMeasureHiddenWorktree, shouldColdParkTerminalPanes, activityTerminalPortals, backgroundMountTabIds, - activationDeferredMountTabIds + activationDeferredMountTabIds, + onInitialTerminalRenderSettled }: { worktreeId: string worktreePath: string layout: TabGroupLayoutNode focusedGroupId?: string isVisible: boolean + isPresented: boolean shouldMeasureHiddenWorktree: boolean shouldColdParkTerminalPanes: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] backgroundMountTabIds: ReadonlySet | null activationDeferredMountTabIds: ReadonlySet | null + onInitialTerminalRenderSettled: (tabId: string) => void }): React.JSX.Element { const browserPageIds = useAppStore( useShallow((state) => @@ -2381,37 +2426,45 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ return (
- + {isVisible || backgroundMountTabIds === null ? ( <> - - + + ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 20bc410c681..09226cd582d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -240,6 +240,7 @@ type TerminalPaneProps = { showSplitButton?: boolean onPtyExit: (ptyId: string) => void onCloseTab: () => void + onInitialRenderSettled?: () => void } type TerminalQuickCommandEditorDialogProps = { @@ -282,7 +283,8 @@ export default function TerminalPane({ isolatedPaneKey = null, showSplitButton = true, onPtyExit, - onCloseTab + onCloseTab, + onInitialRenderSettled }: TerminalPaneProps): React.JSX.Element { const containerRef = useRef(null) const managerRef = useRef(null) @@ -311,6 +313,8 @@ export default function TerminalPane({ const isRendererVisible = isVisible && isWorktreeActive const isVisibleRef = useRef(isRendererVisible) isVisibleRef.current = isRendererVisible + const onInitialRenderSettledRef = useRef(onInitialRenderSettled) + onInitialRenderSettledRef.current = onInitialRenderSettled const sshReconnectTargetId = useAppStore((store) => { const connectionId = getConnectionIdFromState(store, worktreeId) // Why: runtime-owned SSH targets are internal plumbing users can't connect to, so a reconnect prompt would mislead. @@ -1447,7 +1451,8 @@ export default function TerminalPane({ setPaneCount, setPaneLayoutRevision, resolveExternalPaneDropTarget, - onExternalPaneDrop: handleExternalPaneDrop + onExternalPaneDrop: handleExternalPaneDrop, + onInitialRenderSettledRef }) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx index faa8a2ba05e..539e5f71280 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx @@ -72,7 +72,9 @@ function renderSlot(): void { startupCwd={undefined} groupId={GROUP_ID} isWorktreeActive + isWorktreePresented isVisible + isPresented isActive activityTerminalPortal={null} onFocusOwningGroup={vi.fn()} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 30b0855cf63..8bbc254262e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -15,32 +15,19 @@ import { shouldMountBackgroundWorktreeTab } from '../terminal/background-termina import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' - -type TerminalOverlayAssignment = { - unifiedTabId: string - groupId: string - isActiveInGroup: boolean -} +import { useTerminalOverlayPresentation } from './use-terminal-overlay-presentation' +import { buildTerminalOverlayAssignments } from './terminal-overlay-assignments' +import { + FALLBACK_RECT_MIN_CHANGE_PX, + MIN_OVERLAY_FIT_HEIGHT_PX, + MIN_OVERLAY_FIT_WIDTH_PX, + shouldUseCssAnchorPositioning +} from './terminal-overlay-positioning' const EMPTY_TERMINAL_TABS: readonly TerminalTab[] = [] const EMPTY_UNIFIED_TABS: readonly Tab[] = [] const EMPTY_GROUPS: readonly TabGroup[] = [] const EMPTY_ACTIVITY_PORTALS: ActivityTerminalPortalTarget[] = [] -const HAS_CSS_ANCHOR_POSITIONING = - typeof CSS !== 'undefined' && - CSS.supports('position-anchor', '--orca-terminal-overlay-probe') && - CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') && - CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)') -const MIN_OVERLAY_FIT_WIDTH_PX = 48 -const MIN_OVERLAY_FIT_HEIGHT_PX = 24 -const FALLBACK_RECT_MIN_CHANGE_PX = 1 - -function shouldUseCssAnchorPositioning(): boolean { - return ( - HAS_CSS_ANCHOR_POSITIONING && - (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true - ) -} type MeasuredFallbackRect = { top: number @@ -57,12 +44,15 @@ type TerminalOverlaySlotProps = { startupCwd: string | undefined groupId: string | undefined isWorktreeActive: boolean + isWorktreePresented: boolean isVisible: boolean + isPresented: boolean isActive: boolean activityTerminalPortal: ActivityTerminalPortalTarget | null onFocusOwningGroup: ((groupId: string) => void) | undefined consumeSuppressedPtyExit: (ptyId: string) => boolean leaveWorktreeIfEmpty: () => void + onInitialRenderSettled?: () => void } export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ @@ -73,12 +63,15 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ startupCwd, groupId, isWorktreeActive, + isWorktreePresented, isVisible, + isPresented, isActive, activityTerminalPortal, onFocusOwningGroup, consumeSuppressedPtyExit, - leaveWorktreeIfEmpty + leaveWorktreeIfEmpty, + onInitialRenderSettled }: TerminalOverlaySlotProps): React.JSX.Element { const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined const overlayRef = useRef(null) @@ -187,6 +180,8 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ } }, [anchorName, isVisible, measuredFallbackRect]) + const showPresentedTerminal = isPresented && (isWorktreeActive || isWorktreePresented) + const isInteractive = isVisible && isPresented && isWorktreePresented const style: React.CSSProperties = useMemo( () => anchorName && shouldUseCssAnchorPositioning() @@ -197,9 +192,10 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ left: `anchor(${anchorName} left)`, width: `anchor-size(${anchorName} width)`, height: `anchor-size(${anchorName} height)`, - display: isVisible || shouldMeasureHiddenStartup ? 'flex' : 'none', - opacity: isVisible ? 1 : 0, - pointerEvents: isVisible ? 'auto' : 'none' + display: + isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none', + opacity: showPresentedTerminal ? 1 : 0, + pointerEvents: isInteractive ? 'auto' : 'none' } : anchorName ? { @@ -211,9 +207,10 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ left: measuredFallbackRect?.left ?? 0, width: measuredFallbackRect?.width ?? '100%', height: measuredFallbackRect?.height ?? 'calc(100% - 32px)', - display: isVisible || shouldMeasureHiddenStartup ? 'flex' : 'none', - opacity: isVisible ? 1 : 0, - pointerEvents: isVisible ? 'auto' : 'none' + display: + isVisible || showPresentedTerminal || shouldMeasureHiddenStartup ? 'flex' : 'none', + opacity: showPresentedTerminal ? 1 : 0, + pointerEvents: isInteractive ? 'auto' : 'none' } : { position: 'absolute', @@ -224,7 +221,14 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ display: 'none', pointerEvents: 'none' }, - [anchorName, isVisible, measuredFallbackRect, shouldMeasureHiddenStartup] + [ + anchorName, + isInteractive, + isVisible, + measuredFallbackRect, + shouldMeasureHiddenStartup, + showPresentedTerminal + ] ) const focusGroup = useCallback(() => { if (groupId !== undefined && onFocusOwningGroup) { @@ -238,12 +242,14 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ tabId={terminalTabId} worktreeId={worktreeId} cwd={startupCwd ?? worktreePath} - isActive={isActive || activityTerminalPortal?.active === true} + isActive={ + (isActive && isPresented && isWorktreePresented) || activityTerminalPortal?.active === true + } // Why: split-group changes reparent TabGroupPanel subtrees. Keeping the // TerminalPane mounted here preserves alt-screen TUI state while this // flag still lets hidden tabs throttle rendering. - isVisible={isVisible || activityTerminalPortal !== null} - isWorktreeActive={isWorktreeActive || activityTerminalPortal !== null} + isVisible={isVisible || showPresentedTerminal || activityTerminalPortal !== null} + isWorktreeActive={isWorktreeActive || isWorktreePresented || activityTerminalPortal !== null} isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => { if (consumeSuppressedPtyExit(ptyId)) { @@ -267,6 +273,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ // store.closeTab was the path that closed pinned terminals silently. closeTerminalTab(terminalTabId, { onClosed: leaveWorktreeIfEmpty }) }} + onInitialRenderSettled={onInitialRenderSettled} /> ) @@ -283,6 +290,9 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ ref={overlayRef} style={style} data-terminal-overlay-tab-id={terminalTabId} + data-terminal-overlay-presented={showPresentedTerminal ? 'true' : 'false'} + inert={!isInteractive} + aria-hidden={!isInteractive} onPointerDown={focusGroup} onFocusCapture={focusGroup} > @@ -298,15 +308,18 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, + isWorktreePresented = isWorktreeActive, coldParkTerminalPanes = false, shouldMeasureHiddenWorktree = false, activityTerminalPortals = EMPTY_ACTIVITY_PORTALS, backgroundMountTabIds = null, - activationDeferredMountTabIds = null + activationDeferredMountTabIds = null, + onInitialTerminalRenderSettled }: { worktreeId: string worktreePath: string isWorktreeActive: boolean + isWorktreePresented?: boolean coldParkTerminalPanes?: boolean shouldMeasureHiddenWorktree?: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] @@ -316,6 +329,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ /** Only cold-activation deferred tabs receive immediate parked watcher * coverage; targeted mounts keep their existing delayed parking policy. */ activationDeferredMountTabIds?: ReadonlySet | null + onInitialTerminalRenderSettled?: (tabId: string) => void }): React.JSX.Element | null { const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore( useShallow((state) => ({ @@ -353,30 +367,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ [focusGroup, worktreeId] ) - const groupActiveTabById = useMemo(() => { - const lookup: Record = {} - for (const group of groups) { - lookup[group.id] = group.activeTabId - } - return lookup - }, [groups]) + const assignments = useMemo( + () => buildTerminalOverlayAssignments(groups, unifiedTabs), + [groups, unifiedTabs] + ) - const assignments = useMemo(() => { - const entries = new Map() - for (const tab of unifiedTabs) { - if (tab.contentType !== 'terminal') { - continue - } - entries.set(tab.entityId, { - unifiedTabId: tab.id, - groupId: tab.groupId, - isActiveInGroup: groupActiveTabById[tab.groupId] === tab.id - }) - } - return entries - }, [groupActiveTabById, unifiedTabs]) - - const parkedTerminalTabIds = useTerminalTabColdParking({ + const { parkedTerminalTabIds, coldParkedTerminalTabIds } = useTerminalTabColdParking({ worktreeId, terminalTabs, assignments, @@ -386,6 +382,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ activityTerminalPortals, activationDeferredMountTabIds }) + const { presentedTerminalTabIdByGroup: presentationByScope, handleInitialRenderSettled } = + useTerminalOverlayPresentation({ + groups, + terminalTabs, + assignments, + coldParkedTerminalTabIds, + isWorktreeActive, + activeGroupId, + onInitialTerminalRenderSettled + }) if (!worktreePath) { return null @@ -401,6 +407,9 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ const assignment = assignments.get(terminalTab.id) const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) const isActive = Boolean(isVisible && assignment && assignment.groupId === activeGroupId) + const isPresented = Boolean( + assignment && presentationByScope.get(assignment.groupId) === terminalTab.id + ) const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { worktreeId, tabId: terminalTab.id @@ -420,12 +429,15 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ startupCwd={terminalTab.startupCwd} groupId={assignment?.groupId} isWorktreeActive={isWorktreeActive} + isWorktreePresented={isWorktreePresented} isVisible={isVisible} + isPresented={isPresented} isActive={isActive} activityTerminalPortal={activityTerminalPortal} onFocusOwningGroup={focusOwningGroup} consumeSuppressedPtyExit={consumeSuppressedPtyExit} leaveWorktreeIfEmpty={leaveWorktreeIfEmpty} + onInitialRenderSettled={() => handleInitialRenderSettled(terminalTab.id)} /> ) })} diff --git a/src/renderer/src/components/terminal-pane/terminal-initial-render-settle.test.ts b/src/renderer/src/components/terminal-pane/terminal-initial-render-settle.test.ts new file mode 100644 index 00000000000..0fca4abd5d7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-initial-render-settle.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { scheduleTerminalInitialRenderSettled } from './terminal-initial-render-settle' + +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + waitForTerminalOutputParsed: vi.fn(() => Promise.resolve()) +})) + +describe('scheduleTerminalInitialRenderSettled', () => { + const frames: FrameRequestCallback[] = [] + + beforeEach(() => { + frames.length = 0 + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('waits for replay and content before two frames paint', async () => { + let replaySettled = false + let contentReady = false + const onSettled = vi.fn() + scheduleTerminalInitialRenderSettled({ + manager: { getPanes: () => [] }, + isCurrent: () => true, + isReplaySettled: () => replaySettled, + isContentReady: () => contentReady, + onSettled + }) + await Promise.resolve() + + frames.shift()?.(0) + replaySettled = true + frames.shift()?.(16) + contentReady = true + frames.shift()?.(32) + expect(onSettled).not.toHaveBeenCalled() + + frames.shift()?.(48) + expect(onSettled).toHaveBeenCalledOnce() + }) + + it('ignores queued work after cancellation', async () => { + const onSettled = vi.fn() + const cancel = scheduleTerminalInitialRenderSettled({ + manager: { getPanes: () => [] }, + isCurrent: () => true, + isReplaySettled: () => true, + isContentReady: () => true, + onSettled + }) + await Promise.resolve() + cancel() + + frames.shift()?.(0) + expect(onSettled).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-initial-render-settle.ts b/src/renderer/src/components/terminal-pane/terminal-initial-render-settle.ts new file mode 100644 index 00000000000..f0dbfe6eaad --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-initial-render-settle.ts @@ -0,0 +1,51 @@ +import type { Terminal } from '@xterm/xterm' +import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-output-scheduler' + +type InitialRenderPaneManager = { + getPanes(): { terminal: Terminal }[] +} + +export function scheduleTerminalInitialRenderSettled(args: { + manager: InitialRenderPaneManager + isCurrent: () => boolean + isReplaySettled: () => boolean + isContentReady: () => boolean + onSettled: () => void +}): () => void { + let cancelled = false + let frameId: number | null = null + let replaySettledFrameObserved = false + const scheduleFrame = (): void => { + frameId = requestAnimationFrame(() => { + frameId = null + if (cancelled || !args.isCurrent()) { + return + } + if (!args.isReplaySettled() || !args.isContentReady()) { + replaySettledFrameObserved = false + scheduleFrame() + return + } + if (!replaySettledFrameObserved) { + replaySettledFrameObserved = true + scheduleFrame() + return + } + args.onSettled() + }) + } + void Promise.all( + args.manager.getPanes().map((pane) => waitForTerminalOutputParsed(pane.terminal)) + ).then(() => { + if (cancelled || !args.isCurrent()) { + return + } + scheduleFrame() + }) + return () => { + cancelled = true + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-overlay-assignments.ts b/src/renderer/src/components/terminal-pane/terminal-overlay-assignments.ts new file mode 100644 index 00000000000..36cb7d0d8fc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-overlay-assignments.ts @@ -0,0 +1,23 @@ +import type { Tab, TabGroup } from '../../../../shared/types' + +export type TerminalOverlayAssignment = { + groupId: string + isActiveInGroup: boolean +} + +export function buildTerminalOverlayAssignments( + groups: readonly TabGroup[], + unifiedTabs: readonly Tab[] +): Map { + const activeTabByGroupId = new Map(groups.map((group) => [group.id, group.activeTabId])) + const assignments = new Map() + for (const tab of unifiedTabs) { + if (tab.contentType === 'terminal') { + assignments.set(tab.entityId, { + groupId: tab.groupId, + isActiveInGroup: activeTabByGroupId.get(tab.groupId) === tab.id + }) + } + } + return assignments +} diff --git a/src/renderer/src/components/terminal-pane/terminal-overlay-positioning.ts b/src/renderer/src/components/terminal-pane/terminal-overlay-positioning.ts new file mode 100644 index 00000000000..706c579ede8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-overlay-positioning.ts @@ -0,0 +1,16 @@ +const HAS_CSS_ANCHOR_POSITIONING = + typeof CSS !== 'undefined' && + CSS.supports('position-anchor', '--orca-terminal-overlay-probe') && + CSS.supports('top', 'anchor(--orca-terminal-overlay-probe top)') && + CSS.supports('width', 'anchor-size(--orca-terminal-overlay-probe width)') + +export const MIN_OVERLAY_FIT_WIDTH_PX = 48 +export const MIN_OVERLAY_FIT_HEIGHT_PX = 24 +export const FALLBACK_RECT_MIN_CHANGE_PX = 1 + +export function shouldUseCssAnchorPositioning(): boolean { + return ( + HAS_CSS_ANCHOR_POSITIONING && + (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true + ) +} diff --git a/src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.test.ts b/src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.test.ts new file mode 100644 index 00000000000..1aa77fefda2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + COLD_PARKED_PRESENTATION_FALLBACK_MS, + useColdParkedTerminalPresentation +} from './use-cold-parked-terminal-presentation' + +type HookProps = { + desiredTargetId: string | null + coldParkedTargetIds: ReadonlySet +} + +const availableTargetIds = new Set(['a', 'b', 'c']) + +function renderPresentationHook(initialProps: HookProps) { + return renderHook( + ({ desiredTargetId, coldParkedTargetIds }: HookProps) => + useColdParkedTerminalPresentation({ + desiredTargetByScope: new Map([['scope', desiredTargetId]]), + coldParkedTargetIds, + availableTargetIds + }), + { initialProps } + ) +} + +function presentedTargetId( + result: ReturnType['result'] +): string | null | undefined { + return result.current.presentationByScope.get('scope')?.presentedTargetId +} + +describe('useColdParkedTerminalPresentation', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('presents a warm target immediately', () => { + const hook = renderPresentationHook({ + desiredTargetId: 'a', + coldParkedTargetIds: new Set() + }) + + hook.rerender({ desiredTargetId: 'b', coldParkedTargetIds: new Set() }) + + expect(presentedTargetId(hook.result)).toBe('b') + }) + + it('retains the prior target until a cold target settles', () => { + const hook = renderPresentationHook({ + desiredTargetId: 'a', + coldParkedTargetIds: new Set() + }) + + hook.rerender({ desiredTargetId: 'b', coldParkedTargetIds: new Set(['b']) }) + hook.rerender({ desiredTargetId: 'b', coldParkedTargetIds: new Set() }) + + expect(presentedTargetId(hook.result)).toBe('a') + act(() => hook.result.current.settleTarget('b')) + expect(presentedTargetId(hook.result)).toBe('b') + }) + + it('ignores a stale settle after another switch', () => { + const hook = renderPresentationHook({ + desiredTargetId: 'a', + coldParkedTargetIds: new Set() + }) + + hook.rerender({ desiredTargetId: 'b', coldParkedTargetIds: new Set(['b']) }) + hook.rerender({ desiredTargetId: 'c', coldParkedTargetIds: new Set() }) + act(() => hook.result.current.settleTarget('b')) + + expect(presentedTargetId(hook.result)).toBe('c') + }) + + it('falls back when a cold target never settles', () => { + vi.useFakeTimers() + const hook = renderPresentationHook({ + desiredTargetId: 'a', + coldParkedTargetIds: new Set() + }) + + hook.rerender({ desiredTargetId: 'b', coldParkedTargetIds: new Set(['b']) }) + act(() => vi.advanceTimersByTime(COLD_PARKED_PRESENTATION_FALLBACK_MS)) + + expect(presentedTargetId(hook.result)).toBe('b') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.ts b/src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.ts new file mode 100644 index 00000000000..aa52944d389 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-cold-parked-terminal-presentation.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' + +export const COLD_PARKED_PRESENTATION_FALLBACK_MS = 1_000 + +type PresentationEntry = { + presentedTargetId: string | null + pendingTargetId: string | null +} + +export type ColdParkedPresentationState = ReadonlyMap + +type PresentationInputs = { + desiredTargetByScope: ReadonlyMap + coldParkedTargetIds: ReadonlySet + availableTargetIds: ReadonlySet +} + +function haveSameEntries( + left: ColdParkedPresentationState, + right: ColdParkedPresentationState +): boolean { + if (left.size !== right.size) { + return false + } + for (const [scopeId, entry] of left) { + const other = right.get(scopeId) + if ( + !other || + entry.presentedTargetId !== other.presentedTargetId || + entry.pendingTargetId !== other.pendingTargetId + ) { + return false + } + } + return true +} + +export function createColdParkedPresentationState( + desiredTargetByScope: ReadonlyMap +): ColdParkedPresentationState { + return new Map( + Array.from(desiredTargetByScope, ([scopeId, targetId]) => [ + scopeId, + { presentedTargetId: targetId, pendingTargetId: null } + ]) + ) +} + +export function resolveColdParkedPresentation( + current: ColdParkedPresentationState, + inputs: PresentationInputs +): ColdParkedPresentationState { + const next = new Map() + for (const [scopeId, desiredTargetId] of inputs.desiredTargetByScope) { + const entry = current.get(scopeId) ?? { + presentedTargetId: null, + pendingTargetId: null + } + if (entry.pendingTargetId === desiredTargetId) { + next.set(scopeId, entry) + continue + } + const canRetainPresentedTarget = + entry.presentedTargetId !== null && inputs.availableTargetIds.has(entry.presentedTargetId) + const shouldWait = + desiredTargetId !== null && + desiredTargetId !== entry.presentedTargetId && + canRetainPresentedTarget && + inputs.coldParkedTargetIds.has(desiredTargetId) + next.set( + scopeId, + shouldWait + ? { presentedTargetId: entry.presentedTargetId, pendingTargetId: desiredTargetId } + : { presentedTargetId: desiredTargetId, pendingTargetId: null } + ) + } + return haveSameEntries(current, next) ? current : next +} + +function settlePresentationTarget( + current: ColdParkedPresentationState, + desiredTargetByScope: ReadonlyMap, + targetId: string +): ColdParkedPresentationState { + let changed = false + const next = new Map(current) + for (const [scopeId, entry] of current) { + if (entry.pendingTargetId !== targetId || desiredTargetByScope.get(scopeId) !== targetId) { + continue + } + next.set(scopeId, { presentedTargetId: targetId, pendingTargetId: null }) + changed = true + } + return changed ? next : current +} + +export function useColdParkedTerminalPresentation( + inputs: PresentationInputs, + fallbackMs = COLD_PARKED_PRESENTATION_FALLBACK_MS +): { + presentationByScope: ColdParkedPresentationState + settleTarget: (targetId: string) => void +} { + const [committed, setCommitted] = useState(() => + createColdParkedPresentationState(inputs.desiredTargetByScope) + ) + const latestInputsRef = useRef(inputs) + latestInputsRef.current = inputs + const presentationByScope = resolveColdParkedPresentation(committed, inputs) + + useLayoutEffect(() => { + setCommitted((current) => resolveColdParkedPresentation(current, latestInputsRef.current)) + }, [presentationByScope]) + + const settleTarget = useCallback((targetId: string) => { + setCommitted((current) => { + const latest = latestInputsRef.current + const resolved = resolveColdParkedPresentation(current, latest) + return settlePresentationTarget(resolved, latest.desiredTargetByScope, targetId) + }) + }, []) + + useEffect(() => { + const timers = new Set() + const pendingTargetIds = new Set( + Array.from(presentationByScope.values(), (entry) => entry.pendingTargetId).filter( + (targetId): targetId is string => targetId !== null + ) + ) + for (const targetId of pendingTargetIds) { + timers.add(window.setTimeout(() => settleTarget(targetId), fallbackMs)) + } + return () => { + for (const timer of timers) { + window.clearTimeout(timer) + } + } + }, [fallbackMs, presentationByScope, settleTarget]) + + return { presentationByScope, settleTarget } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-overlay-presentation.ts b/src/renderer/src/components/terminal-pane/use-terminal-overlay-presentation.ts new file mode 100644 index 00000000000..856ac67e081 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-overlay-presentation.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo } from 'react' +import type { TabGroup, TerminalTab } from '../../../../shared/types' +import { useColdParkedTerminalPresentation } from './use-cold-parked-terminal-presentation' + +type TerminalOverlayAssignment = { + groupId: string + isActiveInGroup: boolean +} + +export function useTerminalOverlayPresentation(args: { + groups: readonly TabGroup[] + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + coldParkedTerminalTabIds: ReadonlySet + isWorktreeActive: boolean + activeGroupId: string | undefined + onInitialTerminalRenderSettled?: (tabId: string) => void +}): { + presentedTerminalTabIdByGroup: ReadonlyMap + handleInitialRenderSettled: (tabId: string) => void +} { + const { + groups, + terminalTabs, + assignments, + coldParkedTerminalTabIds, + isWorktreeActive, + activeGroupId, + onInitialTerminalRenderSettled + } = args + const desiredTerminalTabByGroup = useMemo(() => { + const desired = new Map() + for (const group of groups) { + desired.set(group.id, null) + } + for (const terminalTab of terminalTabs) { + const assignment = assignments.get(terminalTab.id) + if (assignment?.isActiveInGroup) { + desired.set(assignment.groupId, terminalTab.id) + } + } + return desired + }, [assignments, groups, terminalTabs]) + const availableTerminalTabIds = useMemo( + () => new Set(terminalTabs.map((terminalTab) => terminalTab.id)), + [terminalTabs] + ) + const { presentationByScope, settleTarget } = useColdParkedTerminalPresentation({ + desiredTargetByScope: desiredTerminalTabByGroup, + coldParkedTargetIds: coldParkedTerminalTabIds, + availableTargetIds: availableTerminalTabIds + }) + const presentedTerminalTabIdByGroup = useMemo( + () => + new Map( + Array.from(presentationByScope, ([groupId, entry]) => [groupId, entry.presentedTargetId]) + ), + [presentationByScope] + ) + const handleInitialRenderSettled = useCallback( + (tabId: string) => { + settleTarget(tabId) + const assignment = assignments.get(tabId) + if (isWorktreeActive && assignment?.isActiveInGroup && assignment.groupId === activeGroupId) { + onInitialTerminalRenderSettled?.(tabId) + } + }, + [activeGroupId, assignments, isWorktreeActive, onInitialTerminalRenderSettled, settleTarget] + ) + + return { + presentedTerminalTabIdByGroup, + handleInitialRenderSettled + } +} 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 7650e870b09..bdc8ea6300b 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 @@ -62,6 +62,7 @@ import { replayTerminalLayout, restoreScrollbackBuffers } from './layout-serialization' +import { scheduleTerminalInitialRenderSettled } from './terminal-initial-render-settle' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { makePaneKey } from '../../../../shared/stable-pane-id' import { applyExpandedLayoutTo, restoreExpandedLayoutFrom } from './expand-collapse' @@ -306,6 +307,7 @@ type UseTerminalPaneLifecycleDeps = { setPaneLayoutRevision: React.Dispatch> resolveExternalPaneDropTarget?: PaneExternalDropResolver onExternalPaneDrop?: PaneExternalDropHandler + onInitialRenderSettledRef: React.RefObject<(() => void) | undefined> } export function suppressIntentionalPaneCloseExit( @@ -554,7 +556,8 @@ export function useTerminalPaneLifecycle({ setPaneCount, setPaneLayoutRevision, resolveExternalPaneDropTarget, - onExternalPaneDrop + onExternalPaneDrop, + onInitialRenderSettledRef }: UseTerminalPaneLifecycleDeps): void { const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows @@ -718,6 +721,7 @@ export function useTerminalPaneLifecycle({ initialLayoutRef.current = hydratedInitialScrollback.layout } let shouldPersistLayout = false + let cancelInitialRenderSettle: (() => void) | null = null const startupWithSetupSplitWait = startup && setupSplit ? { ...startup, waitForSetupSplitDirection: setupSplit.direction } @@ -1517,6 +1521,28 @@ export function useTerminalPaneLifecycle({ queueResizeAll(isActive) persistLayoutSnapshot() scheduleRuntimeGraphSync() + if (onInitialRenderSettledRef.current) { + cancelInitialRenderSettle = scheduleTerminalInitialRenderSettled({ + manager, + isCurrent: () => managerRef.current === manager, + isReplaySettled: () => replayingPanesRef.current.size === 0, + isContentReady: () => { + const terminal = (manager.getActivePane() ?? manager.getPanes()[0])?.terminal + if (!terminal) { + return false + } + const buffer = terminal.buffer.active + const viewportEnd = Math.min(buffer.length, buffer.viewportY + terminal.rows) + for (let row = buffer.viewportY; row < viewportEnd; row += 1) { + if (buffer.getLine(row)?.translateToString(true).trim()) { + return true + } + } + return false + }, + onSettled: () => onInitialRenderSettledRef.current?.() + }) + } // Why: deliver the startup command via the PTY connection path (waits for shell readiness), not terminal.paste() which can lose input before the shell reads stdin. function onCliSplitPane(event: Event): void { @@ -1587,6 +1613,7 @@ export function useTerminalPaneLifecycle({ window.addEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane) return () => { + cancelInitialRenderSettle?.() window.removeEventListener(SPLIT_TERMINAL_PANE_EVENT, onCliSplitPane) window.removeEventListener(CLOSE_TERMINAL_PANE_EVENT, onCliClosePane) const currentWorktreeTabs = useAppStore.getState().tabsByWorktree[worktreeId] diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 3bd6b962ba8..28773698d9b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -60,7 +60,10 @@ export function useTerminalTabColdParking(args: { /** Tabs cold activation keeps unmounted — parked-equivalent for watcher * purposes. Targeted background restrictions intentionally stay bounded. */ activationDeferredMountTabIds?: ReadonlySet | null -}): ReadonlySet { +}): { + parkedTerminalTabIds: ReadonlySet + coldParkedTerminalTabIds: ReadonlySet +} { const { worktreeId, terminalTabs, @@ -276,5 +279,5 @@ export function useTerminalTabColdParking(args: { useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) - return parkedTerminalTabIds + return { parkedTerminalTabIds, coldParkedTerminalTabIds } } diff --git a/tests/e2e/terminal-hidden-view-parking.spec.ts b/tests/e2e/terminal-hidden-view-parking.spec.ts index 9f31d69321c..b103964b64c 100644 --- a/tests/e2e/terminal-hidden-view-parking.spec.ts +++ b/tests/e2e/terminal-hidden-view-parking.spec.ts @@ -181,6 +181,87 @@ async function activateTerminalTab(page: Page, tabId: string): Promise { .toBe(tabId) } +type TerminalPresentationFrame = { + previousPresented: boolean + targetPresented: boolean + targetBufferContainsMarker: boolean +} + +async function startTerminalPresentationObservation( + page: Page, + previousTabId: string, + targetTabId: string, + targetMarker: string +): Promise { + await page.evaluate( + ({ previousTabId, targetTabId, targetMarker }) => { + const observedWindow = window as Window & { + __terminalPresentationFrames?: TerminalPresentationFrame[] + __terminalPresentationObservationDone?: boolean + } + const frames: TerminalPresentationFrame[] = [] + observedWindow.__terminalPresentationFrames = frames + observedWindow.__terminalPresentationObservationDone = false + const findOverlay = (tabId: string): HTMLElement | null => + document.querySelector(`[data-terminal-overlay-tab-id="${CSS.escape(tabId)}"]`) + const isPresented = (tabId: string): boolean => { + const overlay = findOverlay(tabId) + return ( + overlay?.dataset.terminalOverlayPresented === 'true' && + getComputedStyle(overlay).display !== 'none' && + getComputedStyle(overlay).opacity !== '0' + ) + } + const sample = (): void => { + const targetPane = + window.__paneManagers?.get(targetTabId)?.getActivePane?.() ?? + window.__paneManagers?.get(targetTabId)?.getPanes?.()[0] + const frame = { + previousPresented: isPresented(previousTabId), + targetPresented: isPresented(targetTabId), + targetBufferContainsMarker: + targetPane?.serializeAddon?.serialize?.().includes(targetMarker) === true + } + frames.push(frame) + if (frame.targetPresented) { + observedWindow.__terminalPresentationObservationDone = true + return + } + requestAnimationFrame(sample) + } + sample() + }, + { previousTabId, targetTabId, targetMarker } + ) +} + +async function readTerminalPresentationObservation( + page: Page +): Promise { + await expect + .poll( + () => + page.evaluate( + () => + ( + window as Window & { + __terminalPresentationObservationDone?: boolean + } + ).__terminalPresentationObservationDone === true + ), + { timeout: 5_000, message: 'terminal presentation handoff did not settle' } + ) + .toBe(true) + return page.evaluate( + () => + ( + window as Window & { + __terminalPresentationFrames?: TerminalPresentationFrame[] + } + ).__terminalPresentationFrames ?? [] + ) +} + async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { const tabId = await page.evaluate((worktreeId) => { const store = window.__store @@ -311,6 +392,16 @@ test.describe('Terminal hidden view parking', () => { expect(tabBState.hasManager).toBe(true) expect(tabBState.paneCount).toBeGreaterThan(0) + const previouslyPresentedTabId = await getActiveTabId(orcaPage) + if (!previouslyPresentedTabId) { + throw new Error('parking reveal had no previously presented terminal tab') + } + await startTerminalPresentationObservation( + orcaPage, + previouslyPresentedTabId, + tabAId, + finalMarker + ) await activateTerminalTab(orcaPage, tabAId) await waitForActiveTerminalManager(orcaPage, 30_000) const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) @@ -326,6 +417,20 @@ test.describe('Terminal hidden view parking', () => { }) .toContain(finalMarker) + const presentationFrames = await readTerminalPresentationObservation(orcaPage) + const firstTargetFrame = presentationFrames.findIndex((frame) => frame.targetPresented) + expect(firstTargetFrame).toBeGreaterThan(0) + expect( + presentationFrames[firstTargetFrame]?.targetBufferContainsMarker, + JSON.stringify(presentationFrames) + ).toBe(true) + expect( + presentationFrames + .slice(0, firstTargetFrame) + .every((frame) => frame.previousPresented && !frame.targetPresented), + JSON.stringify(presentationFrames) + ).toBe(true) + const content = await getTerminalContent(orcaPage, 12_000) expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`) expect(content).toContain('╭') From a142c84ede203dc47d8cbf519853b451be9bb6b5 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:48:20 +0200 Subject: [PATCH 126/150] Fix diff notes overlapping following lines (#7803) Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../DiffCommentCard.resize.test.tsx | 104 ++++++++++++++ .../diff-comments/DiffCommentCard.tsx | 54 ++++++-- .../diff-comments/useDiffCommentDecorator.tsx | 10 +- tests/e2e/diff-note-layout.spec.ts | 128 ++++++++++++++++++ tests/e2e/global-setup.ts | 1 + tests/e2e/helpers/seeded-test-repo.ts | 1 + 6 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx create mode 100644 tests/e2e/diff-note-layout.spec.ts diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx new file mode 100644 index 00000000000..f17dfbb7613 --- /dev/null +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DiffCommentCard } from './DiffCommentCard' + +describe('DiffCommentCard content resize', () => { + let notifyObservedResize: ResizeObserverCallback + let constructObserver = vi.fn<() => void>() + let disconnect: ReturnType + let frameCallbacks: Map + let nextFrameId: number + + beforeEach(() => { + constructObserver = vi.fn<() => void>() + disconnect = vi.fn() + frameCallbacks = new Map() + nextFrameId = 1 + + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + constructObserver() + notifyObservedResize = callback + } + + observe = vi.fn() + unobserve = vi.fn() + disconnect = disconnect + } + + vi.stubGlobal('ResizeObserver', TestResizeObserver) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const frameId = nextFrameId++ + frameCallbacks.set(frameId, callback) + return frameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => frameCallbacks.delete(frameId)) + ) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('re-measures after wrapping and coalesces observer notifications', () => { + const onContentResize = vi.fn() + const view = render( + + ) + + expect(constructObserver).toHaveBeenCalledOnce() + expect(onContentResize).toHaveBeenCalledOnce() + + act(() => { + notifyObservedResize([], {} as ResizeObserver) + notifyObservedResize([], {} as ResizeObserver) + }) + expect(requestAnimationFrame).toHaveBeenCalledOnce() + + const callback = frameCallbacks.values().next().value + frameCallbacks.clear() + act(() => callback?.(0)) + expect(onContentResize).toHaveBeenCalledTimes(2) + + const latestOnContentResize = vi.fn() + view.rerender( + + ) + expect(disconnect).not.toHaveBeenCalled() + + act(() => notifyObservedResize([], {} as ResizeObserver)) + const latestCallback = frameCallbacks.values().next().value + act(() => latestCallback?.(0)) + expect(latestOnContentResize).toHaveBeenCalledOnce() + + view.unmount() + expect(disconnect).toHaveBeenCalledOnce() + }) + + it('does not observe cards outside Monaco view zones', () => { + const onContentResize = vi.fn() + render( + + ) + + expect(constructObserver).not.toHaveBeenCalled() + expect(onContentResize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx index a854a43ae29..9e7ddf229ce 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx @@ -27,10 +27,10 @@ type Props = { url?: string onDelete?: () => void // Why: Monaco view zones have a fixed `heightInPx` set at insertion time - // and aren't auto-measured. While the user is in edit mode the textarea - // grows, so the parent decorator passes a callback we fire on resize and - // it re-syncs the zone height. Without this the editor inputs would clip. + // and aren't auto-measured. The parent decorator re-syncs that height when + // the rendered card wraps or grows so it cannot overlap following lines. onContentResize?: () => void + observeRenderedSize?: boolean onSubmitEdit?: (body: string) => Promise headerActions?: ReactNode } @@ -47,6 +47,7 @@ export function DiffCommentCard({ url, onDelete, onContentResize, + observeRenderedSize, onSubmitEdit, headerActions }: Props): React.JSX.Element { @@ -54,17 +55,52 @@ export function DiffCommentCard({ const [draft, setDraft] = useState(body) const [submitting, setSubmitting] = useState(false) const mountedRef = useMountedRef() + const cardRef = useRef(null) const textareaRef = useRef(null) const resizeAfterCloseRef = useRef(false) + const observesRenderedSize = observeRenderedSize === true && onContentResize !== undefined - // Why: stash `onContentResize` in a ref so the layout/resize effects only - // re-run on `editing` transitions. The decorator passes a fresh arrow every - // render; depending on it directly would re-fire the layout effect on every - // unrelated parent render and yank the caret to the textarea's end while - // the user is mid-edit. + // Why: stash `onContentResize` in a ref so resize effects do not depend on + // the decorator's fresh arrow each render. Re-running the edit layout effect + // would yank the caret to the textarea's end while the user is mid-edit. const onContentResizeRef = useRef(onContentResize) onContentResizeRef.current = onContentResize + useLayoutEffect(() => { + const card = cardRef.current + if (!card || !observesRenderedSize) { + return + } + onContentResizeRef.current?.() + let frameId: number | null = null + const notifyResize = (): void => { + if (frameId !== null) { + return + } + frameId = requestAnimationFrame(() => { + frameId = null + onContentResizeRef.current?.() + }) + } + if (typeof ResizeObserver === 'undefined') { + return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + } + // Why: narrow diff panes can wrap body/header text after Monaco's initial + // estimate; observe the real card height in either diff layout. + const observer = new ResizeObserver(() => notifyResize()) + observer.observe(card) + return () => { + observer.disconnect() + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + }, [observesRenderedSize]) + // Why: focus + auto-grow the textarea on entering edit mode. Layout effect // so the height is set before the browser paints — a measurement pass on // the next animation frame would visibly jump from 0 to N px. @@ -138,7 +174,7 @@ export function DiffCommentCard({ } return ( -
+
{/* Header Row */}
diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index addfcb4e329..2c3214070d9 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -381,12 +381,13 @@ export function useDiffCommentDecorator({ const verticalPadding = Number.parseFloat(wrapperStyle.paddingTop) + Number.parseFloat(wrapperStyle.paddingBottom) // Monaco pins the zone node to its previous height (scrollHeight can't shrink), so measure the rendered card+padding to allow collapse. - const measured = Math.ceil( - (child?.getBoundingClientRect().height ?? entry.domNode.scrollHeight) + verticalPadding - ) - if (measured <= 0) { + const childHeight = child?.getBoundingClientRect().height ?? 0 + // React can commit while Monaco's zone is detached; preserve the safe + // initial estimate until the observer sees a measurable card. + if (childHeight <= 0) { return } + const measured = Math.ceil(childHeight + verticalPadding) if (entry.delegate.heightInPx === measured) { return } @@ -447,6 +448,7 @@ export function useDiffCommentDecorator({ : undefined } onContentResize={() => resizeZone(comment.id)} + observeRenderedSize headerActions={ worktreeId && comment.author === undefined ? ( { + const card = page.locator('.orca-diff-comment-card').first() + const followingLine = page + .locator('.modified-in-monaco-diff-editor .view-lines .view-line') + .filter({ hasText: FOLLOWING_LINE }) + .first() + + await expect(card).toBeVisible({ timeout: 15_000 }) + await expect(followingLine).toBeVisible({ timeout: 15_000 }) + await expect + .poll(async () => (await card.boundingBox())?.height ?? 0) + .toBeGreaterThan(INITIAL_ZONE_HEIGHT) + await expect + .poll( + async () => { + const [cardBox, lineBox] = await Promise.all([ + card.boundingBox(), + followingLine.boundingBox() + ]) + return cardBox && lineBox ? lineBox.y - (cardBox.y + cardBox.height) : -1 + }, + { message: 'saved note overlaps the following diff line' } + ) + .toBeGreaterThanOrEqual(0) +} + +async function attachDiffScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { + const diff = page.locator('.monaco-diff-editor').first() + const screenshotPath = testInfo.outputPath(`${name}.png`) + await diff.screenshot({ path: screenshotPath }) + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }) +} + +test.describe('Diff note layout', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('saved notes reserve their rendered height in both diff layouts', async ({ + orcaPage + }, testInfo) => { + await orcaPage.setViewportSize({ width: 1200, height: 800 }) + const worktreeId = await waitForActiveWorktree(orcaPage) + const relativePath = await orcaPage.evaluate(async (wId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const worktree = Object.values(state.worktreesByRepo) + .flat() + .find((entry) => entry.id === wId) + if (!worktree) { + throw new Error('active worktree not found') + } + const separator = worktree.path.includes('\\') ? '\\' : '/' + const relative = `src${separator}diff-note-layout.ts` + const lines = Array.from({ length: 14 }, (_, index) => { + const number = String(index + 1).padStart(2, '0') + const value = index + 1 === 7 ? 'following-line-marker' : `value-${number}` + return `export const line${number} = "${value}"` + }) + await window.api.fs.writeFile({ + filePath: `${worktree.path}${separator}${relative}`, + content: `${lines.join('\n')}\n` + }) + await state.updateSettings({ diffDefaultView: 'side-by-side' }) + return relative + }, worktreeId) + + const added = await orcaPage.evaluate( + ({ wId, filePath, lineNumber, body }) => + window.__store?.getState().addDiffComment({ + worktreeId: wId, + filePath, + source: 'diff', + lineNumber, + body, + side: 'modified' + }), + { wId: worktreeId, filePath: relativePath, lineNumber: NOTE_LINE, body: NOTE_BODY } + ) + expect(added, 'addDiffComment returned null').not.toBeNull() + + await orcaPage.evaluate( + ({ wId, filePath }) => { + const state = window.__store?.getState() + const worktree = Object.values(state?.worktreesByRepo ?? {}) + .flat() + .find((entry) => entry.id === wId) + if (!state || !worktree) { + throw new Error('active worktree not found') + } + const separator = worktree.path.includes('\\') ? '\\' : '/' + state.openDiff( + wId, + `${worktree.path}${separator}${filePath}`, + filePath, + 'typescript', + false + ) + }, + { wId: worktreeId, filePath: relativePath } + ) + + await expect(orcaPage.locator('button:has(svg.lucide-rows-2)')).toBeVisible() + await assertCardClearsFollowingLine(orcaPage) + await attachDiffScreenshot(orcaPage, testInfo, 'side-by-side-diff-note-layout') + + await orcaPage.evaluate(() => + window.__store?.getState().updateSettings({ diffDefaultView: 'inline' }) + ) + await expect(orcaPage.locator('button:has(svg.lucide-columns-2)')).toBeVisible() + await assertCardClearsFollowingLine(orcaPage) + await attachDiffScreenshot(orcaPage, testInfo, 'inline-diff-note-layout') + }) +}) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 27d66ee23e3..b67eb68d7c6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -104,6 +104,7 @@ export default function globalSetup(): void { writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') + writeFileSync(path.join(testRepoDir, 'src', 'diff-note-layout.ts'), 'export const seed = true\n') execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) diff --git a/tests/e2e/helpers/seeded-test-repo.ts b/tests/e2e/helpers/seeded-test-repo.ts index 3ae984193fd..dd88351b282 100644 --- a/tests/e2e/helpers/seeded-test-repo.ts +++ b/tests/e2e/helpers/seeded-test-repo.ts @@ -50,6 +50,7 @@ export function createSeededTestRepo(): string { writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') + writeFileSync(path.join(testRepoDir, 'src', 'diff-note-layout.ts'), 'export const seed = true\n') execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) From 2b244fa0ea7b82e611d33de554a89766489d1baa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:10:04 -0700 Subject: [PATCH 127/150] fix(terminal): clear search highlights when the find bar closes (#10872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing Ctrl+F left one match highlighted until the window was minimized and restored. xterm's DecorationService keys its SortedList on `decoration.marker.line`, but `SortedList.delete()` only records an index and defers compaction, while `Marker.dispose()` sets `line = -1` — mutating that same sort key. After the first disposal the array is no longer sorted, so the binary search inside `delete()` can miss a decoration that is present. It returns false, `onDecorationRemoved` never fires, and the decoration stays live and keeps painting. Repaints don't help; they faithfully re-paint a live decoration, which is why only a window cycle appeared to fix it. `clearDecorations()` disposes the active match before the match highlights, which is exactly the order that trips this. Patch `delete()` to retry once after compacting pending deletions, on the miss path only, so the common bulk delete keeps its O(log n) search and deferred batching. A 3000-trial randomized differential against upstream semantics shows no behavior change for well-ordered lists. --- .../@xterm__xterm@6.1.0-beta.287.patch | 60 +++-- pnpm-lock.yaml | 48 ++-- .../terminal-search-decoration-leak.test.ts | 205 ++++++++++++++++++ 3 files changed, 274 insertions(+), 39 deletions(-) create mode 100644 src/renderer/src/components/terminal-search-decoration-leak.test.ts diff --git a/config/patches/@xterm__xterm@6.1.0-beta.287.patch b/config/patches/@xterm__xterm@6.1.0-beta.287.patch index 3847fbfbb60..e3ee0376a13 100644 --- a/config/patches/@xterm__xterm@6.1.0-beta.287.patch +++ b/config/patches/@xterm__xterm@6.1.0-beta.287.patch @@ -1,25 +1,26 @@ diff --git a/lib/xterm.js b/lib/xterm.js -index 9602d2a2abbdd6eb1ed884dd2cdcace32d1bb1a3..eecf435886d866a838430b9f81f713deed3eb8c1 100644 +index 9602d2a2abbdd6eb1ed884dd2cdcace32d1bb1a3..2a2bab8beb8a2d412f4349b8338ed3235432a1a2 100644 --- a/lib/xterm.js +++ b/lib/xterm.js @@ -1,2 +1,2 @@ -!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,()=>(()=>{"use strict";var e={2840(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new c.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(e=>this._handleResize(e.rows))),this._register(this._terminal.onRender(e=>this._refreshRows(e.start,e.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(e=>this._handleChar(e))),this._register(this._terminal.onLineFeed(()=>this._handleChar("\n"))),this._register(this._terminal.onA11yTab(e=>this._handleTab(e))),this._register(this._terminal.onKey(e=>this._handleKey(e.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register((0,f.addDisposableListener)(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,d.toDisposable)(()=>{this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent=l.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],o=e?.translateToString(!0,void 0,void 0,t)||"",n=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===o.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=o,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",n),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent===l.tooMuchOutput.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,o;if(0===t?(r=i,o=this._rowElements.pop(),this._rowContainer.removeChild(o)):(r=this._rowElements.shift(),o=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),o.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let o=t=this._terminal.cols&&(++s,o=0),{row:s,column:o}},o=r(t),n=r(i);if(o&&n){if(o.row>n.row||o.row===n.row&&o.column>=n.column)throw new Error("invalid range");this._terminal.select(o.column,o.row,(n.row-o.row)*this._terminal.cols-o.column+n.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;ethis._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(e=>this._reportWindowsOptions(e))),this._register(this._inputHandler.onColor(e=>this._handleColorEvent(e))),this._register(I.EventUtils.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(I.EventUtils.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(I.EventUtils.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(I.EventUtils.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(e=>this._afterResize(e.cols,e.rows))),this._register((0,N.toDisposable)(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i;switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=E.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`]${i};${(0,M.toRgbString)(s)}\\`);break;case 1:if("ansi"===e)this._themeService.modifyColors(e=>e.ansi[t.index]=E.channels.toColor(...t.color));else{const i=e;this._themeService.modifyColors(e=>e[i]=E.channels.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index)}}}_reportColorScheme(){if(!this._themeService)return;const e=E.rgb.relativeLuminance(this._themeService.colors.background.rgba>>8)>8)?1:2;this.coreService.triggerDataEvent(`[?997;${e}n`)}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),o=this._renderService.dimensions.css.cell.width*r,n=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=n+"px",this.textarea.style.width=o+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,H.addDisposableListener)(this.element,"copy",e=>{this.hasSelection()&&(0,a.copyHandler)(e,this._selectionService)}));const e=e=>(0,a.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,H.addDisposableListener)(this.textarea,"paste",e)),this._register((0,H.addDisposableListener)(this.element,"paste",e)),x.isFirefox?this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>{2===e.button&&(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register((0,H.addDisposableListener)(this.element,"contextmenu",e=>{(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),x.isLinux&&this._register((0,H.addDisposableListener)(this.element,"auxclick",e=>{1===e.button&&(0,a.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)}))}_bindKeys(){this._register((0,H.addDisposableListener)(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register((0,H.addDisposableListener)(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register((0,H.addDisposableListener)(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register((0,H.addDisposableListener)(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",e=>this.element.classList.toggle("allow-transparency",e))),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,H.addDisposableListener)(this.screenElement,"mousemove",e=>this.updateCursorStyle(e))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",h.promptLabel.get()),x.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(g.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??("undefined"!=typeof window?window.document:null))),this._instantiationService.setService(C.ICoreBrowserService,this._coreBrowserService),this._register((0,H.addDisposableListener)(this.textarea,"focus",e=>this._handleTextAreaFocus(e))),this._register((0,H.addDisposableListener)(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(p.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(C.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(k.ThemeService),this._instantiationService.setService(C.IThemeService,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(v.CharacterJoinerService),this._instantiationService.setService(C.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(w.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(C.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange(e=>this._onRender.fire(e))),this._register(this._renderService.onDimensionsChange(e=>this._onDimensionsChange.fire({css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}))),this.onResize(e=>this._renderService.resize(e.cols,e.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(u.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(S.MouseCoordsService),this._instantiationService.setService(C.IMouseCoordsService,this._mouseCoordsService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(O.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(e){this._logService.error("onWillOpen handler threw an exception",e)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(c.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(y.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(C.ISelectionService,this._selectionService),this._mouseService=this._instantiationService.createInstance(b.MouseService),this._instantiationService.setService(C.IMouseService,this._mouseService),this._register(this._selectionService.onRequestScrollLines(e=>this.scrollLines(e.amount,e.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()})),this._register(I.EventUtils.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(d.BufferDecorationRenderer,this.screenElement)),this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>this._selectionService.handleMouseDown(e))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",e=>this._handleScreenReaderModeOptionChange(e)));const r=this.options.scrollbar?.showScrollbar??!0,o=this.options.scrollbar?.width;r&&o&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",e=>{const t=(e?.showScrollbar??!0)&&!!e?.width;!this._overviewRulerRenderer&&t&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:e=>this._viewport?.handleTouchScroll(e)},e=>this._register(e),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(f.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,a.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),e.preventDefault(),e.stopPropagation(),!1}if(1===i.type&&this.selectAll(),this._isThirdLevelShift(this.browser,e))return!0;if(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)return!0;if(!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;""!==i.key&&"\r"!==i.key||(this.textarea.value="");const s=this._keyboardService.useWin32InputMode&&W(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return;W(e)||this.focus();const t=this._keyboardService.evaluateKeyUp(e);if(t?.key){const i=this._keyboardService.useWin32InputMode&&W(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;efunction(e){const t=l(e);for(t.animFrameRequested=!1,t.current=t.next,t.next=[],t.inAnimationFrameRunner=!0;t.current.length>0;)t.current.sort(a.sort),t.current.shift().execute();t.inAnimationFrameRunner=!1}(e))),r};const s=i(3132);function r(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView;const i=e;return i?.view?i.view:window}class o{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s,e.addEventListener(t,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function n(e,t,i,s){return new o(e,t,i,s)}t.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class a{constructor(e,t){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){console.error(e)}}static sort(e,t){return t.priority-e.priority}}const h=new Map;function l(e){let t=h.get(e);return t||(t={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},h.set(e,t)),t}class c extends s.IntervalTimer{constructor(e){super(),this._defaultTarget=e?r(e):void 0}cancelAndSet(e,t,i){super.cancelAndSet(e,t,i??this._defaultTarget??window)}}t.WindowIntervalTimer=c},8906(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const o=i(4812),n=i(6501),a=i(7098),h=i(8636),l=i(4159);let c=class extends o.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseCoordsService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new h.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new h.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,o.toDisposable)(()=>{(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register((0,l.addDisposableListener)(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register((0,l.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach(e=>{e.link.dispose&&e.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,t=>{if(this._isMouseOut)return;const r=t?.map(e=>({link:e}));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=o;e<=n;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element);e&&this._askForLink(e,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t){const i=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseCoordsService),r(2,a.IRenderService),r(3,n.IBufferService),r(4,a.ILinkProviderService)],c)},7721(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const o={get:()=>r,set:e=>r=e};t.tooMuchOutput=o},3285(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const o=i(3055),n=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new o.CellData}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=this._workCell,n=i.getTrimmedLength();let a=-1,l=-1,c=!1;for(let t=0;tr?r.activate(e,t,d):h(0,t),hover:(e,t)=>r?.hover?.(e,t,d),leave:(e,t)=>r?.leave?.(e,t,d)})}c=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,a=o.extended.urlId):(l=-1,a=-1)}}t(s)}_getRangeWithLineWrap(e,t,i,s){let r=e,o=t,n=e,a=i;for(;0===o;){const e=this._bufferService.buffer.lines.get(r-1);if(!e?.isWrapped)break;const t=this._bufferService.buffer.lines.get(r-2);if(!t)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,i-1,s))break;let n=i-1;for(;n>0&&this._hasUrlId(t,n-1,s);)n--;r--,o=n}for(;;){const e=this._bufferService.buffer.lines.get(n-1);if(!e)break;if(a!==e.getTrimmedLength())break;const t=this._bufferService.buffer.lines.get(n);if(!t?.isWrapped)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,0,s))break;let r=1;for(;rthis._innerRefresh()),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)void 0!==this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],o=t[i/6%6|0],n=t[i%6];e.push({css:s.channels.toCss(r,o,n),rgba:s.channels.toRgba(r,o,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const o=i(7098),n=i(4812),a=i(6501),h=i(4159),l=i(8566),c=i(8636),d=i(7880);let _=class extends n.Disposable{constructor(e,t,i,s,r,o,a,_,u){super(),this._bufferService=i,this._coreService=r,this._optionsService=_,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1,this._needsSyncOnRender=!1;const f=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,h.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{f.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new l.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},f)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(o.onProtocolChange(e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{e.style.backgroundColor=a.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=a.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,n.toDisposable)(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,n.toDisposable)(()=>this._styleElement.remove())),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${a.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${a.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${a.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(e=>this._handleScroll(e)))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){const e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,i=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:i,verticalHasArrows:t}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._coreService.decPrivateModes.synchronizedOutput?this._needsSyncOnRender=!0:(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1))}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}handleTouchScroll(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};t.Viewport=_,t.Viewport=_=s([r(2,a.IBufferService),r(3,o.ICoreBrowserService),r(4,a.ICoreService),r(5,a.IMouseStateService),r(6,o.IThemeService),r(7,a.IOptionsService),r(8,o.IRenderService)],_)},4196(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const o=i(7098),n=i(4812),a=i(6501);let h=class extends n.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(e=>this._removeDecoration(e))),this._register((0,n.toDisposable)(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h,t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,o.ICoreBrowserService),r(3,a.IDecorationService),r(4,o.IRenderService)],h)},957(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const o=i(957),n=i(7098),a=i(4812),h=i(6501),l={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=class extends a.Disposable{get _width(){const e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??1?e?.width??0:0}constructor(e,t,i,s,r,n,h,l){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=h,this._coreBrowserService=l,this._colorZoneStore=new o.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)(()=>this._canvas?.remove()));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register((0,a.toDisposable)(()=>{void 0!==this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;const e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_,t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,n.IRenderService),r(5,h.IOptionsService),r(6,n.IThemeService),r(7,n.ICoreBrowserService)],_)},3618(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const o=i(7098),n=i(6501);let a=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,o){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=o,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent=""}compositionstart(){this._isComposing=!0;const e=this._textarea.selectionStart??this._textarea.value.length,t=this._textarea.selectionEnd??e;this._compositionPosition.start=Math.min(e,t),this._compositionPosition.end=Math.max(e,t),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=`‎${e.data}‎`,this.updateCompositionElements(),setTimeout(()=>{const e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){let i;if(this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const s=this._textarea.value,r=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;i=s.substring(e.start,Math.max(e.start,r))}i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;const e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0),0)}}};t.CompositionHelper=a,t.CompositionHelper=a=s([r(2,n.IBufferService),r(3,n.IOptionsService),r(4,n.ICoreService),r(5,o.IRenderService)],a)},5251(e,t){function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),o=parseInt(r.getPropertyValue("padding-left"),10),n=parseInt(r.getPropertyValue("padding-top"),10);return[t.clientX-s.left-o,t.clientY-s.top-n]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,o,n,a,h,l){if(!n)return;const c=i(e,t,s);return c[0]=Math.ceil((c[0]+(l?a/2:0))/a),c[1]=Math.ceil(c[1]/h),c[0]=Math.min(Math.max(c[0],1),r+(l?1:0)),c[1]=Math.min(Math.max(c[1],1),o),c}},9686(e,t){function i(e,t,i,o){const h=e-s(e,i),l=t-s(t,i),c=Math.abs(h-l)-function(e,t,i){let o=0;const n=e-s(e,i),a=t-s(t,i);for(let s=0;s=0&&et?"A":"B"}function o(e,t,i,s,r,o){let n=e,a=t,h="";for(;(n!==i||a!==s)&&a>=0&&ao.cols-1?(h+=o.buffer.translateBufferLineToString(a,!1,e,n),n=0,e=0,a++):!r&&n<0&&(h+=o.buffer.translateBufferLineToString(a,!1,0,e+1),n=o.cols-1,e=n,a--);return h+o.buffer.translateBufferLineToString(a,!1,e,n)}function n(e,t){return""+(t?"O":"[")+e}function a(e,t){e=Math.floor(e);let i="";for(let s=0;s0?h-s(h,l):t;const _=h,u=function(e,t,r,o,n,a){let h;return h=i(t,o,n,a).length>0?o-s(o,n):t,e=r&&he?"D":"C",a(Math.abs(l-e),n(d,h));d=c>t?"D":"C";const _=Math.abs(c-t);return a(function(e,t){return t.cols-e}(c>t?e:l,r)+(_-1)*r.cols+1+((c>t?l:e)-1),n(d,h))}},6081(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(f.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new _.ParserApi(this._core)}get unicode(){return this._checkProposedApi(),new u.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new d.BufferNamespaceApi(this._core))}get markers(){return this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.mouseStateService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return a.promptLabel.get()},set promptLabel(e){a.promptLabel.set(e)},get tooMuchOutput(){return a.tooMuchOutput.get()},set tooMuchOutput(e){a.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(p of e)if(p===1/0||isNaN(p)||p%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(p of e)if(p&&(p===1/0||isNaN(p)||p%1!=0||p<0))throw new Error("This API only accepts positive integers")}}t.Terminal=v},3955(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const o=i(1433),n=i(2744),a=i(9176),h=i(6181),l=i(2274),c=i(654),d=i(7098),_=i(4103),u=i(4812),f=i(6501),p=i(8636),v=i(4159);let g=1,m=class extends u.Disposable{constructor(e,t,i,s,r,a,d,_,f,m,b,w,y,C){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=d,this._charSizeService=f,this._optionsService=m,this._bufferService=b,this._coreService=w,this._coreBrowserService=y,this._themeService=C,this._terminalClass=g++,this._rowElements=[],this._selectionRenderModel=(0,l.createSelectionRenderModel)(),this._lastSelectionColumnMode=!1,this._rowHasBlinkingCells=[],this._rowHasBlinkingCellsCount=0,this._onRequestRedraw=this._register(new p.Emitter),this.onRequestRedraw=this._onRequestRedraw.event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(e=>this._injectCss(e))),this._injectCss(this._themeService.colors),this._rowFactory=_.createInstance(o.DomRendererRowFactory,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(e=>this._handleLinkHover(e))),this._register(this._linkifier2.onHideLinkUnderline(e=>this._handleLinkLeave(e))),this._cursorBlinkStateManager=new S(this._rowContainer,this._coreBrowserService),this._register((0,v.addDisposableListener)(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register((0,u.toDisposable)(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new c.TextBlinkStateManager(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register((0,u.toDisposable)(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new n.WidthCache,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${_.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${i} { color: ${s.css}; }${this._terminalSelector} .xterm-fg-${i}.xterm-dim { color: ${_.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .xterm-bg-${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR} { color: ${_.color.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${_.color.multiplyOpacity(_.color.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,i){const s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i);let r=0,o=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(r=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow));let n=0,a=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),this._selectionRenderModel.hasSelection){const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,o=this._selectionRenderModel.viewportCappedStartRow,h=this._selectionRenderModel.viewportCappedEndRow;n=o,a=h;const l=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];l.appendChild(this._createSelectionElement(o,i?t[0]:e[0],i?e[0]:t[0],h-o+1))}else{const i=s===o?e[0]:0,n=o===r?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(o,i,n));const a=h-o-1;if(l.appendChild(this._createSelectionElement(o+1,0,this._bufferService.cols,a)),o!==h){const e=r===h?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(h,0,e))}}this._selectionContainer.appendChild(l)}let h=Math.min(r,n),l=Math.max(o,a);if(l>=0){h=Math.max(h,0),l=Math.min(l,s-1);const e=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&e>=0&&ethis.dimensions.css.canvas.width&&(n=this.dimensions.css.canvas.width-o),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${o}px`,r.style.width=`${n}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),o=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,n=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle,h={hasBlinkingCells:!1};for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l];if(!t)continue;const c=i.lines.get(e);c?(t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,n,a,r,o,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,h)),this._setRowBlinkState(l,h.hasBlinkingCells)):(t.replaceChildren(),this._setRowBlinkState(l,!1))}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,o){i<0&&(e=0),s<0&&(t=0);const n=this._bufferService.rows-1;i=Math.max(Math.min(i,n),0),s=Math.max(Math.min(s,n),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,l=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle,u={hasBlinkingCells:!1};for(let n=i;n<=s;++n){const f=n+a.ydisp,p=this._rowElements[n];if(!p)continue;const v=a.lines.get(f);v?(p.replaceChildren(...this._rowFactory.createRow(v,f,f===h,d,_,l,c,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,o?n===i?e:0:-1,o?(n===s?t:r)-1:-1,u)),this._setRowBlinkState(n,u.hasBlinkingCells)):(p.replaceChildren(),this._setRowBlinkState(n,!1))}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};t.DomRenderer=m,t.DomRenderer=m=s([r(7,f.IInstantiationService),r(8,d.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,f.ICoreService),r(12,d.ICoreBrowserService),r(13,d.IThemeService)],m);class S{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){void 0!==this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}}},1433(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const o=i(9176),n=i(8938),a=i(3055),h=i(6501),l=i(4103),c=i(7098),d=i(945),_=i(6181),u=i(5451);let f=class{constructor(e,t,i,s,r,o,n){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=o,this._themeService=n,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,c,_,f,p,v,g){const m=[];g&&(g.hasBlinkingCells=!1);const S=this._characterJoinerService.getJoinedCharacters(t),b=this._themeService.colors;let w,y=e.getNoBgTrimmedLength();i&&y=A,F=I,W=this._workCell;if(S.length>0&&I===S[0][0]&&N){const s=S.shift(),r=this._isCellInSelection(s[0],t);for(C=s[0]+1;C=s[1],N?(H=!0,W=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),F=s[1]-1,y=W.getWidth()):A=s[1]}const z=this._isCellInSelection(I,t),K=i&&I===a,U=O&&I>=p&&I<=v;g&&W.isBlink()&&(g.hasBlinkingCells=!0),!c&&W.isBlink()&&P.push("xterm-blink-hidden");let j=!1;this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{j=!0});let $=W.getChars()||n.WHITESPACE_CELL_CHAR;if(" "===$&&(W.isUnderline()||W.isOverline())&&($=" "),k=y*_-f.get($,W.isBold(),W.isItalic()),w){if(D&&(z&&T||!z&&!T&&W.bg===L)&&(z&&T&&b.selectionForeground||W.fg===x)&&W.extended.ext===R&&U===M&&k===B&&!K&&!H&&!j&&N){W.isInvisible()?E+=n.WHITESPACE_CELL_CHAR:E+=$,D++;continue}D&&(w.textContent=E),w=this._document.createElement("span"),D=0,E=""}else w=this._document.createElement("span");if(L=W.bg,x=W.fg,R=W.extended.ext,M=U,B=k,T=z,H&&a>=I&&a<=F&&(a=I),!this._coreService.isCursorHidden&&K&&this._coreService.isCursorInitialized)if(P.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&P.push("xterm-cursor-blink"),P.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":P.push("xterm-cursor-outline");break;case"block":P.push("xterm-cursor-block");break;case"bar":P.push("xterm-cursor-bar");break;case"underline":P.push("xterm-cursor-underline")}if(W.isBold()&&P.push("xterm-bold"),W.isItalic()&&P.push("xterm-italic"),W.isDim()&&P.push("xterm-dim"),E=W.isInvisible()?n.WHITESPACE_CELL_CHAR:W.getChars()||n.WHITESPACE_CELL_CHAR,W.isUnderline()&&(P.push(`xterm-underline-${W.extended.underlineStyle}`)," "===E&&(E=" "),!W.isUnderlineColorDefault()))if(W.isUnderlineColorRGB())w.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(W.getUnderlineColor()).join(",")})`;else{let e=W.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&W.isBold()&&e<8&&(e+=8),w.style.textDecorationColor=b.ansi[e].css}W.isOverline()&&(P.push("xterm-overline")," "===E&&(E=" ")),W.isStrikethrough()&&P.push("xterm-strikethrough"),U&&(w.style.textDecoration="underline");let q=W.getFgColor(),V=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();const G=!!W.isInverse();if(G){const e=q;q=X,X=e;const t=V;V=Y,Y=t}let J,Z,Q,ee=!1;switch(this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{"top"!==e.options.layer&&ee||(e.backgroundColorRGB&&(Y=50331648,X=e.backgroundColorRGB.rgba>>8&16777215,J=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,q=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),ee="top"===e.options.layer)}),!ee&&z&&(J=this._coreBrowserService.isFocused?b.selectionBackgroundOpaque:b.selectionInactiveBackgroundOpaque,X=J.rgba>>8&16777215,Y=50331648,ee=!0,b.selectionForeground&&(V=50331648,q=b.selectionForeground.rgba>>8&16777215,Z=b.selectionForeground)),ee&&P.push("xterm-decoration-top"),Y){case 16777216:case 33554432:Q=b.ansi[X],P.push(`xterm-bg-${X}`);break;case 50331648:Q=l.channels.toColor(X>>16,X>>8&255,255&X),this._addStyle(w,`background-color:#${(X>>>0).toString(16).padStart(6,"0")}`);break;default:G?(Q=b.foreground,P.push(`xterm-bg-${o.INVERTED_DEFAULT_COLOR}`)):Q=b.background}switch(J||W.isDim()&&(J=l.color.multiplyOpacity(Q,.5)),V){case 16777216:case 33554432:W.isBold()&&q<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(q+=8),this._applyMinimumContrast(w,Q,b.ansi[q],W,J,void 0)||P.push(`xterm-fg-${q}`);break;case 50331648:const e=l.channels.toColor(q>>16&255,q>>8&255,255&q);this._applyMinimumContrast(w,Q,e,W,J,Z)||this._addStyle(w,`color:#${q.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(w,Q,b.foreground,W,J,Z)||G&&P.push(`xterm-fg-${o.INVERTED_DEFAULT_COLOR}`)}P.length&&(w.className=P.join(" "),P.length=0),K||H||j||!N?w.textContent=E:D++,k!==this.defaultSpacing&&(w.style.letterSpacing=`${k}px`),m.push(w),I=F}return w&&D&&(w.textContent=E),m}_applyMinimumContrast(e,t,i,s,r,o){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const n=this._getContrastCache(s);let a;if(r||o||(a=n.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=l.color.ensureContrastRatio(r??t,o??i,e),n.setColor((r??t).rgba,(o??i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};t.DomRendererRowFactory=f,t.DomRendererRowFactory=f=s([r(1,c.ICharacterJoinerService),r(2,h.IOptionsService),r(3,c.ICoreBrowserService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,c.IThemeService)],f)},2744(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0;const s=i(6181);t.WidthCache=class{constructor(e=()=>new r){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let o=this._holey.get(r);if(void 0===o){let s=0;t&&(s|=1),i&&(s|=2),o=this._measure(e,s),o>0&&this._holey.set(r,o)}return o}_measure(e,t){return this._canvasElements[t].measure(e)}};class r{constructor(){"undefined"!=typeof OffscreenCanvas?(this._canvas=new OffscreenCanvas(1,1),this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d")))}setFont(e,t,i,s){const r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}}},9176(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181(e,t){function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,o){return 1===t&&r>Math.ceil(1.5*o)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,o=t[1]-r,n=i[1]-r,a=Math.max(o,0),h=Math.min(n,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},654(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.TextBlinkStateManager=void 0;const s=i(4812);class r extends s.Disposable{constructor(e,t,i){super(),this._renderCallback=e,this._coreBrowserService=t,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",e=>{this.setIntervalDuration(e)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const e=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(e||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}t.TextBlinkStateManager=r},8501(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._domNodePointerDown(e)))}_createArrow(e){const t=this._register(new c.ScrollbarArrow(e));return this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode),t}_createSlider(e,t,i,s){this.slider=new h.FastDomNode(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(a.addDisposableListener(this.slider.domNode,a.eventType.POINTER_DOWN,e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))})),this._onclick(this.slider.domNode,e=>{e.leftButton&&e.stopPropagation()})}_handleElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._handlePointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._handlePointerDown(e)}_handlePointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=a.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{const r=this._sliderOrthogonalPointerPosition(e),o=Math.abs(r-i);if(u.isWindows&&o>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const n=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(n))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=f},1203(e,t){function i(e){return"number"==typeof e?`${e}px`:e}Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.FastDomNode=class{constructor(e){this.domNode=e,this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._className="",this._position="",this._layerHint=!1,this._contain="none"}setWidth(e){const t=i(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=i(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=i(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=i(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=i(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=i(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=e?"translate3d(0px, 0px, 0px)":"")}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}}},928(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{try{e.releasePointerCapture(t)}catch{}}))}catch{o=a.getWindow(e)}this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_MOVE,e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)})),this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_UP,e=>this.stopMonitoring(!0)))}}},9699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.horizontalScrollbarSize:0,2===t.horizontal?0:t.horizontalScrollbarSize,2===t.vertical?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"xterm-horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._handleElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(2===e.horizontal?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},3988(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;ni&&(s=i-t),s<0&&(s=0),r<0&&(r=0),n+r>o&&(n=o-r),n<0&&(n=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=o,this.scrollTop=n}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,o=this.height!==e.height,n=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:o,scrollHeightChanged:n,scrollTopChanged:a}}}t.ScrollState=o;class n extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0!==this._smoothScrollDuration){if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new l(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):l.start(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=l.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}else this.setScrollPositionNow(e)}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=n;class a{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}class l{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this._scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this._scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let n,a;return e0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),o=Math.abs(t.deltaX),n=Math.abs(t.deltaY),a=Math.max(Math.min(s,o),1),h=Math.max(Math.min(r,n),1),l=Math.max(s,o),c=Math.max(r,n);l%a===0&&c%h===0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}S.INSTANCE=new S;class b extends _.Widget{get options(){return this._options}constructor(e,t,i){let s;super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,t=t??{};const r=!i;i?s=i:(t.mouseWheelSmoothScroll=!1,s=new g.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)})),this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:1,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:1,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,v.isMac&&(t.className+=" xterm-mac"),t}(t),this._scrollable=s,this._register(this._scrollable.onScroll(e=>{this._handleScroll(e),this._onScroll.fire(e)})),r&&this._register(this._scrollable);const o={handleMouseWheel:e=>this._handleMouseWheel(e),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,o)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,o)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,e=>this._handleMouseOver(e)),this._onmouseleave(this._listenOnDomNode,e=>this._handleMouseLeave(e)),this._hideTimeout=this._register(new u.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,v.isMac&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalHasArrows&&(this._options.horizontalHasArrows=e.horizontalHasArrows),void 0!==e.verticalHasArrows&&(this._options.verticalHasArrows=e.verticalHasArrows),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new l.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._handleMouseWheel(new l.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.eventType.MOUSE_WHEEL,e,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const o=!v.isMac&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!o||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const n=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=n.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=n.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(n.scrollLeft!==a.scrollLeft||n.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" xterm-shadow-left":"",r=t?" xterm-shadow-top":"",o=i||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${r}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${o}${r}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet(()=>this._hide(),500)}}t.SmoothScrollableElement=b},9594(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._arrowPointerDown(e))),this._register(c.addStandardDisposableListener(this.domNode,c.eventType.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._pointerdownRepeatTimer=this._register(new c.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,c.getWindow(e))},200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault())}}t.ScrollbarArrow=d},1270(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,o){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setArrowSize(e){const t=Math.round(e);this._arrowSize!==t&&(this._arrowSize=t,this._refreshComputedValues())}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const o=Math.max(0,i-e),n=Math.max(0,o-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(n),computedSliderRatio:0,computedSliderPosition:0};const h=Math.round(Math.max(20,Math.floor(i*n/s))),l=(n-h)/(s-i),c=r*l;return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(h),computedSliderRatio:l,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" xterm-fade":"")))}}t.ScrollbarVisibilityController=o},2650(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{s||(s=!0,this._remove(i))}}_remove(e){if(e.prev!==_.Undefined&&e.next!==_.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===_.Undefined&&e.next===_.Undefined?(this._first=_.Undefined,this._last=_.Undefined):e.next===_.Undefined?(this._last=this._last.prev,this._last.next=_.Undefined):e.prev===_.Undefined&&(this._first=this._first.next,this._first.prev=_.Undefined)}*[Symbol.iterator](){let e=this._first;for(;e!==_.Undefined;)yield e.element,e=e.next}}var f;!function(e){e.TAP="-xterm-gesturetap",e.CHANGE="-xterm-gesturechange",e.START="-xterm-gesturestart",e.END="-xterm-gesturesend",e.CONTEXT_MENU="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends l.Disposable{constructor(){super(),this._dispatched=!1,this._targets=new u,this._ignoreTargets=new u,this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;const e=c;this._register(h.addDisposableListener(e.document,"touchstart",e=>this._handleTouchStart(e),{passive:!1})),this._register(h.addDisposableListener(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(h.addDisposableListener(e.document,"touchmove",e=>this._handleTouchMove(e),{passive:!1}))}static addTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._targets.push(e);return(0,l.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._ignoreTargets.push(e);return(0,l.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in c||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){const t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let i=0,s=e.targetTouches.length;i=p._holdDelay&&Math.abs(n.initialPageX-d(n.rollingPageX))<30&&Math.abs(n.initialPageY-d(n.rollingPageY))<30){const e=this._newGestureEvent(f.CONTEXT_MENU,n.initialTarget);e.pageX=d(n.rollingPageX),e.pageY=d(n.rollingPageY),this._dispatchEvent(e)}else if(1===s){const t=d(n.rollingPageX),s=d(n.rollingPageY),r=d(n.rollingTimestamps)-n.rollingTimestamps[0],o=t-n.rollingPageX[0],a=s-n.rollingPageY[0],h=[...this._targets].filter(e=>n.initialTarget instanceof Node&&e.contains(n.initialTarget));this._inertia(e,h,i,Math.abs(o)/r,o>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this._dispatchEvent(this._newGestureEvent(f.END,n.initialTarget)),delete this._activeTouches[o.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}_dispatchEvent(e){if(e.type===f.TAP){const t=(new Date).getTime();let i;i=t-this._lastSetTapCountTime>p._clearTapCountTime?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.CHANGE&&e.type!==f.CONTEXT_MENU||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this._ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this._targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort((e,t)=>e[0]-t[0]);for(const[,i]of t)i.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,i,s,r,o,n,a,l){this._handle=h.scheduleAtNextAnimationFrame(e,()=>{const h=Date.now(),c=h-i;let d=0,_=0,u=!0;s+=p._scrollFriction*c,n+=p._scrollFriction*c,s>0&&(u=!1,d=r*s*c),n>0&&(u=!1,_=a*n*c);const v=this._newGestureEvent(f.CHANGE);v.translationX=d,v.translationY=_,t.forEach(e=>e.dispatchEvent(v)),u||this._inertia(e,t,h,s,r,o+d,n,a,l+_)})}_handleTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}}t.Gesture=p,p._scrollFriction=-.005,p._holdDelay=700,p._clearTapCountTime=400,n([function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r||!s)throw new Error("not supported");const o=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(o)||Object.defineProperty(this,o,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[o]}}],p,"isTouchDevice",null)},8997(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition(),n=t.verticalHasArrows;super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(n?t.verticalScrollbarSize:0,2===t.vertical?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"xterm-vertical",scrollable:e,scrollByPage:t.scrollByPage}),this._arrowScrollDelta=0,this._setArrows(n,t.verticalScrollbarSize),this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._handleElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}_arrowScroll(e){const t=this._scrollable.getCurrentScrollPosition();this._scrollable.setScrollPositionNow({scrollTop:t.scrollTop+e})}_setArrows(e,t){if(this._arrowScrollDelta=t,!this._arrowUp||!this._arrowDown){const e=0;this._arrowUp=this._createArrow({className:"xterm-scra xterm-arrow-up",top:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})}if(this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;const i=e?"":"none";this._arrowUp.bgDomNode.style.display=i,this._arrowUp.domNode.style.display=i,this._arrowDown.bgDomNode.style.display=i,this._arrowDown.domNode.style.display=i}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){const t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},7741(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nt(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseover(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_OVER,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseleave(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_LEAVE,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}}t.Widget=c},5959(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):!!(this.selectionStart&&this.selectionStart[1]<0)&&(this.selectionStart=[0,0],!0)}}},4792(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const o=i(6501),n=i(4812),a=i(8636);let h=class extends n.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h,t.CharSizeService=h=s([r(2,o.IOptionsService)],h);class l extends n.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends l{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends l{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},o=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(5451),a=i(8938),h=i(3055),l=i(6501);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new h.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;tthis._screenDprMonitor.setWindow(e))),this._register(s.EventUtils.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",()=>this._isFocused=!0)),this._register((0,r.addDisposableListener)(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}}t.CoreBrowserService=n;class a extends o.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new o.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,o.toDisposable)(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},2136(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.KeyboardService=void 0;const o=i(706),n=i(7241),a=i(9249),h=i(701),l=i(6501);let c=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??=new a.Win32InputMode,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new n.KittyKeyboard,this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);const t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):(0,o.evaluateKeyboardEvent)(e,this._coreService.decPrivateModes.applicationCursorKeys,h.isMac,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);const t=this._coreService.kittyKeyboard.flags;return this.useKitty&&2&t?this._getKittyKeyboard().evaluate(e,t,3,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):void 0}get useKitty(){const e=this._coreService.kittyKeyboard.flags;return!(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard||!n.KittyKeyboard.shouldUseProtocol(e))}get useWin32InputMode(){return!(!this._optionsService.rawOptions.vtExtensions?.win32InputMode||!this._coreService.decPrivateModes.win32InputMode)}};t.KeyboardService=c,t.KeyboardService=c=s([r(0,l.ICoreService),r(1,l.IOptionsService)],c)},9820(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(4812);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8294(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseCoordsService=void 0;const o=i(4159),n=i(5251),a=i(7098);let h=class{constructor(e,t){this._charSizeService=e,this._renderService=t}getCoords(e,t,i,s,r){return(0,n.getCoords)((0,o.getWindow)(t),e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,n.getCoordsRelativeToElement)((0,o.getWindow)(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseCoordsService=h,t.MouseCoordsService=h=s([r(0,a.ICharSizeService),r(1,a.IRenderService)],h)},9784(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AltMouseCursorController=t.MouseService=void 0;const o=i(4159),n=i(6501),a=i(4812),h=i(7098),l=i(2650);let c=class{constructor(e,t,i,s,r,o,n,a,h){this._renderService=e,this._mouseCoordsService=t,this._mouseStateService=i,this._coreService=s,this._bufferService=r,this._optionsService=o,this._selectionService=n,this._logService=a,this._coreBrowserService=h,this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}bindMouse(e,t,i){const{element:s,document:r}=e,n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},h={target:e,focus:i,requestedEvents:n},c={mouseup:e=>this._handleMouseUp(h,e),wheel:e=>this._handleWheel(h,e),mousedrag:e=>this._handleMouseDrag(h,e),mousemove:e=>this._handleMouseMove(h,e)};this._altMouseCursor=new d(s,r,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),t(this._altMouseCursor),t(this._mouseStateService.onProtocolChange(e=>{this._handleProtocolChange(h,c,e)})),t(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(s),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,t((0,a.toDisposable)(()=>{n.mouseup&&r.removeEventListener("mouseup",n.mouseup),n.mousedrag&&r.removeEventListener("mousemove",n.mousedrag)})),t((0,o.addDisposableListener)(s,"mousedown",e=>this._handleMouseDown(h,e))),t((0,o.addDisposableListener)(s,"wheel",e=>this._handlePassiveWheel(h,e),{passive:!1})),t(l.Gesture.addTarget(e.screenElement)),t((0,o.addDisposableListener)(e.screenElement,l.EventType.START,()=>this._handleTouchStart())),t((0,o.addDisposableListener)(e.screenElement,l.EventType.CHANGE,e=>this._handleTouchChange(h,e)))}_sendEvent(e,t){const i=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;const e=t.deltaY;if(0===e)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return!1;r=e<0?0:1,s=4;break;default:return!1}if(void 0===r||void 0===s||s>4)return!1;if(4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!t.altKey)return!1;const o=4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:!o&&t.altKey,shift:t.shiftKey})}_handleMouseUp(e,t){this._sendEvent(e,t),t.buttons||(e.requestedEvents.mouseup&&e.target.document.removeEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.removeEventListener("mousemove",e.requestedEvents.mousedrag))}_handleWheel(e,t){return this._sendEvent(e,t),t.preventDefault(),t.stopPropagation(),!1}_handleMouseDrag(e,t){t.buttons&&this._sendEvent(e,t)}_handleMouseMove(e,t){t.buttons||this._sendEvent(e,t)}_handleMouseDown(e,t){t.preventDefault(),e.focus(),this._mouseStateService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(t)&&(this._sendEvent(e,t),e.requestedEvents.mouseup&&e.target.document.addEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.addEventListener("mousemove",e.requestedEvents.mousedrag))}_handlePassiveWheel(e,t){if(!e.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;if(!this._bufferService.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return t.preventDefault(),t.stopPropagation(),!1;const e=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(e,!0),t.preventDefault(),t.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(e,t){t.preventDefault(),t.stopPropagation(),e.requestedEvents.wheel?this._handleTouchScrollAsWheel(e,t):this._bufferService.buffer.hasScrollback?e.target.handleTouchScroll?.(t.translationY):this._handleTouchScrollAsKeys(t)}_handleTouchScrollAsKeys(e){const t=this._renderService?.dimensions.css.cell.height;if(!t)return;this._touchScrollAccumulator-=e.translationY;const i=Math.trunc(this._touchScrollAccumulator/t);if(0===i)return;this._touchScrollAccumulator-=i*t;const s=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(i<0?"A":"B");for(let e=0;e0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._mouseStateService.isPixelEncoding))return!1;if(!this._mouseStateService.restrictMouseEvent(e))return!1;const t=this._mouseStateService.encodeMouseEvent(e);return t&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}_explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.MouseService=c,t.MouseService=c=s([r(0,h.IRenderService),r(1,h.IMouseCoordsService),r(2,n.IMouseStateService),r(3,n.ICoreService),r(4,n.IBufferService),r(5,n.IOptionsService),r(6,h.ISelectionService),r(7,n.ILogService),r(8,h.ICoreBrowserService)],c);class d{constructor(e,t,i){this._element=e,this._document=t,this._isActive=i,this._listeners=new a.MutableDisposable}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;const e=new a.DisposableStore,t=e=>this.syncFromModifier(e);e.add((0,o.addDisposableListener)(this._document,"keydown",t)),e.add((0,o.addDisposableListener)(this._document,"keyup",t)),e.add((0,o.addDisposableListener)(this._element,"mousemove",t));const i=this._element.ownerDocument?.defaultView;i&&e.add((0,o.addDisposableListener)(i,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=e}resetClass(){this._updateClass(!1)}syncFromModifier(e){this._isActive()&&this._updateClass(e.getModifierState("Alt"))}_updateClass(e){e?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}}t.AltMouseCursorController=d},5783(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const o=i(4852),n=i(7098),a=i(4812),h=i(6168),l=i(6501),c=i(8636);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,n,l,d,u,f){super(),this._rowCount=e,this._optionsService=i,this._logService=s,this._charSizeService=r,this._coreService=n,this._coreBrowserService=u,this._renderer=this._register(new a.MutableDisposable),this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._pausedResizeTask=this._register(new h.DebouncedIdleTask(this._logService)),this._renderDebouncer=new o.RenderDebouncer((e,t)=>this._renderRows(e,t),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new _(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register((0,a.toDisposable)(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(d.onResize(()=>this._fullRefresh())),this._register(d.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(d.cols,d.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(d.buffer.y,d.buffer.y,void 0,!0))),this._register(f.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(e=>this._registerIntersectionObserver(e,t)))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver(e=>this._handleIntersectionChange(e[e.length-1]),{threshold:0});this._observerDisposable.value=(0,a.toDisposable)(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=i,i.observe(t)}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(e=>this.refreshRows(e.start,e.end,e.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,l.IOptionsService),r(3,l.ILogService),r(4,n.ICharSizeService),r(5,l.ICoreService),r(6,l.IDecorationService),r(7,l.IBufferService),r(8,n.ICoreBrowserService),r(9,n.IThemeService)],d);class _{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(e=>this._handleTrim(e)),this._register(this._bufferService.buffers.onBufferActivate(e=>this._handleBufferActivate(e))),this.enable(),this._model=new d.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,u.toDisposable)(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(e=>{e.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(b," ")).join(f.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),f.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:f.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(2===e.button&&this.hasSelection||0!==e.button||this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){const t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);const i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return(!this._optionsService.rawOptions.mouseEventsRequireAlt||!this._mouseStateService.areMouseEventsActive)&&e.altKey&&!(f.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,c.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(e=>this._handleTrim(e))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,o=r.lines.get(e[1]);if(!o)return;const n=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(o,e[0]),h=a;const l=e[0]-a;let c=0,d=0,_=0,u=0;if(" "===n.charAt(a)){for(;a>0&&" "===n.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(o.loadCell(t-1,this._workCell));){o.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+l-c+_,p=Math.min(this._bufferService.cols,h-a+c+d-_-u);if(t||""!==n.slice(a,h).trim()){if(i&&0===f&&32!==o.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&o.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==o.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=w,t.SelectionService=w=n([h(3,g.IBufferService),h(4,g.ICoreService),h(5,_.IMouseCoordsService),h(6,g.IOptionsService),h(7,g.IMouseStateService),h(8,_.IRenderService),h(9,_.ICoreBrowserService)],w)},7098(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IKeyboardService=t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.IMouseCoordsService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseCoordsService=(0,s.createDecorator)("MouseCoordsService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService"),t.IKeyboardService=(0,s.createDecorator)("KeyboardService")},9078(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const o=i(7174),n=i(9302),a=i(4103),h=i(4812),l=i(6501),c=i(8636),d=a.css.toColor("#ffffff"),_=a.css.toColor("#000000"),u=a.css.toColor("#ffffff"),f=_,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},v=d;let g=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new o.ColorContrastCache,this._halfContrastCache=new o.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:_,cursor:u,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(_,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(_,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:n.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}_setTheme(e={}){const t=this._colors;if(t.foreground=m(e.foreground,d),t.background=m(e.background,_),t.cursor=a.color.blend(t.background,m(e.cursor,u)),t.cursorAccent=a.color.blend(t.background,m(e.cursorAccent,f)),t.selectionBackgroundTransparent=m(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=m(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?m(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=m(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=m(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=m(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=m(e.overviewRulerBorder,v),t.ansi=n.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=m(e.black,n.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=m(e.red,n.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=m(e.green,n.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=m(e.yellow,n.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=m(e.blue,n.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=m(e.magenta,n.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=m(e.cyan,n.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=m(e.white,n.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=m(e.brightBlack,n.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=m(e.brightRed,n.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=m(e.brightGreen,n.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=m(e.brightYellow,n.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=m(e.brightBlue,n.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=m(e.brightMagenta,n.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=m(e.brightCyan,n.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=m(e.brightWhite,n.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;ssetTimeout(t,e))},t.disposableTimeout=function(e,t=0,i){const r=setTimeout(()=>{e(),i&&o.dispose()},t),o=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(o),o};const s=i(4812);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{e()},t);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},5639(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(4812),r=i(8636);class o extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(n||(t.channels=n={})),function(e){function t(e,t){return o=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:n.toCss(i,s,r,o),rgba:n.toRgba(i,s,r,o)}}e.blend=function(e,t){if(o=(255&t.rgba)/255,1===o)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,l=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),{css:n.toCss(i,s,r),rgba:n.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return n.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:n.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return o=255&e.rgba,t(e,o*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o=parseInt(e.slice(4,5).repeat(2),16),n.toColor(i,s,r,o);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1],10),s=parseInt(h[2],10),r=parseInt(h[3],10),o=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),n.toColor(i,s,r,o);if("transparent"===e)return{css:"transparent",rgba:0};if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,o]=t.getImageData(0,0,1,1).data,255!==o)throw new Error("css.toColor: Unsupported css format");return{rgba:n.toRgba(i,s,r,o),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,o=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(l||(t.rgb=l={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c0||a>0||h>0);)n-=Math.max(0,Math.ceil(.1*n)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));return(n<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c>>0}e.blend=function(e,t){if(o=(255&t)/255,1===o)return t;const a=t>>24&255,h=t>>16&255,l=t>>8&255,c=e>>24&255,d=e>>16&255,_=e>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),n.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=l.relativeLuminance(e>>8),o=l.relativeLuminance(i>>8);if(_(r,o)>8));if(n_(r,l.relativeLuminance(t>>8))?o:t}return o}const n=a(e,i,s),h=_(r,l.relativeLuminance(n>>8));if(h_(r,l.relativeLuminance(o>>8))?n:o}return n}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),o=i(7276),n=i(9640),a=i(56),h=i(4071),l=i(6478),c=i(7428),d=i(6415),_=i(5746),u=i(5882),f=i(2486),p=i(3562),v=i(8811),g=i(8636),m=i(4812);let S=!1;class b extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new g.Emitter),this.onRender=this._onRender.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(n.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(l.MouseStateService)),this._instantiationService.setService(s.IMouseStateService,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(d.UnicodeService)),this.unicodeService.register(new c.UnicodeV6),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(_.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(v.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new f.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(g.EventUtils.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(g.EventUtils.forward(this._bufferService.onResize,this._onResize)),this._register(g.EventUtils.forward(this.coreService.onData,this._onData)),this._register(g.EventUtils.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new p.WriteBuffer((e,t)=>this._inputHandler.parse(e,t))),this._register(g.EventUtils.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!S&&(this._logService.warn("writeSync is unreliable and will be removed soon."),S=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.backend&&void 0!==t.buildNumber&&(e=!!("conpty"===t.backend&&t.buildNumber<21376)),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)(()=>{for(const t of e)t.dispose()})}}}t.CoreTerminal=b},8636(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const s=i(4812);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners.push(r);const o=(0,s.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&this._listeners.splice(e,1)});return i&&(Array.isArray(i)?i.push(o):i.add(o)),o}),this._event}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:t,thisArgs:i}=this._listeners[0];return void t.call(i,e)}default:{const t=this._listeners.slice();for(const{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(e){e.forward=function(e,t){return e(e=>t.fire(e))},e.map=function(e,t){return(i,s,r)=>e(e=>i.call(s,t(e)),void 0,r)},e.any=function(...e){return(t,i,r)=>{const o=new s.DisposableStore;for(const s of e)o.add(s(e=>t.call(i,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(r||(t.EventUtils=r={}))},2486(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=L;const o=i(6760),n=i(6717),a=i(4812),h=i(726),l=i(6107),c=i(8938),d=i(3055),_=i(5451),u=i(6501),f=i(6415),p=i(1346),v=i(9823),g=i(2607),m=i(8693),S=i(8636),b=i(7804),w={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var C;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(C||(t.WindowsOptionsReportType=C={}));let k=0;class D extends a.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,a,c,d,_=new n.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=a,this._mouseStateService=c,this._unicodeService=d,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new S.Emitter),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new E(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(e=>this._activeBuffer=e.activeBuffer)),this._parser.setCsiHandlerFallback((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})}),this._parser.setEscHandlerFallback(e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})}),this._parser.setExecuteHandlerFallback(e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})}),this._parser.setOscHandlerFallback((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})}),this._parser.setDcsHandlerFallback((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setApcHandlerFallback((e,t,i)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setPrintHandler((e,t,i)=>this.print(e,t,i)),this._parser.registerCsiHandler({final:"@"},e=>this.insertChars(e)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},e=>this.scrollLeft(e)),this._parser.registerCsiHandler({final:"A"},e=>this.cursorUp(e)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},e=>this.scrollRight(e)),this._parser.registerCsiHandler({final:"B"},e=>this.cursorDown(e)),this._parser.registerCsiHandler({final:"C"},e=>this.cursorForward(e)),this._parser.registerCsiHandler({final:"D"},e=>this.cursorBackward(e)),this._parser.registerCsiHandler({final:"E"},e=>this.cursorNextLine(e)),this._parser.registerCsiHandler({final:"F"},e=>this.cursorPrecedingLine(e)),this._parser.registerCsiHandler({final:"G"},e=>this.cursorCharAbsolute(e)),this._parser.registerCsiHandler({final:"H"},e=>this.cursorPosition(e)),this._parser.registerCsiHandler({final:"I"},e=>this.cursorForwardTab(e)),this._parser.registerCsiHandler({final:"J"},e=>this.eraseInDisplay(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},e=>this.eraseInDisplay(e,!0)),this._parser.registerCsiHandler({final:"K"},e=>this.eraseInLine(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},e=>this.eraseInLine(e,!0)),this._parser.registerCsiHandler({final:"L"},e=>this.insertLines(e)),this._parser.registerCsiHandler({final:"M"},e=>this.deleteLines(e)),this._parser.registerCsiHandler({final:"P"},e=>this.deleteChars(e)),this._parser.registerCsiHandler({final:"S"},e=>this.scrollUp(e)),this._parser.registerCsiHandler({final:"T"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"X"},e=>this.eraseChars(e)),this._parser.registerCsiHandler({final:"Z"},e=>this.cursorBackwardTab(e)),this._parser.registerCsiHandler({final:"^"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"`"},e=>this.charPosAbsolute(e)),this._parser.registerCsiHandler({final:"a"},e=>this.hPositionRelative(e)),this._parser.registerCsiHandler({final:"b"},e=>this.repeatPrecedingCharacter(e)),this._parser.registerCsiHandler({final:"c"},e=>this.sendDeviceAttributesPrimary(e)),this._parser.registerCsiHandler({prefix:">",final:"c"},e=>this.sendDeviceAttributesSecondary(e)),this._parser.registerCsiHandler({final:"d"},e=>this.linePosAbsolute(e)),this._parser.registerCsiHandler({final:"e"},e=>this.vPositionRelative(e)),this._parser.registerCsiHandler({final:"f"},e=>this.hVPosition(e)),this._parser.registerCsiHandler({final:"g"},e=>this.tabClear(e)),this._parser.registerCsiHandler({final:"h"},e=>this.setMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"h"},e=>this.setModePrivate(e)),this._parser.registerCsiHandler({final:"l"},e=>this.resetMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"l"},e=>this.resetModePrivate(e)),this._parser.registerCsiHandler({final:"m"},e=>this.charAttributes(e)),this._parser.registerCsiHandler({final:"n"},e=>this.deviceStatus(e)),this._parser.registerCsiHandler({prefix:"?",final:"n"},e=>this.deviceStatusPrivate(e)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},e=>this.softReset(e)),this._parser.registerCsiHandler({prefix:">",final:"q"},e=>this.sendXtVersion(e)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},e=>this.setCursorStyle(e)),this._parser.registerCsiHandler({final:"r"},e=>this.setScrollRegion(e)),this._parser.registerCsiHandler({final:"s"},e=>this.saveCursor(e)),this._parser.registerCsiHandler({final:"t"},e=>this.windowOptions(e)),this._parser.registerCsiHandler({final:"u"},e=>this.restoreCursor(e)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},e=>this.insertColumns(e)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},e=>this.deleteColumns(e)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},e=>this.selectProtected(e)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},e=>this.requestMode(e,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},e=>this.requestMode(e,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},e=>this.kittyKeyboardSet(e)),this._parser.registerCsiHandler({prefix:"?",final:"u"},e=>this.kittyKeyboardQuery(e)),this._parser.registerCsiHandler({prefix:">",final:"u"},e=>this.kittyKeyboardPush(e)),this._parser.registerCsiHandler({prefix:"<",final:"u"},e=>this.kittyKeyboardPop(e)),this._parser.setExecuteHandler("",()=>this.bell()),this._parser.setExecuteHandler("\n",()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler("\t",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("„",()=>this.index()),this._parser.setExecuteHandler("…",()=>this.nextLine()),this._parser.setExecuteHandler("ˆ",()=>this.tabSet()),this._parser.registerOscHandler(0,new p.OscHandler(e=>(this.setTitle(e),this.setIconName(e),!0))),this._parser.registerOscHandler(1,new p.OscHandler(e=>this.setIconName(e))),this._parser.registerOscHandler(2,new p.OscHandler(e=>this.setTitle(e))),this._parser.registerOscHandler(4,new p.OscHandler(e=>this.setOrReportIndexedColor(e))),this._parser.registerOscHandler(8,new p.OscHandler(e=>this.setHyperlink(e))),this._parser.registerOscHandler(10,new p.OscHandler(e=>this.setOrReportFgColor(e))),this._parser.registerOscHandler(11,new p.OscHandler(e=>this.setOrReportBgColor(e))),this._parser.registerOscHandler(12,new p.OscHandler(e=>this.setOrReportCursorColor(e))),this._parser.registerOscHandler(104,new p.OscHandler(e=>this.restoreIndexedColor(e))),this._parser.registerOscHandler(110,new p.OscHandler(e=>this.restoreFgColor(e))),this._parser.registerOscHandler(111,new p.OscHandler(e=>this.restoreBgColor(e))),this._parser.registerOscHandler(112,new p.OscHandler(e=>this.restoreCursorColor(e))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},()=>this.selectCharset("("+e)),this._parser.registerEscHandler({intermediates:")",final:e},()=>this.selectCharset(")"+e)),this._parser.registerEscHandler({intermediates:"*",final:e},()=>this.selectCharset("*"+e)),this._parser.registerEscHandler({intermediates:"+",final:e},()=>this.selectCharset("+"+e)),this._parser.registerEscHandler({intermediates:"-",final:e},()=>this.selectCharset("-"+e)),this._parser.registerEscHandler({intermediates:".",final:e},()=>this.selectCharset("."+e)),this._parser.registerEscHandler({intermediates:"/",final:e},()=>this.selectCharset("/"+e));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(e=>(this._logService.error("Parsing error: ",e),e)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new v.DcsHandler((e,t)=>this.requestStatusString(e,t)))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=u.LogLevelEnum.WARN){let t;const i=new Promise((e,i)=>{t=setTimeout(()=>i("#SLOW_TIMEOUT"),5e3)});Promise.race([e,i]).then(()=>{void 0!==t&&clearTimeout(t)},e=>{if(void 0!==t&&clearTimeout(t),"#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,o=0;const n=this._parseStack.paused;if(n){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(o=this._parseStack.position+131072)}if(this._logService.logLevel<=u.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,e=>String.fromCharCode(e)).join("")}"`)),this._logService.logLevel===u.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map(e=>e.charCodeAt(0)):e),this._parseBuffer.length131072)for(let t=o;t0&&2===p.getWidth(this._activeBuffer.x-1)&&p.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(d){const e=p;let t=this._activeBuffer.x-m;if(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),p=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!p)return;for(m>0&&p instanceof l.BufferLine&&p.copyCellsFrom(e,t,0,m,!1);t=0;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(_&&(p.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===p.getWidth(a-1)&&p.setCellFromCodepoint(a-1,c.NULL_CELL_CODE,c.NULL_CELL_WIDTH,u)),p.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===p.getWidth(this._activeBuffer.x)&&!p.hasContent(this._activeBuffer.x)&&p.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new v.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new g.ApcHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const o=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);o&&(o.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(o.isWrapped=!1))}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols){const e=this._activeBuffer.lines.get(i+1);e&&(e.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`P>|xterm.js(${b.XTERM_VERSION})\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(o.triggerDataEvent(`[${t?"":"?"}${e};${i}$y`),!0),_=e=>e?1:2,u=e.params[0];return t?d(u,2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(c.convertEol):0):1===u?d(u,_(i.applicationCursorKeys)):3===u?d(u,c.windowOptions.setWinLines?80===a?2:132===a?1:0:0):6===u?d(u,_(i.origin)):7===u?d(u,_(i.wraparound)):8===u?d(u,3):9===u?d(u,_("X10"===s)):12===u?d(u,_(c.cursorBlink)):25===u?d(u,_(!o.isCursorHidden)):45===u?d(u,_(i.reverseWraparound)):66===u?d(u,_(i.applicationKeypad)):67===u?d(u,4):1e3===u?d(u,_("VT200"===s)):1002===u?d(u,_("DRAG"===s)):1003===u?d(u,_("ANY"===s)):1004===u?d(u,_(i.sendFocus)):1005===u?d(u,4):1006===u?d(u,_("SGR"===r)):1015===u?d(u,4):1016===u?d(u,_("SGR_PIXELS"===r)):1048===u?d(u,1):47===u||1047===u||1049===u?d(u,_(h===l)):2004===u?d(u,_(i.bracketedPasteMode)):2026===u?d(u,_(i.synchronizedOutput)):9001===u&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?d(u,_(i.win32InputMode)):d(u,0)}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-67108864,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,o=0;do{if(s[o+r]=e.params[t+o],e.hasSubParams(t+o)){const i=e.getSubParams(t+o);let n=0;do{5===s[1]&&(r=1),s[o+n+1+r]=i[n]}while(++n=2||2===s[1]&&o+r>=5)break;s[1]&&(r=1)}while(++o+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-67108864,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-67108864,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-67108864,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-67108864,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:221===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.fg&=-134217729:222===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.bg&=-134217729:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("");break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[${e};${t}R`)}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[?${e};${t}R`);break;case 15:case 25:case 26:case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??1)&&this._onRequestColorSchemeQuery.fire()}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(C.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(C.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let e=0;e1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e,10);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex(e=>e.startsWith("id="));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new d.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${e}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let e=0;e0;e++)i.flags=s.pop();return 0===s.length&&t>0&&(i.flags=0),!0}}t.InputHandler=D;let E=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(k=e,e=t,t=k),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}E=s([r(0,u.IBufferService)],E)},4812(e,t){function i(e){return{dispose:e}}function s(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=s,t.combinedDisposable=function(...e){return i(()=>s(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},7710(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,o){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,o)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isMac=t.isSafari=t.isLegacyEdge=t.isChrome=t.isFirefox=t.isNode=void 0,t.getZoomFactor=function(e){return 1},t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1],10)},t.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isChrome=i.includes("Chrome"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e,t){this._getKey=e,this._array=[],this._insertedValues=[],this._isFlushingInserted=!1,this._deletedIndices=[],this._isFlushingDeleted=!1,this._flushInsertedTask=new s.IdleTaskQueue(t),this._flushDeletedTask=new s.IdleTaskQueue(t)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort((e,t)=>this._getKey(e)-this._getKey(t));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(r),!0}while(++re-t);let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},4220(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.LimitedStringBuilder=t.StringBuilder=void 0;class i{constructor(){this._chunks=[],this._length=0}get length(){return this._length}reset(){this._chunks.length=0,this._length=0}append(e){this._chunks.push(e),this._length+=e.length}toString(){return this._chunks.join("")}}t.StringBuilder=i,t.LimitedStringBuilder=class{constructor(e){this._limit=e,this._builder=new i}get length(){return this._builder.length}get limit(){return this._limit}reset(){this._builder.reset()}append(e){return this._builder.append(e),this._builder.length>this._limit&&(this._builder.reset(),!0)}toString(){return this._builder.toString()}}},6168(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;class i{constructor(e){this._tasks=[],this._i=0,this._logService=e}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ii)return r-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=s,t.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:s,t.DebouncedIdleTask=class{constructor(e){this._queue=new t.IdleTaskQueue(e)}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},7804(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.XTERM_VERSION=void 0,t.XTERM_VERSION="6.1.0-beta.287"},5882(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(4812),o=i(6168),n=i(5451),a=i(6107),h=i(3326),l=i(732),c=i(3055),d=i(8938),_=i(8158),u=i(6760);t.MAX_BUFFER_SIZE=4294967295;class f extends r.Disposable{constructor(e,t,i,n){super(),this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this._logService=n,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=a.DEFAULT_ATTR_DATA.clone(),this.savedCharset=u.DEFAULT_CHARSET,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=c.CellData.fromCharData([0,d.NULL_CELL_CHAR,d.NULL_CELL_WIDTH,d.NULL_CELL_CODE]),this._whitespaceCell=c.CellData.fromCharData([0,d.WHITESPACE_CELL_CHAR,d.WHITESPACE_CELL_WIDTH,d.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new o.IdleTaskQueue(this._logService),this._register((0,r.toDisposable)(()=>this._memoryCleanupQueue.clear())),this._register((0,r.toDisposable)(()=>this.clearAllMarkers())),this._stringCache=this._register(new h.BufferLineStringCache)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new a.BufferLine(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){e??=a.DEFAULT_ATTR_DATA;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(a.DEFAULT_ATTR_DATA);this._stringCache.clear();let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+o+1?(this.ybase--,o++,this.ydisp>0&&this.ydisp--):this.lines.push(new a.BufferLine(this._stringCache,e,i,!1)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),o&&(this.y+=o),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t0){const e=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,e)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,l.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(a.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,l.reflowLargerCreateNewLayout)(this.lines,s);(0,l.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(a.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=n&&e0&&(r.push({start:n+c.length+o,newLines:p}),o+=p.length),c.push(...p);let v=_.length-1,g=_[v];0===g&&(v--,g=_[v]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[v])break;if(c[v].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(v--,g=_[v]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,l.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;l--)if(a&&a.start>s+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(l--,a.newLines[e]);l++,e.push({index:s+1,amount:a.newLines.length}),h+=a.newLines.length,a=r[++n]}else this.lines.set(l,t[s--]);let l=0;for(let t=e.length-1;t>=0;t--)e[t].index+=l,this.lines.onInsertEmitter.fire(e[t]),l+=e[t].amount;const c=Math.max(0,i+o-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(e=>{t.line>=e.index&&(t.line+=e.amount)})),t.register(this.lines.onDelete(e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}t.Buffer=f},6107(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),o=i(8938),n=i(726),a=i(4220);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let h=0;const l=new r.CellData,c=new a.StringBuilder;class d{constructor(e,t,i,s=!1){this._stringCache=e,this.isWrapped=s,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const n=i??r.CellData.fromCharData([0,o.NULL_CELL_CHAR,o.NULL_CELL_WIDTH,o.NULL_CELL_CODE]);for(let e=0;e>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._invalidateStringCache(),this._data[3*e+1]=t[o.CHAR_DATA_ATTR_INDEX],t[o.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[o.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[o.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[o.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,n.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,i){return h=3*e,i.content=this._data[h+0],i.fg=this._data[h+1],i.bg=this._data[h+2],2097152&i.content?i.combinedData=this._combined[e]:i.combinedData="",268435456&i.bg?i.extended=this._extendedAttrs[e]:i.extended=t.DEFAULT_ATTR_DATA.extended.clone(),i}setCell(e,t){this._invalidateStringCache(),2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){this._invalidateStringCache(),268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){this._invalidateStringCache();let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,n.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,n.stringFromCodePoint)(2097151&s)+(0,n.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if(this._invalidateStringCache(),(e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,l));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){this._invalidateStringCache();const o=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=o[3*(t+r)+e];this._copyCellMapsFrom(e,t+r,i+r)}else for(let r=0;r>22||1}s&&s.push(t);const h=c.toString();if(c.reset(),r){const t=this._getStringCacheEntry(!0);t.value=h,t.isTrimmed=!!e}return h}_getStringCacheEntry(e){const t=this._stringCacheEntryRef?.deref();if(t&&t.generation===this._stringCache.generation)return t;if(!e)return;const i=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(i),i}_invalidateStringCache(){const e=this._getStringCacheEntry(!1);e&&(e.value=void 0,e.isTrimmed=!1)}_copyCellMapsFrom(e,t,i){const s=3*t;2097152&e._data[s+0]&&(this._combined[i]=e._combined[t]),268435456&e._data[s+2]&&(this._extendedAttrs[i]=e._extendedAttrs[t])}_copySparseMapsFrom(e){this._combined={},this._extendedAttrs={};for(let t=0;tthis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){const e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(const e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),this._clearTimeout.value||this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=(0,s.disposableTimeout)(()=>{const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this.clear():this._scheduleClearTimeout(15e3-e)},e)}}t.BufferLineStringCache=o},9384(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732(e,t){function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,o,n){const a=[];for(let h=0;h=h&&r0&&(e>_||0===d[e].getTrimmedLength());e--)v++;v>0&&(a.push(h+d.length-v),a.push(v)),h+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],o=0;for(let n=0;nl&&(n-=l,a++);const c=2===e[a].getWidth(n-1);c&&n--;const d=c?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},4097(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(4812),r=i(1073),o=i(8636);class n extends s.Disposable{constructor(e,t,i){super(),this._optionsService=e,this._bufferService=t,this._logService=i,this._normalBuffer=this._register(new s.MutableDisposable),this._altBuffer=this._register(new s.MutableDisposable),this._onBufferActivate=this._register(new o.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=n},3055(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),o=i(5451);class n extends o.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new o.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new n;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor())return!1;if(this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor())return!1;if(this.isInverse()!==e.isInverse())return!1;if(this.isBold()!==e.isBold())return!1;if(this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;const t=this.isUnderlineColorDefault(),i=e.isUnderlineColorDefault();if(!t||!i){if(t!==i)return!1;if(this.getUnderlineColor()!==e.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==e.getUnderlineColorMode())return!1}}return this.isOverline()===e.isOverline()&&this.isBlink()===e.isBlink()&&this.isInvisible()===e.isInvisible()&&this.isItalic()===e.isItalic()&&this.isDim()===e.isDim()&&this.isStrikethrough()===e.isStrikethrough()}}t.CellData=n},8938(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(4812),r=i(8636);class o{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=o._nextId++,this._onDispose=this.register(new r.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,s.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=o,o._nextId=1},6760(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},706(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,s,r){const o={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?"OA":"":"UIKeyInputLeftArrow"===e.key?o.key=t?"OD":"":"UIKeyInputRightArrow"===e.key?o.key=t?"OC":"":"UIKeyInputDownArrow"===e.key&&(o.key=t?"OB":"");break;case 8:o.key=e.ctrlKey?"\b":"",e.altKey&&(o.key=""+o.key);break;case 9:if(e.shiftKey){o.key="";break}o.key="\t",o.cancel=!0;break;case 13:"c"===e.key&&e.ctrlKey?o.key="":o.key=e.altKey?"\r":"\r",o.cancel=!0;break;case 27:o.key="",e.altKey&&(o.key=""),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"D":t?"OD":"";break;case 39:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"C":t?"OC":"";break;case 38:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"A":t?"OA":"";break;case 40:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"B":t?"OB":"";break;case 45:e.shiftKey||e.ctrlKey||(o.key="[2~");break;case 46:o.key=n?"[3;"+(n+1)+"~":"[3~";break;case 36:o.key=n?"[1;"+(n+1)+"H":t?"OH":"";break;case 35:o.key=n?"[1;"+(n+1)+"F":t?"OF":"";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key="[5;"+(n+1)+"~":o.key="[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key="[6;"+(n+1)+"~":o.key="[6~";break;case 112:o.key=n?"[1;"+(n+1)+"P":"OP";break;case 113:o.key=n?"[1;"+(n+1)+"Q":"OQ";break;case 114:o.key=n?"[1;"+(n+1)+"R":"OR";break;case 115:o.key=n?"[1;"+(n+1)+"S":"OS";break;case 116:o.key=n?"[15;"+(n+1)+"~":"[15~";break;case 117:o.key=n?"[17;"+(n+1)+"~":"[17~";break;case 118:o.key=n?"[18;"+(n+1)+"~":"[18~";break;case 119:o.key=n?"[19;"+(n+1)+"~":"[19~";break;case 120:o.key=n?"[20;"+(n+1)+"~":"[20~";break;case 121:o.key=n?"[21;"+(n+1)+"~":"[21~";break;case 122:o.key=n?"[23;"+(n+1)+"~":"[23~";break;case 123:o.key=n?"[24;"+(n+1)+"~":"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(s&&!r||!e.altKey||e.metaKey)if(!s||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey){if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length)o.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":o.key="";break;case"Digit2":o.key="\0";break;case"Digit6":o.key=""}}else 65===e.keyCode&&(o.type=1);else{const t=i[e.keyCode],s=t?.[e.shiftKey?1:0];if(s)o.key=""+s;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=""+i}else if(32===e.keyCode)o.key=""+(e.ctrlKey?"\0":" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=""+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key="\0":e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key="":"/"===e.key?o.key="":219===e.keyCode?o.key="":220===e.keyCode?o.key="":221===e.keyCode&&(o.key="")}return o};const i={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},7241(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyKeyboard=void 0,t.KittyKeyboard=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){const t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e,t){const i=this._getNumpadKeyCode(e);if(void 0!==i)return i;const s=this._getModifierKeyCode(e);if(void 0!==s)return s;const r=this._functionalKeyCodes[e.key];if(void 0!==r)return r;if((e.shiftKey||t&&e.altKey)&&e.code){if(e.code.startsWith("Digit")&&6===e.code.length){const t=e.code.charAt(5);if(t>="0"&&t<="9")return t.charCodeAt(0)}if(e.code.startsWith("Key")&&4===e.code.length)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(1===e.key.length){const t=e.key.codePointAt(0);return t>=65&&t<=90?t+32:t}}_isModifierKey(e){return"Shift"===e.key||"Control"===e.key||"Alt"===e.key||"Meta"===e.key}_isLockKey(e){return"CapsLock"===e.key||"NumLock"===e.key||"ScrollLock"===e.key}_buildCsiLetterSequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"["+e}_buildSs3Sequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"O"+e}_buildCsiTildeSequence(e,t,i,s){const r=s&&1!==i;let o="["+e;return(t>0||r)&&(o+=";"+(t>0?t:"1"),r&&(o+=":"+i)),o+="~",o}_buildCsiUSequence(e,t,i,s,r,o,n){const a=!!(2&r);let h,l="["+t;4&r&&e.shiftKey&&1===e.key.length&&!o&&!n&&(h=e.key.codePointAt(0),l+=":"+h);const c=16&r&&3!==s&&1===e.key.length&&!o&&!n&&!e.ctrlKey?e.key.codePointAt(0):void 0,d=a&&1!==s&&(3===s||void 0===c);return(i>0||d||void 0!==c)&&(l+=";",i>0?l+=i:d&&(l+="1"),d&&(l+=":"+s)),void 0!==c&&(l+=";"+c),l+="u",l}evaluate(e,t,i=1,s=!1){const r={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(e),n=this._isModifierKey(e),a=!!(2&t);if(!a&&3===i)return r;if(n&&!(8&t))return r;if(this._isLockKey(e)&&!(8&t))return r;const h=this._csiLetterKeys[e.key];if(h)return r.key=this._buildCsiLetterSequence(h,o,i,a),r.cancel=!0,r;const l=this._ss3FunctionKeys[e.key];if(l)return r.key=this._buildSs3Sequence(l,o,i,a),r.cancel=!0,r;const c=this._csiTildeKeys[e.key];if(void 0!==c)return r.key=this._buildCsiTildeSequence(c,o,i,a),r.cancel=!0,r;const d=this._getKeyCode(e,s);if(void 0===d)return r;const _=13===d||9===d||127===d;if(_&&3===i&&!(8&t))return r;const u=void 0!==this._functionalKeyCodes[e.key]||void 0!==this._getNumpadKeyCode(e);if(8&t||a&&3===i||(1&t||a)&&(u&&!_||o>0&&1!==e.key.length||o-1>1))r.key=this._buildCsiUSequence(e,d,o,i,t,u,n),r.cancel=!0;else{const t=13===d?"\r":9===d?"\t":127===d?"":void 0;t?r.key=t:1!==e.key.length||e.ctrlKey||e.altKey||e.metaKey||(r.key=e.key)}return r}static shouldUseProtocol(e){return e>0}}},726(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let o=r;o=i)return this._interim=r,s;const n=e.charCodeAt(o);56320<=n&&n<=57343?t[s++]=1024*(r-55296)+n-56320+65536:(t[s++]=r,t[s++]=n);continue}65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,o,n,a,h=0,l=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let o,n=0;for(;(o=this.interim[++n])&&n<4;)r<<=6,r|=63&o;const a=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=a-n;for(;l=i)return 0;if(o=e[l++],128!=(192&o)){l--,s=!0;break}this.interim[n++]=o,r<<=6,r|=63&o}s||(2===a?r<128?l--:t[h++]=r:3===a?r<2048||r>=55296&&r<=57343||65279===r||(t[h++]=r):r<65536||r>1114111||(t[h++]=r)),this.interim.fill(0)}const c=i-4;let d=l;for(;d=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(a=(31&s)<<6|63&r,a<128){d--;continue}t[h++]=a}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(a=(15&s)<<12|(63&r)<<6|63&o,a<2048||a>=55296&&a<=57343||65279===a)continue;t[h++]=a}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=o,h;if(n=e[d++],128!=(192&n)){d--;continue}if(a=(7&s)<<18|(63&r)<<12|(63&o)<<6|63&n,a<65536||a>1114111)continue;t[h++]=a}}return h}}},7428(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],o=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let n;t.UnicodeV6=class{constructor(){if(this.version="6",!n){n=new Uint8Array(65536),n.fill(1),n[0]=0,n.fill(0,1,32),n.fill(0,127,160),n.fill(2,4352,4448),n[9001]=2,n[9002]=2,n.fill(2,11904,42192),n[12351]=1,n.fill(2,44032,55204),n.fill(2,63744,64256),n.fill(2,65040,65050),n.fill(2,65072,65136),n.fill(2,65280,65377),n.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},9249(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Win32InputMode=void 0,t.Win32InputMode=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){const t=this._codeToVk[e.code];return void 0!==t?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if("Enter"===e.key)return 10;if("Backspace"===e.key)return 127}const t=this._keyToControlChar[e.key];if(void 0!==t)return t;if(1===e.key.length){const t=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&("ControlRight"===e.code?t|=4:t|=8),e.altKey&&("AltRight"===e.code?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){return{type:0,cancel:!0,key:`[${this._getVirtualKeyCode(e)};${this._getScanCode(e)};${this._getUnicodeChar(e)};${t?1:0};${this._getControlKeyState(e)};1_`}}}},3562(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(3132),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._innerWriteTimer=this._register(new s.TimeoutTimer),this._onWriteParsed=this._register(new o.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._register((0,r.toDisposable)(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed)return;if(this._isSyncWriting)return;let e;this._isSyncWriting=!0;let t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);const i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>{this._store.isDisposed||(performance.now()-i>=12?this._scheduleInnerWrite(0,e):this._innerWrite(i,e))};return void s.catch(e=>(queueMicrotask(()=>{throw e}),Promise.resolve(!1))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(t.startsWith("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(t.startsWith("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,o]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(o,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},2607(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ApcHandler=t.ApcParser=void 0;const s=i(726),r=i(4220),o=[];t.ApcParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._ident=0}start(e){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._ident,"START")}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}end(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"END",e);this._active=o,this._ident=0}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.ApcHandler=n,n._payloadLimit=1e7},9823(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),o=i(4220),n=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=n,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=n,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=n,this._ident=0}};const a=new r.Params;a.addParam(0);class h{constructor(e){this._handler=e,this._data=new o.LimitedStringBuilder(h._payloadLimit),this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString(),this._params),t instanceof Promise))return t.then(e=>(this._params=a,this._data.reset(),this._hitLimit=!1,e));return this._params=a,this._data.reset(),this._hitLimit=!1,t}}t.DcsHandler=h,h._payloadLimit=1e7},6717(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(4812),r=i(7262),o=i(1346),n=i(9823),a=i(2607);class h{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;rt),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const o=i(0,17);e.setDefault(1,0),e.addMany(s,0,2,0);for(const t of o)e.addMany([24,26,153,154],t,3,0),e.addMany(i(128,144),t,3,0),e.addMany(i(144,152),t,3,0),e.add(156,t,0,0),e.add(27,t,11,1),e.add(157,t,4,8),e.addMany([152,158],t,0,7),e.add(159,t,11,14),e.add(155,t,11,3),e.add(144,t,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,11,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany(i(32,48),14,9,15),e.addMany(i(48,127),14,15,16),e.addMany(i(48,127),15,15,16),e.addMany(r,15,0,15),e.addMany(i(32,48),15,9,15),e.add(127,15,0,15),e.addMany(s,16,16,16),e.addMany(r,16,0,16),e.addMany(i(8,14),16,16,16),e.add(127,16,0,16),e.addMany([27,156,24,26],16,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e.add(l,16,16,16),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new o.OscParser),this._dcsParser=this._register(new n.DcsParser),this._apcParser=this._register(new a.ApcParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i<60||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);this._escHandlers[i]??=[];const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){const i=e.charCodeAt(0);this._executeHandlers[i]=t,i<24&&(this._executeHandlersArr[i]=t)}clearExecuteHandler(e){const t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);this._csiHandlers[i]??=[];const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r,o,n=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let r=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](this._params),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 4:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=255&this._parseStack.transition}for(let i=n;i=60&&n<=63&&(this._collect=n,s++);let a=!1;for(;s=48&&n<=57)this._params.addDigit(n-48);else if(59===n)this._params.addParam(0);else{if(58!==n){if(n>=64&&n<=126){const e=this._csiHandlers[this._collect<<8|n];let t=e?e.length-1:-1;for(;t>=0&&(o=e[t](this._params),!0!==o);t--)if(o instanceof Promise)return r=1792,this._preserveStack(3,e,t,r,s),o;t<0&&this._csiHandlerFb(this._collect<<8|n,this._params),this.precedingJoinState=0,i=s,this.currentState=0,a=!0;break}break}this._params.addSubParam(-1)}a||(i=s-1,this.currentState=4);continue}switch(r=this._transitions.table[this.currentState<<8|(s>8){case 2:let n=i;const a=t-4;for(;n=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l););if(n>=a)for(;n=32&&(e[n]<=126||e[n]>=l);)n++;this._printHandler(e,i,n),i=n-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:i,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:const h=this._csiHandlers[this._collect<<8|s];let c=h?h.length-1:-1;for(;c>=0&&(o=h[c](this._params),!0!==o);c--)if(o instanceof Promise)return this._preserveStack(3,h,c,r,i),o;c<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do{switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}}while(++i47&&s<60);i--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:const d=this._escHandlers[this._collect<<8|s];let _=d?d.length-1:-1;for(;_>=0&&(o=d[_](),!0!==o);_--)if(o instanceof Promise)return this._preserveStack(4,d,_,r,i),o;_<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let r=i+1;;++r)if(r>=t||24===(s=e[r])||26===s||27===s||s>127&&s=t||(s=e[r])<32||s>127&&s=32&&e[s]<127||e[s]>=8&&e[s]<14||e[s]>=l))){this._apcParser.put(e,i,s),i=s-1;break}break;case 17:if(o=this._apcParser.end(24!==s&&26!==s),o)return this._preserveStack(7,[],0,r,i),o;27===s&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0}this.currentState=255&r}}}t.EscapeSequenceParser=c},1346(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(726),r=i(4220),o=[];t.OscParser=class{constructor(){this._state=0,this._active=o,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,s.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=o,this._id=-1,this._state=0}}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.OscHandler=n,n._payloadLimit=1e7},7262(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;class i{static fromArray(e){const t=new i;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new i(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>2147483647?2147483647:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=e>2147483647?2147483647:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const i=this._digitIsSub?this._subParams:this.params,s=i[t-1];i[t-1]=~s?Math.min(10*s+e,2147483647):e}}t.Params=i},3027(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new o.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=n},6097(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,e=>t(e.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(e,i)=>t(e,i.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}}},4335(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=void 0;const o=i(4812),n=i(4097),a=i(6501),h=i(8636);let l=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e,t){super(),this.isUserScrolling=!1,this._onResize=this._register(new h.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new h.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new n.BufferSet(e,this,t)),this._register(this.buffers.onBufferActivate(e=>{this._onScroll.fire(e.activeBuffer.ydisp)}))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,o=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;o===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(o+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=o-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(o,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=l,t.BufferService=l=s([r(0,a.IOptionsService),r(1,a.ILogService)],l)},5746(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},4071(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const o=i(4812),n=i(6501),a=i(8636),h=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0});let c=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new a.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new a.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new a.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new a.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}reset(){this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onBinary.fire(e))}};t.CoreService=c,t.CoreService=c=s([r(0,n.IBufferService),r(1,n.ILogService),r(2,n.IOptionsService)],c)},4720(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationLineCache=t.DecorationService=void 0;const o=i(3132),n=i(4103),a=i(4812),h=i(6501),l=i(3087),c=i(8636);let d=0,_=0,u=class extends a.Disposable{get decorations(){return this._decorations.values()}constructor(e,t){super(),this._logService=e,this._bufferService=t,this._lineCache=this._register(new f),this._onDecorationRegistered=this._register(new c.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new c.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._decorations=new l.SortedList(e=>e?.marker.line,this._logService),this._register((0,a.toDisposable)(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}registerDecoration(e){if(e.marker.isDisposed)return;const t=new p(e);if(t){const e=t.marker.onDispose(()=>t.dispose()),i=t.onDispose(()=>{i.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),e.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,i){const s=this._lineCache.getDecorationsOnLine(t);if(s)for(const t of s)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&(yield t)}forEachDecorationAtCell(e,t,i,s){const r=this._lineCache.getDecorationsOnLine(t);if(r)for(const t of r)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&s(t)}};t.DecorationService=u,t.DecorationService=u=s([r(0,h.ILogService),r(1,h.IBufferService)],u);class f extends a.Disposable{constructor(){super(...arguments),this._decorationsByLine=new Map,this._decorations=new Set,this._bufferLineListeners=this._register(new a.MutableDisposable),this._lineIndexSyncTimer=this._register(new o.MicrotaskTimer),this._lineIndexSyncCallbacks=[]}clear(){this._lineIndexSyncCallbacks.length=0,this._lineIndexSyncTimer.cancel(),this._decorationsByLine.clear(),this._decorations.clear()}add(e){this._decorations.add(e),this._addToLineBuckets(e)}remove(e){this._decorations.delete(e),this._removeFromLineBuckets(e)}getDecorationsOnLine(e){return this._decorationsByLine.get(e)}attachToBufferLines(e){const t=new a.DisposableStore;this._bufferLineListeners.value=t,t.add(e.onTrim(e=>this._handleBufferLinesTrim(e))),t.add(e.onInsert(e=>this._handleBufferLinesInsert(e))),t.add(e.onDelete(e=>this._handleBufferLinesDelete(e)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){const t=e.marker.line;if(t<0)return;e._indexedStartLine=t;const i=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{const e=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(const t of e)t()})}_handleBufferLinesTrim(e){if(e<=0)return;const t=new Map;for(const[i,s]of this._decorationsByLine){const r=i-e;r<0||this._mergeLineBucket(t,r,s)}this._decorationsByLine.clear();for(const[e,i]of t)this._decorationsByLine.set(e,i);for(const t of this._decorations)t.marker.isDisposed||(t._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,i){const s=e.get(t);if(s)for(let e=0,t=i.length;et&&(s.push(e),this._removeFromLineBuckets(e))}const r=new Map;for(const[e,s]of this._decorationsByLine){const o=e>=t?e+i:e;this._mergeLineBucket(r,o,s)}this._decorationsByLine.clear();for(const[e,t]of r)this._decorationsByLine.set(e,t);for(const e of this._decorations)e.marker.isDisposed||e._indexedStartLine>=t&&(e._indexedStartLine=e.marker.line);for(const e of s)this._addToLineBuckets(e)}_applyBufferLinesDelete(e){const t=e.index+e.amount,i=new Map;for(const[s,r]of this._decorationsByLine){if(s>=e.index&&s=t?s-e.amount:s;this._mergeLineBucket(i,o,r)}this._decorationsByLine.clear();for(const[e,t]of i)this._decorationsByLine.set(e,t);const s=[];for(const i of this._decorations){if(i.marker.isDisposed)continue;const r=i._indexedStartLine,o=this._getDecorationHeight(i);r>=t?i._indexedStartLine=i.marker.line:rt&&s.push(i)}for(const e of s)this._reindexDecoration(e)}}t.DecorationLineCache=f;class p extends a.DisposableStore{get backgroundColorRGB(){return null===this._cachedBg&&(this.options.backgroundColor?this._cachedBg=n.css.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return null===this._cachedFg&&(this.options.foregroundColor?this._cachedFg=n.css.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}constructor(e){super(),this.options=e,this.onRenderEmitter=this.add(new c.Emitter),this.onRender=this.onRenderEmitter.event,this._onDispose=this.add(new c.Emitter),this.onDispose=this._onDispose.event,this._cachedBg=null,this._cachedFg=null,this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}dispose(){this._onDispose.fire(),super.dispose()}}},6025(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class o{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=o,t.InstantiationService=class{constructor(){this._services=new o,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort((e,t)=>e.index-t.index),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const o=i.length>0?i[0].index:t.length;if(t.length!==o)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${o+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0;const o=i(4812),n=i(6501),a={trace:n.LogLevelEnum.TRACE,debug:n.LogLevelEnum.DEBUG,info:n.LogLevelEnum.INFO,warn:n.LogLevelEnum.WARN,error:n.LogLevelEnum.ERROR,off:n.LogLevelEnum.OFF};let h=class extends o.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=n.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function n(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const a=String.fromCharCode,h={DEFAULT:e=>{const t=[n(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${a(t[0])}${a(t[1])}${a(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.x};${e.y}${t}`}};class l extends s.Disposable{constructor(){super(),this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=this._register(new r.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(o))this.addProtocol(e,o[e]);for(const e of Object.keys(h))this.addEncoding(e,h[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return!this._customWheelEventHandler||!1!==this._customWheelEventHandler(e)}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return"DEFAULT"===this._activeEncoding}get isPixelEncoding(){return"SGR_PIXELS"===this._activeEncoding}}t.MouseStateService=l},56(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(4812),r=i(701),o=i(8636);t.DEFAULT_OPTIONS={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}};const n=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new o.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{-1!==e.indexOf(i)&&t()})}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=n.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"blinkIntervalDuration":if((i=Math.floor(i))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const o=i(6501);let n=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose(()=>this._removeMarkerFromLink(s,i)),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const o=t.addMarker(t.ybase+t.y),n={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(n,o)),this._entriesWithId.set(n.key,n),this._dataByLinkId.set(n.id,n),n.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every(e=>e.line!==t)){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose(()=>this._removeMarkerFromLink(i,e))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=n,t.OscLinkService=n=s([r(0,o.IBufferService)],n)},6201(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e.di$dependencies||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const i=function(e,t,s){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,i){t.di$target===t?t.di$dependencies.push({id:e,index:i}):(t.di$dependencies=[{id:e,index:i}],t.di$target=t)}(i,e,s)};return i._id=e,t.serviceRegistry.set(e,i),i},t.serviceRegistry=new Map},6501(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.IMouseStateService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.IMouseStateService=(0,s.createDecorator)("MouseStateService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8636);class r{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.Emitter,this.onChange=this._onChange.event}static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e,this._active||(this.activeVersion=e.version)}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let o=0;o=s)return t+this.wcwidth(n);const i=e.charCodeAt(o);56320<=i&&i<=57343?n=1024*(n-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(n,i);let h=r.extractWidth(a);r.extractShouldJoin(a)&&(h-=r.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=r}},t={};return function i(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s].call(o.exports,o,o.exports,i),o.exports}(6081)})()); -+!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,()=>(()=>{"use strict";var e={2840(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new c.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(e=>this._handleResize(e.rows))),this._register(this._terminal.onRender(e=>this._refreshRows(e.start,e.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(e=>this._handleChar(e))),this._register(this._terminal.onLineFeed(()=>this._handleChar("\n"))),this._register(this._terminal.onA11yTab(e=>this._handleTab(e))),this._register(this._terminal.onKey(e=>this._handleKey(e.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register((0,f.addDisposableListener)(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,d.toDisposable)(()=>{this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent=l.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],o=e?.translateToString(!0,void 0,void 0,t)||"",n=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===o.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=o,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",n),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent===l.tooMuchOutput.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,o;if(0===t?(r=i,o=this._rowElements.pop(),this._rowContainer.removeChild(o)):(r=this._rowElements.shift(),o=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),o.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let o=t=this._terminal.cols&&(++s,o=0),{row:s,column:o}},o=r(t),n=r(i);if(o&&n){if(o.row>n.row||o.row===n.row&&o.column>=n.column)throw new Error("invalid range");this._terminal.select(o.column,o.row,(n.row-o.row)*this._terminal.cols-o.column+n.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;ethis._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(e=>this._reportWindowsOptions(e))),this._register(this._inputHandler.onColor(e=>this._handleColorEvent(e))),this._register(I.EventUtils.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(I.EventUtils.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(I.EventUtils.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(I.EventUtils.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(e=>this._afterResize(e.cols,e.rows))),this._register((0,N.toDisposable)(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i;switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=E.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`]${i};${(0,M.toRgbString)(s)}\\`);break;case 1:if("ansi"===e)this._themeService.modifyColors(e=>e.ansi[t.index]=E.channels.toColor(...t.color));else{const i=e;this._themeService.modifyColors(e=>e[i]=E.channels.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index)}}}_reportColorScheme(){if(!this._themeService)return;const e=E.rgb.relativeLuminance(this._themeService.colors.background.rgba>>8)>8)?1:2;this.coreService.triggerDataEvent(`[?997;${e}n`)}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),o=this._renderService.dimensions.css.cell.width*r,n=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=n+"px",this.textarea.style.width=o+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,H.addDisposableListener)(this.element,"copy",e=>{this.hasSelection()&&(0,a.copyHandler)(e,this._selectionService)}));const e=e=>(0,a.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,H.addDisposableListener)(this.textarea,"paste",e)),this._register((0,H.addDisposableListener)(this.element,"paste",e)),x.isFirefox?this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>{2===e.button&&(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register((0,H.addDisposableListener)(this.element,"contextmenu",e=>{(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),x.isLinux&&this._register((0,H.addDisposableListener)(this.element,"auxclick",e=>{1===e.button&&(0,a.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)}))}_bindKeys(){this._register((0,H.addDisposableListener)(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register((0,H.addDisposableListener)(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register((0,H.addDisposableListener)(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register((0,H.addDisposableListener)(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",e=>this.element.classList.toggle("allow-transparency",e))),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,H.addDisposableListener)(this.screenElement,"mousemove",e=>this.updateCursorStyle(e))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",h.promptLabel.get()),x.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(g.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??("undefined"!=typeof window?window.document:null))),this._instantiationService.setService(C.ICoreBrowserService,this._coreBrowserService),this._register((0,H.addDisposableListener)(this.textarea,"focus",e=>this._handleTextAreaFocus(e))),this._register((0,H.addDisposableListener)(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(p.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(C.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(k.ThemeService),this._instantiationService.setService(C.IThemeService,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(v.CharacterJoinerService),this._instantiationService.setService(C.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(w.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(C.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange(e=>this._onRender.fire(e))),this._register(this._renderService.onDimensionsChange(e=>this._onDimensionsChange.fire({css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}))),this.onResize(e=>this._renderService.resize(e.cols,e.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(u.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(S.MouseCoordsService),this._instantiationService.setService(C.IMouseCoordsService,this._mouseCoordsService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(O.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(e){this._logService.error("onWillOpen handler threw an exception",e)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(c.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(y.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(C.ISelectionService,this._selectionService),this._mouseService=this._instantiationService.createInstance(b.MouseService),this._instantiationService.setService(C.IMouseService,this._mouseService),this._register(this._selectionService.onRequestScrollLines(e=>this.scrollLines(e.amount,e.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()})),this._register(I.EventUtils.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(d.BufferDecorationRenderer,this.screenElement)),this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>this._selectionService.handleMouseDown(e))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",e=>this._handleScreenReaderModeOptionChange(e)));const r=this.options.scrollbar?.showScrollbar??!0,o=this.options.scrollbar?.width;r&&o&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",e=>{const t=(e?.showScrollbar??!0)&&!!e?.width;!this._overviewRulerRenderer&&t&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:e=>this._viewport?.handleTouchScroll(e)},e=>this._register(e),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(f.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,a.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),e.preventDefault(),e.stopPropagation(),!1}if(1===i.type&&this.selectAll(),this._isThirdLevelShift(this.browser,e))return!0;if(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)return!0;if(!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;""!==i.key&&"\r"!==i.key||(this.textarea.value="");const s=this._keyboardService.useWin32InputMode&&W(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return;W(e)||this.focus();const t=this._keyboardService.evaluateKeyUp(e);if(t?.key){const i=this._keyboardService.useWin32InputMode&&W(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this._compositionHelper.keypress(t)||this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;efunction(e){const t=l(e);for(t.animFrameRequested=!1,t.current=t.next,t.next=[],t.inAnimationFrameRunner=!0;t.current.length>0;)t.current.sort(a.sort),t.current.shift().execute();t.inAnimationFrameRunner=!1}(e))),r};const s=i(3132);function r(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView;const i=e;return i?.view?i.view:window}class o{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s,e.addEventListener(t,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function n(e,t,i,s){return new o(e,t,i,s)}t.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class a{constructor(e,t){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){console.error(e)}}static sort(e,t){return t.priority-e.priority}}const h=new Map;function l(e){let t=h.get(e);return t||(t={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},h.set(e,t)),t}class c extends s.IntervalTimer{constructor(e){super(),this._defaultTarget=e?r(e):void 0}cancelAndSet(e,t,i){super.cancelAndSet(e,t,i??this._defaultTarget??window)}}t.WindowIntervalTimer=c},8906(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const o=i(4812),n=i(6501),a=i(7098),h=i(8636),l=i(4159);let c=class extends o.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseCoordsService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new h.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new h.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,o.toDisposable)(()=>{(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register((0,l.addDisposableListener)(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register((0,l.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach(e=>{e.link.dispose&&e.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,t=>{if(this._isMouseOut)return;const r=t?.map(e=>({link:e}));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=o;e<=n;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element);e&&this._askForLink(e,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t){const i=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseCoordsService),r(2,a.IRenderService),r(3,n.IBufferService),r(4,a.ILinkProviderService)],c)},7721(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const o={get:()=>r,set:e=>r=e};t.tooMuchOutput=o},3285(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const o=i(3055),n=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new o.CellData}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=this._workCell,n=i.getTrimmedLength();let a=-1,l=-1,c=!1;for(let t=0;tr?r.activate(e,t,d):h(0,t),hover:(e,t)=>r?.hover?.(e,t,d),leave:(e,t)=>r?.leave?.(e,t,d)})}c=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,a=o.extended.urlId):(l=-1,a=-1)}}t(s)}_getRangeWithLineWrap(e,t,i,s){let r=e,o=t,n=e,a=i;for(;0===o;){const e=this._bufferService.buffer.lines.get(r-1);if(!e?.isWrapped)break;const t=this._bufferService.buffer.lines.get(r-2);if(!t)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,i-1,s))break;let n=i-1;for(;n>0&&this._hasUrlId(t,n-1,s);)n--;r--,o=n}for(;;){const e=this._bufferService.buffer.lines.get(n-1);if(!e)break;if(a!==e.getTrimmedLength())break;const t=this._bufferService.buffer.lines.get(n);if(!t?.isWrapped)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,0,s))break;let r=1;for(;rthis._innerRefresh()),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)void 0!==this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],o=t[i/6%6|0],n=t[i%6];e.push({css:s.channels.toCss(r,o,n),rgba:s.channels.toRgba(r,o,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const o=i(7098),n=i(4812),a=i(6501),h=i(4159),l=i(8566),c=i(8636),d=i(7880);let _=class extends n.Disposable{constructor(e,t,i,s,r,o,a,_,u){super(),this._bufferService=i,this._coreService=r,this._optionsService=_,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1,this._needsSyncOnRender=!1;const f=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,h.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{f.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new l.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},f)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(o.onProtocolChange(e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{e.style.backgroundColor=a.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=a.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,n.toDisposable)(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,n.toDisposable)(()=>this._styleElement.remove())),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${a.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${a.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${a.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(e=>this._handleScroll(e)))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){const e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,i=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:i,verticalHasArrows:t}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._coreService.decPrivateModes.synchronizedOutput?this._needsSyncOnRender=!0:(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1))}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}handleTouchScroll(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};t.Viewport=_,t.Viewport=_=s([r(2,a.IBufferService),r(3,o.ICoreBrowserService),r(4,a.ICoreService),r(5,a.IMouseStateService),r(6,o.IThemeService),r(7,a.IOptionsService),r(8,o.IRenderService)],_)},4196(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const o=i(7098),n=i(4812),a=i(6501);let h=class extends n.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(e=>this._removeDecoration(e))),this._register((0,n.toDisposable)(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h,t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,o.ICoreBrowserService),r(3,a.IDecorationService),r(4,o.IRenderService)],h)},957(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const o=i(957),n=i(7098),a=i(4812),h=i(6501),l={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=class extends a.Disposable{get _width(){const e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??1?e?.width??0:0}constructor(e,t,i,s,r,n,h,l){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=h,this._coreBrowserService=l,this._colorZoneStore=new o.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)(()=>this._canvas?.remove()));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register((0,a.toDisposable)(()=>{void 0!==this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;const e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_,t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,n.IRenderService),r(5,h.IOptionsService),r(6,n.IThemeService),r(7,n.ICoreBrowserService)],_)},3618(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const o=i(7098),n=i(6501);let a=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,o){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=o,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent="",this._pendingKeypressData=""}compositionstart(){this._isComposing=!0;const e=this._textarea.selectionStart??this._textarea.value.length,t=this._textarea.selectionEnd??e;this._compositionPosition.start=Math.min(e,t),this._compositionPosition.end=Math.max(e,t),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=`‎${e.data}‎`,this.updateCompositionElements(),setTimeout(()=>{const e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}keypress(e){return!!this._isSendingComposition&&(this._pendingKeypressData+=e,!0)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._pendingKeypressData="",this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){let i;if(this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const s=this._textarea.value,r=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;i=s.substring(e.start,Math.max(e.start,r))}this._sendCompositionInput(i)}},0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._sendCompositionInput(e)}}_sendCompositionInput(e){const t=this._pendingKeypressData;if(!e.includes(t))if(t.includes(e))e=t;else{let i=Math.min(e.length,t.length);for(;i>0&&!e.endsWith(t.substring(0,i));)i--;let s=Math.min(e.length,t.length);for(;s>0&&!t.endsWith(e.substring(0,s));)s--;e=i>s?e+t.substring(i):t+e.substring(s)}this._pendingKeypressData="",e.length>0&&this._coreService.triggerDataEvent(e,!0)}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;const e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0),0)}}};t.CompositionHelper=a,t.CompositionHelper=a=s([r(2,n.IBufferService),r(3,n.IOptionsService),r(4,n.ICoreService),r(5,o.IRenderService)],a)},5251(e,t){function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),o=parseInt(r.getPropertyValue("padding-left"),10),n=parseInt(r.getPropertyValue("padding-top"),10);return[t.clientX-s.left-o,t.clientY-s.top-n]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,o,n,a,h,l){if(!n)return;const c=i(e,t,s);return c[0]=Math.ceil((c[0]+(l?a/2:0))/a),c[1]=Math.ceil(c[1]/h),c[0]=Math.min(Math.max(c[0],1),r+(l?1:0)),c[1]=Math.min(Math.max(c[1],1),o),c}},9686(e,t){function i(e,t,i,o){const h=e-s(e,i),l=t-s(t,i),c=Math.abs(h-l)-function(e,t,i){let o=0;const n=e-s(e,i),a=t-s(t,i);for(let s=0;s=0&&et?"A":"B"}function o(e,t,i,s,r,o){let n=e,a=t,h="";for(;(n!==i||a!==s)&&a>=0&&ao.cols-1?(h+=o.buffer.translateBufferLineToString(a,!1,e,n),n=0,e=0,a++):!r&&n<0&&(h+=o.buffer.translateBufferLineToString(a,!1,0,e+1),n=o.cols-1,e=n,a--);return h+o.buffer.translateBufferLineToString(a,!1,e,n)}function n(e,t){return""+(t?"O":"[")+e}function a(e,t){e=Math.floor(e);let i="";for(let s=0;s0?h-s(h,l):t;const _=h,u=function(e,t,r,o,n,a){let h;return h=i(t,o,n,a).length>0?o-s(o,n):t,e=r&&he?"D":"C",a(Math.abs(l-e),n(d,h));d=c>t?"D":"C";const _=Math.abs(c-t);return a(function(e,t){return t.cols-e}(c>t?e:l,r)+(_-1)*r.cols+1+((c>t?l:e)-1),n(d,h))}},6081(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(f.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new _.ParserApi(this._core)}get unicode(){return this._checkProposedApi(),new u.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new d.BufferNamespaceApi(this._core))}get markers(){return this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.mouseStateService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return a.promptLabel.get()},set promptLabel(e){a.promptLabel.set(e)},get tooMuchOutput(){return a.tooMuchOutput.get()},set tooMuchOutput(e){a.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(p of e)if(p===1/0||isNaN(p)||p%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(p of e)if(p&&(p===1/0||isNaN(p)||p%1!=0||p<0))throw new Error("This API only accepts positive integers")}}t.Terminal=v},3955(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const o=i(1433),n=i(2744),a=i(9176),h=i(6181),l=i(2274),c=i(654),d=i(7098),_=i(4103),u=i(4812),f=i(6501),p=i(8636),v=i(4159);let g=1,m=class extends u.Disposable{constructor(e,t,i,s,r,a,d,_,f,m,b,w,y,C){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=d,this._charSizeService=f,this._optionsService=m,this._bufferService=b,this._coreService=w,this._coreBrowserService=y,this._themeService=C,this._terminalClass=g++,this._rowElements=[],this._selectionRenderModel=(0,l.createSelectionRenderModel)(),this._lastSelectionColumnMode=!1,this._rowHasBlinkingCells=[],this._rowHasBlinkingCellsCount=0,this._onRequestRedraw=this._register(new p.Emitter),this.onRequestRedraw=this._onRequestRedraw.event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(e=>this._injectCss(e))),this._injectCss(this._themeService.colors),this._rowFactory=_.createInstance(o.DomRendererRowFactory,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(e=>this._handleLinkHover(e))),this._register(this._linkifier2.onHideLinkUnderline(e=>this._handleLinkLeave(e))),this._cursorBlinkStateManager=new S(this._rowContainer,this._coreBrowserService),this._register((0,v.addDisposableListener)(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register((0,u.toDisposable)(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new c.TextBlinkStateManager(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register((0,u.toDisposable)(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new n.WidthCache,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${_.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${i} { color: ${s.css}; }${this._terminalSelector} .xterm-fg-${i}.xterm-dim { color: ${_.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .xterm-bg-${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR} { color: ${_.color.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${_.color.multiplyOpacity(_.color.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,i){const s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i);let r=0,o=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(r=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow));let n=0,a=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),this._selectionRenderModel.hasSelection){const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,o=this._selectionRenderModel.viewportCappedStartRow,h=this._selectionRenderModel.viewportCappedEndRow;n=o,a=h;const l=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];l.appendChild(this._createSelectionElement(o,i?t[0]:e[0],i?e[0]:t[0],h-o+1))}else{const i=s===o?e[0]:0,n=o===r?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(o,i,n));const a=h-o-1;if(l.appendChild(this._createSelectionElement(o+1,0,this._bufferService.cols,a)),o!==h){const e=r===h?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(h,0,e))}}this._selectionContainer.appendChild(l)}let h=Math.min(r,n),l=Math.max(o,a);if(l>=0){h=Math.max(h,0),l=Math.min(l,s-1);const e=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&e>=0&&ethis.dimensions.css.canvas.width&&(n=this.dimensions.css.canvas.width-o),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${o}px`,r.style.width=`${n}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),o=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,n=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle,h={hasBlinkingCells:!1};for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l];if(!t)continue;const c=i.lines.get(e);c?(t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,n,a,r,o,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,h)),this._setRowBlinkState(l,h.hasBlinkingCells)):(t.replaceChildren(),this._setRowBlinkState(l,!1))}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,o){i<0&&(e=0),s<0&&(t=0);const n=this._bufferService.rows-1;i=Math.max(Math.min(i,n),0),s=Math.max(Math.min(s,n),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,l=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle,u={hasBlinkingCells:!1};for(let n=i;n<=s;++n){const f=n+a.ydisp,p=this._rowElements[n];if(!p)continue;const v=a.lines.get(f);v?(p.replaceChildren(...this._rowFactory.createRow(v,f,f===h,d,_,l,c,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,o?n===i?e:0:-1,o?(n===s?t:r)-1:-1,u)),this._setRowBlinkState(n,u.hasBlinkingCells)):(p.replaceChildren(),this._setRowBlinkState(n,!1))}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};t.DomRenderer=m,t.DomRenderer=m=s([r(7,f.IInstantiationService),r(8,d.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,f.ICoreService),r(12,d.ICoreBrowserService),r(13,d.IThemeService)],m);class S{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){void 0!==this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}}},1433(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const o=i(9176),n=i(8938),a=i(3055),h=i(6501),l=i(4103),c=i(7098),d=i(945),_=i(6181),u=i(5451);let f=class{constructor(e,t,i,s,r,o,n){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=o,this._themeService=n,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,c,_,f,p,v,g){const m=[];g&&(g.hasBlinkingCells=!1);const S=this._characterJoinerService.getJoinedCharacters(t),b=this._themeService.colors;let w,y=e.getNoBgTrimmedLength();i&&y=A,F=I,W=this._workCell;if(S.length>0&&I===S[0][0]&&N){const s=S.shift(),r=this._isCellInSelection(s[0],t);for(C=s[0]+1;C=s[1],N?(H=!0,W=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),F=s[1]-1,y=W.getWidth()):A=s[1]}const z=this._isCellInSelection(I,t),K=i&&I===a,U=O&&I>=p&&I<=v;g&&W.isBlink()&&(g.hasBlinkingCells=!0),!c&&W.isBlink()&&P.push("xterm-blink-hidden");let j=!1;this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{j=!0});let $=W.getChars()||n.WHITESPACE_CELL_CHAR;if(" "===$&&(W.isUnderline()||W.isOverline())&&($=" "),k=y*_-f.get($,W.isBold(),W.isItalic()),w){if(D&&(z&&T||!z&&!T&&W.bg===L)&&(z&&T&&b.selectionForeground||W.fg===x)&&W.extended.ext===R&&U===M&&k===B&&!K&&!H&&!j&&N){W.isInvisible()?E+=n.WHITESPACE_CELL_CHAR:E+=$,D++;continue}D&&(w.textContent=E),w=this._document.createElement("span"),D=0,E=""}else w=this._document.createElement("span");if(L=W.bg,x=W.fg,R=W.extended.ext,M=U,B=k,T=z,H&&a>=I&&a<=F&&(a=I),!this._coreService.isCursorHidden&&K&&this._coreService.isCursorInitialized)if(P.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&P.push("xterm-cursor-blink"),P.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":P.push("xterm-cursor-outline");break;case"block":P.push("xterm-cursor-block");break;case"bar":P.push("xterm-cursor-bar");break;case"underline":P.push("xterm-cursor-underline")}if(W.isBold()&&P.push("xterm-bold"),W.isItalic()&&P.push("xterm-italic"),W.isDim()&&P.push("xterm-dim"),E=W.isInvisible()?n.WHITESPACE_CELL_CHAR:W.getChars()||n.WHITESPACE_CELL_CHAR,W.isUnderline()&&(P.push(`xterm-underline-${W.extended.underlineStyle}`)," "===E&&(E=" "),!W.isUnderlineColorDefault()))if(W.isUnderlineColorRGB())w.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(W.getUnderlineColor()).join(",")})`;else{let e=W.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&W.isBold()&&e<8&&(e+=8),w.style.textDecorationColor=b.ansi[e].css}W.isOverline()&&(P.push("xterm-overline")," "===E&&(E=" ")),W.isStrikethrough()&&P.push("xterm-strikethrough"),U&&(w.style.textDecoration="underline");let q=W.getFgColor(),V=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();const G=!!W.isInverse();if(G){const e=q;q=X,X=e;const t=V;V=Y,Y=t}let J,Z,Q,ee=!1;switch(this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{"top"!==e.options.layer&&ee||(e.backgroundColorRGB&&(Y=50331648,X=e.backgroundColorRGB.rgba>>8&16777215,J=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,q=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),ee="top"===e.options.layer)}),!ee&&z&&(J=this._coreBrowserService.isFocused?b.selectionBackgroundOpaque:b.selectionInactiveBackgroundOpaque,X=J.rgba>>8&16777215,Y=50331648,ee=!0,b.selectionForeground&&(V=50331648,q=b.selectionForeground.rgba>>8&16777215,Z=b.selectionForeground)),ee&&P.push("xterm-decoration-top"),Y){case 16777216:case 33554432:Q=b.ansi[X],P.push(`xterm-bg-${X}`);break;case 50331648:Q=l.channels.toColor(X>>16,X>>8&255,255&X),this._addStyle(w,`background-color:#${(X>>>0).toString(16).padStart(6,"0")}`);break;default:G?(Q=b.foreground,P.push(`xterm-bg-${o.INVERTED_DEFAULT_COLOR}`)):Q=b.background}switch(J||W.isDim()&&(J=l.color.multiplyOpacity(Q,.5)),V){case 16777216:case 33554432:W.isBold()&&q<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(q+=8),this._applyMinimumContrast(w,Q,b.ansi[q],W,J,void 0)||P.push(`xterm-fg-${q}`);break;case 50331648:const e=l.channels.toColor(q>>16&255,q>>8&255,255&q);this._applyMinimumContrast(w,Q,e,W,J,Z)||this._addStyle(w,`color:#${q.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(w,Q,b.foreground,W,J,Z)||G&&P.push(`xterm-fg-${o.INVERTED_DEFAULT_COLOR}`)}P.length&&(w.className=P.join(" "),P.length=0),K||H||j||!N?w.textContent=E:D++,k!==this.defaultSpacing&&(w.style.letterSpacing=`${k}px`),m.push(w),I=F}return w&&D&&(w.textContent=E),m}_applyMinimumContrast(e,t,i,s,r,o){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const n=this._getContrastCache(s);let a;if(r||o||(a=n.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=l.color.ensureContrastRatio(r??t,o??i,e),n.setColor((r??t).rgba,(o??i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};t.DomRendererRowFactory=f,t.DomRendererRowFactory=f=s([r(1,c.ICharacterJoinerService),r(2,h.IOptionsService),r(3,c.ICoreBrowserService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,c.IThemeService)],f)},2744(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0;const s=i(6181);t.WidthCache=class{constructor(e=()=>new r){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let o=this._holey.get(r);if(void 0===o){let s=0;t&&(s|=1),i&&(s|=2),o=this._measure(e,s),o>0&&this._holey.set(r,o)}return o}_measure(e,t){return this._canvasElements[t].measure(e)}};class r{constructor(){"undefined"!=typeof OffscreenCanvas?(this._canvas=new OffscreenCanvas(1,1),this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d")))}setFont(e,t,i,s){const r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}}},9176(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181(e,t){function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,o){return 1===t&&r>Math.ceil(1.5*o)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,o=t[1]-r,n=i[1]-r,a=Math.max(o,0),h=Math.min(n,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},654(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.TextBlinkStateManager=void 0;const s=i(4812);class r extends s.Disposable{constructor(e,t,i){super(),this._renderCallback=e,this._coreBrowserService=t,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",e=>{this.setIntervalDuration(e)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const e=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(e||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}t.TextBlinkStateManager=r},8501(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._domNodePointerDown(e)))}_createArrow(e){const t=this._register(new c.ScrollbarArrow(e));return this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode),t}_createSlider(e,t,i,s){this.slider=new h.FastDomNode(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(a.addDisposableListener(this.slider.domNode,a.eventType.POINTER_DOWN,e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))})),this._onclick(this.slider.domNode,e=>{e.leftButton&&e.stopPropagation()})}_handleElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._handlePointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._handlePointerDown(e)}_handlePointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=a.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{const r=this._sliderOrthogonalPointerPosition(e),o=Math.abs(r-i);if(u.isWindows&&o>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const n=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(n))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=f},1203(e,t){function i(e){return"number"==typeof e?`${e}px`:e}Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.FastDomNode=class{constructor(e){this.domNode=e,this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._className="",this._position="",this._layerHint=!1,this._contain="none"}setWidth(e){const t=i(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=i(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=i(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=i(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=i(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=i(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=e?"translate3d(0px, 0px, 0px)":"")}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}}},928(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{try{e.releasePointerCapture(t)}catch{}}))}catch{o=a.getWindow(e)}this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_MOVE,e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)})),this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_UP,e=>this.stopMonitoring(!0)))}}},9699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.horizontalScrollbarSize:0,2===t.horizontal?0:t.horizontalScrollbarSize,2===t.vertical?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"xterm-horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._handleElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(2===e.horizontal?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},3988(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;ni&&(s=i-t),s<0&&(s=0),r<0&&(r=0),n+r>o&&(n=o-r),n<0&&(n=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=o,this.scrollTop=n}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,o=this.height!==e.height,n=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:o,scrollHeightChanged:n,scrollTopChanged:a}}}t.ScrollState=o;class n extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0!==this._smoothScrollDuration){if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new l(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):l.start(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=l.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}else this.setScrollPositionNow(e)}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=n;class a{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}class l{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this._scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this._scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let n,a;return e0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),o=Math.abs(t.deltaX),n=Math.abs(t.deltaY),a=Math.max(Math.min(s,o),1),h=Math.max(Math.min(r,n),1),l=Math.max(s,o),c=Math.max(r,n);l%a===0&&c%h===0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}S.INSTANCE=new S;class b extends _.Widget{get options(){return this._options}constructor(e,t,i){let s;super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,t=t??{};const r=!i;i?s=i:(t.mouseWheelSmoothScroll=!1,s=new g.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)})),this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:1,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:1,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,v.isMac&&(t.className+=" xterm-mac"),t}(t),this._scrollable=s,this._register(this._scrollable.onScroll(e=>{this._handleScroll(e),this._onScroll.fire(e)})),r&&this._register(this._scrollable);const o={handleMouseWheel:e=>this._handleMouseWheel(e),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,o)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,o)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,e=>this._handleMouseOver(e)),this._onmouseleave(this._listenOnDomNode,e=>this._handleMouseLeave(e)),this._hideTimeout=this._register(new u.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,v.isMac&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalHasArrows&&(this._options.horizontalHasArrows=e.horizontalHasArrows),void 0!==e.verticalHasArrows&&(this._options.verticalHasArrows=e.verticalHasArrows),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new l.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._handleMouseWheel(new l.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.eventType.MOUSE_WHEEL,e,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const o=!v.isMac&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!o||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const n=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=n.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=n.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(n.scrollLeft!==a.scrollLeft||n.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" xterm-shadow-left":"",r=t?" xterm-shadow-top":"",o=i||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${r}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${o}${r}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet(()=>this._hide(),500)}}t.SmoothScrollableElement=b},9594(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._arrowPointerDown(e))),this._register(c.addStandardDisposableListener(this.domNode,c.eventType.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._pointerdownRepeatTimer=this._register(new c.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,c.getWindow(e))},200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault())}}t.ScrollbarArrow=d},1270(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,o){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setArrowSize(e){const t=Math.round(e);this._arrowSize!==t&&(this._arrowSize=t,this._refreshComputedValues())}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const o=Math.max(0,i-e),n=Math.max(0,o-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(n),computedSliderRatio:0,computedSliderPosition:0};const h=Math.round(Math.max(20,Math.floor(i*n/s))),l=(n-h)/(s-i),c=r*l;return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(h),computedSliderRatio:l,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" xterm-fade":"")))}}t.ScrollbarVisibilityController=o},2650(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{s||(s=!0,this._remove(i))}}_remove(e){if(e.prev!==_.Undefined&&e.next!==_.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===_.Undefined&&e.next===_.Undefined?(this._first=_.Undefined,this._last=_.Undefined):e.next===_.Undefined?(this._last=this._last.prev,this._last.next=_.Undefined):e.prev===_.Undefined&&(this._first=this._first.next,this._first.prev=_.Undefined)}*[Symbol.iterator](){let e=this._first;for(;e!==_.Undefined;)yield e.element,e=e.next}}var f;!function(e){e.TAP="-xterm-gesturetap",e.CHANGE="-xterm-gesturechange",e.START="-xterm-gesturestart",e.END="-xterm-gesturesend",e.CONTEXT_MENU="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends l.Disposable{constructor(){super(),this._dispatched=!1,this._targets=new u,this._ignoreTargets=new u,this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;const e=c;this._register(h.addDisposableListener(e.document,"touchstart",e=>this._handleTouchStart(e),{passive:!1})),this._register(h.addDisposableListener(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(h.addDisposableListener(e.document,"touchmove",e=>this._handleTouchMove(e),{passive:!1}))}static addTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._targets.push(e);return(0,l.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._ignoreTargets.push(e);return(0,l.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in c||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){const t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let i=0,s=e.targetTouches.length;i=p._holdDelay&&Math.abs(n.initialPageX-d(n.rollingPageX))<30&&Math.abs(n.initialPageY-d(n.rollingPageY))<30){const e=this._newGestureEvent(f.CONTEXT_MENU,n.initialTarget);e.pageX=d(n.rollingPageX),e.pageY=d(n.rollingPageY),this._dispatchEvent(e)}else if(1===s){const t=d(n.rollingPageX),s=d(n.rollingPageY),r=d(n.rollingTimestamps)-n.rollingTimestamps[0],o=t-n.rollingPageX[0],a=s-n.rollingPageY[0],h=[...this._targets].filter(e=>n.initialTarget instanceof Node&&e.contains(n.initialTarget));this._inertia(e,h,i,Math.abs(o)/r,o>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this._dispatchEvent(this._newGestureEvent(f.END,n.initialTarget)),delete this._activeTouches[o.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}_dispatchEvent(e){if(e.type===f.TAP){const t=(new Date).getTime();let i;i=t-this._lastSetTapCountTime>p._clearTapCountTime?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.CHANGE&&e.type!==f.CONTEXT_MENU||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this._ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this._targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort((e,t)=>e[0]-t[0]);for(const[,i]of t)i.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,i,s,r,o,n,a,l){this._handle=h.scheduleAtNextAnimationFrame(e,()=>{const h=Date.now(),c=h-i;let d=0,_=0,u=!0;s+=p._scrollFriction*c,n+=p._scrollFriction*c,s>0&&(u=!1,d=r*s*c),n>0&&(u=!1,_=a*n*c);const v=this._newGestureEvent(f.CHANGE);v.translationX=d,v.translationY=_,t.forEach(e=>e.dispatchEvent(v)),u||this._inertia(e,t,h,s,r,o+d,n,a,l+_)})}_handleTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}}t.Gesture=p,p._scrollFriction=-.005,p._holdDelay=700,p._clearTapCountTime=400,n([function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r||!s)throw new Error("not supported");const o=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(o)||Object.defineProperty(this,o,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[o]}}],p,"isTouchDevice",null)},8997(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition(),n=t.verticalHasArrows;super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(n?t.verticalScrollbarSize:0,2===t.vertical?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"xterm-vertical",scrollable:e,scrollByPage:t.scrollByPage}),this._arrowScrollDelta=0,this._setArrows(n,t.verticalScrollbarSize),this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._handleElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}_arrowScroll(e){const t=this._scrollable.getCurrentScrollPosition();this._scrollable.setScrollPositionNow({scrollTop:t.scrollTop+e})}_setArrows(e,t){if(this._arrowScrollDelta=t,!this._arrowUp||!this._arrowDown){const e=0;this._arrowUp=this._createArrow({className:"xterm-scra xterm-arrow-up",top:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})}if(this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;const i=e?"":"none";this._arrowUp.bgDomNode.style.display=i,this._arrowUp.domNode.style.display=i,this._arrowDown.bgDomNode.style.display=i,this._arrowDown.domNode.style.display=i}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){const t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},7741(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nt(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseover(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_OVER,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseleave(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_LEAVE,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}}t.Widget=c},5959(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):!!(this.selectionStart&&this.selectionStart[1]<0)&&(this.selectionStart=[0,0],!0)}}},4792(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const o=i(6501),n=i(4812),a=i(8636);let h=class extends n.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h,t.CharSizeService=h=s([r(2,o.IOptionsService)],h);class l extends n.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends l{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends l{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},o=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(5451),a=i(8938),h=i(3055),l=i(6501);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new h.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;tthis._screenDprMonitor.setWindow(e))),this._register(s.EventUtils.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",()=>this._isFocused=!0)),this._register((0,r.addDisposableListener)(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}}t.CoreBrowserService=n;class a extends o.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new o.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,o.toDisposable)(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},2136(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.KeyboardService=void 0;const o=i(706),n=i(7241),a=i(9249),h=i(701),l=i(6501);let c=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??=new a.Win32InputMode,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new n.KittyKeyboard,this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);const t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):(0,o.evaluateKeyboardEvent)(e,this._coreService.decPrivateModes.applicationCursorKeys,h.isMac,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);const t=this._coreService.kittyKeyboard.flags;return this.useKitty&&2&t?this._getKittyKeyboard().evaluate(e,t,3,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):void 0}get useKitty(){const e=this._coreService.kittyKeyboard.flags;return!(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard||!n.KittyKeyboard.shouldUseProtocol(e))}get useWin32InputMode(){return!(!this._optionsService.rawOptions.vtExtensions?.win32InputMode||!this._coreService.decPrivateModes.win32InputMode)}};t.KeyboardService=c,t.KeyboardService=c=s([r(0,l.ICoreService),r(1,l.IOptionsService)],c)},9820(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(4812);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8294(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseCoordsService=void 0;const o=i(4159),n=i(5251),a=i(7098);let h=class{constructor(e,t){this._charSizeService=e,this._renderService=t}getCoords(e,t,i,s,r){return(0,n.getCoords)((0,o.getWindow)(t),e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,n.getCoordsRelativeToElement)((0,o.getWindow)(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseCoordsService=h,t.MouseCoordsService=h=s([r(0,a.ICharSizeService),r(1,a.IRenderService)],h)},9784(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AltMouseCursorController=t.MouseService=void 0;const o=i(4159),n=i(6501),a=i(4812),h=i(7098),l=i(2650);let c=class{constructor(e,t,i,s,r,o,n,a,h){this._renderService=e,this._mouseCoordsService=t,this._mouseStateService=i,this._coreService=s,this._bufferService=r,this._optionsService=o,this._selectionService=n,this._logService=a,this._coreBrowserService=h,this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}bindMouse(e,t,i){const{element:s,document:r}=e,n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},h={target:e,focus:i,requestedEvents:n},c={mouseup:e=>this._handleMouseUp(h,e),wheel:e=>this._handleWheel(h,e),mousedrag:e=>this._handleMouseDrag(h,e),mousemove:e=>this._handleMouseMove(h,e)};this._altMouseCursor=new d(s,r,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),t(this._altMouseCursor),t(this._mouseStateService.onProtocolChange(e=>{this._handleProtocolChange(h,c,e)})),t(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(s),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,t((0,a.toDisposable)(()=>{n.mouseup&&r.removeEventListener("mouseup",n.mouseup),n.mousedrag&&r.removeEventListener("mousemove",n.mousedrag)})),t((0,o.addDisposableListener)(s,"mousedown",e=>this._handleMouseDown(h,e))),t((0,o.addDisposableListener)(s,"wheel",e=>this._handlePassiveWheel(h,e),{passive:!1})),t(l.Gesture.addTarget(e.screenElement)),t((0,o.addDisposableListener)(e.screenElement,l.EventType.START,()=>this._handleTouchStart())),t((0,o.addDisposableListener)(e.screenElement,l.EventType.CHANGE,e=>this._handleTouchChange(h,e)))}_sendEvent(e,t){const i=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;const e=t.deltaY;if(0===e)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return!1;r=e<0?0:1,s=4;break;default:return!1}if(void 0===r||void 0===s||s>4)return!1;if(4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!t.altKey)return!1;const o=4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:!o&&t.altKey,shift:t.shiftKey})}_handleMouseUp(e,t){this._sendEvent(e,t),t.buttons||(e.requestedEvents.mouseup&&e.target.document.removeEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.removeEventListener("mousemove",e.requestedEvents.mousedrag))}_handleWheel(e,t){return this._sendEvent(e,t),t.preventDefault(),t.stopPropagation(),!1}_handleMouseDrag(e,t){t.buttons&&this._sendEvent(e,t)}_handleMouseMove(e,t){t.buttons||this._sendEvent(e,t)}_handleMouseDown(e,t){t.preventDefault(),e.focus(),this._mouseStateService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(t)&&(this._sendEvent(e,t),e.requestedEvents.mouseup&&e.target.document.addEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.addEventListener("mousemove",e.requestedEvents.mousedrag))}_handlePassiveWheel(e,t){if(!e.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;if(!this._bufferService.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return t.preventDefault(),t.stopPropagation(),!1;const e=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(e,!0),t.preventDefault(),t.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(e,t){t.preventDefault(),t.stopPropagation(),e.requestedEvents.wheel?this._handleTouchScrollAsWheel(e,t):this._bufferService.buffer.hasScrollback?e.target.handleTouchScroll?.(t.translationY):this._handleTouchScrollAsKeys(t)}_handleTouchScrollAsKeys(e){const t=this._renderService?.dimensions.css.cell.height;if(!t)return;this._touchScrollAccumulator-=e.translationY;const i=Math.trunc(this._touchScrollAccumulator/t);if(0===i)return;this._touchScrollAccumulator-=i*t;const s=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(i<0?"A":"B");for(let e=0;e0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._mouseStateService.isPixelEncoding))return!1;if(!this._mouseStateService.restrictMouseEvent(e))return!1;const t=this._mouseStateService.encodeMouseEvent(e);return t&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}_explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.MouseService=c,t.MouseService=c=s([r(0,h.IRenderService),r(1,h.IMouseCoordsService),r(2,n.IMouseStateService),r(3,n.ICoreService),r(4,n.IBufferService),r(5,n.IOptionsService),r(6,h.ISelectionService),r(7,n.ILogService),r(8,h.ICoreBrowserService)],c);class d{constructor(e,t,i){this._element=e,this._document=t,this._isActive=i,this._listeners=new a.MutableDisposable}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;const e=new a.DisposableStore,t=e=>this.syncFromModifier(e);e.add((0,o.addDisposableListener)(this._document,"keydown",t)),e.add((0,o.addDisposableListener)(this._document,"keyup",t)),e.add((0,o.addDisposableListener)(this._element,"mousemove",t));const i=this._element.ownerDocument?.defaultView;i&&e.add((0,o.addDisposableListener)(i,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=e}resetClass(){this._updateClass(!1)}syncFromModifier(e){this._isActive()&&this._updateClass(e.getModifierState("Alt"))}_updateClass(e){e?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}}t.AltMouseCursorController=d},5783(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const o=i(4852),n=i(7098),a=i(4812),h=i(6168),l=i(6501),c=i(8636);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,n,l,d,u,f){super(),this._rowCount=e,this._optionsService=i,this._logService=s,this._charSizeService=r,this._coreService=n,this._coreBrowserService=u,this._renderer=this._register(new a.MutableDisposable),this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._pausedResizeTask=this._register(new h.DebouncedIdleTask(this._logService)),this._renderDebouncer=new o.RenderDebouncer((e,t)=>this._renderRows(e,t),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new _(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register((0,a.toDisposable)(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(d.onResize(()=>this._fullRefresh())),this._register(d.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(d.cols,d.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(d.buffer.y,d.buffer.y,void 0,!0))),this._register(f.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(e=>this._registerIntersectionObserver(e,t)))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver(e=>this._handleIntersectionChange(e[e.length-1]),{threshold:0});this._observerDisposable.value=(0,a.toDisposable)(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=i,i.observe(t)}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(e=>this.refreshRows(e.start,e.end,e.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,l.IOptionsService),r(3,l.ILogService),r(4,n.ICharSizeService),r(5,l.ICoreService),r(6,l.IDecorationService),r(7,l.IBufferService),r(8,n.ICoreBrowserService),r(9,n.IThemeService)],d);class _{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(e=>this._handleTrim(e)),this._register(this._bufferService.buffers.onBufferActivate(e=>this._handleBufferActivate(e))),this.enable(),this._model=new d.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,u.toDisposable)(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(e=>{e.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(b," ")).join(f.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),f.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:f.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(2===e.button&&this.hasSelection||0!==e.button||this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){const t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);const i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return(!this._optionsService.rawOptions.mouseEventsRequireAlt||!this._mouseStateService.areMouseEventsActive)&&e.altKey&&!(f.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,c.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(e=>this._handleTrim(e))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,o=r.lines.get(e[1]);if(!o)return;const n=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(o,e[0]),h=a;const l=e[0]-a;let c=0,d=0,_=0,u=0;if(" "===n.charAt(a)){for(;a>0&&" "===n.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(o.loadCell(t-1,this._workCell));){o.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+l-c+_,p=Math.min(this._bufferService.cols,h-a+c+d-_-u);if(t||""!==n.slice(a,h).trim()){if(i&&0===f&&32!==o.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&o.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==o.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=w,t.SelectionService=w=n([h(3,g.IBufferService),h(4,g.ICoreService),h(5,_.IMouseCoordsService),h(6,g.IOptionsService),h(7,g.IMouseStateService),h(8,_.IRenderService),h(9,_.ICoreBrowserService)],w)},7098(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IKeyboardService=t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.IMouseCoordsService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseCoordsService=(0,s.createDecorator)("MouseCoordsService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService"),t.IKeyboardService=(0,s.createDecorator)("KeyboardService")},9078(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const o=i(7174),n=i(9302),a=i(4103),h=i(4812),l=i(6501),c=i(8636),d=a.css.toColor("#ffffff"),_=a.css.toColor("#000000"),u=a.css.toColor("#ffffff"),f=_,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},v=d;let g=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new o.ColorContrastCache,this._halfContrastCache=new o.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:_,cursor:u,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(_,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(_,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:n.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}_setTheme(e={}){const t=this._colors;if(t.foreground=m(e.foreground,d),t.background=m(e.background,_),t.cursor=a.color.blend(t.background,m(e.cursor,u)),t.cursorAccent=a.color.blend(t.background,m(e.cursorAccent,f)),t.selectionBackgroundTransparent=m(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=m(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?m(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=m(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=m(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=m(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=m(e.overviewRulerBorder,v),t.ansi=n.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=m(e.black,n.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=m(e.red,n.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=m(e.green,n.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=m(e.yellow,n.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=m(e.blue,n.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=m(e.magenta,n.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=m(e.cyan,n.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=m(e.white,n.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=m(e.brightBlack,n.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=m(e.brightRed,n.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=m(e.brightGreen,n.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=m(e.brightYellow,n.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=m(e.brightBlue,n.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=m(e.brightMagenta,n.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=m(e.brightCyan,n.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=m(e.brightWhite,n.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;ssetTimeout(t,e))},t.disposableTimeout=function(e,t=0,i){const r=setTimeout(()=>{e(),i&&o.dispose()},t),o=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(o),o};const s=i(4812);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{e()},t);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},5639(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(4812),r=i(8636);class o extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(n||(t.channels=n={})),function(e){function t(e,t){return o=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:n.toCss(i,s,r,o),rgba:n.toRgba(i,s,r,o)}}e.blend=function(e,t){if(o=(255&t.rgba)/255,1===o)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,l=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),{css:n.toCss(i,s,r),rgba:n.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return n.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:n.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return o=255&e.rgba,t(e,o*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o=parseInt(e.slice(4,5).repeat(2),16),n.toColor(i,s,r,o);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1],10),s=parseInt(h[2],10),r=parseInt(h[3],10),o=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),n.toColor(i,s,r,o);if("transparent"===e)return{css:"transparent",rgba:0};if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,o]=t.getImageData(0,0,1,1).data,255!==o)throw new Error("css.toColor: Unsupported css format");return{rgba:n.toRgba(i,s,r,o),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,o=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(l||(t.rgb=l={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c0||a>0||h>0);)n-=Math.max(0,Math.ceil(.1*n)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));return(n<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c>>0}e.blend=function(e,t){if(o=(255&t)/255,1===o)return t;const a=t>>24&255,h=t>>16&255,l=t>>8&255,c=e>>24&255,d=e>>16&255,_=e>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),n.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=l.relativeLuminance(e>>8),o=l.relativeLuminance(i>>8);if(_(r,o)>8));if(n_(r,l.relativeLuminance(t>>8))?o:t}return o}const n=a(e,i,s),h=_(r,l.relativeLuminance(n>>8));if(h_(r,l.relativeLuminance(o>>8))?n:o}return n}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),o=i(7276),n=i(9640),a=i(56),h=i(4071),l=i(6478),c=i(7428),d=i(6415),_=i(5746),u=i(5882),f=i(2486),p=i(3562),v=i(8811),g=i(8636),m=i(4812);let S=!1;class b extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new g.Emitter),this.onRender=this._onRender.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(n.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(l.MouseStateService)),this._instantiationService.setService(s.IMouseStateService,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(d.UnicodeService)),this.unicodeService.register(new c.UnicodeV6),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(_.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(v.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new f.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(g.EventUtils.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(g.EventUtils.forward(this._bufferService.onResize,this._onResize)),this._register(g.EventUtils.forward(this.coreService.onData,this._onData)),this._register(g.EventUtils.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new p.WriteBuffer((e,t)=>this._inputHandler.parse(e,t))),this._register(g.EventUtils.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!S&&(this._logService.warn("writeSync is unreliable and will be removed soon."),S=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.backend&&void 0!==t.buildNumber&&(e=!!("conpty"===t.backend&&t.buildNumber<21376)),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)(()=>{for(const t of e)t.dispose()})}}}t.CoreTerminal=b},8636(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const s=i(4812);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners.push(r);const o=(0,s.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&this._listeners.splice(e,1)});return i&&(Array.isArray(i)?i.push(o):i.add(o)),o}),this._event}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:t,thisArgs:i}=this._listeners[0];return void t.call(i,e)}default:{const t=this._listeners.slice();for(const{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(e){e.forward=function(e,t){return e(e=>t.fire(e))},e.map=function(e,t){return(i,s,r)=>e(e=>i.call(s,t(e)),void 0,r)},e.any=function(...e){return(t,i,r)=>{const o=new s.DisposableStore;for(const s of e)o.add(s(e=>t.call(i,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(r||(t.EventUtils=r={}))},2486(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=L;const o=i(6760),n=i(6717),a=i(4812),h=i(726),l=i(6107),c=i(8938),d=i(3055),_=i(5451),u=i(6501),f=i(6415),p=i(1346),v=i(9823),g=i(2607),m=i(8693),S=i(8636),b=i(7804),w={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var C;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(C||(t.WindowsOptionsReportType=C={}));let k=0;class D extends a.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,a,c,d,_=new n.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=a,this._mouseStateService=c,this._unicodeService=d,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new S.Emitter),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new E(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(e=>this._activeBuffer=e.activeBuffer)),this._parser.setCsiHandlerFallback((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})}),this._parser.setEscHandlerFallback(e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})}),this._parser.setExecuteHandlerFallback(e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})}),this._parser.setOscHandlerFallback((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})}),this._parser.setDcsHandlerFallback((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setApcHandlerFallback((e,t,i)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setPrintHandler((e,t,i)=>this.print(e,t,i)),this._parser.registerCsiHandler({final:"@"},e=>this.insertChars(e)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},e=>this.scrollLeft(e)),this._parser.registerCsiHandler({final:"A"},e=>this.cursorUp(e)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},e=>this.scrollRight(e)),this._parser.registerCsiHandler({final:"B"},e=>this.cursorDown(e)),this._parser.registerCsiHandler({final:"C"},e=>this.cursorForward(e)),this._parser.registerCsiHandler({final:"D"},e=>this.cursorBackward(e)),this._parser.registerCsiHandler({final:"E"},e=>this.cursorNextLine(e)),this._parser.registerCsiHandler({final:"F"},e=>this.cursorPrecedingLine(e)),this._parser.registerCsiHandler({final:"G"},e=>this.cursorCharAbsolute(e)),this._parser.registerCsiHandler({final:"H"},e=>this.cursorPosition(e)),this._parser.registerCsiHandler({final:"I"},e=>this.cursorForwardTab(e)),this._parser.registerCsiHandler({final:"J"},e=>this.eraseInDisplay(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},e=>this.eraseInDisplay(e,!0)),this._parser.registerCsiHandler({final:"K"},e=>this.eraseInLine(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},e=>this.eraseInLine(e,!0)),this._parser.registerCsiHandler({final:"L"},e=>this.insertLines(e)),this._parser.registerCsiHandler({final:"M"},e=>this.deleteLines(e)),this._parser.registerCsiHandler({final:"P"},e=>this.deleteChars(e)),this._parser.registerCsiHandler({final:"S"},e=>this.scrollUp(e)),this._parser.registerCsiHandler({final:"T"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"X"},e=>this.eraseChars(e)),this._parser.registerCsiHandler({final:"Z"},e=>this.cursorBackwardTab(e)),this._parser.registerCsiHandler({final:"^"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"`"},e=>this.charPosAbsolute(e)),this._parser.registerCsiHandler({final:"a"},e=>this.hPositionRelative(e)),this._parser.registerCsiHandler({final:"b"},e=>this.repeatPrecedingCharacter(e)),this._parser.registerCsiHandler({final:"c"},e=>this.sendDeviceAttributesPrimary(e)),this._parser.registerCsiHandler({prefix:">",final:"c"},e=>this.sendDeviceAttributesSecondary(e)),this._parser.registerCsiHandler({final:"d"},e=>this.linePosAbsolute(e)),this._parser.registerCsiHandler({final:"e"},e=>this.vPositionRelative(e)),this._parser.registerCsiHandler({final:"f"},e=>this.hVPosition(e)),this._parser.registerCsiHandler({final:"g"},e=>this.tabClear(e)),this._parser.registerCsiHandler({final:"h"},e=>this.setMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"h"},e=>this.setModePrivate(e)),this._parser.registerCsiHandler({final:"l"},e=>this.resetMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"l"},e=>this.resetModePrivate(e)),this._parser.registerCsiHandler({final:"m"},e=>this.charAttributes(e)),this._parser.registerCsiHandler({final:"n"},e=>this.deviceStatus(e)),this._parser.registerCsiHandler({prefix:"?",final:"n"},e=>this.deviceStatusPrivate(e)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},e=>this.softReset(e)),this._parser.registerCsiHandler({prefix:">",final:"q"},e=>this.sendXtVersion(e)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},e=>this.setCursorStyle(e)),this._parser.registerCsiHandler({final:"r"},e=>this.setScrollRegion(e)),this._parser.registerCsiHandler({final:"s"},e=>this.saveCursor(e)),this._parser.registerCsiHandler({final:"t"},e=>this.windowOptions(e)),this._parser.registerCsiHandler({final:"u"},e=>this.restoreCursor(e)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},e=>this.insertColumns(e)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},e=>this.deleteColumns(e)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},e=>this.selectProtected(e)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},e=>this.requestMode(e,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},e=>this.requestMode(e,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},e=>this.kittyKeyboardSet(e)),this._parser.registerCsiHandler({prefix:"?",final:"u"},e=>this.kittyKeyboardQuery(e)),this._parser.registerCsiHandler({prefix:">",final:"u"},e=>this.kittyKeyboardPush(e)),this._parser.registerCsiHandler({prefix:"<",final:"u"},e=>this.kittyKeyboardPop(e)),this._parser.setExecuteHandler("",()=>this.bell()),this._parser.setExecuteHandler("\n",()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler("\t",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("„",()=>this.index()),this._parser.setExecuteHandler("…",()=>this.nextLine()),this._parser.setExecuteHandler("ˆ",()=>this.tabSet()),this._parser.registerOscHandler(0,new p.OscHandler(e=>(this.setTitle(e),this.setIconName(e),!0))),this._parser.registerOscHandler(1,new p.OscHandler(e=>this.setIconName(e))),this._parser.registerOscHandler(2,new p.OscHandler(e=>this.setTitle(e))),this._parser.registerOscHandler(4,new p.OscHandler(e=>this.setOrReportIndexedColor(e))),this._parser.registerOscHandler(8,new p.OscHandler(e=>this.setHyperlink(e))),this._parser.registerOscHandler(10,new p.OscHandler(e=>this.setOrReportFgColor(e))),this._parser.registerOscHandler(11,new p.OscHandler(e=>this.setOrReportBgColor(e))),this._parser.registerOscHandler(12,new p.OscHandler(e=>this.setOrReportCursorColor(e))),this._parser.registerOscHandler(104,new p.OscHandler(e=>this.restoreIndexedColor(e))),this._parser.registerOscHandler(110,new p.OscHandler(e=>this.restoreFgColor(e))),this._parser.registerOscHandler(111,new p.OscHandler(e=>this.restoreBgColor(e))),this._parser.registerOscHandler(112,new p.OscHandler(e=>this.restoreCursorColor(e))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},()=>this.selectCharset("("+e)),this._parser.registerEscHandler({intermediates:")",final:e},()=>this.selectCharset(")"+e)),this._parser.registerEscHandler({intermediates:"*",final:e},()=>this.selectCharset("*"+e)),this._parser.registerEscHandler({intermediates:"+",final:e},()=>this.selectCharset("+"+e)),this._parser.registerEscHandler({intermediates:"-",final:e},()=>this.selectCharset("-"+e)),this._parser.registerEscHandler({intermediates:".",final:e},()=>this.selectCharset("."+e)),this._parser.registerEscHandler({intermediates:"/",final:e},()=>this.selectCharset("/"+e));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(e=>(this._logService.error("Parsing error: ",e),e)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new v.DcsHandler((e,t)=>this.requestStatusString(e,t)))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=u.LogLevelEnum.WARN){let t;const i=new Promise((e,i)=>{t=setTimeout(()=>i("#SLOW_TIMEOUT"),5e3)});Promise.race([e,i]).then(()=>{void 0!==t&&clearTimeout(t)},e=>{if(void 0!==t&&clearTimeout(t),"#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,o=0;const n=this._parseStack.paused;if(n){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(o=this._parseStack.position+131072)}if(this._logService.logLevel<=u.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,e=>String.fromCharCode(e)).join("")}"`)),this._logService.logLevel===u.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map(e=>e.charCodeAt(0)):e),this._parseBuffer.length131072)for(let t=o;t0&&2===p.getWidth(this._activeBuffer.x-1)&&p.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(d){const e=p;let t=this._activeBuffer.x-m;if(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),p=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!p)return;for(m>0&&p instanceof l.BufferLine&&p.copyCellsFrom(e,t,0,m,!1);t=0;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(_&&(p.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===p.getWidth(a-1)&&p.setCellFromCodepoint(a-1,c.NULL_CELL_CODE,c.NULL_CELL_WIDTH,u)),p.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===p.getWidth(this._activeBuffer.x)&&!p.hasContent(this._activeBuffer.x)&&p.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new v.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new g.ApcHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const o=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);o&&(o.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(o.isWrapped=!1))}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols){const e=this._activeBuffer.lines.get(i+1);e&&(e.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`P>|xterm.js(${b.XTERM_VERSION})\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(o.triggerDataEvent(`[${t?"":"?"}${e};${i}$y`),!0),_=e=>e?1:2,u=e.params[0];return t?d(u,2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(c.convertEol):0):1===u?d(u,_(i.applicationCursorKeys)):3===u?d(u,c.windowOptions.setWinLines?80===a?2:132===a?1:0:0):6===u?d(u,_(i.origin)):7===u?d(u,_(i.wraparound)):8===u?d(u,3):9===u?d(u,_("X10"===s)):12===u?d(u,_(c.cursorBlink)):25===u?d(u,_(!o.isCursorHidden)):45===u?d(u,_(i.reverseWraparound)):66===u?d(u,_(i.applicationKeypad)):67===u?d(u,4):1e3===u?d(u,_("VT200"===s)):1002===u?d(u,_("DRAG"===s)):1003===u?d(u,_("ANY"===s)):1004===u?d(u,_(i.sendFocus)):1005===u?d(u,4):1006===u?d(u,_("SGR"===r)):1015===u?d(u,4):1016===u?d(u,_("SGR_PIXELS"===r)):1048===u?d(u,1):47===u||1047===u||1049===u?d(u,_(h===l)):2004===u?d(u,_(i.bracketedPasteMode)):2026===u?d(u,_(i.synchronizedOutput)):9001===u&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?d(u,_(i.win32InputMode)):d(u,0)}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-67108864,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,o=0;do{if(s[o+r]=e.params[t+o],e.hasSubParams(t+o)){const i=e.getSubParams(t+o);let n=0;do{5===s[1]&&(r=1),s[o+n+1+r]=i[n]}while(++n=2||2===s[1]&&o+r>=5)break;s[1]&&(r=1)}while(++o+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-67108864,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-67108864,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-67108864,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-67108864,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:221===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.fg&=-134217729:222===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.bg&=-134217729:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("");break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[${e};${t}R`)}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[?${e};${t}R`);break;case 15:case 25:case 26:case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??1)&&this._onRequestColorSchemeQuery.fire()}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(C.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(C.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let e=0;e1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e,10);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex(e=>e.startsWith("id="));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new d.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${e}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let e=0;e0;e++)i.flags=s.pop();return 0===s.length&&t>0&&(i.flags=0),!0}}t.InputHandler=D;let E=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(k=e,e=t,t=k),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}E=s([r(0,u.IBufferService)],E)},4812(e,t){function i(e){return{dispose:e}}function s(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=s,t.combinedDisposable=function(...e){return i(()=>s(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},7710(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,o){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,o)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isMac=t.isSafari=t.isLegacyEdge=t.isChrome=t.isFirefox=t.isNode=void 0,t.getZoomFactor=function(e){return 1},t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1],10)},t.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isChrome=i.includes("Chrome"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e,t){this._getKey=e,this._array=[],this._insertedValues=[],this._isFlushingInserted=!1,this._deletedIndices=[],this._isFlushingDeleted=!1,this._flushInsertedTask=new s.IdleTaskQueue(t),this._flushDeletedTask=new s.IdleTaskQueue(t)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort((e,t)=>this._getKey(e)-this._getKey(t));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(r),!0}while(++re-t);let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},4220(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.LimitedStringBuilder=t.StringBuilder=void 0;class i{constructor(){this._chunks=[],this._length=0}get length(){return this._length}reset(){this._chunks.length=0,this._length=0}append(e){this._chunks.push(e),this._length+=e.length}toString(){return this._chunks.join("")}}t.StringBuilder=i,t.LimitedStringBuilder=class{constructor(e){this._limit=e,this._builder=new i}get length(){return this._builder.length}get limit(){return this._limit}reset(){this._builder.reset()}append(e){return this._builder.append(e),this._builder.length>this._limit&&(this._builder.reset(),!0)}toString(){return this._builder.toString()}}},6168(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;class i{constructor(e){this._tasks=[],this._i=0,this._logService=e}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ii)return r-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=s,t.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:s,t.DebouncedIdleTask=class{constructor(e){this._queue=new t.IdleTaskQueue(e)}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},7804(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.XTERM_VERSION=void 0,t.XTERM_VERSION="6.1.0-beta.287"},5882(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(4812),o=i(6168),n=i(5451),a=i(6107),h=i(3326),l=i(732),c=i(3055),d=i(8938),_=i(8158),u=i(6760);t.MAX_BUFFER_SIZE=4294967295;class f extends r.Disposable{constructor(e,t,i,n){super(),this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this._logService=n,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=a.DEFAULT_ATTR_DATA.clone(),this.savedCharset=u.DEFAULT_CHARSET,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=c.CellData.fromCharData([0,d.NULL_CELL_CHAR,d.NULL_CELL_WIDTH,d.NULL_CELL_CODE]),this._whitespaceCell=c.CellData.fromCharData([0,d.WHITESPACE_CELL_CHAR,d.WHITESPACE_CELL_WIDTH,d.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new o.IdleTaskQueue(this._logService),this._register((0,r.toDisposable)(()=>this._memoryCleanupQueue.clear())),this._register((0,r.toDisposable)(()=>this.clearAllMarkers())),this._stringCache=this._register(new h.BufferLineStringCache)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new a.BufferLine(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){e??=a.DEFAULT_ATTR_DATA;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(a.DEFAULT_ATTR_DATA);this._stringCache.clear();let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+o+1?(this.ybase--,o++,this.ydisp>0&&this.ydisp--):this.lines.push(new a.BufferLine(this._stringCache,e,i,!1)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),o&&(this.y+=o),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t0){const e=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,e)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,l.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(a.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,l.reflowLargerCreateNewLayout)(this.lines,s);(0,l.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(a.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=n&&e0&&(r.push({start:n+c.length+o,newLines:p}),o+=p.length),c.push(...p);let v=_.length-1,g=_[v];0===g&&(v--,g=_[v]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[v])break;if(c[v].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(v--,g=_[v]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,l.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;l--)if(a&&a.start>s+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(l--,a.newLines[e]);l++,e.push({index:s+1,amount:a.newLines.length}),h+=a.newLines.length,a=r[++n]}else this.lines.set(l,t[s--]);let l=0;for(let t=e.length-1;t>=0;t--)e[t].index+=l,this.lines.onInsertEmitter.fire(e[t]),l+=e[t].amount;const c=Math.max(0,i+o-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(e=>{t.line>=e.index&&(t.line+=e.amount)})),t.register(this.lines.onDelete(e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}t.Buffer=f},6107(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),o=i(8938),n=i(726),a=i(4220);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let h=0;const l=new r.CellData,c=new a.StringBuilder;class d{constructor(e,t,i,s=!1){this._stringCache=e,this.isWrapped=s,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const n=i??r.CellData.fromCharData([0,o.NULL_CELL_CHAR,o.NULL_CELL_WIDTH,o.NULL_CELL_CODE]);for(let e=0;e>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._invalidateStringCache(),this._data[3*e+1]=t[o.CHAR_DATA_ATTR_INDEX],t[o.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[o.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[o.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[o.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,n.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,i){return h=3*e,i.content=this._data[h+0],i.fg=this._data[h+1],i.bg=this._data[h+2],2097152&i.content?i.combinedData=this._combined[e]:i.combinedData="",268435456&i.bg?i.extended=this._extendedAttrs[e]:i.extended=t.DEFAULT_ATTR_DATA.extended.clone(),i}setCell(e,t){this._invalidateStringCache(),2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){this._invalidateStringCache(),268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){this._invalidateStringCache();let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,n.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,n.stringFromCodePoint)(2097151&s)+(0,n.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if(this._invalidateStringCache(),(e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,l));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){this._invalidateStringCache();const o=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=o[3*(t+r)+e];this._copyCellMapsFrom(e,t+r,i+r)}else for(let r=0;r>22||1}s&&s.push(t);const h=c.toString();if(c.reset(),r){const t=this._getStringCacheEntry(!0);t.value=h,t.isTrimmed=!!e}return h}_getStringCacheEntry(e){const t=this._stringCacheEntryRef?.deref();if(t&&t.generation===this._stringCache.generation)return t;if(!e)return;const i=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(i),i}_invalidateStringCache(){const e=this._getStringCacheEntry(!1);e&&(e.value=void 0,e.isTrimmed=!1)}_copyCellMapsFrom(e,t,i){const s=3*t;2097152&e._data[s+0]&&(this._combined[i]=e._combined[t]),268435456&e._data[s+2]&&(this._extendedAttrs[i]=e._extendedAttrs[t])}_copySparseMapsFrom(e){this._combined={},this._extendedAttrs={};for(let t=0;tthis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){const e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(const e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),this._clearTimeout.value||this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=(0,s.disposableTimeout)(()=>{const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this.clear():this._scheduleClearTimeout(15e3-e)},e)}}t.BufferLineStringCache=o},9384(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732(e,t){function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,o,n){const a=[];for(let h=0;h=h&&r0&&(e>_||0===d[e].getTrimmedLength());e--)v++;v>0&&(a.push(h+d.length-v),a.push(v)),h+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],o=0;for(let n=0;nl&&(n-=l,a++);const c=2===e[a].getWidth(n-1);c&&n--;const d=c?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},4097(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(4812),r=i(1073),o=i(8636);class n extends s.Disposable{constructor(e,t,i){super(),this._optionsService=e,this._bufferService=t,this._logService=i,this._normalBuffer=this._register(new s.MutableDisposable),this._altBuffer=this._register(new s.MutableDisposable),this._onBufferActivate=this._register(new o.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=n},3055(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),o=i(5451);class n extends o.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new o.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new n;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor())return!1;if(this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor())return!1;if(this.isInverse()!==e.isInverse())return!1;if(this.isBold()!==e.isBold())return!1;if(this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;const t=this.isUnderlineColorDefault(),i=e.isUnderlineColorDefault();if(!t||!i){if(t!==i)return!1;if(this.getUnderlineColor()!==e.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==e.getUnderlineColorMode())return!1}}return this.isOverline()===e.isOverline()&&this.isBlink()===e.isBlink()&&this.isInvisible()===e.isInvisible()&&this.isItalic()===e.isItalic()&&this.isDim()===e.isDim()&&this.isStrikethrough()===e.isStrikethrough()}}t.CellData=n},8938(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(4812),r=i(8636);class o{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=o._nextId++,this._onDispose=this.register(new r.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,s.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=o,o._nextId=1},6760(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},706(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,s,r){const o={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?"OA":"":"UIKeyInputLeftArrow"===e.key?o.key=t?"OD":"":"UIKeyInputRightArrow"===e.key?o.key=t?"OC":"":"UIKeyInputDownArrow"===e.key&&(o.key=t?"OB":"");break;case 8:o.key=e.ctrlKey?"\b":"",e.altKey&&(o.key=""+o.key);break;case 9:if(e.shiftKey){o.key="";break}o.key="\t",o.cancel=!0;break;case 13:"c"===e.key&&e.ctrlKey?o.key="":o.key=e.altKey?"\r":"\r",o.cancel=!0;break;case 27:o.key="",e.altKey&&(o.key=""),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"D":t?"OD":"";break;case 39:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"C":t?"OC":"";break;case 38:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"A":t?"OA":"";break;case 40:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"B":t?"OB":"";break;case 45:e.shiftKey||e.ctrlKey||(o.key="[2~");break;case 46:o.key=n?"[3;"+(n+1)+"~":"[3~";break;case 36:o.key=n?"[1;"+(n+1)+"H":t?"OH":"";break;case 35:o.key=n?"[1;"+(n+1)+"F":t?"OF":"";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key="[5;"+(n+1)+"~":o.key="[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key="[6;"+(n+1)+"~":o.key="[6~";break;case 112:o.key=n?"[1;"+(n+1)+"P":"OP";break;case 113:o.key=n?"[1;"+(n+1)+"Q":"OQ";break;case 114:o.key=n?"[1;"+(n+1)+"R":"OR";break;case 115:o.key=n?"[1;"+(n+1)+"S":"OS";break;case 116:o.key=n?"[15;"+(n+1)+"~":"[15~";break;case 117:o.key=n?"[17;"+(n+1)+"~":"[17~";break;case 118:o.key=n?"[18;"+(n+1)+"~":"[18~";break;case 119:o.key=n?"[19;"+(n+1)+"~":"[19~";break;case 120:o.key=n?"[20;"+(n+1)+"~":"[20~";break;case 121:o.key=n?"[21;"+(n+1)+"~":"[21~";break;case 122:o.key=n?"[23;"+(n+1)+"~":"[23~";break;case 123:o.key=n?"[24;"+(n+1)+"~":"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(s&&!r||!e.altKey||e.metaKey)if(!s||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey){if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length)o.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":o.key="";break;case"Digit2":o.key="\0";break;case"Digit6":o.key=""}}else 65===e.keyCode&&(o.type=1);else{const t=i[e.keyCode],s=t?.[e.shiftKey?1:0];if(s)o.key=""+s;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=""+i}else if(32===e.keyCode)o.key=""+(e.ctrlKey?"\0":" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=""+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key="\0":e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key="":"/"===e.key?o.key="":219===e.keyCode?o.key="":220===e.keyCode?o.key="":221===e.keyCode&&(o.key="")}return o};const i={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},7241(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyKeyboard=void 0,t.KittyKeyboard=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){const t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e,t){const i=this._getNumpadKeyCode(e);if(void 0!==i)return i;const s=this._getModifierKeyCode(e);if(void 0!==s)return s;const r=this._functionalKeyCodes[e.key];if(void 0!==r)return r;if((e.shiftKey||t&&e.altKey)&&e.code){if(e.code.startsWith("Digit")&&6===e.code.length){const t=e.code.charAt(5);if(t>="0"&&t<="9")return t.charCodeAt(0)}if(e.code.startsWith("Key")&&4===e.code.length)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(1===e.key.length){const t=e.key.codePointAt(0);return t>=65&&t<=90?t+32:t}}_isModifierKey(e){return"Shift"===e.key||"Control"===e.key||"Alt"===e.key||"Meta"===e.key}_isLockKey(e){return"CapsLock"===e.key||"NumLock"===e.key||"ScrollLock"===e.key}_buildCsiLetterSequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"["+e}_buildSs3Sequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"O"+e}_buildCsiTildeSequence(e,t,i,s){const r=s&&1!==i;let o="["+e;return(t>0||r)&&(o+=";"+(t>0?t:"1"),r&&(o+=":"+i)),o+="~",o}_buildCsiUSequence(e,t,i,s,r,o,n){const a=!!(2&r);let h,l="["+t;4&r&&e.shiftKey&&1===e.key.length&&!o&&!n&&(h=e.key.codePointAt(0),l+=":"+h);const c=16&r&&3!==s&&1===e.key.length&&!o&&!n&&!e.ctrlKey?e.key.codePointAt(0):void 0,d=a&&1!==s&&(3===s||void 0===c);return(i>0||d||void 0!==c)&&(l+=";",i>0?l+=i:d&&(l+="1"),d&&(l+=":"+s)),void 0!==c&&(l+=";"+c),l+="u",l}evaluate(e,t,i=1,s=!1){const r={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(e),n=this._isModifierKey(e),a=!!(2&t);if(!a&&3===i)return r;if(n&&!(8&t))return r;if(this._isLockKey(e)&&!(8&t))return r;const h=this._csiLetterKeys[e.key];if(h)return r.key=this._buildCsiLetterSequence(h,o,i,a),r.cancel=!0,r;const l=this._ss3FunctionKeys[e.key];if(l)return r.key=this._buildSs3Sequence(l,o,i,a),r.cancel=!0,r;const c=this._csiTildeKeys[e.key];if(void 0!==c)return r.key=this._buildCsiTildeSequence(c,o,i,a),r.cancel=!0,r;const d=this._getKeyCode(e,s);if(void 0===d)return r;const _=13===d||9===d||127===d;if(_&&3===i&&!(8&t))return r;const u=void 0!==this._functionalKeyCodes[e.key]||void 0!==this._getNumpadKeyCode(e);if(8&t||a&&3===i||(1&t||a)&&(u&&!_||o>0&&1!==e.key.length||o-1>1))r.key=this._buildCsiUSequence(e,d,o,i,t,u,n),r.cancel=!0;else{const t=13===d?"\r":9===d?"\t":127===d?"":void 0;t?r.key=t:1!==e.key.length||e.ctrlKey||e.altKey||e.metaKey||(r.key=e.key)}return r}static shouldUseProtocol(e){return e>0}}},726(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let o=r;o=i)return this._interim=r,s;const n=e.charCodeAt(o);56320<=n&&n<=57343?t[s++]=1024*(r-55296)+n-56320+65536:(t[s++]=r,t[s++]=n);continue}65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,o,n,a,h=0,l=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let o,n=0;for(;(o=this.interim[++n])&&n<4;)r<<=6,r|=63&o;const a=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=a-n;for(;l=i)return 0;if(o=e[l++],128!=(192&o)){l--,s=!0;break}this.interim[n++]=o,r<<=6,r|=63&o}s||(2===a?r<128?l--:t[h++]=r:3===a?r<2048||r>=55296&&r<=57343||65279===r||(t[h++]=r):r<65536||r>1114111||(t[h++]=r)),this.interim.fill(0)}const c=i-4;let d=l;for(;d=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(a=(31&s)<<6|63&r,a<128){d--;continue}t[h++]=a}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(a=(15&s)<<12|(63&r)<<6|63&o,a<2048||a>=55296&&a<=57343||65279===a)continue;t[h++]=a}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=o,h;if(n=e[d++],128!=(192&n)){d--;continue}if(a=(7&s)<<18|(63&r)<<12|(63&o)<<6|63&n,a<65536||a>1114111)continue;t[h++]=a}}return h}}},7428(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],o=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let n;t.UnicodeV6=class{constructor(){if(this.version="6",!n){n=new Uint8Array(65536),n.fill(1),n[0]=0,n.fill(0,1,32),n.fill(0,127,160),n.fill(2,4352,4448),n[9001]=2,n[9002]=2,n.fill(2,11904,42192),n[12351]=1,n.fill(2,44032,55204),n.fill(2,63744,64256),n.fill(2,65040,65050),n.fill(2,65072,65136),n.fill(2,65280,65377),n.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},9249(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Win32InputMode=void 0,t.Win32InputMode=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){const t=this._codeToVk[e.code];return void 0!==t?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if("Enter"===e.key)return 10;if("Backspace"===e.key)return 127}const t=this._keyToControlChar[e.key];if(void 0!==t)return t;if(1===e.key.length){const t=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&("ControlRight"===e.code?t|=4:t|=8),e.altKey&&("AltRight"===e.code?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){return{type:0,cancel:!0,key:`[${this._getVirtualKeyCode(e)};${this._getScanCode(e)};${this._getUnicodeChar(e)};${t?1:0};${this._getControlKeyState(e)};1_`}}}},3562(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(3132),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._innerWriteTimer=this._register(new s.TimeoutTimer),this._onWriteParsed=this._register(new o.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._register((0,r.toDisposable)(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed)return;if(this._isSyncWriting)return;let e;this._isSyncWriting=!0;let t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);const i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>{this._store.isDisposed||(performance.now()-i>=12?this._scheduleInnerWrite(0,e):this._innerWrite(i,e))};return void s.catch(e=>(queueMicrotask(()=>{throw e}),Promise.resolve(!1))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(t.startsWith("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(t.startsWith("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,o]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(o,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},2607(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ApcHandler=t.ApcParser=void 0;const s=i(726),r=i(4220),o=[];t.ApcParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._ident=0}start(e){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._ident,"START")}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}end(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"END",e);this._active=o,this._ident=0}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.ApcHandler=n,n._payloadLimit=1e7},9823(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),o=i(4220),n=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=n,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=n,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=n,this._ident=0}};const a=new r.Params;a.addParam(0);class h{constructor(e){this._handler=e,this._data=new o.LimitedStringBuilder(h._payloadLimit),this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString(),this._params),t instanceof Promise))return t.then(e=>(this._params=a,this._data.reset(),this._hitLimit=!1,e));return this._params=a,this._data.reset(),this._hitLimit=!1,t}}t.DcsHandler=h,h._payloadLimit=1e7},6717(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(4812),r=i(7262),o=i(1346),n=i(9823),a=i(2607);class h{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;rt),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const o=i(0,17);e.setDefault(1,0),e.addMany(s,0,2,0);for(const t of o)e.addMany([24,26,153,154],t,3,0),e.addMany(i(128,144),t,3,0),e.addMany(i(144,152),t,3,0),e.add(156,t,0,0),e.add(27,t,11,1),e.add(157,t,4,8),e.addMany([152,158],t,0,7),e.add(159,t,11,14),e.add(155,t,11,3),e.add(144,t,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,11,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany(i(32,48),14,9,15),e.addMany(i(48,127),14,15,16),e.addMany(i(48,127),15,15,16),e.addMany(r,15,0,15),e.addMany(i(32,48),15,9,15),e.add(127,15,0,15),e.addMany(s,16,16,16),e.addMany(r,16,0,16),e.addMany(i(8,14),16,16,16),e.add(127,16,0,16),e.addMany([27,156,24,26],16,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e.add(l,16,16,16),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new o.OscParser),this._dcsParser=this._register(new n.DcsParser),this._apcParser=this._register(new a.ApcParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i<60||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);this._escHandlers[i]??=[];const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){const i=e.charCodeAt(0);this._executeHandlers[i]=t,i<24&&(this._executeHandlersArr[i]=t)}clearExecuteHandler(e){const t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);this._csiHandlers[i]??=[];const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r,o,n=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let r=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](this._params),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 4:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=255&this._parseStack.transition}for(let i=n;i=60&&n<=63&&(this._collect=n,s++);let a=!1;for(;s=48&&n<=57)this._params.addDigit(n-48);else if(59===n)this._params.addParam(0);else{if(58!==n){if(n>=64&&n<=126){const e=this._csiHandlers[this._collect<<8|n];let t=e?e.length-1:-1;for(;t>=0&&(o=e[t](this._params),!0!==o);t--)if(o instanceof Promise)return r=1792,this._preserveStack(3,e,t,r,s),o;t<0&&this._csiHandlerFb(this._collect<<8|n,this._params),this.precedingJoinState=0,i=s,this.currentState=0,a=!0;break}break}this._params.addSubParam(-1)}a||(i=s-1,this.currentState=4);continue}switch(r=this._transitions.table[this.currentState<<8|(s>8){case 2:let n=i;const a=t-4;for(;n=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l););if(n>=a)for(;n=32&&(e[n]<=126||e[n]>=l);)n++;this._printHandler(e,i,n),i=n-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:i,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:const h=this._csiHandlers[this._collect<<8|s];let c=h?h.length-1:-1;for(;c>=0&&(o=h[c](this._params),!0!==o);c--)if(o instanceof Promise)return this._preserveStack(3,h,c,r,i),o;c<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do{switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}}while(++i47&&s<60);i--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:const d=this._escHandlers[this._collect<<8|s];let _=d?d.length-1:-1;for(;_>=0&&(o=d[_](),!0!==o);_--)if(o instanceof Promise)return this._preserveStack(4,d,_,r,i),o;_<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let r=i+1;;++r)if(r>=t||24===(s=e[r])||26===s||27===s||s>127&&s=t||(s=e[r])<32||s>127&&s=32&&e[s]<127||e[s]>=8&&e[s]<14||e[s]>=l))){this._apcParser.put(e,i,s),i=s-1;break}break;case 17:if(o=this._apcParser.end(24!==s&&26!==s),o)return this._preserveStack(7,[],0,r,i),o;27===s&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0}this.currentState=255&r}}}t.EscapeSequenceParser=c},1346(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(726),r=i(4220),o=[];t.OscParser=class{constructor(){this._state=0,this._active=o,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,s.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=o,this._id=-1,this._state=0}}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.OscHandler=n,n._payloadLimit=1e7},7262(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;class i{static fromArray(e){const t=new i;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new i(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>2147483647?2147483647:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=e>2147483647?2147483647:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const i=this._digitIsSub?this._subParams:this.params,s=i[t-1];i[t-1]=~s?Math.min(10*s+e,2147483647):e}}t.Params=i},3027(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new o.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=n},6097(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,e=>t(e.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(e,i)=>t(e,i.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}}},4335(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=void 0;const o=i(4812),n=i(4097),a=i(6501),h=i(8636);let l=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e,t){super(),this.isUserScrolling=!1,this._onResize=this._register(new h.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new h.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new n.BufferSet(e,this,t)),this._register(this.buffers.onBufferActivate(e=>{this._onScroll.fire(e.activeBuffer.ydisp)}))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,o=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;o===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(o+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=o-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(o,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=l,t.BufferService=l=s([r(0,a.IOptionsService),r(1,a.ILogService)],l)},5746(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},4071(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const o=i(4812),n=i(6501),a=i(8636),h=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0});let c=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new a.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new a.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new a.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new a.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}reset(){this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onBinary.fire(e))}};t.CoreService=c,t.CoreService=c=s([r(0,n.IBufferService),r(1,n.ILogService),r(2,n.IOptionsService)],c)},4720(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationLineCache=t.DecorationService=void 0;const o=i(3132),n=i(4103),a=i(4812),h=i(6501),l=i(3087),c=i(8636);let d=0,_=0,u=class extends a.Disposable{get decorations(){return this._decorations.values()}constructor(e,t){super(),this._logService=e,this._bufferService=t,this._lineCache=this._register(new f),this._onDecorationRegistered=this._register(new c.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new c.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._decorations=new l.SortedList(e=>e?.marker.line,this._logService),this._register((0,a.toDisposable)(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}registerDecoration(e){if(e.marker.isDisposed)return;const t=new p(e);if(t){const e=t.marker.onDispose(()=>t.dispose()),i=t.onDispose(()=>{i.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),e.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,i){const s=this._lineCache.getDecorationsOnLine(t);if(s)for(const t of s)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&(yield t)}forEachDecorationAtCell(e,t,i,s){const r=this._lineCache.getDecorationsOnLine(t);if(r)for(const t of r)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&s(t)}};t.DecorationService=u,t.DecorationService=u=s([r(0,h.ILogService),r(1,h.IBufferService)],u);class f extends a.Disposable{constructor(){super(...arguments),this._decorationsByLine=new Map,this._decorations=new Set,this._bufferLineListeners=this._register(new a.MutableDisposable),this._lineIndexSyncTimer=this._register(new o.MicrotaskTimer),this._lineIndexSyncCallbacks=[]}clear(){this._lineIndexSyncCallbacks.length=0,this._lineIndexSyncTimer.cancel(),this._decorationsByLine.clear(),this._decorations.clear()}add(e){this._decorations.add(e),this._addToLineBuckets(e)}remove(e){this._decorations.delete(e),this._removeFromLineBuckets(e)}getDecorationsOnLine(e){return this._decorationsByLine.get(e)}attachToBufferLines(e){const t=new a.DisposableStore;this._bufferLineListeners.value=t,t.add(e.onTrim(e=>this._handleBufferLinesTrim(e))),t.add(e.onInsert(e=>this._handleBufferLinesInsert(e))),t.add(e.onDelete(e=>this._handleBufferLinesDelete(e)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){const t=e.marker.line;if(t<0)return;e._indexedStartLine=t;const i=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{const e=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(const t of e)t()})}_handleBufferLinesTrim(e){if(e<=0)return;const t=new Map;for(const[i,s]of this._decorationsByLine){const r=i-e;r<0||this._mergeLineBucket(t,r,s)}this._decorationsByLine.clear();for(const[e,i]of t)this._decorationsByLine.set(e,i);for(const t of this._decorations)t.marker.isDisposed||(t._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,i){const s=e.get(t);if(s)for(let e=0,t=i.length;et&&(s.push(e),this._removeFromLineBuckets(e))}const r=new Map;for(const[e,s]of this._decorationsByLine){const o=e>=t?e+i:e;this._mergeLineBucket(r,o,s)}this._decorationsByLine.clear();for(const[e,t]of r)this._decorationsByLine.set(e,t);for(const e of this._decorations)e.marker.isDisposed||e._indexedStartLine>=t&&(e._indexedStartLine=e.marker.line);for(const e of s)this._addToLineBuckets(e)}_applyBufferLinesDelete(e){const t=e.index+e.amount,i=new Map;for(const[s,r]of this._decorationsByLine){if(s>=e.index&&s=t?s-e.amount:s;this._mergeLineBucket(i,o,r)}this._decorationsByLine.clear();for(const[e,t]of i)this._decorationsByLine.set(e,t);const s=[];for(const i of this._decorations){if(i.marker.isDisposed)continue;const r=i._indexedStartLine,o=this._getDecorationHeight(i);r>=t?i._indexedStartLine=i.marker.line:rt&&s.push(i)}for(const e of s)this._reindexDecoration(e)}}t.DecorationLineCache=f;class p extends a.DisposableStore{get backgroundColorRGB(){return null===this._cachedBg&&(this.options.backgroundColor?this._cachedBg=n.css.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return null===this._cachedFg&&(this.options.foregroundColor?this._cachedFg=n.css.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}constructor(e){super(),this.options=e,this.onRenderEmitter=this.add(new c.Emitter),this.onRender=this.onRenderEmitter.event,this._onDispose=this.add(new c.Emitter),this.onDispose=this._onDispose.event,this._cachedBg=null,this._cachedFg=null,this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}dispose(){this._onDispose.fire(),super.dispose()}}},6025(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class o{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=o,t.InstantiationService=class{constructor(){this._services=new o,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort((e,t)=>e.index-t.index),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const o=i.length>0?i[0].index:t.length;if(t.length!==o)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${o+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0;const o=i(4812),n=i(6501),a={trace:n.LogLevelEnum.TRACE,debug:n.LogLevelEnum.DEBUG,info:n.LogLevelEnum.INFO,warn:n.LogLevelEnum.WARN,error:n.LogLevelEnum.ERROR,off:n.LogLevelEnum.OFF};let h=class extends o.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=n.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function n(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const a=String.fromCharCode,h={DEFAULT:e=>{const t=[n(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${a(t[0])}${a(t[1])}${a(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.x};${e.y}${t}`}};class l extends s.Disposable{constructor(){super(),this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=this._register(new r.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(o))this.addProtocol(e,o[e]);for(const e of Object.keys(h))this.addEncoding(e,h[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return!this._customWheelEventHandler||!1!==this._customWheelEventHandler(e)}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return"DEFAULT"===this._activeEncoding}get isPixelEncoding(){return"SGR_PIXELS"===this._activeEncoding}}t.MouseStateService=l},56(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(4812),r=i(701),o=i(8636);t.DEFAULT_OPTIONS={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}};const n=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new o.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{-1!==e.indexOf(i)&&t()})}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=n.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"blinkIntervalDuration":if((i=Math.floor(i))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const o=i(6501);let n=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose(()=>this._removeMarkerFromLink(s,i)),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const o=t.addMarker(t.ybase+t.y),n={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(n,o)),this._entriesWithId.set(n.key,n),this._dataByLinkId.set(n.id,n),n.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every(e=>e.line!==t)){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose(()=>this._removeMarkerFromLink(i,e))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=n,t.OscLinkService=n=s([r(0,o.IBufferService)],n)},6201(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e.di$dependencies||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const i=function(e,t,s){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,i){t.di$target===t?t.di$dependencies.push({id:e,index:i}):(t.di$dependencies=[{id:e,index:i}],t.di$target=t)}(i,e,s)};return i._id=e,t.serviceRegistry.set(e,i),i},t.serviceRegistry=new Map},6501(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.IMouseStateService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.IMouseStateService=(0,s.createDecorator)("MouseStateService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8636);class r{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.Emitter,this.onChange=this._onChange.event}static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e,this._active||(this.activeVersion=e.version)}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let o=0;o=s)return t+this.wcwidth(n);const i=e.charCodeAt(o);56320<=i&&i<=57343?n=1024*(n-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(n,i);let h=r.extractWidth(a);r.extractShouldJoin(a)&&(h-=r.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=r}},t={};return function i(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s].call(o.exports,o,o.exports,i),o.exports}(6081)})()); ++!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,()=>(()=>{"use strict";var e={2840(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new c.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(e=>this._handleResize(e.rows))),this._register(this._terminal.onRender(e=>this._refreshRows(e.start,e.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(e=>this._handleChar(e))),this._register(this._terminal.onLineFeed(()=>this._handleChar("\n"))),this._register(this._terminal.onA11yTab(e=>this._handleTab(e))),this._register(this._terminal.onKey(e=>this._handleKey(e.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register((0,f.addDisposableListener)(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,d.toDisposable)(()=>{this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent=l.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],o=e?.translateToString(!0,void 0,void 0,t)||"",n=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===o.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=o,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",n),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent===l.tooMuchOutput.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,o;if(0===t?(r=i,o=this._rowElements.pop(),this._rowContainer.removeChild(o)):(r=this._rowElements.shift(),o=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),o.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let o=t=this._terminal.cols&&(++s,o=0),{row:s,column:o}},o=r(t),n=r(i);if(o&&n){if(o.row>n.row||o.row===n.row&&o.column>=n.column)throw new Error("invalid range");this._terminal.select(o.column,o.row,(n.row-o.row)*this._terminal.cols-o.column+n.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;ethis._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(e=>this._reportWindowsOptions(e))),this._register(this._inputHandler.onColor(e=>this._handleColorEvent(e))),this._register(I.EventUtils.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(I.EventUtils.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(I.EventUtils.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(I.EventUtils.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(e=>this._afterResize(e.cols,e.rows))),this._register((0,N.toDisposable)(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i;switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=E.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`]${i};${(0,M.toRgbString)(s)}\\`);break;case 1:if("ansi"===e)this._themeService.modifyColors(e=>e.ansi[t.index]=E.channels.toColor(...t.color));else{const i=e;this._themeService.modifyColors(e=>e[i]=E.channels.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index)}}}_reportColorScheme(){if(!this._themeService)return;const e=E.rgb.relativeLuminance(this._themeService.colors.background.rgba>>8)>8)?1:2;this.coreService.triggerDataEvent(`[?997;${e}n`)}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),o=this._renderService.dimensions.css.cell.width*r,n=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=n+"px",this.textarea.style.width=o+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,H.addDisposableListener)(this.element,"copy",e=>{this.hasSelection()&&(0,a.copyHandler)(e,this._selectionService)}));const e=e=>(0,a.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,H.addDisposableListener)(this.textarea,"paste",e)),this._register((0,H.addDisposableListener)(this.element,"paste",e)),x.isFirefox?this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>{2===e.button&&(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register((0,H.addDisposableListener)(this.element,"contextmenu",e=>{(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),x.isLinux&&this._register((0,H.addDisposableListener)(this.element,"auxclick",e=>{1===e.button&&(0,a.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)}))}_bindKeys(){this._register((0,H.addDisposableListener)(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register((0,H.addDisposableListener)(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register((0,H.addDisposableListener)(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register((0,H.addDisposableListener)(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",e=>this.element.classList.toggle("allow-transparency",e))),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,H.addDisposableListener)(this.screenElement,"mousemove",e=>this.updateCursorStyle(e))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",h.promptLabel.get()),x.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(g.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??("undefined"!=typeof window?window.document:null))),this._instantiationService.setService(C.ICoreBrowserService,this._coreBrowserService),this._register((0,H.addDisposableListener)(this.textarea,"focus",e=>this._handleTextAreaFocus(e))),this._register((0,H.addDisposableListener)(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(p.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(C.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(k.ThemeService),this._instantiationService.setService(C.IThemeService,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(v.CharacterJoinerService),this._instantiationService.setService(C.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(w.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(C.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange(e=>this._onRender.fire(e))),this._register(this._renderService.onDimensionsChange(e=>this._onDimensionsChange.fire({css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}))),this.onResize(e=>this._renderService.resize(e.cols,e.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(u.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(S.MouseCoordsService),this._instantiationService.setService(C.IMouseCoordsService,this._mouseCoordsService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(O.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(e){this._logService.error("onWillOpen handler threw an exception",e)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(c.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(y.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(C.ISelectionService,this._selectionService),this._mouseService=this._instantiationService.createInstance(b.MouseService),this._instantiationService.setService(C.IMouseService,this._mouseService),this._register(this._selectionService.onRequestScrollLines(e=>this.scrollLines(e.amount,e.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()})),this._register(I.EventUtils.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(d.BufferDecorationRenderer,this.screenElement)),this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>this._selectionService.handleMouseDown(e))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",e=>this._handleScreenReaderModeOptionChange(e)));const r=this.options.scrollbar?.showScrollbar??!0,o=this.options.scrollbar?.width;r&&o&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",e=>{const t=(e?.showScrollbar??!0)&&!!e?.width;!this._overviewRulerRenderer&&t&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:e=>this._viewport?.handleTouchScroll(e)},e=>this._register(e),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(f.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,a.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),e.preventDefault(),e.stopPropagation(),!1}if(1===i.type&&this.selectAll(),this._isThirdLevelShift(this.browser,e))return!0;if(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)return!0;if(!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;""!==i.key&&"\r"!==i.key||(this.textarea.value="");const s=this._keyboardService.useWin32InputMode&&W(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return;W(e)||this.focus();const t=this._keyboardService.evaluateKeyUp(e);if(t?.key){const i=this._keyboardService.useWin32InputMode&&W(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this._compositionHelper.keypress(t)||this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;efunction(e){const t=l(e);for(t.animFrameRequested=!1,t.current=t.next,t.next=[],t.inAnimationFrameRunner=!0;t.current.length>0;)t.current.sort(a.sort),t.current.shift().execute();t.inAnimationFrameRunner=!1}(e))),r};const s=i(3132);function r(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView;const i=e;return i?.view?i.view:window}class o{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s,e.addEventListener(t,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function n(e,t,i,s){return new o(e,t,i,s)}t.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class a{constructor(e,t){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){console.error(e)}}static sort(e,t){return t.priority-e.priority}}const h=new Map;function l(e){let t=h.get(e);return t||(t={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},h.set(e,t)),t}class c extends s.IntervalTimer{constructor(e){super(),this._defaultTarget=e?r(e):void 0}cancelAndSet(e,t,i){super.cancelAndSet(e,t,i??this._defaultTarget??window)}}t.WindowIntervalTimer=c},8906(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const o=i(4812),n=i(6501),a=i(7098),h=i(8636),l=i(4159);let c=class extends o.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseCoordsService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new h.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new h.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,o.toDisposable)(()=>{(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register((0,l.addDisposableListener)(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register((0,l.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach(e=>{e.link.dispose&&e.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,t=>{if(this._isMouseOut)return;const r=t?.map(e=>({link:e}));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=o;e<=n;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element);e&&this._askForLink(e,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t){const i=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseCoordsService),r(2,a.IRenderService),r(3,n.IBufferService),r(4,a.ILinkProviderService)],c)},7721(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const o={get:()=>r,set:e=>r=e};t.tooMuchOutput=o},3285(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const o=i(3055),n=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new o.CellData}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=this._workCell,n=i.getTrimmedLength();let a=-1,l=-1,c=!1;for(let t=0;tr?r.activate(e,t,d):h(0,t),hover:(e,t)=>r?.hover?.(e,t,d),leave:(e,t)=>r?.leave?.(e,t,d)})}c=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,a=o.extended.urlId):(l=-1,a=-1)}}t(s)}_getRangeWithLineWrap(e,t,i,s){let r=e,o=t,n=e,a=i;for(;0===o;){const e=this._bufferService.buffer.lines.get(r-1);if(!e?.isWrapped)break;const t=this._bufferService.buffer.lines.get(r-2);if(!t)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,i-1,s))break;let n=i-1;for(;n>0&&this._hasUrlId(t,n-1,s);)n--;r--,o=n}for(;;){const e=this._bufferService.buffer.lines.get(n-1);if(!e)break;if(a!==e.getTrimmedLength())break;const t=this._bufferService.buffer.lines.get(n);if(!t?.isWrapped)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,0,s))break;let r=1;for(;rthis._innerRefresh()),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)void 0!==this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],o=t[i/6%6|0],n=t[i%6];e.push({css:s.channels.toCss(r,o,n),rgba:s.channels.toRgba(r,o,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const o=i(7098),n=i(4812),a=i(6501),h=i(4159),l=i(8566),c=i(8636),d=i(7880);let _=class extends n.Disposable{constructor(e,t,i,s,r,o,a,_,u){super(),this._bufferService=i,this._coreService=r,this._optionsService=_,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1,this._needsSyncOnRender=!1;const f=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,h.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{f.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new l.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},f)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(o.onProtocolChange(e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{e.style.backgroundColor=a.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=a.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,n.toDisposable)(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,n.toDisposable)(()=>this._styleElement.remove())),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${a.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${a.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${a.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(e=>this._handleScroll(e)))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){const e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,i=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:i,verticalHasArrows:t}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._coreService.decPrivateModes.synchronizedOutput?this._needsSyncOnRender=!0:(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1))}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}handleTouchScroll(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};t.Viewport=_,t.Viewport=_=s([r(2,a.IBufferService),r(3,o.ICoreBrowserService),r(4,a.ICoreService),r(5,a.IMouseStateService),r(6,o.IThemeService),r(7,a.IOptionsService),r(8,o.IRenderService)],_)},4196(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const o=i(7098),n=i(4812),a=i(6501);let h=class extends n.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(e=>this._removeDecoration(e))),this._register((0,n.toDisposable)(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h,t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,o.ICoreBrowserService),r(3,a.IDecorationService),r(4,o.IRenderService)],h)},957(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const o=i(957),n=i(7098),a=i(4812),h=i(6501),l={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=class extends a.Disposable{get _width(){const e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??1?e?.width??0:0}constructor(e,t,i,s,r,n,h,l){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=h,this._coreBrowserService=l,this._colorZoneStore=new o.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)(()=>this._canvas?.remove()));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register((0,a.toDisposable)(()=>{void 0!==this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;const e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_,t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,n.IRenderService),r(5,h.IOptionsService),r(6,n.IThemeService),r(7,n.ICoreBrowserService)],_)},3618(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const o=i(7098),n=i(6501);let a=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,o){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=o,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent="",this._pendingKeypressData=""}compositionstart(){this._isComposing=!0;const e=this._textarea.selectionStart??this._textarea.value.length,t=this._textarea.selectionEnd??e;this._compositionPosition.start=Math.min(e,t),this._compositionPosition.end=Math.max(e,t),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=`‎${e.data}‎`,this.updateCompositionElements(),setTimeout(()=>{const e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}keypress(e){return!!this._isSendingComposition&&(this._pendingKeypressData+=e,!0)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._pendingKeypressData="",this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){let i;if(this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const s=this._textarea.value,r=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;i=s.substring(e.start,Math.max(e.start,r))}this._sendCompositionInput(i)}},0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._sendCompositionInput(e)}}_sendCompositionInput(e){const t=this._pendingKeypressData;if(!e.includes(t))if(t.includes(e))e=t;else{let i=Math.min(e.length,t.length);for(;i>0&&!e.endsWith(t.substring(0,i));)i--;let s=Math.min(e.length,t.length);for(;s>0&&!t.endsWith(e.substring(0,s));)s--;e=i>s?e+t.substring(i):t+e.substring(s)}this._pendingKeypressData="",e.length>0&&this._coreService.triggerDataEvent(e,!0)}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;const e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0),0)}}};t.CompositionHelper=a,t.CompositionHelper=a=s([r(2,n.IBufferService),r(3,n.IOptionsService),r(4,n.ICoreService),r(5,o.IRenderService)],a)},5251(e,t){function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),o=parseInt(r.getPropertyValue("padding-left"),10),n=parseInt(r.getPropertyValue("padding-top"),10);return[t.clientX-s.left-o,t.clientY-s.top-n]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,o,n,a,h,l){if(!n)return;const c=i(e,t,s);return c[0]=Math.ceil((c[0]+(l?a/2:0))/a),c[1]=Math.ceil(c[1]/h),c[0]=Math.min(Math.max(c[0],1),r+(l?1:0)),c[1]=Math.min(Math.max(c[1],1),o),c}},9686(e,t){function i(e,t,i,o){const h=e-s(e,i),l=t-s(t,i),c=Math.abs(h-l)-function(e,t,i){let o=0;const n=e-s(e,i),a=t-s(t,i);for(let s=0;s=0&&et?"A":"B"}function o(e,t,i,s,r,o){let n=e,a=t,h="";for(;(n!==i||a!==s)&&a>=0&&ao.cols-1?(h+=o.buffer.translateBufferLineToString(a,!1,e,n),n=0,e=0,a++):!r&&n<0&&(h+=o.buffer.translateBufferLineToString(a,!1,0,e+1),n=o.cols-1,e=n,a--);return h+o.buffer.translateBufferLineToString(a,!1,e,n)}function n(e,t){return""+(t?"O":"[")+e}function a(e,t){e=Math.floor(e);let i="";for(let s=0;s0?h-s(h,l):t;const _=h,u=function(e,t,r,o,n,a){let h;return h=i(t,o,n,a).length>0?o-s(o,n):t,e=r&&he?"D":"C",a(Math.abs(l-e),n(d,h));d=c>t?"D":"C";const _=Math.abs(c-t);return a(function(e,t){return t.cols-e}(c>t?e:l,r)+(_-1)*r.cols+1+((c>t?l:e)-1),n(d,h))}},6081(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(f.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new _.ParserApi(this._core)}get unicode(){return this._checkProposedApi(),new u.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new d.BufferNamespaceApi(this._core))}get markers(){return this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.mouseStateService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return a.promptLabel.get()},set promptLabel(e){a.promptLabel.set(e)},get tooMuchOutput(){return a.tooMuchOutput.get()},set tooMuchOutput(e){a.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(p of e)if(p===1/0||isNaN(p)||p%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(p of e)if(p&&(p===1/0||isNaN(p)||p%1!=0||p<0))throw new Error("This API only accepts positive integers")}}t.Terminal=v},3955(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const o=i(1433),n=i(2744),a=i(9176),h=i(6181),l=i(2274),c=i(654),d=i(7098),_=i(4103),u=i(4812),f=i(6501),p=i(8636),v=i(4159);let g=1,m=class extends u.Disposable{constructor(e,t,i,s,r,a,d,_,f,m,b,w,y,C){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=d,this._charSizeService=f,this._optionsService=m,this._bufferService=b,this._coreService=w,this._coreBrowserService=y,this._themeService=C,this._terminalClass=g++,this._rowElements=[],this._selectionRenderModel=(0,l.createSelectionRenderModel)(),this._lastSelectionColumnMode=!1,this._rowHasBlinkingCells=[],this._rowHasBlinkingCellsCount=0,this._onRequestRedraw=this._register(new p.Emitter),this.onRequestRedraw=this._onRequestRedraw.event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(e=>this._injectCss(e))),this._injectCss(this._themeService.colors),this._rowFactory=_.createInstance(o.DomRendererRowFactory,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(e=>this._handleLinkHover(e))),this._register(this._linkifier2.onHideLinkUnderline(e=>this._handleLinkLeave(e))),this._cursorBlinkStateManager=new S(this._rowContainer,this._coreBrowserService),this._register((0,v.addDisposableListener)(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register((0,u.toDisposable)(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new c.TextBlinkStateManager(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register((0,u.toDisposable)(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new n.WidthCache,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${_.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${i} { color: ${s.css}; }${this._terminalSelector} .xterm-fg-${i}.xterm-dim { color: ${_.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .xterm-bg-${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR} { color: ${_.color.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${_.color.multiplyOpacity(_.color.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,i){const s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i);let r=0,o=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(r=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow));let n=0,a=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),this._selectionRenderModel.hasSelection){const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,o=this._selectionRenderModel.viewportCappedStartRow,h=this._selectionRenderModel.viewportCappedEndRow;n=o,a=h;const l=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];l.appendChild(this._createSelectionElement(o,i?t[0]:e[0],i?e[0]:t[0],h-o+1))}else{const i=s===o?e[0]:0,n=o===r?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(o,i,n));const a=h-o-1;if(l.appendChild(this._createSelectionElement(o+1,0,this._bufferService.cols,a)),o!==h){const e=r===h?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(h,0,e))}}this._selectionContainer.appendChild(l)}let h=Math.min(r,n),l=Math.max(o,a);if(l>=0){h=Math.max(h,0),l=Math.min(l,s-1);const e=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&e>=0&&ethis.dimensions.css.canvas.width&&(n=this.dimensions.css.canvas.width-o),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${o}px`,r.style.width=`${n}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),o=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,n=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle,h={hasBlinkingCells:!1};for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l];if(!t)continue;const c=i.lines.get(e);c?(t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,n,a,r,o,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,h)),this._setRowBlinkState(l,h.hasBlinkingCells)):(t.replaceChildren(),this._setRowBlinkState(l,!1))}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,o){i<0&&(e=0),s<0&&(t=0);const n=this._bufferService.rows-1;i=Math.max(Math.min(i,n),0),s=Math.max(Math.min(s,n),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,l=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle,u={hasBlinkingCells:!1};for(let n=i;n<=s;++n){const f=n+a.ydisp,p=this._rowElements[n];if(!p)continue;const v=a.lines.get(f);v?(p.replaceChildren(...this._rowFactory.createRow(v,f,f===h,d,_,l,c,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,o?n===i?e:0:-1,o?(n===s?t:r)-1:-1,u)),this._setRowBlinkState(n,u.hasBlinkingCells)):(p.replaceChildren(),this._setRowBlinkState(n,!1))}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};t.DomRenderer=m,t.DomRenderer=m=s([r(7,f.IInstantiationService),r(8,d.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,f.ICoreService),r(12,d.ICoreBrowserService),r(13,d.IThemeService)],m);class S{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){void 0!==this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}}},1433(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const o=i(9176),n=i(8938),a=i(3055),h=i(6501),l=i(4103),c=i(7098),d=i(945),_=i(6181),u=i(5451);let f=class{constructor(e,t,i,s,r,o,n){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=o,this._themeService=n,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,c,_,f,p,v,g){const m=[];g&&(g.hasBlinkingCells=!1);const S=this._characterJoinerService.getJoinedCharacters(t),b=this._themeService.colors;let w,y=e.getNoBgTrimmedLength();i&&y=A,F=I,W=this._workCell;if(S.length>0&&I===S[0][0]&&N){const s=S.shift(),r=this._isCellInSelection(s[0],t);for(C=s[0]+1;C=s[1],N?(H=!0,W=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),F=s[1]-1,y=W.getWidth()):A=s[1]}const z=this._isCellInSelection(I,t),K=i&&I===a,U=O&&I>=p&&I<=v;g&&W.isBlink()&&(g.hasBlinkingCells=!0),!c&&W.isBlink()&&P.push("xterm-blink-hidden");let j=!1;this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{j=!0});let $=W.getChars()||n.WHITESPACE_CELL_CHAR;if(" "===$&&(W.isUnderline()||W.isOverline())&&($=" "),k=y*_-f.get($,W.isBold(),W.isItalic()),w){if(D&&(z&&T||!z&&!T&&W.bg===L)&&(z&&T&&b.selectionForeground||W.fg===x)&&W.extended.ext===R&&U===M&&k===B&&!K&&!H&&!j&&N){W.isInvisible()?E+=n.WHITESPACE_CELL_CHAR:E+=$,D++;continue}D&&(w.textContent=E),w=this._document.createElement("span"),D=0,E=""}else w=this._document.createElement("span");if(L=W.bg,x=W.fg,R=W.extended.ext,M=U,B=k,T=z,H&&a>=I&&a<=F&&(a=I),!this._coreService.isCursorHidden&&K&&this._coreService.isCursorInitialized)if(P.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&P.push("xterm-cursor-blink"),P.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":P.push("xterm-cursor-outline");break;case"block":P.push("xterm-cursor-block");break;case"bar":P.push("xterm-cursor-bar");break;case"underline":P.push("xterm-cursor-underline")}if(W.isBold()&&P.push("xterm-bold"),W.isItalic()&&P.push("xterm-italic"),W.isDim()&&P.push("xterm-dim"),E=W.isInvisible()?n.WHITESPACE_CELL_CHAR:W.getChars()||n.WHITESPACE_CELL_CHAR,W.isUnderline()&&(P.push(`xterm-underline-${W.extended.underlineStyle}`)," "===E&&(E=" "),!W.isUnderlineColorDefault()))if(W.isUnderlineColorRGB())w.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(W.getUnderlineColor()).join(",")})`;else{let e=W.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&W.isBold()&&e<8&&(e+=8),w.style.textDecorationColor=b.ansi[e].css}W.isOverline()&&(P.push("xterm-overline")," "===E&&(E=" ")),W.isStrikethrough()&&P.push("xterm-strikethrough"),U&&(w.style.textDecoration="underline");let q=W.getFgColor(),V=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();const G=!!W.isInverse();if(G){const e=q;q=X,X=e;const t=V;V=Y,Y=t}let J,Z,Q,ee=!1;switch(this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{"top"!==e.options.layer&&ee||(e.backgroundColorRGB&&(Y=50331648,X=e.backgroundColorRGB.rgba>>8&16777215,J=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,q=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),ee="top"===e.options.layer)}),!ee&&z&&(J=this._coreBrowserService.isFocused?b.selectionBackgroundOpaque:b.selectionInactiveBackgroundOpaque,X=J.rgba>>8&16777215,Y=50331648,ee=!0,b.selectionForeground&&(V=50331648,q=b.selectionForeground.rgba>>8&16777215,Z=b.selectionForeground)),ee&&P.push("xterm-decoration-top"),Y){case 16777216:case 33554432:Q=b.ansi[X],P.push(`xterm-bg-${X}`);break;case 50331648:Q=l.channels.toColor(X>>16,X>>8&255,255&X),this._addStyle(w,`background-color:#${(X>>>0).toString(16).padStart(6,"0")}`);break;default:G?(Q=b.foreground,P.push(`xterm-bg-${o.INVERTED_DEFAULT_COLOR}`)):Q=b.background}switch(J||W.isDim()&&(J=l.color.multiplyOpacity(Q,.5)),V){case 16777216:case 33554432:W.isBold()&&q<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(q+=8),this._applyMinimumContrast(w,Q,b.ansi[q],W,J,void 0)||P.push(`xterm-fg-${q}`);break;case 50331648:const e=l.channels.toColor(q>>16&255,q>>8&255,255&q);this._applyMinimumContrast(w,Q,e,W,J,Z)||this._addStyle(w,`color:#${q.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(w,Q,b.foreground,W,J,Z)||G&&P.push(`xterm-fg-${o.INVERTED_DEFAULT_COLOR}`)}P.length&&(w.className=P.join(" "),P.length=0),K||H||j||!N?w.textContent=E:D++,k!==this.defaultSpacing&&(w.style.letterSpacing=`${k}px`),m.push(w),I=F}return w&&D&&(w.textContent=E),m}_applyMinimumContrast(e,t,i,s,r,o){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const n=this._getContrastCache(s);let a;if(r||o||(a=n.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=l.color.ensureContrastRatio(r??t,o??i,e),n.setColor((r??t).rgba,(o??i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};t.DomRendererRowFactory=f,t.DomRendererRowFactory=f=s([r(1,c.ICharacterJoinerService),r(2,h.IOptionsService),r(3,c.ICoreBrowserService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,c.IThemeService)],f)},2744(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0;const s=i(6181);t.WidthCache=class{constructor(e=()=>new r){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let o=this._holey.get(r);if(void 0===o){let s=0;t&&(s|=1),i&&(s|=2),o=this._measure(e,s),o>0&&this._holey.set(r,o)}return o}_measure(e,t){return this._canvasElements[t].measure(e)}};class r{constructor(){"undefined"!=typeof OffscreenCanvas?(this._canvas=new OffscreenCanvas(1,1),this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d")))}setFont(e,t,i,s){const r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}}},9176(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181(e,t){function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,o){return 1===t&&r>Math.ceil(1.5*o)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,o=t[1]-r,n=i[1]-r,a=Math.max(o,0),h=Math.min(n,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},654(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.TextBlinkStateManager=void 0;const s=i(4812);class r extends s.Disposable{constructor(e,t,i){super(),this._renderCallback=e,this._coreBrowserService=t,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",e=>{this.setIntervalDuration(e)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const e=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(e||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}t.TextBlinkStateManager=r},8501(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._domNodePointerDown(e)))}_createArrow(e){const t=this._register(new c.ScrollbarArrow(e));return this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode),t}_createSlider(e,t,i,s){this.slider=new h.FastDomNode(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(a.addDisposableListener(this.slider.domNode,a.eventType.POINTER_DOWN,e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))})),this._onclick(this.slider.domNode,e=>{e.leftButton&&e.stopPropagation()})}_handleElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._handlePointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._handlePointerDown(e)}_handlePointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=a.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{const r=this._sliderOrthogonalPointerPosition(e),o=Math.abs(r-i);if(u.isWindows&&o>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const n=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(n))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=f},1203(e,t){function i(e){return"number"==typeof e?`${e}px`:e}Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.FastDomNode=class{constructor(e){this.domNode=e,this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._className="",this._position="",this._layerHint=!1,this._contain="none"}setWidth(e){const t=i(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=i(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=i(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=i(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=i(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=i(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=e?"translate3d(0px, 0px, 0px)":"")}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}}},928(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{try{e.releasePointerCapture(t)}catch{}}))}catch{o=a.getWindow(e)}this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_MOVE,e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)})),this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_UP,e=>this.stopMonitoring(!0)))}}},9699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.horizontalScrollbarSize:0,2===t.horizontal?0:t.horizontalScrollbarSize,2===t.vertical?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"xterm-horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._handleElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(2===e.horizontal?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},3988(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;ni&&(s=i-t),s<0&&(s=0),r<0&&(r=0),n+r>o&&(n=o-r),n<0&&(n=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=o,this.scrollTop=n}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,o=this.height!==e.height,n=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:o,scrollHeightChanged:n,scrollTopChanged:a}}}t.ScrollState=o;class n extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0!==this._smoothScrollDuration){if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new l(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):l.start(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=l.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}else this.setScrollPositionNow(e)}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=n;class a{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}class l{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this._scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this._scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let n,a;return e0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),o=Math.abs(t.deltaX),n=Math.abs(t.deltaY),a=Math.max(Math.min(s,o),1),h=Math.max(Math.min(r,n),1),l=Math.max(s,o),c=Math.max(r,n);l%a===0&&c%h===0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}S.INSTANCE=new S;class b extends _.Widget{get options(){return this._options}constructor(e,t,i){let s;super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,t=t??{};const r=!i;i?s=i:(t.mouseWheelSmoothScroll=!1,s=new g.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)})),this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:1,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:1,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,v.isMac&&(t.className+=" xterm-mac"),t}(t),this._scrollable=s,this._register(this._scrollable.onScroll(e=>{this._handleScroll(e),this._onScroll.fire(e)})),r&&this._register(this._scrollable);const o={handleMouseWheel:e=>this._handleMouseWheel(e),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,o)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,o)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,e=>this._handleMouseOver(e)),this._onmouseleave(this._listenOnDomNode,e=>this._handleMouseLeave(e)),this._hideTimeout=this._register(new u.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,v.isMac&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalHasArrows&&(this._options.horizontalHasArrows=e.horizontalHasArrows),void 0!==e.verticalHasArrows&&(this._options.verticalHasArrows=e.verticalHasArrows),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new l.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._handleMouseWheel(new l.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.eventType.MOUSE_WHEEL,e,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const o=!v.isMac&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!o||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const n=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=n.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=n.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(n.scrollLeft!==a.scrollLeft||n.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" xterm-shadow-left":"",r=t?" xterm-shadow-top":"",o=i||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${r}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${o}${r}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet(()=>this._hide(),500)}}t.SmoothScrollableElement=b},9594(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._arrowPointerDown(e))),this._register(c.addStandardDisposableListener(this.domNode,c.eventType.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._pointerdownRepeatTimer=this._register(new c.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,c.getWindow(e))},200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault())}}t.ScrollbarArrow=d},1270(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,o){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setArrowSize(e){const t=Math.round(e);this._arrowSize!==t&&(this._arrowSize=t,this._refreshComputedValues())}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const o=Math.max(0,i-e),n=Math.max(0,o-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(n),computedSliderRatio:0,computedSliderPosition:0};const h=Math.round(Math.max(20,Math.floor(i*n/s))),l=(n-h)/(s-i),c=r*l;return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(h),computedSliderRatio:l,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" xterm-fade":"")))}}t.ScrollbarVisibilityController=o},2650(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{s||(s=!0,this._remove(i))}}_remove(e){if(e.prev!==_.Undefined&&e.next!==_.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===_.Undefined&&e.next===_.Undefined?(this._first=_.Undefined,this._last=_.Undefined):e.next===_.Undefined?(this._last=this._last.prev,this._last.next=_.Undefined):e.prev===_.Undefined&&(this._first=this._first.next,this._first.prev=_.Undefined)}*[Symbol.iterator](){let e=this._first;for(;e!==_.Undefined;)yield e.element,e=e.next}}var f;!function(e){e.TAP="-xterm-gesturetap",e.CHANGE="-xterm-gesturechange",e.START="-xterm-gesturestart",e.END="-xterm-gesturesend",e.CONTEXT_MENU="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends l.Disposable{constructor(){super(),this._dispatched=!1,this._targets=new u,this._ignoreTargets=new u,this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;const e=c;this._register(h.addDisposableListener(e.document,"touchstart",e=>this._handleTouchStart(e),{passive:!1})),this._register(h.addDisposableListener(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(h.addDisposableListener(e.document,"touchmove",e=>this._handleTouchMove(e),{passive:!1}))}static addTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._targets.push(e);return(0,l.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._ignoreTargets.push(e);return(0,l.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in c||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){const t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let i=0,s=e.targetTouches.length;i=p._holdDelay&&Math.abs(n.initialPageX-d(n.rollingPageX))<30&&Math.abs(n.initialPageY-d(n.rollingPageY))<30){const e=this._newGestureEvent(f.CONTEXT_MENU,n.initialTarget);e.pageX=d(n.rollingPageX),e.pageY=d(n.rollingPageY),this._dispatchEvent(e)}else if(1===s){const t=d(n.rollingPageX),s=d(n.rollingPageY),r=d(n.rollingTimestamps)-n.rollingTimestamps[0],o=t-n.rollingPageX[0],a=s-n.rollingPageY[0],h=[...this._targets].filter(e=>n.initialTarget instanceof Node&&e.contains(n.initialTarget));this._inertia(e,h,i,Math.abs(o)/r,o>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this._dispatchEvent(this._newGestureEvent(f.END,n.initialTarget)),delete this._activeTouches[o.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}_dispatchEvent(e){if(e.type===f.TAP){const t=(new Date).getTime();let i;i=t-this._lastSetTapCountTime>p._clearTapCountTime?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.CHANGE&&e.type!==f.CONTEXT_MENU||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this._ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this._targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort((e,t)=>e[0]-t[0]);for(const[,i]of t)i.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,i,s,r,o,n,a,l){this._handle=h.scheduleAtNextAnimationFrame(e,()=>{const h=Date.now(),c=h-i;let d=0,_=0,u=!0;s+=p._scrollFriction*c,n+=p._scrollFriction*c,s>0&&(u=!1,d=r*s*c),n>0&&(u=!1,_=a*n*c);const v=this._newGestureEvent(f.CHANGE);v.translationX=d,v.translationY=_,t.forEach(e=>e.dispatchEvent(v)),u||this._inertia(e,t,h,s,r,o+d,n,a,l+_)})}_handleTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}}t.Gesture=p,p._scrollFriction=-.005,p._holdDelay=700,p._clearTapCountTime=400,n([function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r||!s)throw new Error("not supported");const o=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(o)||Object.defineProperty(this,o,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[o]}}],p,"isTouchDevice",null)},8997(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition(),n=t.verticalHasArrows;super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(n?t.verticalScrollbarSize:0,2===t.vertical?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"xterm-vertical",scrollable:e,scrollByPage:t.scrollByPage}),this._arrowScrollDelta=0,this._setArrows(n,t.verticalScrollbarSize),this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._handleElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}_arrowScroll(e){const t=this._scrollable.getCurrentScrollPosition();this._scrollable.setScrollPositionNow({scrollTop:t.scrollTop+e})}_setArrows(e,t){if(this._arrowScrollDelta=t,!this._arrowUp||!this._arrowDown){const e=0;this._arrowUp=this._createArrow({className:"xterm-scra xterm-arrow-up",top:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})}if(this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;const i=e?"":"none";this._arrowUp.bgDomNode.style.display=i,this._arrowUp.domNode.style.display=i,this._arrowDown.bgDomNode.style.display=i,this._arrowDown.domNode.style.display=i}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){const t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},7741(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nt(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseover(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_OVER,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseleave(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_LEAVE,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}}t.Widget=c},5959(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):!!(this.selectionStart&&this.selectionStart[1]<0)&&(this.selectionStart=[0,0],!0)}}},4792(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const o=i(6501),n=i(4812),a=i(8636);let h=class extends n.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h,t.CharSizeService=h=s([r(2,o.IOptionsService)],h);class l extends n.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends l{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends l{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},o=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(5451),a=i(8938),h=i(3055),l=i(6501);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new h.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;tthis._screenDprMonitor.setWindow(e))),this._register(s.EventUtils.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",()=>this._isFocused=!0)),this._register((0,r.addDisposableListener)(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}}t.CoreBrowserService=n;class a extends o.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new o.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,o.toDisposable)(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},2136(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.KeyboardService=void 0;const o=i(706),n=i(7241),a=i(9249),h=i(701),l=i(6501);let c=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??=new a.Win32InputMode,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new n.KittyKeyboard,this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);const t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):(0,o.evaluateKeyboardEvent)(e,this._coreService.decPrivateModes.applicationCursorKeys,h.isMac,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);const t=this._coreService.kittyKeyboard.flags;return this.useKitty&&2&t?this._getKittyKeyboard().evaluate(e,t,3,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):void 0}get useKitty(){const e=this._coreService.kittyKeyboard.flags;return!(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard||!n.KittyKeyboard.shouldUseProtocol(e))}get useWin32InputMode(){return!(!this._optionsService.rawOptions.vtExtensions?.win32InputMode||!this._coreService.decPrivateModes.win32InputMode)}};t.KeyboardService=c,t.KeyboardService=c=s([r(0,l.ICoreService),r(1,l.IOptionsService)],c)},9820(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(4812);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8294(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseCoordsService=void 0;const o=i(4159),n=i(5251),a=i(7098);let h=class{constructor(e,t){this._charSizeService=e,this._renderService=t}getCoords(e,t,i,s,r){return(0,n.getCoords)((0,o.getWindow)(t),e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,n.getCoordsRelativeToElement)((0,o.getWindow)(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseCoordsService=h,t.MouseCoordsService=h=s([r(0,a.ICharSizeService),r(1,a.IRenderService)],h)},9784(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AltMouseCursorController=t.MouseService=void 0;const o=i(4159),n=i(6501),a=i(4812),h=i(7098),l=i(2650);let c=class{constructor(e,t,i,s,r,o,n,a,h){this._renderService=e,this._mouseCoordsService=t,this._mouseStateService=i,this._coreService=s,this._bufferService=r,this._optionsService=o,this._selectionService=n,this._logService=a,this._coreBrowserService=h,this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}bindMouse(e,t,i){const{element:s,document:r}=e,n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},h={target:e,focus:i,requestedEvents:n},c={mouseup:e=>this._handleMouseUp(h,e),wheel:e=>this._handleWheel(h,e),mousedrag:e=>this._handleMouseDrag(h,e),mousemove:e=>this._handleMouseMove(h,e)};this._altMouseCursor=new d(s,r,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),t(this._altMouseCursor),t(this._mouseStateService.onProtocolChange(e=>{this._handleProtocolChange(h,c,e)})),t(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(s),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,t((0,a.toDisposable)(()=>{n.mouseup&&r.removeEventListener("mouseup",n.mouseup),n.mousedrag&&r.removeEventListener("mousemove",n.mousedrag)})),t((0,o.addDisposableListener)(s,"mousedown",e=>this._handleMouseDown(h,e))),t((0,o.addDisposableListener)(s,"wheel",e=>this._handlePassiveWheel(h,e),{passive:!1})),t(l.Gesture.addTarget(e.screenElement)),t((0,o.addDisposableListener)(e.screenElement,l.EventType.START,()=>this._handleTouchStart())),t((0,o.addDisposableListener)(e.screenElement,l.EventType.CHANGE,e=>this._handleTouchChange(h,e)))}_sendEvent(e,t){const i=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;const e=t.deltaY;if(0===e)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return!1;r=e<0?0:1,s=4;break;default:return!1}if(void 0===r||void 0===s||s>4)return!1;if(4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!t.altKey)return!1;const o=4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:!o&&t.altKey,shift:t.shiftKey})}_handleMouseUp(e,t){this._sendEvent(e,t),t.buttons||(e.requestedEvents.mouseup&&e.target.document.removeEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.removeEventListener("mousemove",e.requestedEvents.mousedrag))}_handleWheel(e,t){return this._sendEvent(e,t),t.preventDefault(),t.stopPropagation(),!1}_handleMouseDrag(e,t){t.buttons&&this._sendEvent(e,t)}_handleMouseMove(e,t){t.buttons||this._sendEvent(e,t)}_handleMouseDown(e,t){t.preventDefault(),e.focus(),this._mouseStateService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(t)&&(this._sendEvent(e,t),e.requestedEvents.mouseup&&e.target.document.addEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.addEventListener("mousemove",e.requestedEvents.mousedrag))}_handlePassiveWheel(e,t){if(!e.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;if(!this._bufferService.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return t.preventDefault(),t.stopPropagation(),!1;const e=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(e,!0),t.preventDefault(),t.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(e,t){t.preventDefault(),t.stopPropagation(),e.requestedEvents.wheel?this._handleTouchScrollAsWheel(e,t):this._bufferService.buffer.hasScrollback?e.target.handleTouchScroll?.(t.translationY):this._handleTouchScrollAsKeys(t)}_handleTouchScrollAsKeys(e){const t=this._renderService?.dimensions.css.cell.height;if(!t)return;this._touchScrollAccumulator-=e.translationY;const i=Math.trunc(this._touchScrollAccumulator/t);if(0===i)return;this._touchScrollAccumulator-=i*t;const s=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(i<0?"A":"B");for(let e=0;e0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._mouseStateService.isPixelEncoding))return!1;if(!this._mouseStateService.restrictMouseEvent(e))return!1;const t=this._mouseStateService.encodeMouseEvent(e);return t&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}_explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.MouseService=c,t.MouseService=c=s([r(0,h.IRenderService),r(1,h.IMouseCoordsService),r(2,n.IMouseStateService),r(3,n.ICoreService),r(4,n.IBufferService),r(5,n.IOptionsService),r(6,h.ISelectionService),r(7,n.ILogService),r(8,h.ICoreBrowserService)],c);class d{constructor(e,t,i){this._element=e,this._document=t,this._isActive=i,this._listeners=new a.MutableDisposable}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;const e=new a.DisposableStore,t=e=>this.syncFromModifier(e);e.add((0,o.addDisposableListener)(this._document,"keydown",t)),e.add((0,o.addDisposableListener)(this._document,"keyup",t)),e.add((0,o.addDisposableListener)(this._element,"mousemove",t));const i=this._element.ownerDocument?.defaultView;i&&e.add((0,o.addDisposableListener)(i,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=e}resetClass(){this._updateClass(!1)}syncFromModifier(e){this._isActive()&&this._updateClass(e.getModifierState("Alt"))}_updateClass(e){e?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}}t.AltMouseCursorController=d},5783(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const o=i(4852),n=i(7098),a=i(4812),h=i(6168),l=i(6501),c=i(8636);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,n,l,d,u,f){super(),this._rowCount=e,this._optionsService=i,this._logService=s,this._charSizeService=r,this._coreService=n,this._coreBrowserService=u,this._renderer=this._register(new a.MutableDisposable),this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._pausedResizeTask=this._register(new h.DebouncedIdleTask(this._logService)),this._renderDebouncer=new o.RenderDebouncer((e,t)=>this._renderRows(e,t),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new _(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register((0,a.toDisposable)(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(d.onResize(()=>this._fullRefresh())),this._register(d.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(d.cols,d.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(d.buffer.y,d.buffer.y,void 0,!0))),this._register(f.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(e=>this._registerIntersectionObserver(e,t)))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver(e=>this._handleIntersectionChange(e[e.length-1]),{threshold:0});this._observerDisposable.value=(0,a.toDisposable)(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=i,i.observe(t)}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(e=>this.refreshRows(e.start,e.end,e.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,l.IOptionsService),r(3,l.ILogService),r(4,n.ICharSizeService),r(5,l.ICoreService),r(6,l.IDecorationService),r(7,l.IBufferService),r(8,n.ICoreBrowserService),r(9,n.IThemeService)],d);class _{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(e=>this._handleTrim(e)),this._register(this._bufferService.buffers.onBufferActivate(e=>this._handleBufferActivate(e))),this.enable(),this._model=new d.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,u.toDisposable)(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(e=>{e.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(b," ")).join(f.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),f.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:f.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(2===e.button&&this.hasSelection||0!==e.button||this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){const t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);const i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return(!this._optionsService.rawOptions.mouseEventsRequireAlt||!this._mouseStateService.areMouseEventsActive)&&e.altKey&&!(f.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,c.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(e=>this._handleTrim(e))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,o=r.lines.get(e[1]);if(!o)return;const n=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(o,e[0]),h=a;const l=e[0]-a;let c=0,d=0,_=0,u=0;if(" "===n.charAt(a)){for(;a>0&&" "===n.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(o.loadCell(t-1,this._workCell));){o.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+l-c+_,p=Math.min(this._bufferService.cols,h-a+c+d-_-u);if(t||""!==n.slice(a,h).trim()){if(i&&0===f&&32!==o.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&o.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==o.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=w,t.SelectionService=w=n([h(3,g.IBufferService),h(4,g.ICoreService),h(5,_.IMouseCoordsService),h(6,g.IOptionsService),h(7,g.IMouseStateService),h(8,_.IRenderService),h(9,_.ICoreBrowserService)],w)},7098(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IKeyboardService=t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.IMouseCoordsService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseCoordsService=(0,s.createDecorator)("MouseCoordsService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService"),t.IKeyboardService=(0,s.createDecorator)("KeyboardService")},9078(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const o=i(7174),n=i(9302),a=i(4103),h=i(4812),l=i(6501),c=i(8636),d=a.css.toColor("#ffffff"),_=a.css.toColor("#000000"),u=a.css.toColor("#ffffff"),f=_,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},v=d;let g=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new o.ColorContrastCache,this._halfContrastCache=new o.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:_,cursor:u,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(_,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(_,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:n.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}_setTheme(e={}){const t=this._colors;if(t.foreground=m(e.foreground,d),t.background=m(e.background,_),t.cursor=a.color.blend(t.background,m(e.cursor,u)),t.cursorAccent=a.color.blend(t.background,m(e.cursorAccent,f)),t.selectionBackgroundTransparent=m(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=m(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?m(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=m(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=m(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=m(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=m(e.overviewRulerBorder,v),t.ansi=n.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=m(e.black,n.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=m(e.red,n.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=m(e.green,n.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=m(e.yellow,n.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=m(e.blue,n.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=m(e.magenta,n.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=m(e.cyan,n.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=m(e.white,n.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=m(e.brightBlack,n.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=m(e.brightRed,n.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=m(e.brightGreen,n.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=m(e.brightYellow,n.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=m(e.brightBlue,n.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=m(e.brightMagenta,n.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=m(e.brightCyan,n.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=m(e.brightWhite,n.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;ssetTimeout(t,e))},t.disposableTimeout=function(e,t=0,i){const r=setTimeout(()=>{e(),i&&o.dispose()},t),o=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(o),o};const s=i(4812);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{e()},t);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},5639(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(4812),r=i(8636);class o extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(n||(t.channels=n={})),function(e){function t(e,t){return o=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:n.toCss(i,s,r,o),rgba:n.toRgba(i,s,r,o)}}e.blend=function(e,t){if(o=(255&t.rgba)/255,1===o)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,l=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),{css:n.toCss(i,s,r),rgba:n.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return n.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:n.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return o=255&e.rgba,t(e,o*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o=parseInt(e.slice(4,5).repeat(2),16),n.toColor(i,s,r,o);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1],10),s=parseInt(h[2],10),r=parseInt(h[3],10),o=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),n.toColor(i,s,r,o);if("transparent"===e)return{css:"transparent",rgba:0};if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,o]=t.getImageData(0,0,1,1).data,255!==o)throw new Error("css.toColor: Unsupported css format");return{rgba:n.toRgba(i,s,r,o),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,o=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(l||(t.rgb=l={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c0||a>0||h>0);)n-=Math.max(0,Math.ceil(.1*n)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));return(n<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c>>0}e.blend=function(e,t){if(o=(255&t)/255,1===o)return t;const a=t>>24&255,h=t>>16&255,l=t>>8&255,c=e>>24&255,d=e>>16&255,_=e>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),n.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=l.relativeLuminance(e>>8),o=l.relativeLuminance(i>>8);if(_(r,o)>8));if(n_(r,l.relativeLuminance(t>>8))?o:t}return o}const n=a(e,i,s),h=_(r,l.relativeLuminance(n>>8));if(h_(r,l.relativeLuminance(o>>8))?n:o}return n}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),o=i(7276),n=i(9640),a=i(56),h=i(4071),l=i(6478),c=i(7428),d=i(6415),_=i(5746),u=i(5882),f=i(2486),p=i(3562),v=i(8811),g=i(8636),m=i(4812);let S=!1;class b extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new g.Emitter),this.onRender=this._onRender.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(n.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(l.MouseStateService)),this._instantiationService.setService(s.IMouseStateService,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(d.UnicodeService)),this.unicodeService.register(new c.UnicodeV6),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(_.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(v.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new f.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(g.EventUtils.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(g.EventUtils.forward(this._bufferService.onResize,this._onResize)),this._register(g.EventUtils.forward(this.coreService.onData,this._onData)),this._register(g.EventUtils.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new p.WriteBuffer((e,t)=>this._inputHandler.parse(e,t))),this._register(g.EventUtils.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!S&&(this._logService.warn("writeSync is unreliable and will be removed soon."),S=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.backend&&void 0!==t.buildNumber&&(e=!!("conpty"===t.backend&&t.buildNumber<21376)),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)(()=>{for(const t of e)t.dispose()})}}}t.CoreTerminal=b},8636(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const s=i(4812);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners.push(r);const o=(0,s.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&this._listeners.splice(e,1)});return i&&(Array.isArray(i)?i.push(o):i.add(o)),o}),this._event}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:t,thisArgs:i}=this._listeners[0];return void t.call(i,e)}default:{const t=this._listeners.slice();for(const{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(e){e.forward=function(e,t){return e(e=>t.fire(e))},e.map=function(e,t){return(i,s,r)=>e(e=>i.call(s,t(e)),void 0,r)},e.any=function(...e){return(t,i,r)=>{const o=new s.DisposableStore;for(const s of e)o.add(s(e=>t.call(i,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(r||(t.EventUtils=r={}))},2486(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=L;const o=i(6760),n=i(6717),a=i(4812),h=i(726),l=i(6107),c=i(8938),d=i(3055),_=i(5451),u=i(6501),f=i(6415),p=i(1346),v=i(9823),g=i(2607),m=i(8693),S=i(8636),b=i(7804),w={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var C;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(C||(t.WindowsOptionsReportType=C={}));let k=0;class D extends a.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,a,c,d,_=new n.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=a,this._mouseStateService=c,this._unicodeService=d,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new S.Emitter),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new E(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(e=>this._activeBuffer=e.activeBuffer)),this._parser.setCsiHandlerFallback((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})}),this._parser.setEscHandlerFallback(e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})}),this._parser.setExecuteHandlerFallback(e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})}),this._parser.setOscHandlerFallback((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})}),this._parser.setDcsHandlerFallback((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setApcHandlerFallback((e,t,i)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setPrintHandler((e,t,i)=>this.print(e,t,i)),this._parser.registerCsiHandler({final:"@"},e=>this.insertChars(e)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},e=>this.scrollLeft(e)),this._parser.registerCsiHandler({final:"A"},e=>this.cursorUp(e)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},e=>this.scrollRight(e)),this._parser.registerCsiHandler({final:"B"},e=>this.cursorDown(e)),this._parser.registerCsiHandler({final:"C"},e=>this.cursorForward(e)),this._parser.registerCsiHandler({final:"D"},e=>this.cursorBackward(e)),this._parser.registerCsiHandler({final:"E"},e=>this.cursorNextLine(e)),this._parser.registerCsiHandler({final:"F"},e=>this.cursorPrecedingLine(e)),this._parser.registerCsiHandler({final:"G"},e=>this.cursorCharAbsolute(e)),this._parser.registerCsiHandler({final:"H"},e=>this.cursorPosition(e)),this._parser.registerCsiHandler({final:"I"},e=>this.cursorForwardTab(e)),this._parser.registerCsiHandler({final:"J"},e=>this.eraseInDisplay(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},e=>this.eraseInDisplay(e,!0)),this._parser.registerCsiHandler({final:"K"},e=>this.eraseInLine(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},e=>this.eraseInLine(e,!0)),this._parser.registerCsiHandler({final:"L"},e=>this.insertLines(e)),this._parser.registerCsiHandler({final:"M"},e=>this.deleteLines(e)),this._parser.registerCsiHandler({final:"P"},e=>this.deleteChars(e)),this._parser.registerCsiHandler({final:"S"},e=>this.scrollUp(e)),this._parser.registerCsiHandler({final:"T"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"X"},e=>this.eraseChars(e)),this._parser.registerCsiHandler({final:"Z"},e=>this.cursorBackwardTab(e)),this._parser.registerCsiHandler({final:"^"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"`"},e=>this.charPosAbsolute(e)),this._parser.registerCsiHandler({final:"a"},e=>this.hPositionRelative(e)),this._parser.registerCsiHandler({final:"b"},e=>this.repeatPrecedingCharacter(e)),this._parser.registerCsiHandler({final:"c"},e=>this.sendDeviceAttributesPrimary(e)),this._parser.registerCsiHandler({prefix:">",final:"c"},e=>this.sendDeviceAttributesSecondary(e)),this._parser.registerCsiHandler({final:"d"},e=>this.linePosAbsolute(e)),this._parser.registerCsiHandler({final:"e"},e=>this.vPositionRelative(e)),this._parser.registerCsiHandler({final:"f"},e=>this.hVPosition(e)),this._parser.registerCsiHandler({final:"g"},e=>this.tabClear(e)),this._parser.registerCsiHandler({final:"h"},e=>this.setMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"h"},e=>this.setModePrivate(e)),this._parser.registerCsiHandler({final:"l"},e=>this.resetMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"l"},e=>this.resetModePrivate(e)),this._parser.registerCsiHandler({final:"m"},e=>this.charAttributes(e)),this._parser.registerCsiHandler({final:"n"},e=>this.deviceStatus(e)),this._parser.registerCsiHandler({prefix:"?",final:"n"},e=>this.deviceStatusPrivate(e)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},e=>this.softReset(e)),this._parser.registerCsiHandler({prefix:">",final:"q"},e=>this.sendXtVersion(e)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},e=>this.setCursorStyle(e)),this._parser.registerCsiHandler({final:"r"},e=>this.setScrollRegion(e)),this._parser.registerCsiHandler({final:"s"},e=>this.saveCursor(e)),this._parser.registerCsiHandler({final:"t"},e=>this.windowOptions(e)),this._parser.registerCsiHandler({final:"u"},e=>this.restoreCursor(e)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},e=>this.insertColumns(e)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},e=>this.deleteColumns(e)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},e=>this.selectProtected(e)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},e=>this.requestMode(e,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},e=>this.requestMode(e,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},e=>this.kittyKeyboardSet(e)),this._parser.registerCsiHandler({prefix:"?",final:"u"},e=>this.kittyKeyboardQuery(e)),this._parser.registerCsiHandler({prefix:">",final:"u"},e=>this.kittyKeyboardPush(e)),this._parser.registerCsiHandler({prefix:"<",final:"u"},e=>this.kittyKeyboardPop(e)),this._parser.setExecuteHandler("",()=>this.bell()),this._parser.setExecuteHandler("\n",()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler("\t",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("„",()=>this.index()),this._parser.setExecuteHandler("…",()=>this.nextLine()),this._parser.setExecuteHandler("ˆ",()=>this.tabSet()),this._parser.registerOscHandler(0,new p.OscHandler(e=>(this.setTitle(e),this.setIconName(e),!0))),this._parser.registerOscHandler(1,new p.OscHandler(e=>this.setIconName(e))),this._parser.registerOscHandler(2,new p.OscHandler(e=>this.setTitle(e))),this._parser.registerOscHandler(4,new p.OscHandler(e=>this.setOrReportIndexedColor(e))),this._parser.registerOscHandler(8,new p.OscHandler(e=>this.setHyperlink(e))),this._parser.registerOscHandler(10,new p.OscHandler(e=>this.setOrReportFgColor(e))),this._parser.registerOscHandler(11,new p.OscHandler(e=>this.setOrReportBgColor(e))),this._parser.registerOscHandler(12,new p.OscHandler(e=>this.setOrReportCursorColor(e))),this._parser.registerOscHandler(104,new p.OscHandler(e=>this.restoreIndexedColor(e))),this._parser.registerOscHandler(110,new p.OscHandler(e=>this.restoreFgColor(e))),this._parser.registerOscHandler(111,new p.OscHandler(e=>this.restoreBgColor(e))),this._parser.registerOscHandler(112,new p.OscHandler(e=>this.restoreCursorColor(e))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},()=>this.selectCharset("("+e)),this._parser.registerEscHandler({intermediates:")",final:e},()=>this.selectCharset(")"+e)),this._parser.registerEscHandler({intermediates:"*",final:e},()=>this.selectCharset("*"+e)),this._parser.registerEscHandler({intermediates:"+",final:e},()=>this.selectCharset("+"+e)),this._parser.registerEscHandler({intermediates:"-",final:e},()=>this.selectCharset("-"+e)),this._parser.registerEscHandler({intermediates:".",final:e},()=>this.selectCharset("."+e)),this._parser.registerEscHandler({intermediates:"/",final:e},()=>this.selectCharset("/"+e));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(e=>(this._logService.error("Parsing error: ",e),e)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new v.DcsHandler((e,t)=>this.requestStatusString(e,t)))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=u.LogLevelEnum.WARN){let t;const i=new Promise((e,i)=>{t=setTimeout(()=>i("#SLOW_TIMEOUT"),5e3)});Promise.race([e,i]).then(()=>{void 0!==t&&clearTimeout(t)},e=>{if(void 0!==t&&clearTimeout(t),"#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,o=0;const n=this._parseStack.paused;if(n){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(o=this._parseStack.position+131072)}if(this._logService.logLevel<=u.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,e=>String.fromCharCode(e)).join("")}"`)),this._logService.logLevel===u.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map(e=>e.charCodeAt(0)):e),this._parseBuffer.length131072)for(let t=o;t0&&2===p.getWidth(this._activeBuffer.x-1)&&p.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(d){const e=p;let t=this._activeBuffer.x-m;if(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),p=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!p)return;for(m>0&&p instanceof l.BufferLine&&p.copyCellsFrom(e,t,0,m,!1);t=0;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(_&&(p.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===p.getWidth(a-1)&&p.setCellFromCodepoint(a-1,c.NULL_CELL_CODE,c.NULL_CELL_WIDTH,u)),p.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===p.getWidth(this._activeBuffer.x)&&!p.hasContent(this._activeBuffer.x)&&p.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new v.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new g.ApcHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const o=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);o&&(o.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(o.isWrapped=!1))}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols){const e=this._activeBuffer.lines.get(i+1);e&&(e.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`P>|xterm.js(${b.XTERM_VERSION})\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(o.triggerDataEvent(`[${t?"":"?"}${e};${i}$y`),!0),_=e=>e?1:2,u=e.params[0];return t?d(u,2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(c.convertEol):0):1===u?d(u,_(i.applicationCursorKeys)):3===u?d(u,c.windowOptions.setWinLines?80===a?2:132===a?1:0:0):6===u?d(u,_(i.origin)):7===u?d(u,_(i.wraparound)):8===u?d(u,3):9===u?d(u,_("X10"===s)):12===u?d(u,_(c.cursorBlink)):25===u?d(u,_(!o.isCursorHidden)):45===u?d(u,_(i.reverseWraparound)):66===u?d(u,_(i.applicationKeypad)):67===u?d(u,4):1e3===u?d(u,_("VT200"===s)):1002===u?d(u,_("DRAG"===s)):1003===u?d(u,_("ANY"===s)):1004===u?d(u,_(i.sendFocus)):1005===u?d(u,4):1006===u?d(u,_("SGR"===r)):1015===u?d(u,4):1016===u?d(u,_("SGR_PIXELS"===r)):1048===u?d(u,1):47===u||1047===u||1049===u?d(u,_(h===l)):2004===u?d(u,_(i.bracketedPasteMode)):2026===u?d(u,_(i.synchronizedOutput)):9001===u&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?d(u,_(i.win32InputMode)):d(u,0)}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-67108864,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,o=0;do{if(s[o+r]=e.params[t+o],e.hasSubParams(t+o)){const i=e.getSubParams(t+o);let n=0;do{5===s[1]&&(r=1),s[o+n+1+r]=i[n]}while(++n=2||2===s[1]&&o+r>=5)break;s[1]&&(r=1)}while(++o+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-67108864,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-67108864,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-67108864,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-67108864,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:221===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.fg&=-134217729:222===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.bg&=-134217729:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("");break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[${e};${t}R`)}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[?${e};${t}R`);break;case 15:case 25:case 26:case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??1)&&this._onRequestColorSchemeQuery.fire()}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(C.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(C.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let e=0;e1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e,10);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex(e=>e.startsWith("id="));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new d.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${e}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let e=0;e0;e++)i.flags=s.pop();return 0===s.length&&t>0&&(i.flags=0),!0}}t.InputHandler=D;let E=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(k=e,e=t,t=k),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}E=s([r(0,u.IBufferService)],E)},4812(e,t){function i(e){return{dispose:e}}function s(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=s,t.combinedDisposable=function(...e){return i(()=>s(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},7710(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,o){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,o)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isMac=t.isSafari=t.isLegacyEdge=t.isChrome=t.isFirefox=t.isNode=void 0,t.getZoomFactor=function(e){return 1},t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1],10)},t.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isChrome=i.includes("Chrome"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e,t){this._getKey=e,this._array=[],this._insertedValues=[],this._isFlushingInserted=!1,this._deletedIndices=[],this._isFlushingDeleted=!1,this._flushInsertedTask=new s.IdleTaskQueue(t),this._flushDeletedTask=new s.IdleTaskQueue(t)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort((e,t)=>this._getKey(e)-this._getKey(t));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(this._deleteAtKey(e,t))return!0;if(0===this._deletedIndices.length)return!1;return this._flushCleanupDeleted(),this._deleteAtKey(e,t)}_deleteAtKey(e,t){if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(r),!0}while(++re-t);let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},4220(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.LimitedStringBuilder=t.StringBuilder=void 0;class i{constructor(){this._chunks=[],this._length=0}get length(){return this._length}reset(){this._chunks.length=0,this._length=0}append(e){this._chunks.push(e),this._length+=e.length}toString(){return this._chunks.join("")}}t.StringBuilder=i,t.LimitedStringBuilder=class{constructor(e){this._limit=e,this._builder=new i}get length(){return this._builder.length}get limit(){return this._limit}reset(){this._builder.reset()}append(e){return this._builder.append(e),this._builder.length>this._limit&&(this._builder.reset(),!0)}toString(){return this._builder.toString()}}},6168(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;class i{constructor(e){this._tasks=[],this._i=0,this._logService=e}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ii)return r-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=s,t.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:s,t.DebouncedIdleTask=class{constructor(e){this._queue=new t.IdleTaskQueue(e)}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},7804(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.XTERM_VERSION=void 0,t.XTERM_VERSION="6.1.0-beta.287"},5882(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(4812),o=i(6168),n=i(5451),a=i(6107),h=i(3326),l=i(732),c=i(3055),d=i(8938),_=i(8158),u=i(6760);t.MAX_BUFFER_SIZE=4294967295;class f extends r.Disposable{constructor(e,t,i,n){super(),this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this._logService=n,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=a.DEFAULT_ATTR_DATA.clone(),this.savedCharset=u.DEFAULT_CHARSET,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=c.CellData.fromCharData([0,d.NULL_CELL_CHAR,d.NULL_CELL_WIDTH,d.NULL_CELL_CODE]),this._whitespaceCell=c.CellData.fromCharData([0,d.WHITESPACE_CELL_CHAR,d.WHITESPACE_CELL_WIDTH,d.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new o.IdleTaskQueue(this._logService),this._register((0,r.toDisposable)(()=>this._memoryCleanupQueue.clear())),this._register((0,r.toDisposable)(()=>this.clearAllMarkers())),this._stringCache=this._register(new h.BufferLineStringCache)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new a.BufferLine(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){e??=a.DEFAULT_ATTR_DATA;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(a.DEFAULT_ATTR_DATA);this._stringCache.clear();let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+o+1?(this.ybase--,o++,this.ydisp>0&&this.ydisp--):this.lines.push(new a.BufferLine(this._stringCache,e,i,!1)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),o&&(this.y+=o),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t0){const e=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,e)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,l.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(a.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,l.reflowLargerCreateNewLayout)(this.lines,s);(0,l.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(a.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=n&&e0&&(r.push({start:n+c.length+o,newLines:p}),o+=p.length),c.push(...p);let v=_.length-1,g=_[v];0===g&&(v--,g=_[v]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[v])break;if(c[v].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(v--,g=_[v]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,l.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;l--)if(a&&a.start>s+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(l--,a.newLines[e]);l++,e.push({index:s+1,amount:a.newLines.length}),h+=a.newLines.length,a=r[++n]}else this.lines.set(l,t[s--]);let l=0;for(let t=e.length-1;t>=0;t--)e[t].index+=l,this.lines.onInsertEmitter.fire(e[t]),l+=e[t].amount;const c=Math.max(0,i+o-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(e=>{t.line>=e.index&&(t.line+=e.amount)})),t.register(this.lines.onDelete(e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}t.Buffer=f},6107(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),o=i(8938),n=i(726),a=i(4220);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let h=0;const l=new r.CellData,c=new a.StringBuilder;class d{constructor(e,t,i,s=!1){this._stringCache=e,this.isWrapped=s,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const n=i??r.CellData.fromCharData([0,o.NULL_CELL_CHAR,o.NULL_CELL_WIDTH,o.NULL_CELL_CODE]);for(let e=0;e>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._invalidateStringCache(),this._data[3*e+1]=t[o.CHAR_DATA_ATTR_INDEX],t[o.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[o.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[o.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[o.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,n.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,i){return h=3*e,i.content=this._data[h+0],i.fg=this._data[h+1],i.bg=this._data[h+2],2097152&i.content?i.combinedData=this._combined[e]:i.combinedData="",268435456&i.bg?i.extended=this._extendedAttrs[e]:i.extended=t.DEFAULT_ATTR_DATA.extended.clone(),i}setCell(e,t){this._invalidateStringCache(),2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){this._invalidateStringCache(),268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){this._invalidateStringCache();let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,n.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,n.stringFromCodePoint)(2097151&s)+(0,n.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if(this._invalidateStringCache(),(e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,l));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){this._invalidateStringCache();const o=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=o[3*(t+r)+e];this._copyCellMapsFrom(e,t+r,i+r)}else for(let r=0;r>22||1}s&&s.push(t);const h=c.toString();if(c.reset(),r){const t=this._getStringCacheEntry(!0);t.value=h,t.isTrimmed=!!e}return h}_getStringCacheEntry(e){const t=this._stringCacheEntryRef?.deref();if(t&&t.generation===this._stringCache.generation)return t;if(!e)return;const i=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(i),i}_invalidateStringCache(){const e=this._getStringCacheEntry(!1);e&&(e.value=void 0,e.isTrimmed=!1)}_copyCellMapsFrom(e,t,i){const s=3*t;2097152&e._data[s+0]&&(this._combined[i]=e._combined[t]),268435456&e._data[s+2]&&(this._extendedAttrs[i]=e._extendedAttrs[t])}_copySparseMapsFrom(e){this._combined={},this._extendedAttrs={};for(let t=0;tthis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){const e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(const e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),this._clearTimeout.value||this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=(0,s.disposableTimeout)(()=>{const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this.clear():this._scheduleClearTimeout(15e3-e)},e)}}t.BufferLineStringCache=o},9384(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732(e,t){function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,o,n){const a=[];for(let h=0;h=h&&r0&&(e>_||0===d[e].getTrimmedLength());e--)v++;v>0&&(a.push(h+d.length-v),a.push(v)),h+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],o=0;for(let n=0;nl&&(n-=l,a++);const c=2===e[a].getWidth(n-1);c&&n--;const d=c?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},4097(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(4812),r=i(1073),o=i(8636);class n extends s.Disposable{constructor(e,t,i){super(),this._optionsService=e,this._bufferService=t,this._logService=i,this._normalBuffer=this._register(new s.MutableDisposable),this._altBuffer=this._register(new s.MutableDisposable),this._onBufferActivate=this._register(new o.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=n},3055(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),o=i(5451);class n extends o.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new o.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new n;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor())return!1;if(this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor())return!1;if(this.isInverse()!==e.isInverse())return!1;if(this.isBold()!==e.isBold())return!1;if(this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;const t=this.isUnderlineColorDefault(),i=e.isUnderlineColorDefault();if(!t||!i){if(t!==i)return!1;if(this.getUnderlineColor()!==e.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==e.getUnderlineColorMode())return!1}}return this.isOverline()===e.isOverline()&&this.isBlink()===e.isBlink()&&this.isInvisible()===e.isInvisible()&&this.isItalic()===e.isItalic()&&this.isDim()===e.isDim()&&this.isStrikethrough()===e.isStrikethrough()}}t.CellData=n},8938(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(4812),r=i(8636);class o{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=o._nextId++,this._onDispose=this.register(new r.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,s.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=o,o._nextId=1},6760(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},706(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,s,r){const o={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?"OA":"":"UIKeyInputLeftArrow"===e.key?o.key=t?"OD":"":"UIKeyInputRightArrow"===e.key?o.key=t?"OC":"":"UIKeyInputDownArrow"===e.key&&(o.key=t?"OB":"");break;case 8:o.key=e.ctrlKey?"\b":"",e.altKey&&(o.key=""+o.key);break;case 9:if(e.shiftKey){o.key="";break}o.key="\t",o.cancel=!0;break;case 13:"c"===e.key&&e.ctrlKey?o.key="":o.key=e.altKey?"\r":"\r",o.cancel=!0;break;case 27:o.key="",e.altKey&&(o.key=""),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"D":t?"OD":"";break;case 39:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"C":t?"OC":"";break;case 38:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"A":t?"OA":"";break;case 40:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"B":t?"OB":"";break;case 45:e.shiftKey||e.ctrlKey||(o.key="[2~");break;case 46:o.key=n?"[3;"+(n+1)+"~":"[3~";break;case 36:o.key=n?"[1;"+(n+1)+"H":t?"OH":"";break;case 35:o.key=n?"[1;"+(n+1)+"F":t?"OF":"";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key="[5;"+(n+1)+"~":o.key="[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key="[6;"+(n+1)+"~":o.key="[6~";break;case 112:o.key=n?"[1;"+(n+1)+"P":"OP";break;case 113:o.key=n?"[1;"+(n+1)+"Q":"OQ";break;case 114:o.key=n?"[1;"+(n+1)+"R":"OR";break;case 115:o.key=n?"[1;"+(n+1)+"S":"OS";break;case 116:o.key=n?"[15;"+(n+1)+"~":"[15~";break;case 117:o.key=n?"[17;"+(n+1)+"~":"[17~";break;case 118:o.key=n?"[18;"+(n+1)+"~":"[18~";break;case 119:o.key=n?"[19;"+(n+1)+"~":"[19~";break;case 120:o.key=n?"[20;"+(n+1)+"~":"[20~";break;case 121:o.key=n?"[21;"+(n+1)+"~":"[21~";break;case 122:o.key=n?"[23;"+(n+1)+"~":"[23~";break;case 123:o.key=n?"[24;"+(n+1)+"~":"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(s&&!r||!e.altKey||e.metaKey)if(!s||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey){if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length)o.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":o.key="";break;case"Digit2":o.key="\0";break;case"Digit6":o.key=""}}else 65===e.keyCode&&(o.type=1);else{const t=i[e.keyCode],s=t?.[e.shiftKey?1:0];if(s)o.key=""+s;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=""+i}else if(32===e.keyCode)o.key=""+(e.ctrlKey?"\0":" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=""+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key="\0":e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key="":"/"===e.key?o.key="":219===e.keyCode?o.key="":220===e.keyCode?o.key="":221===e.keyCode&&(o.key="")}return o};const i={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},7241(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyKeyboard=void 0,t.KittyKeyboard=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){const t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e,t){const i=this._getNumpadKeyCode(e);if(void 0!==i)return i;const s=this._getModifierKeyCode(e);if(void 0!==s)return s;const r=this._functionalKeyCodes[e.key];if(void 0!==r)return r;if((e.shiftKey||t&&e.altKey)&&e.code){if(e.code.startsWith("Digit")&&6===e.code.length){const t=e.code.charAt(5);if(t>="0"&&t<="9")return t.charCodeAt(0)}if(e.code.startsWith("Key")&&4===e.code.length)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(1===e.key.length){const t=e.key.codePointAt(0);return t>=65&&t<=90?t+32:t}}_isModifierKey(e){return"Shift"===e.key||"Control"===e.key||"Alt"===e.key||"Meta"===e.key}_isLockKey(e){return"CapsLock"===e.key||"NumLock"===e.key||"ScrollLock"===e.key}_buildCsiLetterSequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"["+e}_buildSs3Sequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"O"+e}_buildCsiTildeSequence(e,t,i,s){const r=s&&1!==i;let o="["+e;return(t>0||r)&&(o+=";"+(t>0?t:"1"),r&&(o+=":"+i)),o+="~",o}_buildCsiUSequence(e,t,i,s,r,o,n){const a=!!(2&r);let h,l="["+t;4&r&&e.shiftKey&&1===e.key.length&&!o&&!n&&(h=e.key.codePointAt(0),l+=":"+h);const c=16&r&&3!==s&&1===e.key.length&&!o&&!n&&!e.ctrlKey?e.key.codePointAt(0):void 0,d=a&&1!==s&&(3===s||void 0===c);return(i>0||d||void 0!==c)&&(l+=";",i>0?l+=i:d&&(l+="1"),d&&(l+=":"+s)),void 0!==c&&(l+=";"+c),l+="u",l}evaluate(e,t,i=1,s=!1){const r={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(e),n=this._isModifierKey(e),a=!!(2&t);if(!a&&3===i)return r;if(n&&!(8&t))return r;if(this._isLockKey(e)&&!(8&t))return r;const h=this._csiLetterKeys[e.key];if(h)return r.key=this._buildCsiLetterSequence(h,o,i,a),r.cancel=!0,r;const l=this._ss3FunctionKeys[e.key];if(l)return r.key=this._buildSs3Sequence(l,o,i,a),r.cancel=!0,r;const c=this._csiTildeKeys[e.key];if(void 0!==c)return r.key=this._buildCsiTildeSequence(c,o,i,a),r.cancel=!0,r;const d=this._getKeyCode(e,s);if(void 0===d)return r;const _=13===d||9===d||127===d;if(_&&3===i&&!(8&t))return r;const u=void 0!==this._functionalKeyCodes[e.key]||void 0!==this._getNumpadKeyCode(e);if(8&t||a&&3===i||(1&t||a)&&(u&&!_||o>0&&1!==e.key.length||o-1>1))r.key=this._buildCsiUSequence(e,d,o,i,t,u,n),r.cancel=!0;else{const t=13===d?"\r":9===d?"\t":127===d?"":void 0;t?r.key=t:1!==e.key.length||e.ctrlKey||e.altKey||e.metaKey||(r.key=e.key)}return r}static shouldUseProtocol(e){return e>0}}},726(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let o=r;o=i)return this._interim=r,s;const n=e.charCodeAt(o);56320<=n&&n<=57343?t[s++]=1024*(r-55296)+n-56320+65536:(t[s++]=r,t[s++]=n);continue}65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,o,n,a,h=0,l=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let o,n=0;for(;(o=this.interim[++n])&&n<4;)r<<=6,r|=63&o;const a=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=a-n;for(;l=i)return 0;if(o=e[l++],128!=(192&o)){l--,s=!0;break}this.interim[n++]=o,r<<=6,r|=63&o}s||(2===a?r<128?l--:t[h++]=r:3===a?r<2048||r>=55296&&r<=57343||65279===r||(t[h++]=r):r<65536||r>1114111||(t[h++]=r)),this.interim.fill(0)}const c=i-4;let d=l;for(;d=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(a=(31&s)<<6|63&r,a<128){d--;continue}t[h++]=a}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(a=(15&s)<<12|(63&r)<<6|63&o,a<2048||a>=55296&&a<=57343||65279===a)continue;t[h++]=a}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=o,h;if(n=e[d++],128!=(192&n)){d--;continue}if(a=(7&s)<<18|(63&r)<<12|(63&o)<<6|63&n,a<65536||a>1114111)continue;t[h++]=a}}return h}}},7428(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],o=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let n;t.UnicodeV6=class{constructor(){if(this.version="6",!n){n=new Uint8Array(65536),n.fill(1),n[0]=0,n.fill(0,1,32),n.fill(0,127,160),n.fill(2,4352,4448),n[9001]=2,n[9002]=2,n.fill(2,11904,42192),n[12351]=1,n.fill(2,44032,55204),n.fill(2,63744,64256),n.fill(2,65040,65050),n.fill(2,65072,65136),n.fill(2,65280,65377),n.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},9249(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Win32InputMode=void 0,t.Win32InputMode=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){const t=this._codeToVk[e.code];return void 0!==t?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if("Enter"===e.key)return 10;if("Backspace"===e.key)return 127}const t=this._keyToControlChar[e.key];if(void 0!==t)return t;if(1===e.key.length){const t=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&("ControlRight"===e.code?t|=4:t|=8),e.altKey&&("AltRight"===e.code?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){return{type:0,cancel:!0,key:`[${this._getVirtualKeyCode(e)};${this._getScanCode(e)};${this._getUnicodeChar(e)};${t?1:0};${this._getControlKeyState(e)};1_`}}}},3562(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(3132),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._innerWriteTimer=this._register(new s.TimeoutTimer),this._onWriteParsed=this._register(new o.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._register((0,r.toDisposable)(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed)return;if(this._isSyncWriting)return;let e;this._isSyncWriting=!0;let t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);const i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>{this._store.isDisposed||(performance.now()-i>=12?this._scheduleInnerWrite(0,e):this._innerWrite(i,e))};return void s.catch(e=>(queueMicrotask(()=>{throw e}),Promise.resolve(!1))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(t.startsWith("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(t.startsWith("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,o]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(o,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},2607(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ApcHandler=t.ApcParser=void 0;const s=i(726),r=i(4220),o=[];t.ApcParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._ident=0}start(e){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._ident,"START")}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}end(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"END",e);this._active=o,this._ident=0}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.ApcHandler=n,n._payloadLimit=1e7},9823(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),o=i(4220),n=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=n,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=n,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=n,this._ident=0}};const a=new r.Params;a.addParam(0);class h{constructor(e){this._handler=e,this._data=new o.LimitedStringBuilder(h._payloadLimit),this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString(),this._params),t instanceof Promise))return t.then(e=>(this._params=a,this._data.reset(),this._hitLimit=!1,e));return this._params=a,this._data.reset(),this._hitLimit=!1,t}}t.DcsHandler=h,h._payloadLimit=1e7},6717(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(4812),r=i(7262),o=i(1346),n=i(9823),a=i(2607);class h{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;rt),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const o=i(0,17);e.setDefault(1,0),e.addMany(s,0,2,0);for(const t of o)e.addMany([24,26,153,154],t,3,0),e.addMany(i(128,144),t,3,0),e.addMany(i(144,152),t,3,0),e.add(156,t,0,0),e.add(27,t,11,1),e.add(157,t,4,8),e.addMany([152,158],t,0,7),e.add(159,t,11,14),e.add(155,t,11,3),e.add(144,t,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,11,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany(i(32,48),14,9,15),e.addMany(i(48,127),14,15,16),e.addMany(i(48,127),15,15,16),e.addMany(r,15,0,15),e.addMany(i(32,48),15,9,15),e.add(127,15,0,15),e.addMany(s,16,16,16),e.addMany(r,16,0,16),e.addMany(i(8,14),16,16,16),e.add(127,16,0,16),e.addMany([27,156,24,26],16,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e.add(l,16,16,16),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new o.OscParser),this._dcsParser=this._register(new n.DcsParser),this._apcParser=this._register(new a.ApcParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i<60||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);this._escHandlers[i]??=[];const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){const i=e.charCodeAt(0);this._executeHandlers[i]=t,i<24&&(this._executeHandlersArr[i]=t)}clearExecuteHandler(e){const t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);this._csiHandlers[i]??=[];const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r,o,n=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let r=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](this._params),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 4:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=255&this._parseStack.transition}for(let i=n;i=60&&n<=63&&(this._collect=n,s++);let a=!1;for(;s=48&&n<=57)this._params.addDigit(n-48);else if(59===n)this._params.addParam(0);else{if(58!==n){if(n>=64&&n<=126){const e=this._csiHandlers[this._collect<<8|n];let t=e?e.length-1:-1;for(;t>=0&&(o=e[t](this._params),!0!==o);t--)if(o instanceof Promise)return r=1792,this._preserveStack(3,e,t,r,s),o;t<0&&this._csiHandlerFb(this._collect<<8|n,this._params),this.precedingJoinState=0,i=s,this.currentState=0,a=!0;break}break}this._params.addSubParam(-1)}a||(i=s-1,this.currentState=4);continue}switch(r=this._transitions.table[this.currentState<<8|(s>8){case 2:let n=i;const a=t-4;for(;n=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l););if(n>=a)for(;n=32&&(e[n]<=126||e[n]>=l);)n++;this._printHandler(e,i,n),i=n-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:i,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:const h=this._csiHandlers[this._collect<<8|s];let c=h?h.length-1:-1;for(;c>=0&&(o=h[c](this._params),!0!==o);c--)if(o instanceof Promise)return this._preserveStack(3,h,c,r,i),o;c<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do{switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}}while(++i47&&s<60);i--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:const d=this._escHandlers[this._collect<<8|s];let _=d?d.length-1:-1;for(;_>=0&&(o=d[_](),!0!==o);_--)if(o instanceof Promise)return this._preserveStack(4,d,_,r,i),o;_<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let r=i+1;;++r)if(r>=t||24===(s=e[r])||26===s||27===s||s>127&&s=t||(s=e[r])<32||s>127&&s=32&&e[s]<127||e[s]>=8&&e[s]<14||e[s]>=l))){this._apcParser.put(e,i,s),i=s-1;break}break;case 17:if(o=this._apcParser.end(24!==s&&26!==s),o)return this._preserveStack(7,[],0,r,i),o;27===s&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0}this.currentState=255&r}}}t.EscapeSequenceParser=c},1346(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(726),r=i(4220),o=[];t.OscParser=class{constructor(){this._state=0,this._active=o,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,s.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=o,this._id=-1,this._state=0}}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.OscHandler=n,n._payloadLimit=1e7},7262(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;class i{static fromArray(e){const t=new i;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new i(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>2147483647?2147483647:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=e>2147483647?2147483647:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const i=this._digitIsSub?this._subParams:this.params,s=i[t-1];i[t-1]=~s?Math.min(10*s+e,2147483647):e}}t.Params=i},3027(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new o.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=n},6097(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,e=>t(e.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(e,i)=>t(e,i.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}}},4335(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=void 0;const o=i(4812),n=i(4097),a=i(6501),h=i(8636);let l=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e,t){super(),this.isUserScrolling=!1,this._onResize=this._register(new h.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new h.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new n.BufferSet(e,this,t)),this._register(this.buffers.onBufferActivate(e=>{this._onScroll.fire(e.activeBuffer.ydisp)}))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,o=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;o===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(o+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=o-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(o,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=l,t.BufferService=l=s([r(0,a.IOptionsService),r(1,a.ILogService)],l)},5746(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},4071(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const o=i(4812),n=i(6501),a=i(8636),h=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0});let c=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new a.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new a.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new a.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new a.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}reset(){this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onBinary.fire(e))}};t.CoreService=c,t.CoreService=c=s([r(0,n.IBufferService),r(1,n.ILogService),r(2,n.IOptionsService)],c)},4720(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationLineCache=t.DecorationService=void 0;const o=i(3132),n=i(4103),a=i(4812),h=i(6501),l=i(3087),c=i(8636);let d=0,_=0,u=class extends a.Disposable{get decorations(){return this._decorations.values()}constructor(e,t){super(),this._logService=e,this._bufferService=t,this._lineCache=this._register(new f),this._onDecorationRegistered=this._register(new c.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new c.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._decorations=new l.SortedList(e=>e?.marker.line,this._logService),this._register((0,a.toDisposable)(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}registerDecoration(e){if(e.marker.isDisposed)return;const t=new p(e);if(t){const e=t.marker.onDispose(()=>t.dispose()),i=t.onDispose(()=>{i.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),e.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,i){const s=this._lineCache.getDecorationsOnLine(t);if(s)for(const t of s)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&(yield t)}forEachDecorationAtCell(e,t,i,s){const r=this._lineCache.getDecorationsOnLine(t);if(r)for(const t of r)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&s(t)}};t.DecorationService=u,t.DecorationService=u=s([r(0,h.ILogService),r(1,h.IBufferService)],u);class f extends a.Disposable{constructor(){super(...arguments),this._decorationsByLine=new Map,this._decorations=new Set,this._bufferLineListeners=this._register(new a.MutableDisposable),this._lineIndexSyncTimer=this._register(new o.MicrotaskTimer),this._lineIndexSyncCallbacks=[]}clear(){this._lineIndexSyncCallbacks.length=0,this._lineIndexSyncTimer.cancel(),this._decorationsByLine.clear(),this._decorations.clear()}add(e){this._decorations.add(e),this._addToLineBuckets(e)}remove(e){this._decorations.delete(e),this._removeFromLineBuckets(e)}getDecorationsOnLine(e){return this._decorationsByLine.get(e)}attachToBufferLines(e){const t=new a.DisposableStore;this._bufferLineListeners.value=t,t.add(e.onTrim(e=>this._handleBufferLinesTrim(e))),t.add(e.onInsert(e=>this._handleBufferLinesInsert(e))),t.add(e.onDelete(e=>this._handleBufferLinesDelete(e)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){const t=e.marker.line;if(t<0)return;e._indexedStartLine=t;const i=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{const e=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(const t of e)t()})}_handleBufferLinesTrim(e){if(e<=0)return;const t=new Map;for(const[i,s]of this._decorationsByLine){const r=i-e;r<0||this._mergeLineBucket(t,r,s)}this._decorationsByLine.clear();for(const[e,i]of t)this._decorationsByLine.set(e,i);for(const t of this._decorations)t.marker.isDisposed||(t._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,i){const s=e.get(t);if(s)for(let e=0,t=i.length;et&&(s.push(e),this._removeFromLineBuckets(e))}const r=new Map;for(const[e,s]of this._decorationsByLine){const o=e>=t?e+i:e;this._mergeLineBucket(r,o,s)}this._decorationsByLine.clear();for(const[e,t]of r)this._decorationsByLine.set(e,t);for(const e of this._decorations)e.marker.isDisposed||e._indexedStartLine>=t&&(e._indexedStartLine=e.marker.line);for(const e of s)this._addToLineBuckets(e)}_applyBufferLinesDelete(e){const t=e.index+e.amount,i=new Map;for(const[s,r]of this._decorationsByLine){if(s>=e.index&&s=t?s-e.amount:s;this._mergeLineBucket(i,o,r)}this._decorationsByLine.clear();for(const[e,t]of i)this._decorationsByLine.set(e,t);const s=[];for(const i of this._decorations){if(i.marker.isDisposed)continue;const r=i._indexedStartLine,o=this._getDecorationHeight(i);r>=t?i._indexedStartLine=i.marker.line:rt&&s.push(i)}for(const e of s)this._reindexDecoration(e)}}t.DecorationLineCache=f;class p extends a.DisposableStore{get backgroundColorRGB(){return null===this._cachedBg&&(this.options.backgroundColor?this._cachedBg=n.css.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return null===this._cachedFg&&(this.options.foregroundColor?this._cachedFg=n.css.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}constructor(e){super(),this.options=e,this.onRenderEmitter=this.add(new c.Emitter),this.onRender=this.onRenderEmitter.event,this._onDispose=this.add(new c.Emitter),this.onDispose=this._onDispose.event,this._cachedBg=null,this._cachedFg=null,this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}dispose(){this._onDispose.fire(),super.dispose()}}},6025(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class o{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=o,t.InstantiationService=class{constructor(){this._services=new o,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort((e,t)=>e.index-t.index),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const o=i.length>0?i[0].index:t.length;if(t.length!==o)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${o+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0;const o=i(4812),n=i(6501),a={trace:n.LogLevelEnum.TRACE,debug:n.LogLevelEnum.DEBUG,info:n.LogLevelEnum.INFO,warn:n.LogLevelEnum.WARN,error:n.LogLevelEnum.ERROR,off:n.LogLevelEnum.OFF};let h=class extends o.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=n.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function n(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const a=String.fromCharCode,h={DEFAULT:e=>{const t=[n(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${a(t[0])}${a(t[1])}${a(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.x};${e.y}${t}`}};class l extends s.Disposable{constructor(){super(),this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=this._register(new r.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(o))this.addProtocol(e,o[e]);for(const e of Object.keys(h))this.addEncoding(e,h[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return!this._customWheelEventHandler||!1!==this._customWheelEventHandler(e)}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return"DEFAULT"===this._activeEncoding}get isPixelEncoding(){return"SGR_PIXELS"===this._activeEncoding}}t.MouseStateService=l},56(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(4812),r=i(701),o=i(8636);t.DEFAULT_OPTIONS={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}};const n=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new o.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{-1!==e.indexOf(i)&&t()})}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=n.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"blinkIntervalDuration":if((i=Math.floor(i))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const o=i(6501);let n=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose(()=>this._removeMarkerFromLink(s,i)),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const o=t.addMarker(t.ybase+t.y),n={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(n,o)),this._entriesWithId.set(n.key,n),this._dataByLinkId.set(n.id,n),n.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every(e=>e.line!==t)){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose(()=>this._removeMarkerFromLink(i,e))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=n,t.OscLinkService=n=s([r(0,o.IBufferService)],n)},6201(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e.di$dependencies||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const i=function(e,t,s){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,i){t.di$target===t?t.di$dependencies.push({id:e,index:i}):(t.di$dependencies=[{id:e,index:i}],t.di$target=t)}(i,e,s)};return i._id=e,t.serviceRegistry.set(e,i),i},t.serviceRegistry=new Map},6501(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.IMouseStateService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.IMouseStateService=(0,s.createDecorator)("MouseStateService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8636);class r{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.Emitter,this.onChange=this._onChange.event}static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e,this._active||(this.activeVersion=e.version)}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let o=0;o=s)return t+this.wcwidth(n);const i=e.charCodeAt(o);56320<=i&&i<=57343?n=1024*(n-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(n,i);let h=r.extractWidth(a);r.extractShouldJoin(a)&&(h-=r.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=r}},t={};return function i(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s].call(o.exports,o,o.exports,i),o.exports}(6081)})()); //# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/lib/xterm.mjs b/lib/xterm.mjs -index 9bc5087a0729e74e20b59eddf5d8259e056de338..9fa15d1e81c591d9e8cffa621d37a4ed54b80a4f 100644 +index 9bc5087a0729e74e20b59eddf5d8259e056de338..e09e996e12e197c60efa1178b2e228024225526a 100644 --- a/lib/xterm.mjs +++ b/lib/xterm.mjs @@ -17,11 +17,11 @@ var Ms=Object.defineProperty;var kn=Object.getOwnPropertyDescriptor;var Mn=(n,i)=>{for(var e in i)Ms(n,e,{get:i[e],enumerable:!0})};var y=(n,i,e,t)=>{for(var r=t>1?void 0:t?kn(i,e):i,s=n.length-1,o;s>=0;s--)(o=n[s])&&(r=(t?o(i,e,r):o(r))||r);return t&&r&&Ms(i,e,r),r},m=(n,i)=>(e,t)=>i(e,t,n);var Bs="Terminal input",Ut={get:()=>Bs,set:n=>Bs=n},Ps="Too much output to announce, navigate to rows manually to read",Ze={get:()=>Ps,set:n=>Ps=n};function Bn(n){return n.replace(/\r?\n/g,"\r")}function Pn(n,i){return i?`\x1B[200~${n.replace(/\x1b/g,"\u241B")}\x1B[201~`:n}function Os(n,i){n.clipboardData&&n.clipboardData.setData("text/plain",i.selectionText),n.preventDefault()}function Ns(n,i,e,t){if(n.stopPropagation(),n.clipboardData){let r=n.clipboardData.getData("text/plain");Nr(r,i,e,t)}}function Nr(n,i,e,t){n=Bn(n),n=Pn(n,e.decPrivateModes.bracketedPasteMode&&t.rawOptions.ignoreBracketedPasteMode!==!0),e.triggerDataEvent(n,!0),i.value=""}function Fr(n,i,e){let t=e.getBoundingClientRect(),r=n.clientX-t.left-10,s=n.clientY-t.top-10;i.style.width="20px",i.style.height="20px",i.style.left=`${r}px`,i.style.top=`${s}px`,i.style.zIndex="1000",i.focus()}function Hr(n,i,e,t,r){Fr(n,i,e),r&&t.rightClickSelect(n),i.value=t.selectionText,i.select()}function be(n){return n>65535?(n-=65536,String.fromCharCode((n>>10)+55296)+String.fromCharCode(n%1024+56320)):String.fromCharCode(n)}function ye(n,i=0,e=n.length){let t="";for(let r=i;r65535?(s-=65536,t+=String.fromCharCode((s>>10)+55296)+String.fromCharCode(s%1024+56320)):t+=String.fromCharCode(s)}return t}var mi=class{constructor(){this._interim=0}clear(){this._interim=0}decode(i,e){let t=i.length;if(!t)return 0;let r=0,s=0;if(this._interim){let o=i.charCodeAt(s++);56320<=o&&o<=57343?e[r++]=(this._interim-55296)*1024+o-56320+65536:(e[r++]=this._interim,e[r++]=o),this._interim=0}for(let o=s;o=t)return this._interim=a,r;let l=i.charCodeAt(o);56320<=l&&l<=57343?e[r++]=(a-55296)*1024+l-56320+65536:(e[r++]=a,e[r++]=l);continue}a!==65279&&(e[r++]=a)}return r}},bi=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(i,e){let t=i.length;if(!t)return 0;let r=0,s,o,a,l,h,d=0;if(this.interim[0]){let _=!1,p=this.interim[0];p&=(p&224)===192?31:(p&240)===224?15:7;let v=0,f;for(;(f=this.interim[++v])&&v<4;)p<<=6,p|=f&63;let S=(this.interim[0]&224)===192?2:(this.interim[0]&240)===224?3:4,I=S-v;for(;d=t)return 0;if(f=i[d++],(f&192)!==128){d--,_=!0;break}else this.interim[v++]=f,p<<=6,p|=f&63}_||(S===2?p<128?d--:e[r++]=p:S===3?p<2048||p>=55296&&p<=57343||p===65279||(e[r++]=p):p<65536||p>1114111||(e[r++]=p)),this.interim.fill(0)}let c=t-4,u=d;for(;u=t)return this.interim[0]=s,r;if(o=i[u++],(o&192)!==128){u--;continue}if(h=(s&31)<<6|o&63,h<128){u--;continue}e[r++]=h}else if((s&240)===224){if(u>=t)return this.interim[0]=s,r;if(o=i[u++],(o&192)!==128){u--;continue}if(u>=t)return this.interim[0]=s,this.interim[1]=o,r;if(a=i[u++],(a&192)!==128){u--;continue}if(h=(s&15)<<12|(o&63)<<6|a&63,h<2048||h>=55296&&h<=57343||h===65279)continue;e[r++]=h}else if((s&248)===240){if(u>=t)return this.interim[0]=s,r;if(o=i[u++],(o&192)!==128){u--;continue}if(u>=t)return this.interim[0]=s,this.interim[1]=o,r;if(a=i[u++],(a&192)!==128){u--;continue}if(u>=t)return this.interim[0]=s,this.interim[1]=o,this.interim[2]=a,r;if(l=i[u++],(l&192)!==128){u--;continue}if(h=(s&7)<<18|(o&63)<<12|(a&63)<<6|l&63,h<65536||h>1114111)continue;e[r++]=h}}return r}};var ue=class n{constructor(){this.fg=0;this.bg=0;this.extended=new ke}static toColorRGB(i){return[i>>>16&255,i>>>8&255,i&255]}static fromColorRGB(i){return(i[0]&255)<<16|(i[1]&255)<<8|i[2]&255}clone(){let i=new n;return i.fg=this.fg,i.bg=this.bg,i.extended=this.extended.clone(),i}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},ke=class n{constructor(i=0,e=0){this._ext=0;this._urlId=0;this._ext=i,this._urlId=e}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(i){this._ext=i}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(i){this._ext&=-469762049,this._ext|=i<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(i){this._ext&=-67108864,this._ext|=i&67108863}get urlId(){return this._urlId}set urlId(i){this._urlId=i}get underlineVariantOffset(){let i=(this._ext&3758096384)>>29;return i<0?i^4294967288:i}set underlineVariantOffset(i){this._ext&=536870911,this._ext|=i<<29&3758096384}clone(){return new n(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}};var F=class n extends ue{constructor(){super(...arguments);this.content=0;this.fg=0;this.bg=0;this.extended=new ke;this.combinedData=""}static fromCharData(e){let t=new n;return t.setFromCharData(e),t}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?be(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(e){this.fg=e[0],this.bg=0;let t=!1;if(e[1].length>2)t=!0;else if(e[1].length===2){let r=e[1].charCodeAt(0);if(55296<=r&&r<=56319){let s=e[1].charCodeAt(1);56320<=s&&s<=57343?this.content=(r-55296)*1024+s-56320+65536|e[2]<<22:t=!0}else t=!0}else this.content=e[1].charCodeAt(0)|e[2]<<22;t&&(this.combinedData=e[1],this.content=2097152|e[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor()||this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor()||this.isInverse()!==e.isInverse()||this.isBold()!==e.isBold()||this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;let t=this.isUnderlineColorDefault(),r=e.isUnderlineColorDefault();if(!(t&&r)&&(t!==r||this.getUnderlineColor()!==e.getUnderlineColor()||this.getUnderlineColorMode()!==e.getUnderlineColorMode()))return!1}return!(this.isOverline()!==e.isOverline()||this.isBlink()!==e.isBlink()||this.isInvisible()!==e.isInvisible()||this.isItalic()!==e.isItalic()||this.isDim()!==e.isDim()||this.isStrikethrough()!==e.isStrikethrough())}};var zr=new Map;function Hs(n){return n.di$dependencies||[]}function H(n){if(zr.has(n))return zr.get(n);let i=function(e,t,r){if(arguments.length!==3)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");Fn(i,e,r)};return i._id=n,zr.set(n,i),i}function Fn(n,i,e){i.di$target===i?i.di$dependencies.push({id:n,index:e}):(i.di$dependencies=[{id:n,index:e}],i.di$target=i)}var D=H("BufferService"),Me=H("MouseStateService"),Y=H("CoreService"),Ws=H("CharsetService"),Qe=H("InstantiationService");var fe=H("LogService"),R=H("OptionsService"),vi=H("OscLinkService"),Us=H("UnicodeService"),ge=H("DecorationService");var et=class{constructor(i,e,t){this._bufferService=i;this._optionsService=e;this._oscLinkService=t;this._workCell=new F}provideLinks(i,e){let t=this._bufferService.buffer.lines.get(i-1);if(!t){e(void 0);return}let r=[],s=this._optionsService.rawOptions.linkHandler,o=this._workCell,a=t.getTrimmedLength(),l=-1,h=-1,d=!1;for(let c=0;cs?s.activate(f,S,p):Hn(f,S),hover:(f,S)=>s?.hover?.(f,S,p),leave:(f,S)=>s?.leave?.(f,S,p)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(h=c,l=o.extended.urlId):(h=-1,l=-1)}}e(r)}_getRangeWithLineWrap(i,e,t,r){let s=i,o=e,a=i,l=t;for(;o===0&&this._bufferService.buffer.lines.get(s-1)?.isWrapped;){let d=this._bufferService.buffer.lines.get(s-2);if(!d)break;let c=d.getTrimmedLength();if(c===0||!this._hasUrlId(d,c-1,r))break;let u=c-1;for(;u>0&&this._hasUrlId(d,u-1,r);)u--;s--,o=u}for(;;){let h=this._bufferService.buffer.lines.get(a-1);if(!h)break;let d=h.getTrimmedLength();if(l!==d)break;let c=this._bufferService.buffer.lines.get(a);if(!c?.isWrapped)break;let u=c.getTrimmedLength();if(u===0||!this._hasUrlId(c,0,r))break;let _=1;for(;_{n(),e&&r.dispose()},i),r=E(()=>{clearTimeout(t)});return e?.add(r),r}var Ie=class{constructor(){this._token=-1;this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._token!==-1&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(i,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,i()},e)}setIfNotSet(i,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");this._token===-1&&(this._token=setTimeout(()=>{this._token=-1,i()},e))}},Ci=class{constructor(){this._isScheduled=!1;this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(i){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,i())}))}},Ei=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(i,e,t=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();let r=t.setInterval(()=>{i()},e);this._disposable={dispose:()=>{t.clearInterval(r),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}};function se(n){let i=n;if(i?.ownerDocument?.defaultView)return i.ownerDocument.defaultView;let e=n;return e?.view?e.view:window}var Gr=class{constructor(i,e,t,r){this._node=i,this._type=e,this._handler=t,this._options=r,i.addEventListener(e,t,r)}dispose(){!this._node||!this._handler||(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}};function C(n,i,e,t){return new Gr(n,i,e,t)}function Vr(n,i,e,t){return C(n,i,e,t)}var le={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};function $s(n){let i=n.getBoundingClientRect(),e=se(n);return{left:i.left+e.scrollX,top:i.top+e.scrollY,width:i.width,height:i.height}}var yi=class{constructor(i,e){this._runner=i;this.priority=e;this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(i){console.error(i)}}static sort(i,e){return e.priority-i.priority}},Vs=new Map;function qs(n){let i=Vs.get(n);return i||(i={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},Vs.set(n,i)),i}function Wn(n){let i=qs(n);for(i.animFrameRequested=!1,i.current=i.next,i.next=[],i.inAnimationFrameRunner=!0;i.current.length>0;)i.current.sort(yi.sort),i.current.shift().execute();i.inAnimationFrameRunner=!1}function tt(n,i,e=0){let t=qs(n),r=new yi(i,e);return t.next.push(r),t.animFrameRequested||(t.animFrameRequested=!0,n.requestAnimationFrame(()=>Wn(n))),r}var xi=class extends Ei{constructor(i){super(),this._defaultTarget=i?se(i):void 0}cancelAndSet(i,e,t){super.cancelAndSet(i,e,t??this._defaultTarget??window)}};var we=class{constructor(i){this.domNode=i;this._width="";this._height="";this._top="";this._left="";this._bottom="";this._right="";this._className="";this._position="";this._layerHint=!1;this._contain="none"}setWidth(i){let e=rt(i);this._width!==e&&(this._width=e,this.domNode.style.width=this._width)}setHeight(i){let e=rt(i);this._height!==e&&(this._height=e,this.domNode.style.height=this._height)}setTop(i){let e=rt(i);this._top!==e&&(this._top=e,this.domNode.style.top=this._top)}setLeft(i){let e=rt(i);this._left!==e&&(this._left=e,this.domNode.style.left=this._left)}setBottom(i){let e=rt(i);this._bottom!==e&&(this._bottom=e,this.domNode.style.bottom=this._bottom)}setRight(i){let e=rt(i);this._right!==e&&(this._right=e,this.domNode.style.right=this._right)}setClassName(i){this._className!==i&&(this._className=i,this.domNode.className=this._className)}toggleClassName(i,e){this.domNode.classList.toggle(i,e),this._className=this.domNode.className}setPosition(i){this._position!==i&&(this._position=i,this.domNode.style.position=this._position)}setLayerHinting(i){this._layerHint!==i&&(this._layerHint=i,i?this.domNode.style.transform="translate3d(0px, 0px, 0px)":this.domNode.style.transform="")}setContain(i){this._contain!==i&&(this._contain=i,this.domNode.style.contain=this._contain)}setAttribute(i,e){this.domNode.setAttribute(i,e)}};function rt(n){return typeof n=="number"?`${n}px`:n}var Ke={};Mn(Ke,{getSafariVersion:()=>Kn,getZoomFactor:()=>Xr,isChrome:()=>Kt,isChromeOS:()=>Yr,isFirefox:()=>nt,isLegacyEdge:()=>Un,isLinux:()=>zt,isMac:()=>ie,isNode:()=>$r,isSafari:()=>wi,isWindows:()=>Ue});var $r=!!(typeof process<"u"&&"title"in process&&(typeof navigator>"u"||navigator.userAgent.startsWith("Node.js/"))),st=$r?"node":navigator.userAgent,qr=$r?"node":navigator.platform,nt=st.includes("Firefox"),Kt=st.includes("Chrome"),Un=st.includes("Edge"),wi=/^((?!chrome|android).)*safari/i.test(st);function Xr(n){return 1}function Kn(){if(!wi)return 0;let n=st.match(/Version\/(\d+)/);return n===null||n.length<2?0:parseInt(n[1],10)}var ie=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(qr),Ue=["Windows","Win16","Win32","WinCE"].includes(qr),zt=qr.indexOf("Linux")>=0,Yr=/\bCrOS\b/.test(st);var Xs=new WeakMap;function zn(n){if(!n.parent||n.parent===n)return null;try{let i=n.location,e=n.parent.location;if(i.origin!=="null"&&e.origin!=="null"&&i.origin!==e.origin)return null}catch{return null}return n.parent}var jr=class{static _getSameOriginWindowChain(i){let e=Xs.get(i);if(!e){e=[],Xs.set(i,e);let t=i,r;do r=zn(t),r?e.push({window:new WeakRef(t),iframeElement:t.frameElement??null}):e.push({window:new WeakRef(t),iframeElement:null}),t=r;while(t)}return e.slice(0)}static getPositionOfChildWindowRelativeToAncestorWindow(i,e){if(!e||i===e)return{top:0,left:0};let t=0,r=0,s=this._getSameOriginWindowChain(i);for(let o of s){let a=o.window.deref();if(t+=a?.scrollY??0,r+=a?.scrollX??0,a===e||!o.iframeElement)break;let l=o.iframeElement.getBoundingClientRect();t+=l.top,r+=l.left}return{top:t,left:r}}},ot=class{constructor(i,e){this.timestamp=Date.now(),this.browserEvent=e,this.leftButton=e.button===0,this.middleButton=e.button===1,this.rightButton=e.button===2,this.buttons=e.buttons,this.target=e.target,this.detail=e.detail??1,e.type==="dblclick"&&(this.detail=2),this.ctrlKey=e.ctrlKey,this.shiftKey=e.shiftKey,this.altKey=e.altKey,this.metaKey=e.metaKey,typeof e.pageX=="number"?(this.posx=e.pageX,this.posy=e.pageY):(this.posx=e.clientX+this.target.ownerDocument.body.scrollLeft+this.target.ownerDocument.documentElement.scrollLeft,this.posy=e.clientY+this.target.ownerDocument.body.scrollTop+this.target.ownerDocument.documentElement.scrollTop);let t=jr.getPositionOfChildWindowRelativeToAncestorWindow(i,e.view);this.posx-=t.left,this.posy-=t.top}preventDefault(){this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent.stopPropagation()}},Gt=class{constructor(i,e=0,t=0){this.browserEvent=i??null,this.target=i?i.target??i.targetNode??i.srcElement??null:null,this.deltaY=t,this.deltaX=e;let r=!1;if(Kt){let s=navigator.userAgent.match(/Chrome\/(\d+)/);r=(s?parseInt(s[1],10):123)<=122}if(i){let s=i,o=i,a=i.view?.devicePixelRatio??1;if(typeof s.wheelDeltaY<"u")r?this.deltaY=s.wheelDeltaY/(120*a):this.deltaY=s.wheelDeltaY/120;else if(typeof o.VERTICAL_AXIS<"u"&&o.axis===o.VERTICAL_AXIS)this.deltaY=-o.detail/3;else if(i.type==="wheel"){let l=i;l.deltaMode===l.DOM_DELTA_LINE?nt&&!ie?this.deltaY=-i.deltaY/3:this.deltaY=-i.deltaY:this.deltaY=-i.deltaY/40}if(typeof s.wheelDeltaX<"u")wi&&Ue?this.deltaX=-(s.wheelDeltaX/120):r?this.deltaX=s.wheelDeltaX/(120*a):this.deltaX=s.wheelDeltaX/120;else if(typeof o.HORIZONTAL_AXIS<"u"&&o.axis===o.HORIZONTAL_AXIS)this.deltaX=-i.detail/3;else if(i.type==="wheel"){let l=i;l.deltaMode===l.DOM_DELTA_LINE?nt&&!ie?this.deltaX=-i.deltaX/3:this.deltaX=-i.deltaX:this.deltaX=-i.deltaX/40}this.deltaY===0&&this.deltaX===0&&i.wheelDelta&&(r?this.deltaY=i.wheelDelta/(120*a):this.deltaY=i.wheelDelta/120)}}preventDefault(){this.browserEvent?.preventDefault()}stopPropagation(){this.browserEvent?.stopPropagation()}};var at=class{constructor(){this._hooks=new pe;this._pointerMoveCallback=null;this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(i){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;let e=this._onStopCallback;this._onStopCallback=null,i&&e&&e()}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(i,e,t,r,s){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=r,this._onStopCallback=s;let o=i;try{i.setPointerCapture(e),this._hooks.add(E(()=>{try{i.releasePointerCapture(e)}catch{}}))}catch{o=se(i)}this._hooks.add(C(o,le.POINTER_MOVE,a=>{if(a.buttons!==t){this.stopMonitoring(!0);return}a.preventDefault(),this._pointerMoveCallback(a)})),this._hooks.add(C(o,le.POINTER_UP,a=>this.stopMonitoring(!0)))}};var Ne=class extends g{_onclick(i,e){this._register(C(i,le.CLICK,t=>e(new ot(se(i),t))))}_onmouseover(i,e){this._register(C(i,le.MOUSE_OVER,t=>e(new ot(se(i),t))))}_onmouseleave(i,e){this._register(C(i,le.MOUSE_LEAVE,t=>e(new ot(se(i),t))))}};var Ti=class extends Ne{constructor(i){super(),this._handleActivate=i.handleActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="xterm-arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=i.bgWidth+"px",this.bgDomNode.style.height=i.bgHeight+"px",typeof i.top<"u"&&(this.bgDomNode.style.top="0px"),typeof i.left<"u"&&(this.bgDomNode.style.left="0px"),typeof i.bottom<"u"&&(this.bgDomNode.style.bottom="0px"),typeof i.right<"u"&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=i.className,this.domNode.style.position="absolute";let e=Math.min(i.bgWidth,i.bgHeight);this.domNode.style.width=e+"px",this.domNode.style.height=e+"px",typeof i.top<"u"&&(this.domNode.style.top=i.top+"px"),typeof i.left<"u"&&(this.domNode.style.left=i.left+"px"),typeof i.bottom<"u"&&(this.domNode.style.bottom=i.bottom+"px"),typeof i.right<"u"&&(this.domNode.style.right=i.right+"px"),this._pointerMoveMonitor=this._register(new at),this._register(Vr(this.bgDomNode,le.POINTER_DOWN,t=>this._arrowPointerDown(t))),this._register(Vr(this.domNode,le.POINTER_DOWN,t=>this._arrowPointerDown(t))),this._pointerdownRepeatTimer=this._register(new xi),this._pointerdownScheduleRepeatTimer=this._register(new Ie)}_arrowPointerDown(i){if(!i.target||!(i.target instanceof Element))return;let e=()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,se(i))};this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(e,200),this._pointerMoveMonitor.startMonitoring(i.target,i.pointerId,i.buttons,t=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),i.preventDefault()}};var b=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(i,e,t)=>{if(this._disposed)return E(()=>{});let r={fn:i,thisArgs:e};this._listeners.push(r);let s=E(()=>{let o=this._listeners.indexOf(r);o!==-1&&this._listeners.splice(o,1)});return t&&(Array.isArray(t)?t.push(s):t.add(s)),s},this._event)}fire(i){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:e,thisArgs:t}=this._listeners[0];e.call(t,i);return}default:{let e=this._listeners.slice();for(let{fn:t,thisArgs:r}of e)t.call(r,i)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},j;(r=>{function n(s,o){return s(a=>o.fire(a))}r.forward=n;function i(s,o){return(a,l,h)=>s(d=>a.call(l,o(d)),void 0,h)}r.map=i;function e(...s){return(o,a,l)=>{let h=new pe;for(let d of s)h.add(d(c=>o.call(a,c)));return l&&(Array.isArray(l)?l.push(h):l.add(h)),h}}r.any=e;function t(s,o,a){return o(a),s(l=>o(l))}r.runAndSubscribe=t})(j||={});var Jr=class n{constructor(i,e,t,r,s,o,a){this._forceIntegerValues=i;this._scrollStateBrand=void 0;this._forceIntegerValues&&(e=e|0,t=t|0,r=r|0,s=s|0,o=o|0,a=a|0),this.rawScrollLeft=r,this.rawScrollTop=a,e<0&&(e=0),r+e>t&&(r=t-e),r<0&&(r=0),s<0&&(s=0),a+s>o&&(a=o-s),a<0&&(a=0),this.width=e,this.scrollWidth=t,this.scrollLeft=r,this.height=s,this.scrollHeight=o,this.scrollTop=a}equals(i){return this.rawScrollLeft===i.rawScrollLeft&&this.rawScrollTop===i.rawScrollTop&&this.width===i.width&&this.scrollWidth===i.scrollWidth&&this.scrollLeft===i.scrollLeft&&this.height===i.height&&this.scrollHeight===i.scrollHeight&&this.scrollTop===i.scrollTop}withScrollDimensions(i,e){return new n(this._forceIntegerValues,typeof i.width<"u"?i.width:this.width,typeof i.scrollWidth<"u"?i.scrollWidth:this.scrollWidth,e?this.rawScrollLeft:this.scrollLeft,typeof i.height<"u"?i.height:this.height,typeof i.scrollHeight<"u"?i.scrollHeight:this.scrollHeight,e?this.rawScrollTop:this.scrollTop)}withScrollPosition(i){return new n(this._forceIntegerValues,this.width,this.scrollWidth,typeof i.scrollLeft<"u"?i.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,typeof i.scrollTop<"u"?i.scrollTop:this.rawScrollTop)}createScrollEvent(i,e){let t=this.width!==i.width,r=this.scrollWidth!==i.scrollWidth,s=this.scrollLeft!==i.scrollLeft,o=this.height!==i.height,a=this.scrollHeight!==i.scrollHeight,l=this.scrollTop!==i.scrollTop;return{inSmoothScrolling:e,oldWidth:i.width,oldScrollWidth:i.scrollWidth,oldScrollLeft:i.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:i.height,oldScrollHeight:i.scrollHeight,oldScrollTop:i.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:t,scrollWidthChanged:r,scrollLeftChanged:s,heightChanged:o,scrollHeightChanged:a,scrollTopChanged:l}}},lt=class extends g{constructor(e){super();this._scrollableBrand=void 0;this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new Jr(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){let r=this._state.withScrollDimensions(e,t);this._setState(r,!!this._smoothScrolling),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){let t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(this._smoothScrollDuration===0){this.setScrollPositionNow(e);return}if(this._smoothScrolling){e={scrollLeft:typeof e.scrollLeft>"u"?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:typeof e.scrollTop>"u"?this._smoothScrolling.to.scrollTop:e.scrollTop};let r=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===r.scrollLeft&&this._smoothScrolling.to.scrollTop===r.scrollTop)return;let s;t?s=new Vt(this._smoothScrolling.from,r,this._smoothScrolling.startTime,this._smoothScrolling.duration):s=Vt.start(this._state,r,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{let r=this._state.withScrollPosition(e);this._smoothScrolling=Vt.start(this._state,r,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}hasPendingScrollAnimation(){return!!this._smoothScrolling}_performSmoothScrolling(){if(!this._smoothScrolling)return;let e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);if(this._setState(t,!0),!!this._smoothScrolling){if(e.isDone){this._smoothScrolling.dispose(),this._smoothScrolling=null;return}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}}_setState(e,t){let r=this._state;r.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(r,t)))}},Di=class{constructor(i,e,t){this.scrollLeft=i,this.scrollTop=e,this.isDone=t}};function Zr(n,i){let e=i-n;return function(t){return n+e*$n(t)}}function Gn(n,i,e){return function(t){return t2.5*t){let s,o;return i{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(i){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(i?" xterm-fade":"")))}};var qn=140,ct=class extends Ne{constructor(i){super(),this._lazyRender=i.lazyRender,this._host=i.host,this._scrollable=i.scrollable,this._scrollByPage=i.scrollByPage,this._scrollbarState=i.scrollbarState,this._visibilityController=this._register(new Ri(i.visibility,"xterm-visible xterm-scrollbar "+i.extraScrollbarClassName,"xterm-invisible xterm-scrollbar "+i.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new at),this._shouldRender=!0,this.domNode=new we(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(C(this.domNode.domNode,le.POINTER_DOWN,e=>this._domNodePointerDown(e)))}_createArrow(i){let e=this._register(new Ti(i));return this.domNode.domNode.appendChild(e.bgDomNode),this.domNode.domNode.appendChild(e.domNode),e}_createSlider(i,e,t,r){this.slider=new we(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(i),this.slider.setLeft(e),typeof t=="number"&&this.slider.setWidth(t),typeof r=="number"&&this.slider.setHeight(r),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(C(this.slider.domNode,le.POINTER_DOWN,s=>{s.button===0&&(s.preventDefault(),this._sliderPointerDown(s))})),this._onclick(this.slider.domNode,s=>{s.leftButton&&s.stopPropagation()})}_handleElementSize(i){return this._scrollbarState.setVisibleSize(i)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(i){return this._scrollbarState.setScrollSize(i)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(i){return this._scrollbarState.setScrollPosition(i)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(i){i.target===this.domNode.domNode&&this._handlePointerDown(i)}delegatePointerDown(i){let e=this.domNode.domNode.getClientRects()[0].top,t=e+this._scrollbarState.getSliderPosition(),r=e+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),s=this._sliderPointerPosition(i);t<=s&&s<=r?i.button===0&&(i.preventDefault(),this._sliderPointerDown(i)):this._handlePointerDown(i)}_handlePointerDown(i){let e,t;if(i.target===this.domNode.domNode&&typeof i.offsetX=="number"&&typeof i.offsetY=="number")e=i.offsetX,t=i.offsetY;else{let s=$s(this.domNode.domNode);e=i.pageX-s.left,t=i.pageY-s.top}let r=this._pointerDownRelativePosition(e,t);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(r):this._scrollbarState.getDesiredScrollPositionFromOffset(r)),i.button===0&&(i.preventDefault(),this._sliderPointerDown(i))}_sliderPointerDown(i){if(!i.target||!(i.target instanceof Element))return;let e=this._sliderPointerPosition(i),t=this._sliderOrthogonalPointerPosition(i),r=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(i.target,i.pointerId,i.buttons,s=>{let o=this._sliderOrthogonalPointerPosition(s),a=Math.abs(o-t);if(Ue&&a>qn){this._setDesiredScrollPositionNow(r.getScrollPosition());return}let h=this._sliderPointerPosition(s)-e;this._setDesiredScrollPositionNow(r.getDesiredScrollPositionFromDelta(h))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(i){let e={};this.writeScrollPosition(e,i),this._scrollable.setScrollPositionNow(e)}updateScrollbarSize(i){this._updateScrollbarSize(i),this._scrollbarState.setScrollbarSize(i),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}};var ht=class n{constructor(i,e,t,r,s,o){this._scrollbarSize=Math.round(e),this._oppositeScrollbarSize=Math.round(t),this._arrowSize=Math.round(i),this._visibleSize=r,this._scrollSize=s,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new n(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(i){let e=Math.round(i);return this._visibleSize!==e?(this._visibleSize=e,this._refreshComputedValues(),!0):!1}setScrollSize(i){let e=Math.round(i);return this._scrollSize!==e?(this._scrollSize=e,this._refreshComputedValues(),!0):!1}setScrollPosition(i){let e=Math.round(i);return this._scrollPosition!==e?(this._scrollPosition=e,this._refreshComputedValues(),!0):!1}setScrollbarSize(i){this._scrollbarSize=Math.round(i)}setArrowSize(i){let e=Math.round(i);this._arrowSize!==e&&(this._arrowSize=e,this._refreshComputedValues())}setOppositeScrollbarSize(i){this._oppositeScrollbarSize=Math.round(i)}static _computeValues(i,e,t,r,s){let o=Math.max(0,t-i),a=Math.max(0,o-2*e),l=r>0&&r>t;if(!l)return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(a),computedSliderRatio:0,computedSliderPosition:0};let h=Math.round(Math.max(20,Math.floor(t*a/r))),d=(a-h)/(r-t),c=s*d;return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(h),computedSliderRatio:d,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){let i=n._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=i.computedAvailableSize,this._computedIsNeeded=i.computedIsNeeded,this._computedSliderSize=i.computedSliderSize,this._computedSliderRatio=i.computedSliderRatio,this._computedSliderPosition=i.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(i){if(!this._computedIsNeeded)return 0;let e=i-this._arrowSize-this._computedSliderSize/2;return Math.round(e/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(i){if(!this._computedIsNeeded)return 0;let e=i-this._arrowSize,t=this._scrollPosition;return ethis._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:0,left:0,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})),this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;let r=e?"":"none";this._arrowUp.bgDomNode.style.display=r,this._arrowUp.domNode.style.display=r,this._arrowDown.bgDomNode.style.display=r,this._arrowDown.domNode.style.display=r}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){let t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(e.vertical===2?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}};var Qr=class{constructor(i,e,t){this.timestamp=i,this.deltaX=e,this.deltaY=t,this.score=0}},Bi=class Bi{constructor(){this._capacity=5,this._memory=[],this._front=-1,this._rear=-1}isPhysicalMouseWheel(){if(this._front===-1&&this._rear===-1)return!1;let i=1,e=0,t=1,r=this._rear;for(;r!==-1;){let s=r===this._front?i:Math.pow(2,-t);if(i-=s,e+=this._memory[r].score*s,r===this._front)break;r=(this._capacity+r-1)%this._capacity,t++}return e<=.5}acceptStandardWheelEvent(i){if(Kt){let e=se(i.browserEvent),t=Xr(e);this.accept(Date.now(),i.deltaX*t,i.deltaY*t)}else this.accept(Date.now(),i.deltaX,i.deltaY)}accept(i,e,t){let r=null,s=new Qr(i,e,t);this._front===-1&&this._rear===-1?(this._memory[0]=s,this._front=0,this._rear=0):(r=this._memory[this._rear],this._rear=(this._rear+1)%this._capacity,this._rear===this._front&&(this._front=(this._front+1)%this._capacity),this._memory[this._rear]=s),s.score=this._computeScore(s,r)}_computeScore(i,e){if(Math.abs(i.deltaX)>0&&Math.abs(i.deltaY)>0)return 1;let t=.5;if((!this._isAlmostInt(i.deltaX)||!this._isAlmostInt(i.deltaY))&&(t+=.25),e){let r=Math.abs(i.deltaX),s=Math.abs(i.deltaY),o=Math.abs(e.deltaX),a=Math.abs(e.deltaY),l=Math.max(Math.min(r,o),1),h=Math.max(Math.min(s,a),1),d=Math.max(r,o),c=Math.max(s,a);d%l===0&&c%h===0&&(t-=.5)}return Math.min(Math.max(t,0),1)}_isAlmostInt(i){return Math.abs(Math.round(i)-i)<.01}};Bi.INSTANCE=new Bi;var es=Bi,Mi=class extends Ne{constructor(e,t,r){super();this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;t=t??{};let s,o=!r;r?s=r:(t.mouseWheelSmoothScroll=!1,s=new lt({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:l=>tt(se(e),l)})),this._options=Xn(t),this._scrollable=s,this._register(this._scrollable.onScroll(l=>{this._handleScroll(l),this._onScroll.fire(l)})),o&&this._register(this._scrollable);let a={handleMouseWheel:l=>this._handleMouseWheel(l),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new ki(this._scrollable,this._options,a)),this._horizontalScrollbar=this._register(new Ai(this._scrollable,this._options,a)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new we(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new we(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new we(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,l=>this._handleMouseOver(l)),this._onmouseleave(this._listenOnDomNode,l=>this._handleMouseLeave(l)),this._hideTimeout=this._register(new Ie),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}get options(){return this._options}dispose(){this._mouseWheelToDispose=Oe(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,ie&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){typeof e.handleMouseWheel<"u"&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),typeof e.mouseWheelScrollSensitivity<"u"&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),typeof e.fastScrollSensitivity<"u"&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),typeof e.scrollPredominantAxis<"u"&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),typeof e.horizontal<"u"&&(this._options.horizontal=e.horizontal),typeof e.vertical<"u"&&(this._options.vertical=e.vertical),typeof e.horizontalHasArrows<"u"&&(this._options.horizontalHasArrows=e.horizontalHasArrows),typeof e.verticalHasArrows<"u"&&(this._options.verticalHasArrows=e.verticalHasArrows),typeof e.horizontalScrollbarSize<"u"&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),typeof e.verticalScrollbarSize<"u"&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),typeof e.scrollByPage<"u"&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new Gt(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=Oe(this._mouseWheelToDispose),e)){let r=s=>{this._handleMouseWheel(new Gt(s))};this._mouseWheelToDispose.push(C(this._listenOnDomNode,le.MOUSE_WHEEL,r,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;let t=es.INSTANCE;t.acceptStandardWheelEvent(e);let r=!1;if(e.deltaY||e.deltaX){let o=e.deltaY*this._options.mouseWheelScrollSensitivity,a=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&a+o===0?a=o=0:Math.abs(o)>=Math.abs(a)?a=0:o=0),this._options.flipAxes&&([o,a]=[a,o]);let l=!ie&&e.browserEvent&&e.browserEvent.shiftKey;(this._options.scrollYToX||l)&&!a&&(a=o,o=0),e.browserEvent&&e.browserEvent.altKey&&(a=a*this._options.fastScrollSensitivity,o=o*this._options.fastScrollSensitivity);let h=this._scrollable.getFutureScrollPosition(),d={};if(o){let c=50*o,u=h.scrollTop-(c<0?Math.floor(c):Math.ceil(c));this._verticalScrollbar.writeScrollPosition(d,u)}if(a){let c=50*a,u=h.scrollLeft-(c<0?Math.floor(c):Math.ceil(c));this._horizontalScrollbar.writeScrollPosition(d,u)}d=this._scrollable.validateScrollPosition(d),(h.scrollLeft!==d.scrollLeft||h.scrollTop!==d.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(d):this._scrollable.setScrollPositionNow(d),r=!0)}let s=r;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){let e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,r=e.scrollLeft>0,s=r?" xterm-shadow-left":"",o=t?" xterm-shadow-top":"",a=r||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${o}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${a}${o}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){!this._mouseIsOver&&!this._isDragging&&(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){!this._mouseIsOver&&!this._isDragging&&this._hideTimeout.cancelAndSet(()=>this._hide(),500)}};function Xn(n){let i={lazyRender:typeof n.lazyRender<"u"?n.lazyRender:!1,className:typeof n.className<"u"?n.className:"",useShadows:typeof n.useShadows<"u"?n.useShadows:!0,handleMouseWheel:typeof n.handleMouseWheel<"u"?n.handleMouseWheel:!0,flipAxes:typeof n.flipAxes<"u"?n.flipAxes:!1,consumeMouseWheelIfScrollbarIsNeeded:typeof n.consumeMouseWheelIfScrollbarIsNeeded<"u"?n.consumeMouseWheelIfScrollbarIsNeeded:!1,alwaysConsumeMouseWheel:typeof n.alwaysConsumeMouseWheel<"u"?n.alwaysConsumeMouseWheel:!1,scrollYToX:typeof n.scrollYToX<"u"?n.scrollYToX:!1,mouseWheelScrollSensitivity:typeof n.mouseWheelScrollSensitivity<"u"?n.mouseWheelScrollSensitivity:1,fastScrollSensitivity:typeof n.fastScrollSensitivity<"u"?n.fastScrollSensitivity:5,scrollPredominantAxis:typeof n.scrollPredominantAxis<"u"?n.scrollPredominantAxis:!0,mouseWheelSmoothScroll:typeof n.mouseWheelSmoothScroll<"u"?n.mouseWheelSmoothScroll:!0,listenOnDomNode:typeof n.listenOnDomNode<"u"?n.listenOnDomNode:null,horizontal:typeof n.horizontal<"u"?n.horizontal:1,horizontalScrollbarSize:typeof n.horizontalScrollbarSize<"u"?n.horizontalScrollbarSize:10,horizontalSliderSize:typeof n.horizontalSliderSize<"u"?n.horizontalSliderSize:0,horizontalHasArrows:typeof n.horizontalHasArrows<"u"?n.horizontalHasArrows:!1,vertical:typeof n.vertical<"u"?n.vertical:1,verticalScrollbarSize:typeof n.verticalScrollbarSize<"u"?n.verticalScrollbarSize:10,verticalHasArrows:typeof n.verticalHasArrows<"u"?n.verticalHasArrows:!1,verticalSliderSize:typeof n.verticalSliderSize<"u"?n.verticalSliderSize:0,scrollByPage:typeof n.scrollByPage<"u"?n.scrollByPage:!1};return i.horizontalSliderSize=typeof n.horizontalSliderSize<"u"?n.horizontalSliderSize:i.horizontalScrollbarSize,i.verticalSliderSize=typeof n.verticalSliderSize<"u"?n.verticalSliderSize:i.verticalScrollbarSize,ie&&(i.className+=" xterm-mac"),i}var dt=class extends g{constructor(e,t,r,s,o,a,l,h,d){super();this._bufferService=r;this._coreService=o;this._optionsService=h;this._renderService=d;this._onRequestScrollLines=this._register(new b);this.onRequestScrollLines=this._onRequestScrollLines.event;this._isSyncing=!1;this._isHandlingScroll=!1;this._suppressOnScrollHandler=!1;this._needsSyncOnRender=!1;let c=this._register(new lt({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:u=>tt(s.window,u)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{c.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new Mi(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},c)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(a.onProtocolChange(u=>{this._scrollableElement.updateOptions({handleMouseWheel:!(u&16)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(j.runAndSubscribe(l.onChangeColors,()=>{e.style.backgroundColor=l.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=l.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register(E(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register(E(()=>this._styleElement.remove())),this._register(j.runAndSubscribe(l.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${l.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${l.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${l.colors.scrollbarSliderActiveBackground.css};`,"}"].join(` -`)})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(u=>this._handleScroll(u)))}scrollLines(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){let e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,r=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:r,verticalHasArrows:t}}queueSync(e){e!==void 0&&(this._latestYDisp=e),this._queuedAnimationFrame===void 0&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){if(!(!this._renderService||this._isSyncing)){if(this._coreService.decPrivateModes.synchronizedOutput){this._needsSyncOnRender=!0;return}this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1}}_handleScroll(e){if(!this._renderService||this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;let t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),r=t-this._bufferService.buffer.ydisp;r!==0&&(this._latestYDisp=t,this._onRequestScrollLines.fire(r)),this._isHandlingScroll=!1}handleTouchScroll(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};dt=y([m(2,D),m(3,G),m(4,Y),m(5,Me),m(6,_e),m(7,R),m(8,V)],dt);var ut=class extends g{constructor(e,t,r,s,o){super();this._screenElement=e;this._bufferService=t;this._coreBrowserService=r;this._decorationService=s;this._renderService=o;this._decorationElements=new Map;this._altBufferIsActive=!1;this._dimensionsChanged=!1;this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(a=>this._removeDecoration(a))),this._register(E(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(let e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){let t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",e?.options?.layer==="top"),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,t.style.top=`${(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height}px`,t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;let r=e.options.x??0;return r&&r>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){let t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let r=this._decorationElements.get(e);r||(r=this._createElement(e),e.element=r,this._decorationElements.set(e,r),this._container.appendChild(r),e.onDispose(()=>{this._decorationElements.delete(e),r.remove()})),r.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(r.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,r.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,r.style.top=`${t*this._renderService.dimensions.css.cell.height}px`,r.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(r)}}_refreshXPosition(e,t=e.element){if(!t)return;let r=e.options.x??0;(e.options.anchor||"left")==="right"?t.style.right=r?`${r*this._renderService.dimensions.css.cell.width}px`:"":t.style.left=r?`${r*this._renderService.dimensions.css.cell.width}px`:""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};ut=y([m(1,D),m(2,G),m(3,ge),m(4,V)],ut);var Pi=class{constructor(){this._zones=[];this._zonePool=[];this._zonePoolIndex=0;this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(i){if(i.options.overviewRulerOptions){for(let e of this._zones)if(e.color===i.options.overviewRulerOptions.color&&e.position===i.options.overviewRulerOptions.position){if(this._lineIntersectsZone(e,i.marker.line))return;if(this._lineAdjacentToZone(e,i.marker.line,i.options.overviewRulerOptions.position)){this._addLineToZone(e,i.marker.line);return}}if(this._zonePoolIndex=i.startBufferLine&&e<=i.endBufferLine}_lineAdjacentToZone(i,e,t){return e>=i.startBufferLine-this._linePadding[t||"full"]&&e<=i.endBufferLine+this._linePadding[t||"full"]}_addLineToZone(i,e){i.startBufferLine=Math.min(i.startBufferLine,e),i.endBufferLine=Math.max(i.endBufferLine,e)}};var Ce={full:0,left:0,center:0,right:0},Fe={full:0,left:0,center:0,right:0},$t={full:0,left:0,center:0,right:0},ze=class extends g{constructor(e,t,r,s,o,a,l,h){super();this._viewportElement=e;this._screenElement=t;this._bufferService=r;this._decorationService=s;this._renderService=o;this._optionsService=a;this._themeService=l;this._coreBrowserService=h;this._colorZoneStore=new Pi;this._shouldUpdateDimensions=!0;this._shouldUpdateAnchor=!0;this._lastKnownBufferLength=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register(E(()=>this._canvas?.remove()));let d=this._canvas.getContext("2d");if(d)this._ctx=d;else throw new Error("Ctx cannot be null");this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register(E(()=>{this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}get _width(){let e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??!0?e?.width??0:0}_refreshDrawConstants(){let e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);Fe.full=this._canvas.width,Fe.left=e,Fe.center=t,Fe.right=e,this._refreshDrawHeightConstants(),$t.full=1,$t.left=1,$t.center=1+Fe.left,$t.right=1+Fe.left+Fe.center}_refreshDrawHeightConstants(){Ce.full=Math.round(2*this._coreBrowserService.dpr);let e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);Ce.left=t,Ce.center=t,Ce.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;let e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(let t of this._decorationService.decorations)this._colorZoneStore.addDecoration(t);this._ctx.lineWidth=1,this._renderRulerOutline();let e=this._colorZoneStore.zones;for(let t of e)t.position!=="full"&&this._renderColorZone(t);for(let t of e)t.position==="full"&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect($t[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-Ce[e.position||"full"]/2),Fe[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+Ce[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};ze=y([m(2,D),m(3,ge),m(4,V),m(5,R),m(6,_e),m(7,G)],ze);var ft=class{constructor(i,e,t,r,s,o){this._textarea=i;this._compositionView=e;this._bufferService=t;this._optionsService=r;this._coreService=s;this._renderService=o;this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent=""}get isComposing(){return this._isComposing}compositionstart(){this._isComposing=!0;let i=this._textarea.selectionStart??this._textarea.value.length,e=this._textarea.selectionEnd??i;this._compositionPosition.start=Math.min(i,e),this._compositionPosition.end=Math.max(i,e),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(i){this._compositionView.textContent=`\u200E${i.data}\u200E`,this.updateCompositionElements(),setTimeout(()=>{let e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(i){if(this._isComposing||this._isSendingComposition){if(i.keyCode===20||i.keyCode===229||i.keyCode===16||i.keyCode===17||i.keyCode===18)return!1;this._finalizeComposition(!1)}return i.keyCode===229?(this._handleAnyTextareaChanges(),!1):!0}_finalizeComposition(i){if(this._compositionView.classList.remove("active"),this._isComposing=!1,i){let e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){this._isSendingComposition=!1;let r;if(e.start+=this._dataAlreadySent.length,this._isComposing)r=this._textarea.value.substring(e.start,this._compositionPosition.start);else{let s=this._textarea.value,o=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;r=s.substring(e.start,Math.max(e.start,o))}r.length>0&&this._coreService.triggerDataEvent(r,!0)}},0)}else{this._isSendingComposition=!1;let e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;let i=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){let e=this._textarea.value,t=e.replace(i,"");this._dataAlreadySent=t,e.length>i.length?this._coreService.triggerDataEvent(t,!0):e.lengththis.updateCompositionElements(!0),0)}}};ft=y([m(2,D),m(3,R),m(4,Y),m(5,V)],ft);var J=0,Q=0,ee=0,W=0,ts={css:"#00000000",rgba:0},O;(t=>{function n(r,s,o,a){return a!==void 0?`#${Ve(r)}${Ve(s)}${Ve(o)}${Ve(a)}`:`#${Ve(r)}${Ve(s)}${Ve(o)}`}t.toCss=n;function i(r,s,o,a=255){return(r<<24|s<<16|o<<8|a)>>>0}t.toRgba=i;function e(r,s,o,a){return{css:t.toCss(r,s,o,a),rgba:t.toRgba(r,s,o,a)}}t.toColor=e})(O||={});var k;(a=>{function n(l,h){if(W=(h.rgba&255)/255,W===1)return{css:h.css,rgba:h.rgba};let d=h.rgba>>24&255,c=h.rgba>>16&255,u=h.rgba>>8&255,_=l.rgba>>24&255,p=l.rgba>>16&255,v=l.rgba>>8&255;J=_+Math.round((d-_)*W),Q=p+Math.round((c-p)*W),ee=v+Math.round((u-v)*W);let f=O.toCss(J,Q,ee),S=O.toRgba(J,Q,ee);return{css:f,rgba:S}}a.blend=n;function i(l){return(l.rgba&255)===255}a.isOpaque=i;function e(l,h,d){let c=Oi.ensureContrastRatio(l.rgba,h.rgba,d);if(c)return O.toColor(c>>24&255,c>>16&255,c>>8&255)}a.ensureContrastRatio=e;function t(l){let h=(l.rgba|255)>>>0;return[J,Q,ee]=Oi.toChannels(h),{css:O.toCss(J,Q,ee),rgba:h}}a.opaque=t;function r(l,h){return W=Math.round(h*255),[J,Q,ee]=Oi.toChannels(l.rgba),{css:O.toCss(J,Q,ee,W),rgba:O.toRgba(J,Q,ee,W)}}a.opacity=r;function s(l,h){return W=l.rgba&255,r(l,W*h/255)}a.multiplyOpacity=s;function o(l){return[l.rgba>>24&255,l.rgba>>16&255,l.rgba>>8&255]}a.toColorRGB=o})(k||={});var B;(t=>{let n,i;try{let r=document.createElement("canvas");r.width=1,r.height=1;let s=r.getContext("2d",{willReadFrequently:!0});s&&(n=s,n.globalCompositeOperation="copy",i=n.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),O.toColor(J,Q,ee);case 5:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),W=parseInt(r.slice(4,5).repeat(2),16),O.toColor(J,Q,ee,W);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let s=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return J=parseInt(s[1],10),Q=parseInt(s[2],10),ee=parseInt(s[3],10),W=Math.round((s[5]===void 0?1:parseFloat(s[5]))*255),O.toColor(J,Q,ee,W);if(r==="transparent")return{css:"transparent",rgba:0};if(!n||!i)throw new Error("css.toColor: Unsupported css format");if(n.fillStyle=i,n.fillStyle=r,typeof n.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(n.fillRect(0,0,1,1),[J,Q,ee,W]=n.getImageData(0,0,1,1).data,W!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:O.toRgba(J,Q,ee,W),css:r}}t.toColor=e})(B||={});var Z;(e=>{function n(t){return i(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=n;function i(t,r,s){let o=t/255,a=r/255,l=s/255,h=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),d=a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4),c=l<=.03928?l/12.92:Math.pow((l+.055)/1.055,2.4);return h*.2126+d*.7152+c*.0722}e.relativeLuminance2=i})(Z||={});var Oi;(s=>{function n(o,a){if(W=(a&255)/255,W===1)return a;let l=a>>24&255,h=a>>16&255,d=a>>8&255,c=o>>24&255,u=o>>16&255,_=o>>8&255;return J=c+Math.round((l-c)*W),Q=u+Math.round((h-u)*W),ee=_+Math.round((d-_)*W),O.toRgba(J,Q,ee)}s.blend=n;function i(o,a,l){let h=Z.relativeLuminance(o>>8),d=Z.relativeLuminance(a>>8);if(Te(h,d)>8));if(v>8));return v>S?p:f}return p}let u=t(o,a,l),_=Te(h,Z.relativeLuminance(u>>8));if(_>8));return _>v?u:p}return u}}s.ensureContrastRatio=i;function e(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v0||_>0||p>0);)u-=Math.max(0,Math.ceil(u*.1)),_-=Math.max(0,Math.ceil(_*.1)),p-=Math.max(0,Math.ceil(p*.1)),v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));return(u<<24|_<<16|p<<8|255)>>>0}s.reduceLuminance=e;function t(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v>>0}s.increaseLuminance=t;function r(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}s.toChannels=r})(Oi||={});function Ve(n){let i=n.toString(16);return i.length<2?"0"+i:i}function Te(n,i){return n1){let u=this._getJoinedRanges(r,l,a,e,o);for(let _=0;_1){let c=this._getJoinedRanges(r,l,a,e,o);for(let u=0;u=ks,Lr=ae,x=this._workCell;if(v.length>0&&ae===v[0][0]&&je){let A=v.shift(),Pr=this._isCellInSelection(A[0],e);for(T=A[0]+1;T=A[1],je?(fi=!0,x=new Ni(this._workCell,i.translateToString(!0,A[0],A[1]),A[1]-A[0]),Lr=A[1]-1,Rr=x.getWidth()):ks=A[1]}let Nt=this._isCellInSelection(ae,e),Ar=t&&ae===o,kr=An&&ae>=c&&ae<=u;_&&x.isBlink()&&(_.hasBlinkingCells=!0),!l&&x.isBlink()&&N.push("xterm-blink-hidden");let Mr=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{Mr=!0});let _i=x.getChars()||" ";if(_i===" "&&(x.isUnderline()||x.isOverline())&&(_i="\xA0"),Ot=Rr*h-d.get(_i,x.isBold(),x.isItalic()),!I)I=this._document.createElement("span");else if(w&&(Nt&&ui||!Nt&&!ui&&x.bg===te)&&(Nt&&ui&&f.selectionForeground||x.fg===Ds)&&x.extended.ext===Rs&&kr===Ls&&Ot===As&&!Ar&&!fi&&!Mr&&je){x.isInvisible()?L+=" ":L+=_i,w++;continue}else w&&(I.textContent=L),I=this._document.createElement("span"),w=0,L="";if(te=x.bg,Ds=x.fg,Rs=x.extended.ext,Ls=kr,As=Ot,ui=Nt,fi&&o>=ae&&o<=Lr&&(o=ae),!this._coreService.isCursorHidden&&Ar&&this._coreService.isCursorInitialized){if(N.push("xterm-cursor"),this._coreBrowserService.isFocused)a&&N.push("xterm-cursor-blink"),N.push(r==="bar"?"xterm-cursor-bar":r==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(s)switch(s){case"outline":N.push("xterm-cursor-outline");break;case"block":N.push("xterm-cursor-block");break;case"bar":N.push("xterm-cursor-bar");break;case"underline":N.push("xterm-cursor-underline");break;default:break}}if(x.isBold()&&N.push("xterm-bold"),x.isItalic()&&N.push("xterm-italic"),x.isDim()&&N.push("xterm-dim"),x.isInvisible()?L=" ":L=x.getChars()||" ",x.isUnderline()&&(N.push(`xterm-underline-${x.extended.underlineStyle}`),L===" "&&(L="\xA0"),!x.isUnderlineColorDefault()))if(x.isUnderlineColorRGB())I.style.textDecorationColor=`rgb(${ue.toColorRGB(x.getUnderlineColor()).join(",")})`;else{let A=x.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&x.isBold()&&A<8&&(A+=8),I.style.textDecorationColor=f.ansi[A].css}x.isOverline()&&(N.push("xterm-overline"),L===" "&&(L="\xA0")),x.isStrikethrough()&&N.push("xterm-strikethrough"),kr&&(I.style.textDecoration="underline");let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();if(Br){let A=de;de=Se,Se=A;let Pr=Ft;Ft=Ht,Ht=Pr}let Le,pi,Wt=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{A.options.layer!=="top"&&Wt||(A.backgroundColorRGB&&(Ht=50331648,Se=A.backgroundColorRGB.rgba>>8&16777215,Le=A.backgroundColorRGB),A.foregroundColorRGB&&(Ft=50331648,de=A.foregroundColorRGB.rgba>>8&16777215,pi=A.foregroundColorRGB),Wt=A.options.layer==="top")}),!Wt&&Nt&&(Le=this._coreBrowserService.isFocused?f.selectionBackgroundOpaque:f.selectionInactiveBackgroundOpaque,Se=Le.rgba>>8&16777215,Ht=50331648,Wt=!0,f.selectionForeground&&(Ft=50331648,de=f.selectionForeground.rgba>>8&16777215,pi=f.selectionForeground)),Wt&&N.push("xterm-decoration-top");let Ae;switch(Ht){case 16777216:case 33554432:Ae=f.ansi[Se],N.push(`xterm-bg-${Se}`);break;case 50331648:Ae=O.toColor(Se>>16,Se>>8&255,Se&255),this._addStyle(I,`background-color:#${(Se>>>0).toString(16).padStart(6,"0")}`);break;case 0:default:Br?(Ae=f.foreground,N.push(`xterm-bg-${257}`)):Ae=f.background}switch(Le||x.isDim()&&(Le=k.multiplyOpacity(Ae,.5)),Ft){case 16777216:case 33554432:x.isBold()&&de<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(de+=8),this._applyMinimumContrast(I,Ae,f.ansi[de],x,Le,void 0)||N.push(`xterm-fg-${de}`);break;case 50331648:let A=O.toColor(de>>16&255,de>>8&255,de&255);this._applyMinimumContrast(I,Ae,A,x,Le,pi)||this._addStyle(I,`color:#${de.toString(16).padStart(6,"0")}`);break;case 0:default:this._applyMinimumContrast(I,Ae,f.foreground,x,Le,pi)||Br&&N.push(`xterm-fg-${257}`)}N.length&&(I.className=N.join(" "),N.length=0),!Ar&&!fi&&!Mr&&je?w++:I.textContent=L,Ot!==this.defaultSpacing&&(I.style.letterSpacing=`${Ot}px`),p.push(I),ae=Lr}return I&&w&&(I.textContent=L),p}_applyMinimumContrast(i,e,t,r,s,o){if(this._optionsService.rawOptions.minimumContrastRatio===1||js(r.getCode()))return!1;let a=this._getContrastCache(r),l;if(!s&&!o&&(l=a.getColor(e.rgba,t.rgba)),l===void 0){let h=this._optionsService.rawOptions.minimumContrastRatio/(r.isDim()?2:1);l=k.ensureContrastRatio(s??e,o??t,h),a.setColor((s??e).rgba,(o??t).rgba,l??null)}return l?(this._addStyle(i,`color:${l.css}`),!0):!1}_getContrastCache(i){return i.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(i,e){i.setAttribute("style",`${i.getAttribute("style")||""}${e};`)}_isCellInSelection(i,e){let t=this._selectionStart,r=this._selectionEnd;return!t||!r?!1:this._columnSelectMode?t[0]<=r[0]?i>=t[0]&&e>=t[1]&&i=t[1]&&i>=r[0]&&e<=r[1]:e>t[1]&&e=t[0]&&i=t[0]}};_t=y([m(1,gi),m(2,R),m(3,G),m(4,Y),m(5,ge),m(6,_e)],_t);var Hi=class{constructor(i=()=>new rs){this._flat=new Float32Array(256);this._font="";this._fontSize=0;this._weight="normal";this._weightBold="bold";this._canvasElements=[];this._canvasElements=[i(),i(),i(),i()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(i,e,t,r){i===this._font&&e===this._fontSize&&t===this._weight&&r===this._weightBold||(this._font=i,this._fontSize=e,this._weight=t,this._weightBold=r,this._canvasElements[0].setFont(i,e,t,!1),this._canvasElements[1].setFont(i,e,r,!1),this._canvasElements[2].setFont(i,e,t,!0),this._canvasElements[3].setFont(i,e,r,!0),this.clear())}get(i,e,t){let r;if(!e&&!t&&i.length===1&&(r=i.charCodeAt(0))<256){if(this._flat[r]!==-9999)return this._flat[r];let a=this._measure(i,0);return a>0&&(this._flat[r]=a),a}let s=i;e&&(s+="B"),t&&(s+="I");let o=this._holey.get(s);if(o===void 0){let a=0;e&&(a|=1),t&&(a|=2),o=this._measure(i,a),o>0&&this._holey.set(s,o)}return o}_measure(i,e){return this._canvasElements[e].measure(i)}},rs=class{constructor(){typeof OffscreenCanvas<"u"?(this._canvas=new OffscreenCanvas(1,1),this._ctx=is(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=is(this._canvas.getContext("2d")))}setFont(i,e,t,r){let s=r?"italic":"";this._ctx.font=`${s} ${t} ${e}px ${i}`.trim()}measure(i){return this._ctx.measureText(i).width}};var ss=class{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(i,e,t,r=!1){if(this.selectionStart=e,this.selectionEnd=t,!e||!t||e[0]===t[0]&&e[1]===t[1]){this.clear();return}let s=i.buffers.active.ydisp,o=e[1]-s,a=t[1]-s,l=Math.max(o,0),h=Math.min(a,i.rows-1);if(l>=i.rows||h<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=r,this.viewportStartRow=o,this.viewportEndRow=a,this.viewportCappedStartRow=l,this.viewportCappedEndRow=h,this.startCol=e[0],this.endCol=t[0]}isCellSelected(i,e,t){return this.hasSelection?(t-=i.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&t>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&t<=this.viewportCappedEndRow:t>this.viewportStartRow&&t=this.startCol&&e=this.startCol):!1}};function Js(){return new ss}var Wi=class extends g{constructor(e,t,r){super();this._renderCallback=e;this._coreBrowserService=t;this._optionsService=r;this._intervalDuration=0;this._blinkOn=!0;this._needsBlinkInViewport=!1;this._isViewportVisible=!0;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",s=>{this.setIntervalDuration(s)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(E(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let t=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),t||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var Zn=1,mt=class extends g{constructor(e,t,r,s,o,a,l,h,d,c,u,_,p,v){super();this._terminal=e;this._document=t;this._element=r;this._screenElement=s;this._viewportElement=o;this._helperContainer=a;this._linkifier2=l;this._charSizeService=d;this._optionsService=c;this._bufferService=u;this._coreService=_;this._coreBrowserService=p;this._themeService=v;this._terminalClass=Zn++;this._rowElements=[];this._selectionRenderModel=Js();this._lastSelectionColumnMode=!1;this._rowHasBlinkingCells=[];this._rowHasBlinkingCellsCount=0;this._onRequestRedraw=this._register(new b);this.onRequestRedraw=this._onRequestRedraw.event;this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=Zs(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(f=>this._injectCss(f))),this._injectCss(this._themeService.colors),this._rowFactory=h.createInstance(_t,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(f=>this._handleLinkHover(f))),this._register(this._linkifier2.onHideLinkUnderline(f=>this._handleLinkLeave(f))),this._cursorBlinkStateManager=new ns(this._rowContainer,this._coreBrowserService),this._register(C(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register(E(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new Wi(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register(E(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new Hi,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){let e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(let r of this._rowElements)r.style.width=`${this.dimensions.css.canvas.width}px`,r.style.height=`${this.dimensions.css.cell.height}px`,r.style.lineHeight=`${this.dimensions.css.cell.height}px`,r.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));let t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${k.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;let r=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,o=`blink_block_${this._terminalClass}`;t+=`@keyframes ${r} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${o} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${o} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(let[a,l]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${a} { color: ${l.css}; }${this._terminalSelector} .xterm-fg-${a}.xterm-dim { color: ${k.multiplyOpacity(l,.5).css}; }${this._terminalSelector} .xterm-bg-${a} { background-color: ${l.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${257} { color: ${k.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${257}.xterm-dim { color: ${k.multiplyOpacity(k.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${257} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){let e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let r=this._rowElements.length;r<=t;r++){let s=this._document.createElement("div");this._rowContainer.appendChild(s),this._rowElements.push(s),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,r){let s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,r);let o=0,a=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(o=this._selectionRenderModel.viewportCappedStartRow,a=this._selectionRenderModel.viewportCappedEndRow));let l=0,h=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,r),this._selectionRenderModel.hasSelection){let u=this._selectionRenderModel.viewportStartRow,_=this._selectionRenderModel.viewportEndRow,p=this._selectionRenderModel.viewportCappedStartRow,v=this._selectionRenderModel.viewportCappedEndRow;l=p,h=v;let f=this._document.createDocumentFragment();if(r){let S=e[0]>t[0];f.appendChild(this._createSelectionElement(p,S?t[0]:e[0],S?e[0]:t[0],v-p+1))}else{let S=u===p?e[0]:0,I=p===_?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(p,S,I));let w=v-p-1;if(f.appendChild(this._createSelectionElement(p+1,0,this._bufferService.cols,w)),p!==v){let L=_===v?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(v,0,L))}}this._selectionContainer.appendChild(f)}let d=Math.min(o,l),c=Math.max(a,h);if(c>=0){d=Math.max(d,0),c=Math.min(c,s-1);let _=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&_>=0&&_this.dimensions.css.canvas.width&&(l=this.dimensions.css.canvas.width-a),o.style.height=`${s*this.dimensions.css.cell.height}px`,o.style.top=`${e*this.dimensions.css.cell.height}px`,o.style.left=`${a}px`,o.style.width=`${l}px`,o}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(let e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){let r=this._bufferService.buffer,s=r.ybase+r.y,o=Math.min(r.x,this._bufferService.cols-1),a=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,l=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,h=this._optionsService.rawOptions.cursorInactiveStyle,d={hasBlinkingCells:!1};for(let c=e;c<=t;c++){let u=c+r.ydisp,_=this._rowElements[c];if(!_)continue;let p=r.lines.get(u);if(!p){_.replaceChildren(),this._setRowBlinkState(c,!1);continue}_.replaceChildren(...this._rowFactory.createRow(p,u,u===s,l,h,o,a,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,d)),this._setRowBlinkState(c,d.hasBlinkingCells)}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,r,s,o,a){r<0&&(e=0),s<0&&(t=0);let l=this._bufferService.rows-1;r=Math.max(Math.min(r,l),0),s=Math.max(Math.min(s,l),0),o=Math.min(o,this._bufferService.cols);let h=this._bufferService.buffer,d=h.ybase+h.y,c=Math.min(h.x,o-1),u=this._optionsService.rawOptions.cursorBlink,_=this._optionsService.rawOptions.cursorStyle,p=this._optionsService.rawOptions.cursorInactiveStyle,v={hasBlinkingCells:!1};for(let f=r;f<=s;++f){let S=f+h.ydisp,I=this._rowElements[f];if(!I)continue;let w=h.lines.get(S);if(!w){I.replaceChildren(),this._setRowBlinkState(f,!1);continue}I.replaceChildren(...this._rowFactory.createRow(w,S,S===d,_,p,c,u,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,a?f===r?e:0:-1,a?(f===s?t:o)-1:-1,v)),this._setRowBlinkState(f,v.hasBlinkingCells)}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};mt=y([m(7,Qe),m(8,Be),m(9,R),m(10,D),m(11,Y),m(12,G),m(13,_e)],mt);var ns=class{constructor(i,e){this._rowContainer=i;this._coreBrowserService=e;this._isIdlePaused=!1;this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){this._idleTimeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}};var bt=class extends g{constructor(e,t,r){super();this._optionsService=r;this.width=0;this.height=0;this._onCharSizeChange=this._register(new b);this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new as(this._optionsService))}catch{this._measureStrategy=this._register(new os(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}get hasValidSize(){return this.width>0&&this.height>0}measure(){let e=this._measureStrategy.measure();(e.width!==this.width||e.height!==this.height)&&(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};bt=y([m(2,R)],bt);var Ui=class extends g{constructor(){super(...arguments);this._result={width:0,height:0}}_validateAndSet(e,t){e!==void 0&&e>0&&t!==void 0&&t>0&&(this._result.width=e,this._result.height=t)}},os=class extends Ui{constructor(e,t,r){super();this._document=e;this._parentElement=t;this._optionsService=r;this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}},as=class extends Ui{constructor(e){super();this._optionsService=e;this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");let t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;let e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}};var Ki=class extends g{constructor(e,t,r){super();this._textarea=e;this._window=t;this.mainDocument=r;this._isFocused=!1;this._cachedIsFocused=void 0;this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._onWindowChange=this._register(new b);this.onWindowChange=this._onWindowChange.event;this._screenDprMonitor=this._register(new ls(this._window)),this._register(this.onWindowChange(s=>this._screenDprMonitor.setWindow(s))),this._register(j.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register(C(this._textarea,"focus",()=>this._isFocused=!0)),this._register(C(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}},ls=class extends g{constructor(e){super();this._parentWindow=e;this._windowResizeListener=this._register(new P);this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register(E(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=C(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){!this._resolutionMediaMatchList||!this._outerListener||(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}};var zi=class extends g{constructor(){super();this.linkProviders=[];this._register(E(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{let t=this.linkProviders.indexOf(e);t!==-1&&this.linkProviders.splice(t,1)}}}};function qt(n,i,e){let t=e.getBoundingClientRect(),r=n.getComputedStyle(e),s=parseInt(r.getPropertyValue("padding-left"),10),o=parseInt(r.getPropertyValue("padding-top"),10);return[i.clientX-t.left-s,i.clientY-t.top-o]}function Qs(n,i,e,t,r,s,o,a,l){if(!s)return;let h=qt(n,i,e);return h[0]=Math.ceil((h[0]+(l?o/2:0))/o),h[1]=Math.ceil(h[1]/a),h[0]=Math.min(Math.max(h[0],1),t+(l?1:0)),h[1]=Math.min(Math.max(h[1],1),r),h}var vt=class{constructor(i,e){this._charSizeService=i;this._renderService=e}getCoords(i,e,t,r,s){return Qs(se(e),i,e,t,r,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,s)}getMouseReportCoords(i,e){let t=qt(se(e),i,e);if(this._charSizeService.hasValidSize)return t[0]=Math.min(Math.max(t[0],0),this._renderService.dimensions.css.canvas.width-1),t[1]=Math.min(Math.max(t[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(t[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(t[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(t[0]),y:Math.floor(t[1])}}};vt=y([m(0,Be),m(1,V)],vt);var en=typeof window=="object"?window:globalThis;function ce(n,i=0){return n[n.length-(1+i)]}function Jn(n,i,e){let t=null,r=null;if(typeof e.value=="function"?(t="value",r=e.value,r.length!==0&&console.warn("Memoize should only be used in functions with zero parameters")):typeof e.get=="function"&&(t="get",r=e.get),!r||!t)throw new Error("not supported");let s=`$memoize$${i}`,o=e;o[t]=function(...a){return this.hasOwnProperty(s)||Object.defineProperty(this,s,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,a)}),this[s]}}var St=class St{constructor(i){this.element=i,this.next=St.Undefined,this.prev=St.Undefined}};St.Undefined=new St(void 0);var re=St,Gi=class{constructor(){this._first=re.Undefined;this._last=re.Undefined}push(i){return this._insert(i,!0)}_insert(i,e){let t=new re(i);if(this._first===re.Undefined)this._first=t,this._last=t;else if(e){let s=this._last;this._last=t,t.prev=s,s.next=t}else{let s=this._first;this._first=t,t.next=s,s.prev=t}let r=!1;return()=>{r||(r=!0,this._remove(t))}}_remove(i){if(i.prev!==re.Undefined&&i.next!==re.Undefined){let e=i.prev;e.next=i.next,i.next.prev=e}else i.prev===re.Undefined&&i.next===re.Undefined?(this._first=re.Undefined,this._last=re.Undefined):i.next===re.Undefined?(this._last=this._last.prev,this._last.next=re.Undefined):i.prev===re.Undefined&&(this._first=this._first.next,this._first.prev=re.Undefined)}*[Symbol.iterator](){let i=this._first;for(;i!==re.Undefined;)yield i.element,i=i.next}},he;(s=>(s.TAP="-xterm-gesturetap",s.CHANGE="-xterm-gesturechange",s.START="-xterm-gesturestart",s.END="-xterm-gesturesend",s.CONTEXT_MENU="-xterm-gesturecontextmenu"))(he||={});var K=class K extends g{constructor(){super();this._dispatched=!1;this._targets=new Gi;this._ignoreTargets=new Gi;this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;let e=en;this._register(C(e.document,"touchstart",t=>this._handleTouchStart(t),{passive:!1})),this._register(C(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(C(e.document,"touchmove",t=>this._handleTouchMove(t),{passive:!1}))}static addTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._targets.push(e);return E(t)}static ignoreTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._ignoreTargets.push(e);return E(t)}static isTouchDevice(){return"ontouchstart"in en||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){let t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let r=0,s=e.targetTouches.length;r=K._holdDelay&&Math.abs(h.initialPageX-ce(h.rollingPageX))<30&&Math.abs(h.initialPageY-ce(h.rollingPageY))<30){let c=this._newGestureEvent(he.CONTEXT_MENU,h.initialTarget);c.pageX=ce(h.rollingPageX),c.pageY=ce(h.rollingPageY),this._dispatchEvent(c)}else if(s===1){let c=ce(h.rollingPageX),u=ce(h.rollingPageY),_=ce(h.rollingTimestamps)-h.rollingTimestamps[0],p=c-h.rollingPageX[0],v=u-h.rollingPageY[0],f=[...this._targets].filter(S=>h.initialTarget instanceof Node&&S.contains(h.initialTarget));this._inertia(e,f,r,Math.abs(p)/_,p>0?1:-1,c,Math.abs(v)/_,v>0?1:-1,u)}this._dispatchEvent(this._newGestureEvent(he.END,h.initialTarget)),delete this._activeTouches[l.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){let r=document.createEvent("CustomEvent");return r.initEvent(e,!1,!0),r.initialTarget=t,r.tapCount=0,r}_dispatchEvent(e){if(e.type===he.TAP){let t=new Date().getTime(),r;t-this._lastSetTapCountTime>K._clearTapCountTime?r=1:r=2,this._lastSetTapCountTime=t,e.tapCount=r}else(e.type===he.CHANGE||e.type===he.CONTEXT_MENU)&&(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(let r of this._ignoreTargets)if(r.contains(e.initialTarget))return;let t=[];for(let r of this._targets)if(r.contains(e.initialTarget)){let s=0,o=e.initialTarget;for(;o&&o!==r;)s++,o=o.parentElement;t.push([s,r])}t.sort((r,s)=>r[0]-s[0]);for(let[,r]of t)r.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,r,s,o,a,l,h,d){this._handle=tt(e,()=>{let c=Date.now(),u=c-r,_=0,p=0,v=!0;s+=K._scrollFriction*u,l+=K._scrollFriction*u,s>0&&(v=!1,_=o*s*u),l>0&&(v=!1,p=h*l*u);let f=this._newGestureEvent(he.CHANGE);f.translationX=_,f.translationY=p,t.forEach(S=>S.dispatchEvent(f)),v||this._inertia(e,t,c,s,o,a+_,l,h,d+p)})}_handleTouchMove(e){let t=Date.now();for(let r=0,s=e.changedTouches.length;r3&&(a.rollingPageX.shift(),a.rollingPageY.shift(),a.rollingTimestamps.shift()),a.rollingPageX.push(o.pageX),a.rollingPageY.push(o.pageY),a.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}};K._scrollFriction=-.005,K._holdDelay=700,K._clearTapCountTime=400,y([Jn],K,"isTouchDevice",1);var Vi=K;var gt=class{constructor(i,e,t,r,s,o,a,l,h){this._renderService=i;this._mouseCoordsService=e;this._mouseStateService=t;this._coreService=r;this._bufferService=s;this._optionsService=o;this._selectionService=a;this._logService=l;this._coreBrowserService=h;this._lastEvent=null;this._wheelPartialScroll=0;this._touchScrollAccumulator=0}bindMouse(i,e,t){let{element:r,document:s}=i,o={mouseup:null,wheel:null,mousedrag:null,mousemove:null},a={target:i,focus:t,requestedEvents:o},l={mouseup:h=>this._handleMouseUp(a,h),wheel:h=>this._handleWheel(a,h),mousedrag:h=>this._handleMouseDrag(a,h),mousemove:h=>this._handleMouseMove(a,h)};this._altMouseCursor=new cs(r,s,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),e(this._altMouseCursor),e(this._mouseStateService.onProtocolChange(h=>{this._handleProtocolChange(a,l,h)})),e(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(r),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,e(E(()=>{o.mouseup&&s.removeEventListener("mouseup",o.mouseup),o.mousedrag&&s.removeEventListener("mousemove",o.mousedrag)})),e(C(r,"mousedown",h=>this._handleMouseDown(a,h))),e(C(r,"wheel",h=>this._handlePassiveWheel(a,h),{passive:!1})),e(Vi.addTarget(i.screenElement)),e(C(i.screenElement,he.START,()=>this._handleTouchStart())),e(C(i.screenElement,he.CHANGE,h=>this._handleTouchChange(a,h)))}_sendEvent(i,e){let t=this._mouseCoordsService.getMouseReportCoords(e,i.target.screenElement);if(!t)return!1;let r,s;switch(e.overrideType||e.type){case"mousemove":s=32,e.buttons===void 0?(r=3,e.button!==void 0&&(r=e.button<3?e.button:3)):r=e.buttons&1?0:e.buttons&4?1:e.buttons&2?2:3;break;case"mouseup":s=0,r=e.button<3?e.button:3;break;case"mousedown":s=1,r=e.button<3?e.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;let a=e.deltaY;if(a===0||this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return!1;s=a<0?0:1,r=4;break;default:return!1}if(s===void 0||r===void 0||r>4||r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!e.altKey)return!1;let o=r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:t.col,row:t.row,x:t.x,y:t.y,button:r,action:s,ctrl:e.ctrlKey,alt:o?!1:e.altKey,shift:e.shiftKey})}_handleMouseUp(i,e){this._sendEvent(i,e),e.buttons||(i.requestedEvents.mouseup&&i.target.document.removeEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.removeEventListener("mousemove",i.requestedEvents.mousedrag))}_handleWheel(i,e){return this._sendEvent(i,e),e.preventDefault(),e.stopPropagation(),!1}_handleMouseDrag(i,e){e.buttons&&this._sendEvent(i,e)}_handleMouseMove(i,e){e.buttons||this._sendEvent(i,e)}_handleMouseDown(i,e){e.preventDefault(),i.focus(),!(!this._mouseStateService.areMouseEventsActive||this._selectionService.shouldForceSelection(e))&&(this._sendEvent(i,e),i.requestedEvents.mouseup&&i.target.document.addEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.addEventListener("mousemove",i.requestedEvents.mousedrag))}_handlePassiveWheel(i,e){if(!i.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;if(!this._bufferService.buffer.hasScrollback){if(e.deltaY===0)return!1;if(this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return e.preventDefault(),e.stopPropagation(),!1;let s="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(s,!0),e.preventDefault(),e.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(i,e){if(e.preventDefault(),e.stopPropagation(),i.requestedEvents.wheel){this._handleTouchScrollAsWheel(i,e);return}if(!this._bufferService.buffer.hasScrollback){this._handleTouchScrollAsKeys(e);return}i.target.handleTouchScroll?.(e.translationY)}_handleTouchScrollAsKeys(i){let e=this._renderService?.dimensions.css.cell.height;if(!e)return;this._touchScrollAccumulator-=i.translationY;let t=Math.trunc(this._touchScrollAccumulator/e);if(t===0)return;this._touchScrollAccumulator-=t*e;let r="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t<0?"A":"B");for(let s=0;s0?1:-1),this._wheelPartialScroll%=1):i.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(s*=this._bufferService.rows),s}_triggerMouseEvent(i){if(i.col<0||i.col>=this._bufferService.cols||i.row<0||i.row>=this._bufferService.rows||i.button===4&&i.action===32||i.button===3&&i.action!==32||i.button!==4&&(i.action===2||i.action===3)||(i.col++,i.row++,i.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,i,this._mouseStateService.isPixelEncoding))||!this._mouseStateService.restrictMouseEvent(i))return!1;let e=this._mouseStateService.encodeMouseEvent(i);return e&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(e):this._coreService.triggerDataEvent(e,!0)),this._lastEvent=i,!0}_explainEvents(i){return{down:!!(i&1),up:!!(i&2),drag:!!(i&4),move:!!(i&8),wheel:!!(i&16)}}_equalEvents(i,e,t){if(t){if(i.x!==e.x||i.y!==e.y)return!1}else if(i.col!==e.col||i.row!==e.row)return!1;return!(i.button!==e.button||i.action!==e.action||i.ctrl!==e.ctrl||i.alt!==e.alt||i.shift!==e.shift)}};gt=y([m(0,V),m(1,Pe),m(2,Me),m(3,Y),m(4,D),m(5,R),m(6,Si),m(7,fe),m(8,G)],gt);var cs=class{constructor(i,e,t){this._element=i;this._document=e;this._isActive=t;this._listeners=new P}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;let i=new pe,e=r=>this.syncFromModifier(r);i.add(C(this._document,"keydown",e)),i.add(C(this._document,"keyup",e)),i.add(C(this._element,"mousemove",e));let t=this._element.ownerDocument?.defaultView;t&&i.add(C(t,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=i}resetClass(){this._updateClass(!1)}syncFromModifier(i){this._isActive()&&this._updateClass(i.getModifierState("Alt"))}_updateClass(i){i?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}};var $i=class{constructor(i,e){this._renderCallback=i;this._coreBrowserService=e;this._refreshCallbacks=[]}dispose(){this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(i){return this._refreshCallbacks.push(i),this._animationFrame??=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()),this._animationFrame}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0){this._runRefreshCallbacks();return}let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(let i of this._refreshCallbacks)i(0);this._refreshCallbacks=[]}};var qi=class{constructor(i){this._tasks=[];this._i=0;this._logService=i}enqueue(i){this._tasks.push(i),this._start()}flush(){for(;this._is){r-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-e))}ms`),this._start();return}r=s}this.clear()}},hs=class extends qi{_requestCallback(i){return setTimeout(()=>i(this._createDeadline(16)))}_cancelCallback(i){clearTimeout(i)}_createDeadline(i){let e=performance.now()+i;return{timeRemaining:()=>Math.max(0,e-performance.now())}}},ds=class extends qi{_requestCallback(i){return requestIdleCallback(i)}_cancelCallback(i){cancelIdleCallback(i)}},It="requestIdleCallback"in globalThis?ds:hs,Xi=class{constructor(i){this._queue=new It(i)}set(i){this._queue.clear(),this._queue.enqueue(i)}flush(){this._queue.flush()}dispose(){this._queue.clear()}};var Ct=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._rowCount=e;this._optionsService=r;this._logService=s;this._charSizeService=o;this._coreService=a;this._coreBrowserService=d;this._renderer=this._register(new P);this._observerDisposable=this._register(new P);this._isPaused=!1;this._needsFullRefresh=!1;this._isNextRenderRedrawOnly=!0;this._needsSelectionRefresh=!1;this._canvasWidth=0;this._canvasHeight=0;this._selectionState={start:void 0,end:void 0,columnSelectMode:!1};this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._onRenderedViewportChange=this._register(new b);this.onRenderedViewportChange=this._onRenderedViewportChange.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onRefreshRequest=this._register(new b);this.onRefreshRequest=this._onRefreshRequest.event;this._pausedResizeTask=this._register(new Xi(this._logService)),this._renderDebouncer=new $i((u,_)=>this._renderRows(u,_),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new us(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register(E(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(h.onResize(()=>this._fullRefresh())),this._register(h.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(h.buffer.y,h.buffer.y,void 0,!0))),this._register(c.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(u=>this._registerIntersectionObserver(u,t)))}get dimensions(){return this._renderer.value.dimensions}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){let r=new e.IntersectionObserver(s=>this._handleIntersectionChange(s[s.length-1]),{threshold:0});this._observerDisposable.value=E(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=r,r.observe(t)}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),!this._isPaused&&!this._charSizeService.hasValidSize&&this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){if(this._renderer.value){if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0}}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(t=>this.refreshRows(t.start,t.end,t.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,r){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=r,this._renderer.value?.handleSelectionChanged(e,t,r)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};Ct=y([m(2,R),m(3,fe),m(4,Be),m(5,Y),m(6,ge),m(7,D),m(8,G),m(9,_e)],Ct);var us=class{constructor(i,e,t){this._coreBrowserService=i;this._coreService=e;this._onTimeout=t;this._start=0;this._end=0;this._isBuffering=!1}bufferRows(i,e){this._isBuffering?(this._start=Math.min(this._start,i),this._end=Math.max(this._end,e)):(this._start=i,this._end=e,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;let i={start:this._start,end:this._end};return this._isBuffering=!1,i}dispose(){this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}};function tn(n,i,e,t){let r=e.buffer.x,s=e.buffer.y;if(!e.buffer.hasScrollback)return ro(r,s,n,i,e,t)+Yi(s,i,e,t)+so(r,s,n,i,e,t);let o;if(s===i)return o=r>n?"D":"C",Yt(Math.abs(r-n),Xt(o,t));o=s>i?"D":"C";let a=Math.abs(s-i),l=io(s>i?n:r,e)+(a-1)*e.cols+1+to(s>i?r:n,e);return Yt(l,Xt(o,t))}function to(n,i){return n-1}function io(n,i){return i.cols-n}function ro(n,i,e,t,r,s){return Yi(i,t,r,s).length===0?"":Yt(sn(n,i,n,i-$e(i,r),!1,r).length,Xt("D",s))}function Yi(n,i,e,t){let r=n-$e(n,e),s=i-$e(i,e),o=Math.abs(r-s)-no(n,i,e);return Yt(o,Xt(rn(n,i),t))}function so(n,i,e,t,r,s){let o;Yi(i,t,r,s).length>0?o=t-$e(t,r):o=i;let a=t,l=oo(n,i,e,t,r,s);return Yt(sn(n,o,e,a,l==="C",r).length,Xt(l,s))}function no(n,i,e){let t=0,r=n-$e(n,e),s=i-$e(i,e);for(let o=0;o=0&&n0?o=t-$e(t,r):o=i,n=e&&oi?"A":"B"}function sn(n,i,e,t,r,s){let o=n,a=i,l="";for(;(o!==e||a!==t)&&a>=0&&as.cols-1?(l+=s.buffer.translateBufferLineToString(a,!1,n,o),o=0,n=0,a++):!r&&o<0&&(l+=s.buffer.translateBufferLineToString(a,!1,0,n+1),o=s.cols-1,n=o,a--);return l+s.buffer.translateBufferLineToString(a,!1,n,o)}function Xt(n,i){let e=i?"O":"[";return"\x1B"+e+n}function Yt(n,i){n=Math.floor(n);let e="";for(let t=0;tthis._bufferService.cols?i%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)-1]:[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[i,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){let i=this.selectionStart[0]+this.selectionStartLength;return i>this._bufferService.cols?[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[Math.max(i,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){let i=this.selectionStart,e=this.selectionEnd;return!i||!e?!1:i[1]>e[1]||i[1]===e[1]&&i[0]>e[0]}handleTrim(i){return this.selectionStart&&(this.selectionStart[1]-=i),this.selectionEnd&&(this.selectionEnd[1]-=i),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):this.selectionStart&&this.selectionStart[1]<0?(this.selectionStart=[0,0],!0):!1}};function fs(n,i){if(n.start.y>n.end.y)throw new Error(`Buffer range end (${n.end.x}, ${n.end.y}) cannot be before start (${n.start.x}, ${n.start.y})`);return i*(n.end.y-n.start.y)+(n.end.x-n.start.x+1)}var ao="\xA0",lo=new RegExp(ao,"g");var Et=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._element=e;this._screenElement=t;this._linkifier=r;this._bufferService=s;this._coreService=o;this._mouseCoordsService=a;this._optionsService=l;this._mouseStateService=h;this._renderService=d;this._coreBrowserService=c;this._dragScrollAmount=0;this._enabled=!0;this._trimListener=this._register(new P);this._workCell=new F;this._mouseDownTimeStamp=0;this._oldHasSelection=!1;this._oldSelectionStart=void 0;this._oldSelectionEnd=void 0;this._onLinuxMouseSelection=this._register(new b);this.onLinuxMouseSelection=this._onLinuxMouseSelection.event;this._onRedrawRequest=this._register(new b);this.onRequestRedraw=this._onRedrawRequest.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onRequestScrollLines=this._register(new b);this.onRequestScrollLines=this._onRequestScrollLines.event;this._mouseMoveListener=u=>this._handleMouseMove(u),this._mouseUpListener=u=>this._handleMouseUp(u),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(u=>this._handleTrim(u)),this._register(this._bufferService.buffers.onBufferActivate(u=>this._handleBufferActivate(u))),this.enable(),this._model=new ji(this._bufferService),this._activeSelectionMode=0,this._register(E(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(u=>{u.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!e||!t?!1:e[0]!==t[0]||e[1]!==t[1]}get selectionText(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";let r=this._bufferService.buffer,s=[];if(this._activeSelectionMode===3){if(e[0]===t[0])return"";let a=e[0]a.replace(lo," ")).join(Ue?`\r +`)})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(u=>this._handleScroll(u)))}scrollLines(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){let e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,r=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:r,verticalHasArrows:t}}queueSync(e){e!==void 0&&(this._latestYDisp=e),this._queuedAnimationFrame===void 0&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){if(!(!this._renderService||this._isSyncing)){if(this._coreService.decPrivateModes.synchronizedOutput){this._needsSyncOnRender=!0;return}this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1}}_handleScroll(e){if(!this._renderService||this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;let t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),r=t-this._bufferService.buffer.ydisp;r!==0&&(this._latestYDisp=t,this._onRequestScrollLines.fire(r)),this._isHandlingScroll=!1}handleTouchScroll(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};dt=y([m(2,D),m(3,G),m(4,Y),m(5,Me),m(6,_e),m(7,R),m(8,V)],dt);var ut=class extends g{constructor(e,t,r,s,o){super();this._screenElement=e;this._bufferService=t;this._coreBrowserService=r;this._decorationService=s;this._renderService=o;this._decorationElements=new Map;this._altBufferIsActive=!1;this._dimensionsChanged=!1;this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(a=>this._removeDecoration(a))),this._register(E(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(let e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){let t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",e?.options?.layer==="top"),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,t.style.top=`${(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height}px`,t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;let r=e.options.x??0;return r&&r>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){let t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let r=this._decorationElements.get(e);r||(r=this._createElement(e),e.element=r,this._decorationElements.set(e,r),this._container.appendChild(r),e.onDispose(()=>{this._decorationElements.delete(e),r.remove()})),r.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(r.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,r.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,r.style.top=`${t*this._renderService.dimensions.css.cell.height}px`,r.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(r)}}_refreshXPosition(e,t=e.element){if(!t)return;let r=e.options.x??0;(e.options.anchor||"left")==="right"?t.style.right=r?`${r*this._renderService.dimensions.css.cell.width}px`:"":t.style.left=r?`${r*this._renderService.dimensions.css.cell.width}px`:""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};ut=y([m(1,D),m(2,G),m(3,ge),m(4,V)],ut);var Pi=class{constructor(){this._zones=[];this._zonePool=[];this._zonePoolIndex=0;this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(i){if(i.options.overviewRulerOptions){for(let e of this._zones)if(e.color===i.options.overviewRulerOptions.color&&e.position===i.options.overviewRulerOptions.position){if(this._lineIntersectsZone(e,i.marker.line))return;if(this._lineAdjacentToZone(e,i.marker.line,i.options.overviewRulerOptions.position)){this._addLineToZone(e,i.marker.line);return}}if(this._zonePoolIndex=i.startBufferLine&&e<=i.endBufferLine}_lineAdjacentToZone(i,e,t){return e>=i.startBufferLine-this._linePadding[t||"full"]&&e<=i.endBufferLine+this._linePadding[t||"full"]}_addLineToZone(i,e){i.startBufferLine=Math.min(i.startBufferLine,e),i.endBufferLine=Math.max(i.endBufferLine,e)}};var Ce={full:0,left:0,center:0,right:0},Fe={full:0,left:0,center:0,right:0},$t={full:0,left:0,center:0,right:0},ze=class extends g{constructor(e,t,r,s,o,a,l,h){super();this._viewportElement=e;this._screenElement=t;this._bufferService=r;this._decorationService=s;this._renderService=o;this._optionsService=a;this._themeService=l;this._coreBrowserService=h;this._colorZoneStore=new Pi;this._shouldUpdateDimensions=!0;this._shouldUpdateAnchor=!0;this._lastKnownBufferLength=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register(E(()=>this._canvas?.remove()));let d=this._canvas.getContext("2d");if(d)this._ctx=d;else throw new Error("Ctx cannot be null");this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register(E(()=>{this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}get _width(){let e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??!0?e?.width??0:0}_refreshDrawConstants(){let e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);Fe.full=this._canvas.width,Fe.left=e,Fe.center=t,Fe.right=e,this._refreshDrawHeightConstants(),$t.full=1,$t.left=1,$t.center=1+Fe.left,$t.right=1+Fe.left+Fe.center}_refreshDrawHeightConstants(){Ce.full=Math.round(2*this._coreBrowserService.dpr);let e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);Ce.left=t,Ce.center=t,Ce.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;let e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(let t of this._decorationService.decorations)this._colorZoneStore.addDecoration(t);this._ctx.lineWidth=1,this._renderRulerOutline();let e=this._colorZoneStore.zones;for(let t of e)t.position!=="full"&&this._renderColorZone(t);for(let t of e)t.position==="full"&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect($t[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-Ce[e.position||"full"]/2),Fe[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+Ce[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};ze=y([m(2,D),m(3,ge),m(4,V),m(5,R),m(6,_e),m(7,G)],ze);var ft=class{constructor(i,e,t,r,s,o){this._textarea=i;this._compositionView=e;this._bufferService=t;this._optionsService=r;this._coreService=s;this._renderService=o;this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent="",this._pendingKeypressData=""}get isComposing(){return this._isComposing}compositionstart(){this._isComposing=!0;let i=this._textarea.selectionStart??this._textarea.value.length,e=this._textarea.selectionEnd??i;this._compositionPosition.start=Math.min(i,e),this._compositionPosition.end=Math.max(i,e),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(i){this._compositionView.textContent=`\u200E${i.data}\u200E`,this.updateCompositionElements(),setTimeout(()=>{let e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(i){if(this._isComposing||this._isSendingComposition){if(i.keyCode===20||i.keyCode===229||i.keyCode===16||i.keyCode===17||i.keyCode===18)return!1;this._finalizeComposition(!1)}return i.keyCode===229?(this._handleAnyTextareaChanges(),!1):!0}keypress(i){return this._isSendingComposition?(this._pendingKeypressData+=i,!0):!1}_finalizeComposition(i){if(this._compositionView.classList.remove("active"),this._isComposing=!1,i){let e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._pendingKeypressData="",this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){this._isSendingComposition=!1;let r;if(e.start+=this._dataAlreadySent.length,this._isComposing)r=this._textarea.value.substring(e.start,this._compositionPosition.start);else{let s=this._textarea.value,o=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;r=s.substring(e.start,Math.max(e.start,o))}this._sendCompositionInput(r)}},0)}else{this._isSendingComposition=!1;let e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._sendCompositionInput(e)}}_sendCompositionInput(i){let e=this._pendingKeypressData;if(!i.includes(e))if(e.includes(i))i=e;else{let t=Math.min(i.length,e.length);for(;t>0&&!i.endsWith(e.substring(0,t));)t--;let r=Math.min(i.length,e.length);for(;r>0&&!e.endsWith(i.substring(0,r));)r--;i=t>r?i+e.substring(t):e+i.substring(r)}this._pendingKeypressData="",i.length>0&&this._coreService.triggerDataEvent(i,!0)}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;let i=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){let e=this._textarea.value,t=e.replace(i,"");this._dataAlreadySent=t,e.length>i.length?this._coreService.triggerDataEvent(t,!0):e.lengththis.updateCompositionElements(!0),0)}}};ft=y([m(2,D),m(3,R),m(4,Y),m(5,V)],ft);var J=0,Q=0,ee=0,W=0,ts={css:"#00000000",rgba:0},O;(t=>{function n(r,s,o,a){return a!==void 0?`#${Ve(r)}${Ve(s)}${Ve(o)}${Ve(a)}`:`#${Ve(r)}${Ve(s)}${Ve(o)}`}t.toCss=n;function i(r,s,o,a=255){return(r<<24|s<<16|o<<8|a)>>>0}t.toRgba=i;function e(r,s,o,a){return{css:t.toCss(r,s,o,a),rgba:t.toRgba(r,s,o,a)}}t.toColor=e})(O||={});var k;(a=>{function n(l,h){if(W=(h.rgba&255)/255,W===1)return{css:h.css,rgba:h.rgba};let d=h.rgba>>24&255,c=h.rgba>>16&255,u=h.rgba>>8&255,_=l.rgba>>24&255,p=l.rgba>>16&255,v=l.rgba>>8&255;J=_+Math.round((d-_)*W),Q=p+Math.round((c-p)*W),ee=v+Math.round((u-v)*W);let f=O.toCss(J,Q,ee),S=O.toRgba(J,Q,ee);return{css:f,rgba:S}}a.blend=n;function i(l){return(l.rgba&255)===255}a.isOpaque=i;function e(l,h,d){let c=Oi.ensureContrastRatio(l.rgba,h.rgba,d);if(c)return O.toColor(c>>24&255,c>>16&255,c>>8&255)}a.ensureContrastRatio=e;function t(l){let h=(l.rgba|255)>>>0;return[J,Q,ee]=Oi.toChannels(h),{css:O.toCss(J,Q,ee),rgba:h}}a.opaque=t;function r(l,h){return W=Math.round(h*255),[J,Q,ee]=Oi.toChannels(l.rgba),{css:O.toCss(J,Q,ee,W),rgba:O.toRgba(J,Q,ee,W)}}a.opacity=r;function s(l,h){return W=l.rgba&255,r(l,W*h/255)}a.multiplyOpacity=s;function o(l){return[l.rgba>>24&255,l.rgba>>16&255,l.rgba>>8&255]}a.toColorRGB=o})(k||={});var B;(t=>{let n,i;try{let r=document.createElement("canvas");r.width=1,r.height=1;let s=r.getContext("2d",{willReadFrequently:!0});s&&(n=s,n.globalCompositeOperation="copy",i=n.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),O.toColor(J,Q,ee);case 5:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),W=parseInt(r.slice(4,5).repeat(2),16),O.toColor(J,Q,ee,W);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let s=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return J=parseInt(s[1],10),Q=parseInt(s[2],10),ee=parseInt(s[3],10),W=Math.round((s[5]===void 0?1:parseFloat(s[5]))*255),O.toColor(J,Q,ee,W);if(r==="transparent")return{css:"transparent",rgba:0};if(!n||!i)throw new Error("css.toColor: Unsupported css format");if(n.fillStyle=i,n.fillStyle=r,typeof n.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(n.fillRect(0,0,1,1),[J,Q,ee,W]=n.getImageData(0,0,1,1).data,W!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:O.toRgba(J,Q,ee,W),css:r}}t.toColor=e})(B||={});var Z;(e=>{function n(t){return i(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=n;function i(t,r,s){let o=t/255,a=r/255,l=s/255,h=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),d=a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4),c=l<=.03928?l/12.92:Math.pow((l+.055)/1.055,2.4);return h*.2126+d*.7152+c*.0722}e.relativeLuminance2=i})(Z||={});var Oi;(s=>{function n(o,a){if(W=(a&255)/255,W===1)return a;let l=a>>24&255,h=a>>16&255,d=a>>8&255,c=o>>24&255,u=o>>16&255,_=o>>8&255;return J=c+Math.round((l-c)*W),Q=u+Math.round((h-u)*W),ee=_+Math.round((d-_)*W),O.toRgba(J,Q,ee)}s.blend=n;function i(o,a,l){let h=Z.relativeLuminance(o>>8),d=Z.relativeLuminance(a>>8);if(Te(h,d)>8));if(v>8));return v>S?p:f}return p}let u=t(o,a,l),_=Te(h,Z.relativeLuminance(u>>8));if(_>8));return _>v?u:p}return u}}s.ensureContrastRatio=i;function e(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v0||_>0||p>0);)u-=Math.max(0,Math.ceil(u*.1)),_-=Math.max(0,Math.ceil(_*.1)),p-=Math.max(0,Math.ceil(p*.1)),v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));return(u<<24|_<<16|p<<8|255)>>>0}s.reduceLuminance=e;function t(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v>>0}s.increaseLuminance=t;function r(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}s.toChannels=r})(Oi||={});function Ve(n){let i=n.toString(16);return i.length<2?"0"+i:i}function Te(n,i){return n1){let u=this._getJoinedRanges(r,l,a,e,o);for(let _=0;_1){let c=this._getJoinedRanges(r,l,a,e,o);for(let u=0;u=ks,Lr=ae,x=this._workCell;if(v.length>0&&ae===v[0][0]&&je){let A=v.shift(),Pr=this._isCellInSelection(A[0],e);for(T=A[0]+1;T=A[1],je?(fi=!0,x=new Ni(this._workCell,i.translateToString(!0,A[0],A[1]),A[1]-A[0]),Lr=A[1]-1,Rr=x.getWidth()):ks=A[1]}let Nt=this._isCellInSelection(ae,e),Ar=t&&ae===o,kr=An&&ae>=c&&ae<=u;_&&x.isBlink()&&(_.hasBlinkingCells=!0),!l&&x.isBlink()&&N.push("xterm-blink-hidden");let Mr=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{Mr=!0});let _i=x.getChars()||" ";if(_i===" "&&(x.isUnderline()||x.isOverline())&&(_i="\xA0"),Ot=Rr*h-d.get(_i,x.isBold(),x.isItalic()),!I)I=this._document.createElement("span");else if(w&&(Nt&&ui||!Nt&&!ui&&x.bg===te)&&(Nt&&ui&&f.selectionForeground||x.fg===Ds)&&x.extended.ext===Rs&&kr===Ls&&Ot===As&&!Ar&&!fi&&!Mr&&je){x.isInvisible()?L+=" ":L+=_i,w++;continue}else w&&(I.textContent=L),I=this._document.createElement("span"),w=0,L="";if(te=x.bg,Ds=x.fg,Rs=x.extended.ext,Ls=kr,As=Ot,ui=Nt,fi&&o>=ae&&o<=Lr&&(o=ae),!this._coreService.isCursorHidden&&Ar&&this._coreService.isCursorInitialized){if(N.push("xterm-cursor"),this._coreBrowserService.isFocused)a&&N.push("xterm-cursor-blink"),N.push(r==="bar"?"xterm-cursor-bar":r==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(s)switch(s){case"outline":N.push("xterm-cursor-outline");break;case"block":N.push("xterm-cursor-block");break;case"bar":N.push("xterm-cursor-bar");break;case"underline":N.push("xterm-cursor-underline");break;default:break}}if(x.isBold()&&N.push("xterm-bold"),x.isItalic()&&N.push("xterm-italic"),x.isDim()&&N.push("xterm-dim"),x.isInvisible()?L=" ":L=x.getChars()||" ",x.isUnderline()&&(N.push(`xterm-underline-${x.extended.underlineStyle}`),L===" "&&(L="\xA0"),!x.isUnderlineColorDefault()))if(x.isUnderlineColorRGB())I.style.textDecorationColor=`rgb(${ue.toColorRGB(x.getUnderlineColor()).join(",")})`;else{let A=x.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&x.isBold()&&A<8&&(A+=8),I.style.textDecorationColor=f.ansi[A].css}x.isOverline()&&(N.push("xterm-overline"),L===" "&&(L="\xA0")),x.isStrikethrough()&&N.push("xterm-strikethrough"),kr&&(I.style.textDecoration="underline");let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();if(Br){let A=de;de=Se,Se=A;let Pr=Ft;Ft=Ht,Ht=Pr}let Le,pi,Wt=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{A.options.layer!=="top"&&Wt||(A.backgroundColorRGB&&(Ht=50331648,Se=A.backgroundColorRGB.rgba>>8&16777215,Le=A.backgroundColorRGB),A.foregroundColorRGB&&(Ft=50331648,de=A.foregroundColorRGB.rgba>>8&16777215,pi=A.foregroundColorRGB),Wt=A.options.layer==="top")}),!Wt&&Nt&&(Le=this._coreBrowserService.isFocused?f.selectionBackgroundOpaque:f.selectionInactiveBackgroundOpaque,Se=Le.rgba>>8&16777215,Ht=50331648,Wt=!0,f.selectionForeground&&(Ft=50331648,de=f.selectionForeground.rgba>>8&16777215,pi=f.selectionForeground)),Wt&&N.push("xterm-decoration-top");let Ae;switch(Ht){case 16777216:case 33554432:Ae=f.ansi[Se],N.push(`xterm-bg-${Se}`);break;case 50331648:Ae=O.toColor(Se>>16,Se>>8&255,Se&255),this._addStyle(I,`background-color:#${(Se>>>0).toString(16).padStart(6,"0")}`);break;case 0:default:Br?(Ae=f.foreground,N.push(`xterm-bg-${257}`)):Ae=f.background}switch(Le||x.isDim()&&(Le=k.multiplyOpacity(Ae,.5)),Ft){case 16777216:case 33554432:x.isBold()&&de<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(de+=8),this._applyMinimumContrast(I,Ae,f.ansi[de],x,Le,void 0)||N.push(`xterm-fg-${de}`);break;case 50331648:let A=O.toColor(de>>16&255,de>>8&255,de&255);this._applyMinimumContrast(I,Ae,A,x,Le,pi)||this._addStyle(I,`color:#${de.toString(16).padStart(6,"0")}`);break;case 0:default:this._applyMinimumContrast(I,Ae,f.foreground,x,Le,pi)||Br&&N.push(`xterm-fg-${257}`)}N.length&&(I.className=N.join(" "),N.length=0),!Ar&&!fi&&!Mr&&je?w++:I.textContent=L,Ot!==this.defaultSpacing&&(I.style.letterSpacing=`${Ot}px`),p.push(I),ae=Lr}return I&&w&&(I.textContent=L),p}_applyMinimumContrast(i,e,t,r,s,o){if(this._optionsService.rawOptions.minimumContrastRatio===1||js(r.getCode()))return!1;let a=this._getContrastCache(r),l;if(!s&&!o&&(l=a.getColor(e.rgba,t.rgba)),l===void 0){let h=this._optionsService.rawOptions.minimumContrastRatio/(r.isDim()?2:1);l=k.ensureContrastRatio(s??e,o??t,h),a.setColor((s??e).rgba,(o??t).rgba,l??null)}return l?(this._addStyle(i,`color:${l.css}`),!0):!1}_getContrastCache(i){return i.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(i,e){i.setAttribute("style",`${i.getAttribute("style")||""}${e};`)}_isCellInSelection(i,e){let t=this._selectionStart,r=this._selectionEnd;return!t||!r?!1:this._columnSelectMode?t[0]<=r[0]?i>=t[0]&&e>=t[1]&&i=t[1]&&i>=r[0]&&e<=r[1]:e>t[1]&&e=t[0]&&i=t[0]}};_t=y([m(1,gi),m(2,R),m(3,G),m(4,Y),m(5,ge),m(6,_e)],_t);var Hi=class{constructor(i=()=>new rs){this._flat=new Float32Array(256);this._font="";this._fontSize=0;this._weight="normal";this._weightBold="bold";this._canvasElements=[];this._canvasElements=[i(),i(),i(),i()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(i,e,t,r){i===this._font&&e===this._fontSize&&t===this._weight&&r===this._weightBold||(this._font=i,this._fontSize=e,this._weight=t,this._weightBold=r,this._canvasElements[0].setFont(i,e,t,!1),this._canvasElements[1].setFont(i,e,r,!1),this._canvasElements[2].setFont(i,e,t,!0),this._canvasElements[3].setFont(i,e,r,!0),this.clear())}get(i,e,t){let r;if(!e&&!t&&i.length===1&&(r=i.charCodeAt(0))<256){if(this._flat[r]!==-9999)return this._flat[r];let a=this._measure(i,0);return a>0&&(this._flat[r]=a),a}let s=i;e&&(s+="B"),t&&(s+="I");let o=this._holey.get(s);if(o===void 0){let a=0;e&&(a|=1),t&&(a|=2),o=this._measure(i,a),o>0&&this._holey.set(s,o)}return o}_measure(i,e){return this._canvasElements[e].measure(i)}},rs=class{constructor(){typeof OffscreenCanvas<"u"?(this._canvas=new OffscreenCanvas(1,1),this._ctx=is(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=is(this._canvas.getContext("2d")))}setFont(i,e,t,r){let s=r?"italic":"";this._ctx.font=`${s} ${t} ${e}px ${i}`.trim()}measure(i){return this._ctx.measureText(i).width}};var ss=class{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(i,e,t,r=!1){if(this.selectionStart=e,this.selectionEnd=t,!e||!t||e[0]===t[0]&&e[1]===t[1]){this.clear();return}let s=i.buffers.active.ydisp,o=e[1]-s,a=t[1]-s,l=Math.max(o,0),h=Math.min(a,i.rows-1);if(l>=i.rows||h<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=r,this.viewportStartRow=o,this.viewportEndRow=a,this.viewportCappedStartRow=l,this.viewportCappedEndRow=h,this.startCol=e[0],this.endCol=t[0]}isCellSelected(i,e,t){return this.hasSelection?(t-=i.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&t>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&t<=this.viewportCappedEndRow:t>this.viewportStartRow&&t=this.startCol&&e=this.startCol):!1}};function Js(){return new ss}var Wi=class extends g{constructor(e,t,r){super();this._renderCallback=e;this._coreBrowserService=t;this._optionsService=r;this._intervalDuration=0;this._blinkOn=!0;this._needsBlinkInViewport=!1;this._isViewportVisible=!0;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",s=>{this.setIntervalDuration(s)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(E(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let t=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),t||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var Zn=1,mt=class extends g{constructor(e,t,r,s,o,a,l,h,d,c,u,_,p,v){super();this._terminal=e;this._document=t;this._element=r;this._screenElement=s;this._viewportElement=o;this._helperContainer=a;this._linkifier2=l;this._charSizeService=d;this._optionsService=c;this._bufferService=u;this._coreService=_;this._coreBrowserService=p;this._themeService=v;this._terminalClass=Zn++;this._rowElements=[];this._selectionRenderModel=Js();this._lastSelectionColumnMode=!1;this._rowHasBlinkingCells=[];this._rowHasBlinkingCellsCount=0;this._onRequestRedraw=this._register(new b);this.onRequestRedraw=this._onRequestRedraw.event;this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=Zs(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(f=>this._injectCss(f))),this._injectCss(this._themeService.colors),this._rowFactory=h.createInstance(_t,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(f=>this._handleLinkHover(f))),this._register(this._linkifier2.onHideLinkUnderline(f=>this._handleLinkLeave(f))),this._cursorBlinkStateManager=new ns(this._rowContainer,this._coreBrowserService),this._register(C(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register(E(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new Wi(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register(E(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new Hi,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){let e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(let r of this._rowElements)r.style.width=`${this.dimensions.css.canvas.width}px`,r.style.height=`${this.dimensions.css.cell.height}px`,r.style.lineHeight=`${this.dimensions.css.cell.height}px`,r.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));let t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${k.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;let r=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,o=`blink_block_${this._terminalClass}`;t+=`@keyframes ${r} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${o} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${o} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(let[a,l]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${a} { color: ${l.css}; }${this._terminalSelector} .xterm-fg-${a}.xterm-dim { color: ${k.multiplyOpacity(l,.5).css}; }${this._terminalSelector} .xterm-bg-${a} { background-color: ${l.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${257} { color: ${k.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${257}.xterm-dim { color: ${k.multiplyOpacity(k.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${257} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){let e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let r=this._rowElements.length;r<=t;r++){let s=this._document.createElement("div");this._rowContainer.appendChild(s),this._rowElements.push(s),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,r){let s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,r);let o=0,a=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(o=this._selectionRenderModel.viewportCappedStartRow,a=this._selectionRenderModel.viewportCappedEndRow));let l=0,h=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,r),this._selectionRenderModel.hasSelection){let u=this._selectionRenderModel.viewportStartRow,_=this._selectionRenderModel.viewportEndRow,p=this._selectionRenderModel.viewportCappedStartRow,v=this._selectionRenderModel.viewportCappedEndRow;l=p,h=v;let f=this._document.createDocumentFragment();if(r){let S=e[0]>t[0];f.appendChild(this._createSelectionElement(p,S?t[0]:e[0],S?e[0]:t[0],v-p+1))}else{let S=u===p?e[0]:0,I=p===_?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(p,S,I));let w=v-p-1;if(f.appendChild(this._createSelectionElement(p+1,0,this._bufferService.cols,w)),p!==v){let L=_===v?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(v,0,L))}}this._selectionContainer.appendChild(f)}let d=Math.min(o,l),c=Math.max(a,h);if(c>=0){d=Math.max(d,0),c=Math.min(c,s-1);let _=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&_>=0&&_this.dimensions.css.canvas.width&&(l=this.dimensions.css.canvas.width-a),o.style.height=`${s*this.dimensions.css.cell.height}px`,o.style.top=`${e*this.dimensions.css.cell.height}px`,o.style.left=`${a}px`,o.style.width=`${l}px`,o}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(let e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){let r=this._bufferService.buffer,s=r.ybase+r.y,o=Math.min(r.x,this._bufferService.cols-1),a=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,l=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,h=this._optionsService.rawOptions.cursorInactiveStyle,d={hasBlinkingCells:!1};for(let c=e;c<=t;c++){let u=c+r.ydisp,_=this._rowElements[c];if(!_)continue;let p=r.lines.get(u);if(!p){_.replaceChildren(),this._setRowBlinkState(c,!1);continue}_.replaceChildren(...this._rowFactory.createRow(p,u,u===s,l,h,o,a,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,d)),this._setRowBlinkState(c,d.hasBlinkingCells)}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,r,s,o,a){r<0&&(e=0),s<0&&(t=0);let l=this._bufferService.rows-1;r=Math.max(Math.min(r,l),0),s=Math.max(Math.min(s,l),0),o=Math.min(o,this._bufferService.cols);let h=this._bufferService.buffer,d=h.ybase+h.y,c=Math.min(h.x,o-1),u=this._optionsService.rawOptions.cursorBlink,_=this._optionsService.rawOptions.cursorStyle,p=this._optionsService.rawOptions.cursorInactiveStyle,v={hasBlinkingCells:!1};for(let f=r;f<=s;++f){let S=f+h.ydisp,I=this._rowElements[f];if(!I)continue;let w=h.lines.get(S);if(!w){I.replaceChildren(),this._setRowBlinkState(f,!1);continue}I.replaceChildren(...this._rowFactory.createRow(w,S,S===d,_,p,c,u,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,a?f===r?e:0:-1,a?(f===s?t:o)-1:-1,v)),this._setRowBlinkState(f,v.hasBlinkingCells)}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};mt=y([m(7,Qe),m(8,Be),m(9,R),m(10,D),m(11,Y),m(12,G),m(13,_e)],mt);var ns=class{constructor(i,e){this._rowContainer=i;this._coreBrowserService=e;this._isIdlePaused=!1;this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){this._idleTimeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}};var bt=class extends g{constructor(e,t,r){super();this._optionsService=r;this.width=0;this.height=0;this._onCharSizeChange=this._register(new b);this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new as(this._optionsService))}catch{this._measureStrategy=this._register(new os(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}get hasValidSize(){return this.width>0&&this.height>0}measure(){let e=this._measureStrategy.measure();(e.width!==this.width||e.height!==this.height)&&(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};bt=y([m(2,R)],bt);var Ui=class extends g{constructor(){super(...arguments);this._result={width:0,height:0}}_validateAndSet(e,t){e!==void 0&&e>0&&t!==void 0&&t>0&&(this._result.width=e,this._result.height=t)}},os=class extends Ui{constructor(e,t,r){super();this._document=e;this._parentElement=t;this._optionsService=r;this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}},as=class extends Ui{constructor(e){super();this._optionsService=e;this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");let t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;let e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}};var Ki=class extends g{constructor(e,t,r){super();this._textarea=e;this._window=t;this.mainDocument=r;this._isFocused=!1;this._cachedIsFocused=void 0;this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._onWindowChange=this._register(new b);this.onWindowChange=this._onWindowChange.event;this._screenDprMonitor=this._register(new ls(this._window)),this._register(this.onWindowChange(s=>this._screenDprMonitor.setWindow(s))),this._register(j.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register(C(this._textarea,"focus",()=>this._isFocused=!0)),this._register(C(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}},ls=class extends g{constructor(e){super();this._parentWindow=e;this._windowResizeListener=this._register(new P);this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register(E(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=C(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){!this._resolutionMediaMatchList||!this._outerListener||(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}};var zi=class extends g{constructor(){super();this.linkProviders=[];this._register(E(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{let t=this.linkProviders.indexOf(e);t!==-1&&this.linkProviders.splice(t,1)}}}};function qt(n,i,e){let t=e.getBoundingClientRect(),r=n.getComputedStyle(e),s=parseInt(r.getPropertyValue("padding-left"),10),o=parseInt(r.getPropertyValue("padding-top"),10);return[i.clientX-t.left-s,i.clientY-t.top-o]}function Qs(n,i,e,t,r,s,o,a,l){if(!s)return;let h=qt(n,i,e);return h[0]=Math.ceil((h[0]+(l?o/2:0))/o),h[1]=Math.ceil(h[1]/a),h[0]=Math.min(Math.max(h[0],1),t+(l?1:0)),h[1]=Math.min(Math.max(h[1],1),r),h}var vt=class{constructor(i,e){this._charSizeService=i;this._renderService=e}getCoords(i,e,t,r,s){return Qs(se(e),i,e,t,r,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,s)}getMouseReportCoords(i,e){let t=qt(se(e),i,e);if(this._charSizeService.hasValidSize)return t[0]=Math.min(Math.max(t[0],0),this._renderService.dimensions.css.canvas.width-1),t[1]=Math.min(Math.max(t[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(t[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(t[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(t[0]),y:Math.floor(t[1])}}};vt=y([m(0,Be),m(1,V)],vt);var en=typeof window=="object"?window:globalThis;function ce(n,i=0){return n[n.length-(1+i)]}function Jn(n,i,e){let t=null,r=null;if(typeof e.value=="function"?(t="value",r=e.value,r.length!==0&&console.warn("Memoize should only be used in functions with zero parameters")):typeof e.get=="function"&&(t="get",r=e.get),!r||!t)throw new Error("not supported");let s=`$memoize$${i}`,o=e;o[t]=function(...a){return this.hasOwnProperty(s)||Object.defineProperty(this,s,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,a)}),this[s]}}var St=class St{constructor(i){this.element=i,this.next=St.Undefined,this.prev=St.Undefined}};St.Undefined=new St(void 0);var re=St,Gi=class{constructor(){this._first=re.Undefined;this._last=re.Undefined}push(i){return this._insert(i,!0)}_insert(i,e){let t=new re(i);if(this._first===re.Undefined)this._first=t,this._last=t;else if(e){let s=this._last;this._last=t,t.prev=s,s.next=t}else{let s=this._first;this._first=t,t.next=s,s.prev=t}let r=!1;return()=>{r||(r=!0,this._remove(t))}}_remove(i){if(i.prev!==re.Undefined&&i.next!==re.Undefined){let e=i.prev;e.next=i.next,i.next.prev=e}else i.prev===re.Undefined&&i.next===re.Undefined?(this._first=re.Undefined,this._last=re.Undefined):i.next===re.Undefined?(this._last=this._last.prev,this._last.next=re.Undefined):i.prev===re.Undefined&&(this._first=this._first.next,this._first.prev=re.Undefined)}*[Symbol.iterator](){let i=this._first;for(;i!==re.Undefined;)yield i.element,i=i.next}},he;(s=>(s.TAP="-xterm-gesturetap",s.CHANGE="-xterm-gesturechange",s.START="-xterm-gesturestart",s.END="-xterm-gesturesend",s.CONTEXT_MENU="-xterm-gesturecontextmenu"))(he||={});var K=class K extends g{constructor(){super();this._dispatched=!1;this._targets=new Gi;this._ignoreTargets=new Gi;this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;let e=en;this._register(C(e.document,"touchstart",t=>this._handleTouchStart(t),{passive:!1})),this._register(C(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(C(e.document,"touchmove",t=>this._handleTouchMove(t),{passive:!1}))}static addTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._targets.push(e);return E(t)}static ignoreTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._ignoreTargets.push(e);return E(t)}static isTouchDevice(){return"ontouchstart"in en||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){let t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let r=0,s=e.targetTouches.length;r=K._holdDelay&&Math.abs(h.initialPageX-ce(h.rollingPageX))<30&&Math.abs(h.initialPageY-ce(h.rollingPageY))<30){let c=this._newGestureEvent(he.CONTEXT_MENU,h.initialTarget);c.pageX=ce(h.rollingPageX),c.pageY=ce(h.rollingPageY),this._dispatchEvent(c)}else if(s===1){let c=ce(h.rollingPageX),u=ce(h.rollingPageY),_=ce(h.rollingTimestamps)-h.rollingTimestamps[0],p=c-h.rollingPageX[0],v=u-h.rollingPageY[0],f=[...this._targets].filter(S=>h.initialTarget instanceof Node&&S.contains(h.initialTarget));this._inertia(e,f,r,Math.abs(p)/_,p>0?1:-1,c,Math.abs(v)/_,v>0?1:-1,u)}this._dispatchEvent(this._newGestureEvent(he.END,h.initialTarget)),delete this._activeTouches[l.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){let r=document.createEvent("CustomEvent");return r.initEvent(e,!1,!0),r.initialTarget=t,r.tapCount=0,r}_dispatchEvent(e){if(e.type===he.TAP){let t=new Date().getTime(),r;t-this._lastSetTapCountTime>K._clearTapCountTime?r=1:r=2,this._lastSetTapCountTime=t,e.tapCount=r}else(e.type===he.CHANGE||e.type===he.CONTEXT_MENU)&&(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(let r of this._ignoreTargets)if(r.contains(e.initialTarget))return;let t=[];for(let r of this._targets)if(r.contains(e.initialTarget)){let s=0,o=e.initialTarget;for(;o&&o!==r;)s++,o=o.parentElement;t.push([s,r])}t.sort((r,s)=>r[0]-s[0]);for(let[,r]of t)r.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,r,s,o,a,l,h,d){this._handle=tt(e,()=>{let c=Date.now(),u=c-r,_=0,p=0,v=!0;s+=K._scrollFriction*u,l+=K._scrollFriction*u,s>0&&(v=!1,_=o*s*u),l>0&&(v=!1,p=h*l*u);let f=this._newGestureEvent(he.CHANGE);f.translationX=_,f.translationY=p,t.forEach(S=>S.dispatchEvent(f)),v||this._inertia(e,t,c,s,o,a+_,l,h,d+p)})}_handleTouchMove(e){let t=Date.now();for(let r=0,s=e.changedTouches.length;r3&&(a.rollingPageX.shift(),a.rollingPageY.shift(),a.rollingTimestamps.shift()),a.rollingPageX.push(o.pageX),a.rollingPageY.push(o.pageY),a.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}};K._scrollFriction=-.005,K._holdDelay=700,K._clearTapCountTime=400,y([Jn],K,"isTouchDevice",1);var Vi=K;var gt=class{constructor(i,e,t,r,s,o,a,l,h){this._renderService=i;this._mouseCoordsService=e;this._mouseStateService=t;this._coreService=r;this._bufferService=s;this._optionsService=o;this._selectionService=a;this._logService=l;this._coreBrowserService=h;this._lastEvent=null;this._wheelPartialScroll=0;this._touchScrollAccumulator=0}bindMouse(i,e,t){let{element:r,document:s}=i,o={mouseup:null,wheel:null,mousedrag:null,mousemove:null},a={target:i,focus:t,requestedEvents:o},l={mouseup:h=>this._handleMouseUp(a,h),wheel:h=>this._handleWheel(a,h),mousedrag:h=>this._handleMouseDrag(a,h),mousemove:h=>this._handleMouseMove(a,h)};this._altMouseCursor=new cs(r,s,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),e(this._altMouseCursor),e(this._mouseStateService.onProtocolChange(h=>{this._handleProtocolChange(a,l,h)})),e(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(r),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,e(E(()=>{o.mouseup&&s.removeEventListener("mouseup",o.mouseup),o.mousedrag&&s.removeEventListener("mousemove",o.mousedrag)})),e(C(r,"mousedown",h=>this._handleMouseDown(a,h))),e(C(r,"wheel",h=>this._handlePassiveWheel(a,h),{passive:!1})),e(Vi.addTarget(i.screenElement)),e(C(i.screenElement,he.START,()=>this._handleTouchStart())),e(C(i.screenElement,he.CHANGE,h=>this._handleTouchChange(a,h)))}_sendEvent(i,e){let t=this._mouseCoordsService.getMouseReportCoords(e,i.target.screenElement);if(!t)return!1;let r,s;switch(e.overrideType||e.type){case"mousemove":s=32,e.buttons===void 0?(r=3,e.button!==void 0&&(r=e.button<3?e.button:3)):r=e.buttons&1?0:e.buttons&4?1:e.buttons&2?2:3;break;case"mouseup":s=0,r=e.button<3?e.button:3;break;case"mousedown":s=1,r=e.button<3?e.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;let a=e.deltaY;if(a===0||this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return!1;s=a<0?0:1,r=4;break;default:return!1}if(s===void 0||r===void 0||r>4||r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!e.altKey)return!1;let o=r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:t.col,row:t.row,x:t.x,y:t.y,button:r,action:s,ctrl:e.ctrlKey,alt:o?!1:e.altKey,shift:e.shiftKey})}_handleMouseUp(i,e){this._sendEvent(i,e),e.buttons||(i.requestedEvents.mouseup&&i.target.document.removeEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.removeEventListener("mousemove",i.requestedEvents.mousedrag))}_handleWheel(i,e){return this._sendEvent(i,e),e.preventDefault(),e.stopPropagation(),!1}_handleMouseDrag(i,e){e.buttons&&this._sendEvent(i,e)}_handleMouseMove(i,e){e.buttons||this._sendEvent(i,e)}_handleMouseDown(i,e){e.preventDefault(),i.focus(),!(!this._mouseStateService.areMouseEventsActive||this._selectionService.shouldForceSelection(e))&&(this._sendEvent(i,e),i.requestedEvents.mouseup&&i.target.document.addEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.addEventListener("mousemove",i.requestedEvents.mousedrag))}_handlePassiveWheel(i,e){if(!i.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;if(!this._bufferService.buffer.hasScrollback){if(e.deltaY===0)return!1;if(this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return e.preventDefault(),e.stopPropagation(),!1;let s="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(s,!0),e.preventDefault(),e.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(i,e){if(e.preventDefault(),e.stopPropagation(),i.requestedEvents.wheel){this._handleTouchScrollAsWheel(i,e);return}if(!this._bufferService.buffer.hasScrollback){this._handleTouchScrollAsKeys(e);return}i.target.handleTouchScroll?.(e.translationY)}_handleTouchScrollAsKeys(i){let e=this._renderService?.dimensions.css.cell.height;if(!e)return;this._touchScrollAccumulator-=i.translationY;let t=Math.trunc(this._touchScrollAccumulator/e);if(t===0)return;this._touchScrollAccumulator-=t*e;let r="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t<0?"A":"B");for(let s=0;s0?1:-1),this._wheelPartialScroll%=1):i.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(s*=this._bufferService.rows),s}_triggerMouseEvent(i){if(i.col<0||i.col>=this._bufferService.cols||i.row<0||i.row>=this._bufferService.rows||i.button===4&&i.action===32||i.button===3&&i.action!==32||i.button!==4&&(i.action===2||i.action===3)||(i.col++,i.row++,i.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,i,this._mouseStateService.isPixelEncoding))||!this._mouseStateService.restrictMouseEvent(i))return!1;let e=this._mouseStateService.encodeMouseEvent(i);return e&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(e):this._coreService.triggerDataEvent(e,!0)),this._lastEvent=i,!0}_explainEvents(i){return{down:!!(i&1),up:!!(i&2),drag:!!(i&4),move:!!(i&8),wheel:!!(i&16)}}_equalEvents(i,e,t){if(t){if(i.x!==e.x||i.y!==e.y)return!1}else if(i.col!==e.col||i.row!==e.row)return!1;return!(i.button!==e.button||i.action!==e.action||i.ctrl!==e.ctrl||i.alt!==e.alt||i.shift!==e.shift)}};gt=y([m(0,V),m(1,Pe),m(2,Me),m(3,Y),m(4,D),m(5,R),m(6,Si),m(7,fe),m(8,G)],gt);var cs=class{constructor(i,e,t){this._element=i;this._document=e;this._isActive=t;this._listeners=new P}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;let i=new pe,e=r=>this.syncFromModifier(r);i.add(C(this._document,"keydown",e)),i.add(C(this._document,"keyup",e)),i.add(C(this._element,"mousemove",e));let t=this._element.ownerDocument?.defaultView;t&&i.add(C(t,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=i}resetClass(){this._updateClass(!1)}syncFromModifier(i){this._isActive()&&this._updateClass(i.getModifierState("Alt"))}_updateClass(i){i?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}};var $i=class{constructor(i,e){this._renderCallback=i;this._coreBrowserService=e;this._refreshCallbacks=[]}dispose(){this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(i){return this._refreshCallbacks.push(i),this._animationFrame??=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()),this._animationFrame}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0){this._runRefreshCallbacks();return}let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(let i of this._refreshCallbacks)i(0);this._refreshCallbacks=[]}};var qi=class{constructor(i){this._tasks=[];this._i=0;this._logService=i}enqueue(i){this._tasks.push(i),this._start()}flush(){for(;this._is){r-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-e))}ms`),this._start();return}r=s}this.clear()}},hs=class extends qi{_requestCallback(i){return setTimeout(()=>i(this._createDeadline(16)))}_cancelCallback(i){clearTimeout(i)}_createDeadline(i){let e=performance.now()+i;return{timeRemaining:()=>Math.max(0,e-performance.now())}}},ds=class extends qi{_requestCallback(i){return requestIdleCallback(i)}_cancelCallback(i){cancelIdleCallback(i)}},It="requestIdleCallback"in globalThis?ds:hs,Xi=class{constructor(i){this._queue=new It(i)}set(i){this._queue.clear(),this._queue.enqueue(i)}flush(){this._queue.flush()}dispose(){this._queue.clear()}};var Ct=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._rowCount=e;this._optionsService=r;this._logService=s;this._charSizeService=o;this._coreService=a;this._coreBrowserService=d;this._renderer=this._register(new P);this._observerDisposable=this._register(new P);this._isPaused=!1;this._needsFullRefresh=!1;this._isNextRenderRedrawOnly=!0;this._needsSelectionRefresh=!1;this._canvasWidth=0;this._canvasHeight=0;this._selectionState={start:void 0,end:void 0,columnSelectMode:!1};this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._onRenderedViewportChange=this._register(new b);this.onRenderedViewportChange=this._onRenderedViewportChange.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onRefreshRequest=this._register(new b);this.onRefreshRequest=this._onRefreshRequest.event;this._pausedResizeTask=this._register(new Xi(this._logService)),this._renderDebouncer=new $i((u,_)=>this._renderRows(u,_),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new us(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register(E(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(h.onResize(()=>this._fullRefresh())),this._register(h.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(h.buffer.y,h.buffer.y,void 0,!0))),this._register(c.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(u=>this._registerIntersectionObserver(u,t)))}get dimensions(){return this._renderer.value.dimensions}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){let r=new e.IntersectionObserver(s=>this._handleIntersectionChange(s[s.length-1]),{threshold:0});this._observerDisposable.value=E(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=r,r.observe(t)}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),!this._isPaused&&!this._charSizeService.hasValidSize&&this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){if(this._renderer.value){if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0}}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(t=>this.refreshRows(t.start,t.end,t.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,r){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=r,this._renderer.value?.handleSelectionChanged(e,t,r)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};Ct=y([m(2,R),m(3,fe),m(4,Be),m(5,Y),m(6,ge),m(7,D),m(8,G),m(9,_e)],Ct);var us=class{constructor(i,e,t){this._coreBrowserService=i;this._coreService=e;this._onTimeout=t;this._start=0;this._end=0;this._isBuffering=!1}bufferRows(i,e){this._isBuffering?(this._start=Math.min(this._start,i),this._end=Math.max(this._end,e)):(this._start=i,this._end=e,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;let i={start:this._start,end:this._end};return this._isBuffering=!1,i}dispose(){this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}};function tn(n,i,e,t){let r=e.buffer.x,s=e.buffer.y;if(!e.buffer.hasScrollback)return ro(r,s,n,i,e,t)+Yi(s,i,e,t)+so(r,s,n,i,e,t);let o;if(s===i)return o=r>n?"D":"C",Yt(Math.abs(r-n),Xt(o,t));o=s>i?"D":"C";let a=Math.abs(s-i),l=io(s>i?n:r,e)+(a-1)*e.cols+1+to(s>i?r:n,e);return Yt(l,Xt(o,t))}function to(n,i){return n-1}function io(n,i){return i.cols-n}function ro(n,i,e,t,r,s){return Yi(i,t,r,s).length===0?"":Yt(sn(n,i,n,i-$e(i,r),!1,r).length,Xt("D",s))}function Yi(n,i,e,t){let r=n-$e(n,e),s=i-$e(i,e),o=Math.abs(r-s)-no(n,i,e);return Yt(o,Xt(rn(n,i),t))}function so(n,i,e,t,r,s){let o;Yi(i,t,r,s).length>0?o=t-$e(t,r):o=i;let a=t,l=oo(n,i,e,t,r,s);return Yt(sn(n,o,e,a,l==="C",r).length,Xt(l,s))}function no(n,i,e){let t=0,r=n-$e(n,e),s=i-$e(i,e);for(let o=0;o=0&&n0?o=t-$e(t,r):o=i,n=e&&oi?"A":"B"}function sn(n,i,e,t,r,s){let o=n,a=i,l="";for(;(o!==e||a!==t)&&a>=0&&as.cols-1?(l+=s.buffer.translateBufferLineToString(a,!1,n,o),o=0,n=0,a++):!r&&o<0&&(l+=s.buffer.translateBufferLineToString(a,!1,0,n+1),o=s.cols-1,n=o,a--);return l+s.buffer.translateBufferLineToString(a,!1,n,o)}function Xt(n,i){let e=i?"O":"[";return"\x1B"+e+n}function Yt(n,i){n=Math.floor(n);let e="";for(let t=0;tthis._bufferService.cols?i%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)-1]:[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[i,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){let i=this.selectionStart[0]+this.selectionStartLength;return i>this._bufferService.cols?[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[Math.max(i,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){let i=this.selectionStart,e=this.selectionEnd;return!i||!e?!1:i[1]>e[1]||i[1]===e[1]&&i[0]>e[0]}handleTrim(i){return this.selectionStart&&(this.selectionStart[1]-=i),this.selectionEnd&&(this.selectionEnd[1]-=i),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):this.selectionStart&&this.selectionStart[1]<0?(this.selectionStart=[0,0],!0):!1}};function fs(n,i){if(n.start.y>n.end.y)throw new Error(`Buffer range end (${n.end.x}, ${n.end.y}) cannot be before start (${n.start.x}, ${n.start.y})`);return i*(n.end.y-n.start.y)+(n.end.x-n.start.x+1)}var ao="\xA0",lo=new RegExp(ao,"g");var Et=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._element=e;this._screenElement=t;this._linkifier=r;this._bufferService=s;this._coreService=o;this._mouseCoordsService=a;this._optionsService=l;this._mouseStateService=h;this._renderService=d;this._coreBrowserService=c;this._dragScrollAmount=0;this._enabled=!0;this._trimListener=this._register(new P);this._workCell=new F;this._mouseDownTimeStamp=0;this._oldHasSelection=!1;this._oldSelectionStart=void 0;this._oldSelectionEnd=void 0;this._onLinuxMouseSelection=this._register(new b);this.onLinuxMouseSelection=this._onLinuxMouseSelection.event;this._onRedrawRequest=this._register(new b);this.onRequestRedraw=this._onRedrawRequest.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onRequestScrollLines=this._register(new b);this.onRequestScrollLines=this._onRequestScrollLines.event;this._mouseMoveListener=u=>this._handleMouseMove(u),this._mouseUpListener=u=>this._handleMouseUp(u),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(u=>this._handleTrim(u)),this._register(this._bufferService.buffers.onBufferActivate(u=>this._handleBufferActivate(u))),this.enable(),this._model=new ji(this._bufferService),this._activeSelectionMode=0,this._register(E(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(u=>{u.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!e||!t?!1:e[0]!==t[0]||e[1]!==t[1]}get selectionText(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";let r=this._bufferService.buffer,s=[];if(this._activeSelectionMode===3){if(e[0]===t[0])return"";let a=e[0]a.replace(lo," ")).join(Ue?`\r `:` `)}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),zt&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:this._activeSelectionMode===3})}_isClickInSelection(e){let t=this._getMouseBufferCoords(e),r=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!r||!s||!t?!1:this._areCoordsInSelection(t,r,s)}isCellInSelection(e,t){let r=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!r||!s?!1:this._areCoordsInSelection([e,t],r,s)}_areCoordsInSelection(e,t,r){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){let r=this._linkifier.currentLink?.link?.range;if(r)return this._model.selectionStart=[r.start.x-1,r.start.y-1],this._model.selectionStartLength=fs(r,this._bufferService.cols),this._model.selectionEnd=void 0,!0;let s=this._getMouseBufferCoords(e);return s?(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0):!1}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){let t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=qt(this._coreBrowserService.window,e,this._screenElement)[1],r=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=r?0:(t>r&&(t-=r),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(t*14))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:ie?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(e.button===2&&this.hasSelection)&&e.button===0&&!(this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):e.detail===1?this._handleSingleClick(e):e.detail===2?this._handleDoubleClick(e):e.detail===3&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){let t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);let r=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);r&&r.length!==this._model.selectionStart[0]&&r.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){let t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!1:e.altKey&&!(ie&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;let t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd){this.refresh(!0);return}this._activeSelectionMode===2?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));let r=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){let t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){let r=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(r&&r[0]!==void 0&&r[1]!==void 0){let s=tn(r[0]-1,r[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(s,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,r=!!e&&!!t&&(e[0]!==t[0]||e[1]!==t[1]);if(!r){this._oldHasSelection&&this._fireOnSelectionChange(e,t,r);return}!e||!t||(!this._oldSelectionStart||!this._oldSelectionEnd||e[0]!==this._oldSelectionStart[0]||e[1]!==this._oldSelectionStart[1]||t[0]!==this._oldSelectionEnd[0]||t[1]!==this._oldSelectionEnd[1])&&this._fireOnSelectionChange(e,t,r)}_fireOnSelectionChange(e,t,r){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=r,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(t=>this._handleTrim(t))}_convertViewportColToCharacterIndex(e,t){let r=t;for(let s=0;t>=s;s++){let o=e.loadCell(s,this._workCell).getChars().length;this._workCell.getWidth()===0?r--:o>1&&t!==s&&(r+=o-1)}return r}setSelection(e,t,r){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=r,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,r=!0,s=!0){if(e[0]>=this._bufferService.cols)return;let o=this._bufferService.buffer,a=o.lines.get(e[1]);if(!a)return;let l=o.translateBufferLineToString(e[1],!1),h=this._convertViewportColToCharacterIndex(a,e[0]),d=h,c=e[0]-h,u=0,_=0,p=0,v=0;if(l.charAt(h)===" "){for(;h>0&&l.charAt(h-1)===" ";)h--;for(;d1&&(v+=L-1,d+=L-1);I>0&&h>0&&!this._isCharWordSeparator(a.loadCell(I-1,this._workCell));){a.loadCell(I-1,this._workCell);let T=this._workCell.getChars().length;this._workCell.getWidth()===0?(u++,I--):T>1&&(p+=T-1,h-=T-1),h--,I--}for(;w1&&(v+=T-1,d+=T-1),d++,w++}}d++;let f=h+c-u+p,S=Math.min(this._bufferService.cols,d-h+u+_-p-v);if(!(!t&&l.slice(h,d).trim()==="")){if(r&&f===0&&a.getCodePoint(0)!==32){let I=o.lines.get(e[1]-1);if(I&&a.isWrapped&&I.getCodePoint(this._bufferService.cols-1)!==32){let w=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(w){let L=this._bufferService.cols-w.start;f-=L,S+=L}}}if(s&&f+S===this._bufferService.cols&&a.getCodePoint(this._bufferService.cols-1)!==32){let I=o.lines.get(e[1]+1);if(I?.isWrapped&&I.getCodePoint(0)!==32){let w=this._getWordAt([0,e[1]+1],!1,!1,!0);w&&(S+=w.length)}}return{start:f,length:S}}}_selectWordAt(e,t){let r=this._getWordAt(e,t);if(r){for(;r.start<0;)r.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[r.start,e[1]],this._model.selectionStartLength=r.length}}_selectToWordAt(e){let t=this._getWordAt(e,!0);if(t){let r=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,r--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,r++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,r]}}_isCharWordSeparator(e){return e.getWidth()===0?!1:this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){let t=this._bufferService.buffer.getWrappedRangeForLine(e),r={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=fs(r,this._bufferService.cols)}};Et=y([m(3,D),m(4,Y),m(5,Pe),m(6,R),m(7,Me),m(8,V),m(9,G)],Et);var jt=class{constructor(){this._data={}}set(i,e,t){this._data[i]||(this._data[i]={}),this._data[i][e]=t}get(i,e){return this._data[i]?this._data[i][e]:void 0}clear(){this._data={}}};var Zt=class{constructor(){this._color=new jt;this._css=new jt}setCss(i,e,t){this._css.set(i,e,t)}getCss(i,e){return this._css.get(i,e)}setColor(i,e,t){this._color.set(i,e,t)}getColor(i,e){return this._color.get(i,e)}clear(){this._color.clear(),this._css.clear()}};var $=Object.freeze((()=>{let n=[B.toColor("#2e3436"),B.toColor("#cc0000"),B.toColor("#4e9a06"),B.toColor("#c4a000"),B.toColor("#3465a4"),B.toColor("#75507b"),B.toColor("#06989a"),B.toColor("#d3d7cf"),B.toColor("#555753"),B.toColor("#ef2929"),B.toColor("#8ae234"),B.toColor("#fce94f"),B.toColor("#729fcf"),B.toColor("#ad7fa8"),B.toColor("#34e2e2"),B.toColor("#eeeeec")],i=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=i[e/36%6|0],r=i[e/6%6|0],s=i[e%6];n.push({css:O.toCss(t,r,s),rgba:O.toRgba(t,r,s)})}for(let e=0;e<24;e++){let t=8+e*10;n.push({css:O.toCss(t,t,t),rgba:O.toRgba(t,t,t)})}return n})());var qe=B.toColor("#ffffff"),Qt=B.toColor("#000000"),nn=B.toColor("#ffffff"),on=Qt,Jt={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},co=qe,yt=class extends g{constructor(e){super();this._optionsService=e;this._contrastCache=new Zt;this._halfContrastCache=new Zt;this._onChangeColors=this._register(new b);this.onChangeColors=this._onChangeColors.event;this._colors={foreground:qe,background:Qt,cursor:nn,cursorAccent:on,selectionForeground:void 0,selectionBackgroundTransparent:Jt,selectionBackgroundOpaque:k.blend(Qt,Jt),selectionInactiveBackgroundTransparent:Jt,selectionInactiveBackgroundOpaque:k.blend(Qt,Jt),scrollbarSliderBackground:k.opacity(qe,.2),scrollbarSliderHoverBackground:k.opacity(qe,.4),scrollbarSliderActiveBackground:k.opacity(qe,.5),overviewRulerBorder:qe,ansi:$.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}get colors(){return this._colors}_setTheme(e={}){let t=this._colors;if(t.foreground=M(e.foreground,qe),t.background=M(e.background,Qt),t.cursor=k.blend(t.background,M(e.cursor,nn)),t.cursorAccent=k.blend(t.background,M(e.cursorAccent,on)),t.selectionBackgroundTransparent=M(e.selectionBackground,Jt),t.selectionBackgroundOpaque=k.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=M(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=k.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?M(e.selectionForeground,ts):void 0,t.selectionForeground===ts&&(t.selectionForeground=void 0),k.isOpaque(t.selectionBackgroundTransparent)&&(t.selectionBackgroundTransparent=k.opacity(t.selectionBackgroundTransparent,.3)),k.isOpaque(t.selectionInactiveBackgroundTransparent)&&(t.selectionInactiveBackgroundTransparent=k.opacity(t.selectionInactiveBackgroundTransparent,.3)),t.scrollbarSliderBackground=M(e.scrollbarSliderBackground,k.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=M(e.scrollbarSliderHoverBackground,k.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=M(e.scrollbarSliderActiveBackground,k.opacity(t.foreground,.5)),t.overviewRulerBorder=M(e.overviewRulerBorder,co),t.ansi=$.slice(),t.ansi[0]=M(e.black,$[0]),t.ansi[1]=M(e.red,$[1]),t.ansi[2]=M(e.green,$[2]),t.ansi[3]=M(e.yellow,$[3]),t.ansi[4]=M(e.blue,$[4]),t.ansi[5]=M(e.magenta,$[5]),t.ansi[6]=M(e.cyan,$[6]),t.ansi[7]=M(e.white,$[7]),t.ansi[8]=M(e.brightBlack,$[8]),t.ansi[9]=M(e.brightRed,$[9]),t.ansi[10]=M(e.brightGreen,$[10]),t.ansi[11]=M(e.brightYellow,$[11]),t.ansi[12]=M(e.brightBlue,$[12]),t.ansi[13]=M(e.brightMagenta,$[13]),t.ansi[14]=M(e.brightCyan,$[14]),t.ansi[15]=M(e.brightWhite,$[15]),e.extendedAnsi){let r=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};function ln(n,i,e,t){let r={type:0,cancel:!1,key:void 0},s=(n.shiftKey?1:0)|(n.altKey?2:0)|(n.ctrlKey?4:0)|(n.metaKey?8:0);switch(n.keyCode){case 0:n.key==="UIKeyInputUpArrow"?i?r.key="\x1BOA":r.key="\x1B[A":n.key==="UIKeyInputLeftArrow"?i?r.key="\x1BOD":r.key="\x1B[D":n.key==="UIKeyInputRightArrow"?i?r.key="\x1BOC":r.key="\x1B[C":n.key==="UIKeyInputDownArrow"&&(i?r.key="\x1BOB":r.key="\x1B[B");break;case 8:r.key=n.ctrlKey?"\b":"\x7F",n.altKey&&(r.key="\x1B"+r.key);break;case 9:if(n.shiftKey){r.key="\x1B[Z";break}r.key=" ",r.cancel=!0;break;case 13:n.key==="c"&&n.ctrlKey?r.key="":r.key=n.altKey?"\x1B\r":"\r",r.cancel=!0;break;case 27:r.key="\x1B",n.altKey&&(r.key="\x1B\x1B"),r.cancel=!0;break;case 37:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"D":i?r.key="\x1BOD":r.key="\x1B[D";break;case 39:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"C":i?r.key="\x1BOC":r.key="\x1B[C";break;case 38:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"A":i?r.key="\x1BOA":r.key="\x1B[A";break;case 40:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"B":i?r.key="\x1BOB":r.key="\x1B[B";break;case 45:!n.shiftKey&&!n.ctrlKey&&(r.key="\x1B[2~");break;case 46:s?r.key="\x1B[3;"+(s+1)+"~":r.key="\x1B[3~";break;case 36:s?r.key="\x1B[1;"+(s+1)+"H":i?r.key="\x1BOH":r.key="\x1B[H";break;case 35:s?r.key="\x1B[1;"+(s+1)+"F":i?r.key="\x1BOF":r.key="\x1B[F";break;case 33:n.shiftKey?r.type=2:n.ctrlKey?r.key="\x1B[5;"+(s+1)+"~":r.key="\x1B[5~";break;case 34:n.shiftKey?r.type=3:n.ctrlKey?r.key="\x1B[6;"+(s+1)+"~":r.key="\x1B[6~";break;case 112:s?r.key="\x1B[1;"+(s+1)+"P":r.key="\x1BOP";break;case 113:s?r.key="\x1B[1;"+(s+1)+"Q":r.key="\x1BOQ";break;case 114:s?r.key="\x1B[1;"+(s+1)+"R":r.key="\x1BOR";break;case 115:s?r.key="\x1B[1;"+(s+1)+"S":r.key="\x1BOS";break;case 116:s?r.key="\x1B[15;"+(s+1)+"~":r.key="\x1B[15~";break;case 117:s?r.key="\x1B[17;"+(s+1)+"~":r.key="\x1B[17~";break;case 118:s?r.key="\x1B[18;"+(s+1)+"~":r.key="\x1B[18~";break;case 119:s?r.key="\x1B[19;"+(s+1)+"~":r.key="\x1B[19~";break;case 120:s?r.key="\x1B[20;"+(s+1)+"~":r.key="\x1B[20~";break;case 121:s?r.key="\x1B[21;"+(s+1)+"~":r.key="\x1B[21~";break;case 122:s?r.key="\x1B[23;"+(s+1)+"~":r.key="\x1B[23~";break;case 123:s?r.key="\x1B[24;"+(s+1)+"~":r.key="\x1B[24~";break;default:if(n.ctrlKey&&!n.shiftKey&&!n.altKey&&!n.metaKey)n.keyCode>=65&&n.keyCode<=90?r.key=String.fromCharCode(n.keyCode-64):n.keyCode===32?r.key="\0":n.keyCode>=51&&n.keyCode<=55?r.key=String.fromCharCode(n.keyCode-51+27):n.keyCode===56?r.key="\x7F":n.key==="/"?r.key="":n.keyCode===219?r.key="\x1B":n.keyCode===220?r.key="":n.keyCode===221&&(r.key="");else if((!e||t)&&n.altKey&&!n.metaKey){let a=ho[n.keyCode]?.[n.shiftKey?1:0];if(a)r.key="\x1B"+a;else if(n.keyCode>=65&&n.keyCode<=90){let l=n.ctrlKey?n.keyCode-64:n.keyCode+32,h=String.fromCharCode(l);n.shiftKey&&(h=h.toUpperCase()),r.key="\x1B"+h}else if(n.keyCode===32)r.key="\x1B"+(n.ctrlKey?"\0":" ");else if(n.key==="Dead"&&n.code.startsWith("Key")){let l=n.code.slice(3,4);n.shiftKey||(l=l.toLowerCase()),r.key="\x1B"+l,r.cancel=!0}}else if(e&&!n.altKey&&!n.ctrlKey&&!n.shiftKey&&n.metaKey)n.keyCode===65&&(r.type=1);else if(n.key&&!n.ctrlKey&&!n.altKey&&!n.metaKey&&n.keyCode>=48&&n.key.length===1)r.key=n.key;else if(n.key&&n.ctrlKey&&n.shiftKey)switch(n.code){case"Minus":r.key="";break;case"Digit2":r.key="\0";break;case"Digit6":r.key="";break}break}return r}var ei=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440};this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24};this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"};this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(i){if(i.code.startsWith("Numpad")){let e=i.code.slice(6);if(e>="0"&&e<="9")return 57399+parseInt(e,10);switch(e){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(i){switch(i.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(i){let e=0;return i.shiftKey&&(e|=1),i.altKey&&(e|=2),i.ctrlKey&&(e|=4),i.metaKey&&(e|=8),e>0?e+1:0}_getKeyCode(i,e){let t=this._getNumpadKeyCode(i);if(t!==void 0)return t;let r=this._getModifierKeyCode(i);if(r!==void 0)return r;let s=this._functionalKeyCodes[i.key];if(s!==void 0)return s;if((i.shiftKey||e&&i.altKey)&&i.code){if(i.code.startsWith("Digit")&&i.code.length===6){let o=i.code.charAt(5);if(o>="0"&&o<="9")return o.charCodeAt(0)}if(i.code.startsWith("Key")&&i.code.length===4)return i.code.charAt(3).toLowerCase().charCodeAt(0)}if(i.key.length===1){let o=i.key.codePointAt(0);return o>=65&&o<=90?o+32:o}}_isModifierKey(i){return i.key==="Shift"||i.key==="Control"||i.key==="Alt"||i.key==="Meta"}_isLockKey(i){return i.key==="CapsLock"||i.key==="NumLock"||i.key==="ScrollLock"}_buildCsiLetterSequence(i,e,t,r){let s=r&&t!==1;if(e>0||s){let o="\x1B[1;"+(e>0?e:"1");return s&&(o+=":"+t),o+=i,o}return"\x1B["+i}_buildSs3Sequence(i,e,t,r){let s=r&&t!==1;if(e>0||s){let o="\x1B[1;"+(e>0?e:"1");return s&&(o+=":"+t),o+=i,o}return"\x1BO"+i}_buildCsiTildeSequence(i,e,t,r){let s=r&&t!==1,o="\x1B["+i;return(e>0||s)&&(o+=";"+(e>0?e:"1"),s&&(o+=":"+t)),o+="~",o}_buildCsiUSequence(i,e,t,r,s,o,a){let l=!!(s&2),h=!!(s&4),d="\x1B["+e,c;h&&i.shiftKey&&i.key.length===1&&!o&&!a&&(c=i.key.codePointAt(0),d+=":"+c);let _=!!(s&16)&&r!==3&&i.key.length===1&&!o&&!a&&!i.ctrlKey?i.key.codePointAt(0):void 0,p=l&&r!==1&&(r===3||_===void 0);return(t>0||p||_!==void 0)&&(d+=";",t>0?d+=t:p&&(d+="1"),p&&(d+=":"+r)),_!==void 0&&(d+=";"+_),d+="u",d}evaluate(i,e,t=1,r=!1){let s={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(i),a=this._isModifierKey(i),l=!!(e&2);if(!l&&t===3||a&&!(e&8)||this._isLockKey(i)&&!(e&8))return s;let h=this._csiLetterKeys[i.key];if(h)return s.key=this._buildCsiLetterSequence(h,o,t,l),s.cancel=!0,s;let d=this._ss3FunctionKeys[i.key];if(d)return s.key=this._buildSs3Sequence(d,o,t,l),s.cancel=!0,s;let c=this._csiTildeKeys[i.key];if(c!==void 0)return s.key=this._buildCsiTildeSequence(c,o,t,l),s.cancel=!0,s;let u=this._getKeyCode(i,r);if(u===void 0)return s;let _=u===13||u===9||u===127;if(_&&t===3&&!(e&8))return s;let p=this._functionalKeyCodes[i.key]!==void 0||this._getNumpadKeyCode(i)!==void 0;if(!!(e&8||l&&t===3||(e&1||l)&&(p&&!_||o>0&&i.key.length!==1||o-1>1)))s.key=this._buildCsiUSequence(i,u,o,t,e,p,a),s.cancel=!0;else{let f=u===13?"\r":u===9?" ":u===127?"\x7F":void 0;f?s.key=f:i.key.length===1&&!i.ctrlKey&&!i.altKey&&!i.metaKey&&(s.key=i.key)}return s}static shouldUseProtocol(i){return i>0}};var Zi=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226};this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40};this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]);this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(i){let e=this._codeToVk[i.code];return e!==void 0?e:i.keyCode||0}_getScanCode(i){return this._codeToScancode[i.code]||0}_getUnicodeChar(i){if(i.ctrlKey&&!i.altKey&&!i.metaKey){if(i.key==="Enter")return 10;if(i.key==="Backspace")return 127}let e=this._keyToControlChar[i.key];if(e!==void 0)return e;if(i.key.length===1){let t=i.key.codePointAt(0)||0;if(i.ctrlKey&&!i.altKey&&!i.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(i){let e=0;return i.shiftKey&&(e|=16),i.ctrlKey&&(i.code==="ControlRight"?e|=4:e|=8),i.altKey&&(i.code==="AltRight"?e|=1:e|=2),this._enhancedKeyCodes.has(i.code)&&(e|=256),e}evaluateKeyboardEvent(i,e){let t=this._getVirtualKeyCode(i),r=this._getScanCode(i),s=this._getUnicodeChar(i),o=e?1:0,a=this._getControlKeyState(i);return{type:0,cancel:!0,key:`\x1B[${t};${r};${s};${o};${a};1_`}}};var xt=class{constructor(i,e){this._coreService=i;this._optionsService=e}_getWin32InputMode(){return this._win32InputMode??=new Zi,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new ei,this._kittyKeyboard}evaluateKeyDown(i){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(i,!0);let e=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(i,e,i.repeat?2:1,ie&&this._optionsService.rawOptions.macOptionIsMeta):ln(i,this._coreService.decPrivateModes.applicationCursorKeys,ie,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(i){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(i,!1);let e=this._coreService.kittyKeyboard.flags;if(this.useKitty&&e&2)return this._getKittyKeyboard().evaluate(i,e,3,ie&&this._optionsService.rawOptions.macOptionIsMeta)}get useKitty(){let i=this._coreService.kittyKeyboard.flags;return!!(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard&&ei.shouldUseProtocol(i))}get useWin32InputMode(){return!!(this._optionsService.rawOptions.vtExtensions?.win32InputMode&&this._coreService.decPrivateModes.win32InputMode)}};xt=y([m(0,Y),m(1,R)],xt);var ps=class{constructor(...i){this._entries=new Map;for(let[e,t]of i)this.set(e,t)}set(i,e){let t=this._entries.get(i);return this._entries.set(i,e),t}forEach(i){for(let[e,t]of this._entries.entries())i(e,t)}has(i){return this._entries.has(i)}get(i){return this._entries.get(i)}},Ji=class{constructor(){this._services=new ps;this._services.set(Qe,this)}setService(i,e){this._services.set(i,e)}getService(i){return this._services.get(i)}createInstance(i,...e){let t=Hs(i).sort((o,a)=>o.index-a.index),r=[];for(let o of t){let a=this._services.get(o.id);if(!a)throw new Error(`[createInstance] ${i.name} depends on UNKNOWN service ${o.id._id}.`);r.push(a)}let s=t.length>0?t[0].index:e.length;if(e.length!==s)throw new Error(`[createInstance] First service dependency of ${i.name} at position ${s+1} conflicts with ${e.length} static arguments`);return new i(...e,...r)}};var uo={trace:0,debug:1,info:2,warn:3,error:4,off:5},fo="xterm.js: ",wt=class extends g{constructor(e){super();this._optionsService=e;this._logLevel=5;this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}get logLevel(){return this._logLevel}_updateLogLevel(){this._logLevel=uo[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tthis._length)for(let t=this._length;t=e;s--)this._array[this._getCyclicIndex(s+r.length)]=this._array[this._getCyclicIndex(s)];for(let s=0;sthis._maxLength){let s=this._length+r.length-this._maxLength;this._startIndex+=s,this._length=this._maxLength,this.onTrimEmitter.fire(s)}else this._length+=r.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,r){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+r<0)throw new Error("Cannot shift elements in list beyond index 0");if(r>0){for(let o=t-1;o>=0;o--)this.set(e+o+r,this.get(e+o));let s=e+t+r-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;sthis._limit?(this._builder.reset(),!0):!1}toString(){return this._builder.toString()}};var U=Object.freeze(new ue),Qi=0,hn=new F,er=new ii,De=class n{constructor(i,e,t,r=!1){this._stringCache=i;this.isWrapped=r;this._combined={};this._extendedAttrs={};this._data=new Uint32Array(e*3);let s=t??F.fromCharData([0,"",1,0]);for(let o=0;o>22,e&2097152?this._combined[i].charCodeAt(this._combined[i].length-1):t]}set(i,e){this._invalidateStringCache(),this._data[i*3+1]=e[0],e[1].length>1?(this._combined[i]=e[1],this._data[i*3+0]=i|2097152|e[2]<<22):this._data[i*3+0]=e[1].charCodeAt(0)|e[2]<<22}getWidth(i){return this._data[i*3+0]>>22}hasWidth(i){return this._data[i*3+0]&12582912}getFg(i){return this._data[i*3+1]}getBg(i){return this._data[i*3+2]}hasContent(i){return this._data[i*3+0]&4194303}getCodePoint(i){let e=this._data[i*3+0];return e&2097152?this._combined[i].charCodeAt(this._combined[i].length-1):e&2097151}isCombined(i){return this._data[i*3+0]&2097152}getString(i){let e=this._data[i*3+0];return e&2097152?this._combined[i]:e&2097151?be(e&2097151):""}isProtected(i){return this._data[i*3+2]&536870912}loadCell(i,e){return Qi=i*3,e.content=this._data[Qi+0],e.fg=this._data[Qi+1],e.bg=this._data[Qi+2],e.content&2097152?e.combinedData=this._combined[i]:e.combinedData="",e.bg&268435456?e.extended=this._extendedAttrs[i]:e.extended=U.extended.clone(),e}setCell(i,e){this._invalidateStringCache(),e.content&2097152&&(this._combined[i]=e.combinedData),e.bg&268435456&&(this._extendedAttrs[i]=e.extended),this._data[i*3+0]=e.content,this._data[i*3+1]=e.fg,this._data[i*3+2]=e.bg}setCellFromCodepoint(i,e,t,r){this._invalidateStringCache(),r.bg&268435456&&(this._extendedAttrs[i]=r.extended),this._data[i*3+0]=e|t<<22,this._data[i*3+1]=r.fg,this._data[i*3+2]=r.bg}addCodepointToCell(i,e,t){this._invalidateStringCache();let r=this._data[i*3+0];r&2097152?this._combined[i]+=be(e):r&2097151?(this._combined[i]=be(r&2097151)+be(e),r&=-2097152,r|=2097152):r=e|1<<22,t&&(r&=-12582913,r|=t<<22),this._data[i*3+0]=r}insertCells(i,e,t){if(this._invalidateStringCache(),i%=this.length,i&&this.getWidth(i-1)===2&&this.setCellFromCodepoint(i-1,0,1,t),e=0;--r)this.setCell(i+e+r,this.loadCell(i+r,hn));for(let r=0;rthis.length){if(this._data.buffer.byteLength>=t*4)this._data=new Uint32Array(this._data.buffer,0,t);else{let r=new Uint32Array(t);r.set(this._data),this._data=r}for(let r=this.length;r=i&&delete this._combined[a]}let s=Object.keys(this._extendedAttrs);for(let o=0;o=i&&delete this._extendedAttrs[a]}}return this.length=i,t*4*2=0;--i)if(this._data[i*3+0]&4194303)return i+(this._data[i*3+0]>>22);return 0}getNoBgTrimmedLength(){for(let i=this.length-1;i>=0;--i)if(this._data[i*3+0]&4194303||this._data[i*3+2]&50331648)return i+(this._data[i*3+0]>>22);return 0}copyCellsFrom(i,e,t,r,s){this._invalidateStringCache();let o=i._data;if(s)for(let a=r-1;a>=0;a--){for(let l=0;l<3;l++)this._data[(t+a)*3+l]=o[(e+a)*3+l];this._copyCellMapsFrom(i,e+a,t+a)}else for(let a=0;a>22||1}r&&r.push(e);let a=er.toString();if(er.reset(),s){let l=this._getStringCacheEntry(!0);l.value=a,l.isTrimmed=!!i}return a}_getStringCacheEntry(i){let e=this._stringCacheEntryRef?.deref();if(e&&e.generation===this._stringCache.generation)return e;if(!i)return;let t=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(t),t}_invalidateStringCache(){let i=this._getStringCacheEntry(!1);i&&(i.value=void 0,i.isTrimmed=!1)}_copyCellMapsFrom(i,e,t){let r=e*3;i._data[r+0]&2097152&&(this._combined[t]=i._combined[e]),i._data[r+2]&268435456&&(this._extendedAttrs[t]=i._extendedAttrs[e])}_copySparseMapsFrom(i){this._combined={},this._extendedAttrs={};for(let e=0;ethis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){let e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(let e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),!this._clearTimeout.value&&this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=Gs(()=>{let t=Date.now()-this._lastAccessTimestamp;if(t>=15e3){this.clear();return}this._scheduleClearTimeout(15e3-t)},e)}};function dn(n,i,e,t,r,s){let o=[];for(let a=0;a=a&&t0&&(f>c||d[f].getTrimmedLength()===0);f--)v++;v>0&&(o.push(a+d.length-v),o.push(v)),a+=d.length-1}return o}function un(n,i){let e=[],t=0,r=i[t],s=0;for(let o=0;ol&&(s-=l,o++);let h=n[o].getWidth(s-1)===2;h&&s--;let d=h?e-1:e;t.push(d),a+=d}return t}function Tt(n,i,e){if(i===n.length-1)return n[i].getTrimmedLength();let t=!n[i].hasContent(e-1)&&n[i].getWidth(e-1)===1,r=n[i+1].getWidth(0)===2;return t&&r?e-1:e}var rr=class rr{constructor(i){this.line=i;this.isDisposed=!1;this._disposables=[];this._id=rr._nextId++;this._onDispose=this.register(new b);this.onDispose=this._onDispose.event}get id(){return this._id}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),Oe(this._disposables),this._disposables.length=0)}register(i){return this._disposables.push(i),i}};rr._nextId=1;var ir=rr;var q={},Re=q.B;q[0]={"`":"\u25C6",a:"\u2592",b:"\u2409",c:"\u240C",d:"\u240D",e:"\u240A",f:"\xB0",g:"\xB1",h:"\u2424",i:"\u240B",j:"\u2518",k:"\u2510",l:"\u250C",m:"\u2514",n:"\u253C",o:"\u23BA",p:"\u23BB",q:"\u2500",r:"\u23BC",s:"\u23BD",t:"\u251C",u:"\u2524",v:"\u2534",w:"\u252C",x:"\u2502",y:"\u2264",z:"\u2265","{":"\u03C0","|":"\u2260","}":"\xA3","~":"\xB7"};q.A={"#":"\xA3"};q.B=void 0;q[4]={"#":"\xA3","@":"\xBE","[":"ij","\\":"\xBD","]":"|","{":"\xA8","|":"f","}":"\xBC","~":"\xB4"};q.C=q[5]={"[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};q.R={"#":"\xA3","@":"\xE0","[":"\xB0","\\":"\xE7","]":"\xA7","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xA8"};q.Q={"@":"\xE0","[":"\xE2","\\":"\xE7","]":"\xEA","^":"\xEE","`":"\xF4","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xFB"};q.K={"@":"\xA7","[":"\xC4","\\":"\xD6","]":"\xDC","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xDF"};q.Y={"#":"\xA3","@":"\xA7","[":"\xB0","\\":"\xE7","]":"\xE9","`":"\xF9","{":"\xE0","|":"\xF2","}":"\xE8","~":"\xEC"};q.E=q[6]={"@":"\xC4","[":"\xC6","\\":"\xD8","]":"\xC5","^":"\xDC","`":"\xE4","{":"\xE6","|":"\xF8","}":"\xE5","~":"\xFC"};q.Z={"#":"\xA3","@":"\xA7","[":"\xA1","\\":"\xD1","]":"\xBF","{":"\xB0","|":"\xF1","}":"\xE7"};q.H=q[7]={"@":"\xC9","[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};q["="]={"#":"\xF9","@":"\xE0","[":"\xE9","\\":"\xE7","]":"\xEA","^":"\xEE",_:"\xE8","`":"\xF4","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xFB"};var pn=4294967295,si=class extends g{constructor(e,t,r,s){super();this._hasScrollback=e;this._optionsService=t;this._bufferService=r;this._logService=s;this.ydisp=0;this.ybase=0;this.y=0;this.x=0;this.tabs={};this.savedY=0;this.savedX=0;this.savedCurAttrData=U.clone();this.savedCharset=Re;this.savedCharsets=[];this.savedGlevel=0;this.savedOriginMode=!1;this.savedWraparoundMode=!0;this.markers=[];this._nullCell=F.fromCharData([0,"",1,0]);this._whitespaceCell=F.fromCharData([0," ",1,32]);this._isClearing=!1;this._memoryCleanupPosition=0;this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new ti(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new It(this._logService),this._register(E(()=>this._memoryCleanupQueue.clear())),this._register(E(()=>this.clearAllMarkers())),this._stringCache=this._register(new tr)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new ke),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new ke),this._whitespaceCell}getBlankLine(e,t){return new De(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){let t=this.ybase+this.y-this.ydisp;return t>=0&&tpn?pn:t}fillViewportRows(e){if(this.lines.length===0){e??=U;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new ti(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){let r=this.getNullCell(U);this._stringCache.clear();let s=0,o=this._getCorrectBufferLength(t);if(o>this.lines.maxLength&&(this.lines.maxLength=o),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+a+1?(this.ybase--,a++,this.ydisp>0&&this.ydisp--):this.lines.push(new De(this._stringCache,e,r,!1)));else for(let l=this._rows;l>t;l--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(o0&&(this.lines.trimStart(l),this.ybase=Math.max(this.ybase-l,0),this.ydisp=Math.max(this.ydisp-l,0),this.savedY=Math.max(this.savedY-l,0)),this.lines.maxLength=o}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),a&&(this.y+=a),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let a=0;a0){let a=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,a)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){let e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&e.backend==="conpty"&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){let r=this._optionsService.rawOptions.reflowCursorLine,s=dn(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(U),r);if(s.length>0){let o=un(this.lines,s);fn(this.lines,o.layout),this._reflowLargerAdjustViewport(e,t,o.countRemoved)}}_reflowLargerAdjustViewport(e,t,r){let s=this.getNullCell(U),o=r;for(;o-- >0;)this.ybase===0?(this.y>0&&this.y--,this.lines.length=0;l--){let h=this.lines.get(l);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;let d=[h];for(;h.isWrapped&&l>0;)h=this.lines.get(--l),d.unshift(h);if(!r){let T=this.ybase+this.y;if(T>=l&&T0&&(o.push({start:l+d.length+a,newLines:v}),a+=v.length),d.push(...v);let f=u.length-1,S=u[f];S===0&&(f--,S=u[f]);let I=d.length-_-1,w=c;for(;I>=0;){let T=Math.min(w,S);if(d[f]===void 0)break;if(d[f].copyCellsFrom(d[I],w-T,S-T,T,!0),S-=T,S===0&&(f--,S=u[f]),w-=T,w===0){I--;let te=Math.max(I,0);w=Tt(d,te,this._cols)}}for(let T=0;T0;)this.ybase===0?this.y0){let l=[],h=[];for(let S=0;S=0;S--)if(_&&_.start>c+p){for(let I=_.newLines.length-1;I>=0;I--)this.lines.set(S--,_.newLines[I]);S++,l.push({index:c+1,amount:_.newLines.length}),p+=_.newLines.length,_=o[++u]}else this.lines.set(S,h[c--]);let v=0;for(let S=l.length-1;S>=0;S--)l[S].index+=v,this.lines.onInsertEmitter.fire(l[S]),v+=l[S].amount;let f=Math.max(0,d+a-this.lines.maxLength);f>0&&this.lines.onTrimEmitter.fire(f)}}translateBufferLineToString(e,t,r=0,s){let o=this.lines.get(e);return o?o.translateToString(t,r,s):""}getWrappedRangeForLine(e){let t=e,r=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;r+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=r,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(r=>{t.line>=r.index&&(t.line+=r.amount)})),t.register(this.lines.onDelete(r=>{t.line>=r.index&&t.liner.index&&(t.line-=r.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}};var sr=class extends g{constructor(e,t,r){super();this._optionsService=e;this._bufferService=t;this._logService=r;this._normalBuffer=this._register(new P);this._altBuffer=this._register(new P);this._onBufferActivate=this._register(new b);this.onBufferActivate=this._onBufferActivate.event;this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new si(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new si(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}};var Dt=class extends g{constructor(e,t){super();this.isUserScrolling=!1;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new sr(e,this,t)),this._register(this.buffers.onBufferActivate(r=>{this._onScroll.fire(r.activeBuffer.ydisp)}))}get buffer(){return this.buffers.active}resize(e,t){let r=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:r,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){let r=this.buffer,s;s=this._cachedBlankLine,(!s||s.length!==this.cols||s.getFg(0)!==e.fg||s.getBg(0)!==e.bg)&&(s=r.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;let o=r.ybase+r.scrollTop,a=r.ybase+r.scrollBottom;if(r.scrollTop===0){let l=r.lines.isFull;a===r.lines.length-1?l?r.lines.recycle().copyFrom(s):r.lines.push(s.clone()):r.lines.splice(a+1,0,s.clone()),l?this.isUserScrolling&&(r.ydisp=Math.max(r.ydisp-1,0)):(r.ybase++,this.isUserScrolling||r.ydisp++)}else{let l=a-o+1;r.lines.shiftElements(o+1,l-1,-1),r.lines.set(a,s.clone())}this.isUserScrolling||(r.ydisp=r.ybase),this._onScroll.fire(r.ydisp)}scrollLines(e,t){let r=this.buffer;if(e<0){if(r.ydisp===0)return;this.isUserScrolling=!0}else e+r.ydisp>=r.ybase&&(this.isUserScrolling=!1);let s=r.ydisp;r.ydisp=Math.max(Math.min(r.ydisp+e,r.ybase),0),s!==r.ydisp&&(t||this._onScroll.fire(r.ydisp))}};Dt=y([m(0,R),m(1,fe)],Dt);var Rt={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:ie,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}},po=["normal","bold","100","200","300","400","500","600","700","800","900"],nr=class extends g{constructor(e){super();this._onOptionChange=this._register(new b);this.onOptionChange=this._onOptionChange.event;let t={...Rt};for(let r in e)if(r in t)try{let s=e[r];t[r]=this._sanitizeAndValidateOption(r,s)}catch(s){console.error(s)}this.rawOptions=t,this.options={...t},this._setupOptions(),this._register(E(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(r=>{r===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(r=>{e.indexOf(r)!==-1&&t()})}_setupOptions(){let e=r=>{if(!(r in Rt))throw new Error(`No option with key "${r}"`);return this.rawOptions[r]},t=(r,s)=>{if(!(r in Rt))throw new Error(`No option with key "${r}"`);s=this._sanitizeAndValidateOption(r,s),this.rawOptions[r]!==s&&(this.rawOptions[r]=s,this._onOptionChange.fire(r))};for(let r in this.rawOptions){let s={get:e.bind(this,r),set:t.bind(this,r)};Object.defineProperty(this.options,r,s)}}_sanitizeAndValidateOption(e,t){switch(e){case"cursorStyle":if(t||(t=Rt[e]),!mo(t))throw new Error(`"${t}" is not a valid value for ${e}`);break;case"wordSeparator":t||(t=Rt[e]);break;case"fontWeight":case"fontWeightBold":if(typeof t=="number"&&1<=t&&t<=1e3)break;t=po.includes(t)?t:Rt[e];break;case"blinkIntervalDuration":if(t=Math.floor(t),t<0)throw new Error(`${e} cannot be less than 0, value: ${t}`);break;case"cursorWidth":t=Math.floor(t);case"lineHeight":case"tabStopWidth":if(t<1)throw new Error(`${e} cannot be less than 1, value: ${t}`);break;case"minimumContrastRatio":t=Math.max(1,Math.min(21,Math.round(t*10)/10));break;case"scrollback":if(t=Math.min(t,4294967295),t<0)throw new Error(`${e} cannot be less than 0, value: ${t}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(t<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${t}`);break;case"rows":case"cols":if(!t&&t!==0)throw new Error(`${e} must be numeric, value: ${t}`);break;case"windowsPty":t=t??{};break}return t}};function mo(n){return n==="block"||n==="underline"||n==="bar"}var mn=Object.freeze({insertMode:!1}),bn=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0}),vn=()=>({flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}),Lt=class extends g{constructor(e,t,r){super();this._bufferService=e;this._logService=t;this._optionsService=r;this.isCursorHidden=!1;this._onData=this._register(new b);this.onData=this._onData.event;this._onUserInput=this._register(new b);this.onUserInput=this._onUserInput.event;this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onRequestScrollToBottom=this._register(new b);this.onRequestScrollToBottom=this._onRequestScrollToBottom.event;this.isCursorInitialized=r.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(mn),this.decPrivateModes=structuredClone(bn),this.kittyKeyboard=vn()}reset(){this.modes=structuredClone(mn),this.decPrivateModes=structuredClone(bn),this.kittyKeyboard=vn()}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;let r=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&r.ybase!==r.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(s=>s.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(t=>t.charCodeAt(0))),this._onBinary.fire(e))}};Lt=y([m(0,D),m(1,fe),m(2,R)],Lt);var Sn={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:n=>n.button===4||n.action!==1?!1:(n.ctrl=!1,n.alt=!1,n.shift=!1,!0)},VT200:{events:19,restrict:n=>n.action!==32},DRAG:{events:23,restrict:n=>!(n.action===32&&n.button===3)},ANY:{events:31,restrict:n=>!0}};function vs(n,i){let e=(n.ctrl?16:0)|(n.shift?4:0)|(n.alt?8:0);return n.button===4?(e|=64,e|=n.action):(e|=n.button&3,n.button&4&&(e|=64),n.button&8&&(e|=128),n.action===32?e|=32:n.action===0&&!i&&(e|=3)),e}var Ss=String.fromCharCode,gn={DEFAULT:n=>{let i=[vs(n,!1)+32,n.col+32,n.row+32];return i[0]>255||i[1]>255||i[2]>255?"":`\x1B[M${Ss(i[0])}${Ss(i[1])}${Ss(i[2])}`},SGR:n=>{let i=n.action===0&&n.button!==4?"m":"M";return`\x1B[<${vs(n,!0)};${n.col};${n.row}${i}`},SGR_PIXELS:n=>{let i=n.action===0&&n.button!==4?"m":"M";return`\x1B[<${vs(n,!0)};${n.x};${n.y}${i}`}},or=class extends g{constructor(){super();this._protocols={};this._encodings={};this._activeProtocol="";this._activeEncoding="";this._onProtocolChange=this._register(new b);this.onProtocolChange=this._onProtocolChange.event;for(let e of Object.keys(Sn))this.addProtocol(e,Sn[e]);for(let e of Object.keys(gn))this.addEncoding(e,gn[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return this._protocols[this._activeProtocol].events!==0}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return this._customWheelEventHandler?this._customWheelEventHandler(e)!==!1:!0}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return this._activeEncoding==="DEFAULT"}get isPixelEncoding(){return this._activeEncoding==="SGR_PIXELS"}};var me=class n{constructor(){this._providers=Object.create(null);this._active="";this._onChange=new b;this.onChange=this._onChange.event}static extractShouldJoin(i){return(i&1)!==0}static extractWidth(i){return i>>1&3}static extractCharKind(i){return i>>3}static createPropertyValue(i,e,t=!1){return(i&16777215)<<3|(e&3)<<1|(t?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(i){if(!this._providers[i])throw new Error(`unknown Unicode version "${i}"`);this._active=i,this._activeProvider=this._providers[i],this._onChange.fire(i)}register(i){this._providers[i.version]=i,this._active||(this.activeVersion=i.version)}wcwidth(i){return this._activeProvider.wcwidth(i)}getStringCellWidth(i){let e=0,t=0,r=i.length;for(let s=0;s=r)return e+this.wcwidth(o);let h=i.charCodeAt(s);56320<=h&&h<=57343?o=(o-55296)*1024+h-56320+65536:e+=this.wcwidth(h)}let a=this.charProperties(o,t),l=n.extractWidth(a);n.extractShouldJoin(a)&&(l-=n.extractWidth(t)),e+=l,t=a}return e}charProperties(i,e){return this._activeProvider.charProperties(i,e)}};var gs=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],bo=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]],X;function vo(n,i){let e=0,t=i.length-1,r;if(ni[t][1])return!1;for(;t>=e;)if(r=e+t>>1,n>i[r][1])e=r+1;else if(n=131072&&i<=196605||i>=196608&&i<=262141?2:1}charProperties(i,e){let t=this.wcwidth(i),r=t===0&&e!==0;if(r){let s=me.extractWidth(e);s===0?r=!1:s>t&&(t=s)}return me.createPropertyValue(0,t,r)}};var lr=class{constructor(){this.glevel=0;this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(i){this.glevel=i,this.charset=this._charsets[i]}setgCharset(i,e){this._charsets[i]=e,this.glevel===i&&(this.charset=e)}};function Is(n){let e=n.buffer.lines.get(n.buffer.ybase+n.buffer.y-1)?.get(n.cols-1),t=n.buffer.lines.get(n.buffer.ybase+n.buffer.y);t&&e&&(t.isWrapped=e[3]!==0&&e[3]!==32)}var At=class n{constructor(i=32,e=32){this.maxLength=i;this.maxSubParamsLength=e;if(e>256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(i),this.length=0,this._subParams=new Int32Array(e),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(i),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}static fromArray(i){let e=new n;if(!i.length)return e;for(let t=Array.isArray(i[0])?1:0;t>8,r=this._subParamsIdx[e]&255;r-t>0&&i.push(Array.prototype.slice.call(this._subParams,t,r))}return i}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(i){if(this._digitIsSub=!1,this.length>=this.maxLength){this._rejectDigits=!0;return}if(i<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=i>2147483647?2147483647:i}addSubParam(i){if(this._digitIsSub=!0,!!this.length){if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength){this._rejectSubDigits=!0;return}if(i<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=i>2147483647?2147483647:i,this._subParamsIdx[this.length-1]++}}hasSubParams(i){return(this._subParamsIdx[i]&255)-(this._subParamsIdx[i]>>8)>0}getSubParams(i){let e=this._subParamsIdx[i]>>8,t=this._subParamsIdx[i]&255;return t-e>0?this._subParams.subarray(e,t):null}getSubParamsAll(){let i={};for(let e=0;e>8,r=this._subParamsIdx[e]&255;r-t>0&&(i[e]=this._subParams.slice(t,r))}return i}addDigit(i){let e;if(this._rejectDigits||!(e=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;let t=this._digitIsSub?this._subParams:this.params,r=t[e-1];t[e-1]=~r?Math.min(r*10+i,2147483647):i}};var ni=[],cr=class{constructor(){this._state=0;this._active=ni;this._id=-1;this._handlers=Object.create(null);this._handlerFb=()=>{};this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(i,e){this._handlers[i]??=[];let t=this._handlers[i];return t.push(e),{dispose:()=>{let r=t.indexOf(e);r!==-1&&t.splice(r,1)}}}clearHandler(i){this._handlers[i]&&delete this._handlers[i]}setHandlerFallback(i){this._handlerFb=i}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=ni}reset(){if(this._state===2)for(let i=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;i>=0;--i)this._active[i].end(!1);this._stack.paused=!1,this._active=ni,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||ni,!this._active.length)this._handlerFb(this._id,"START");else for(let i=this._active.length-1;i>=0;i--)this._active[i].start()}_put(i,e,t){if(!this._active.length)this._handlerFb(this._id,"PUT",ye(i,e,t));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(i,e,t)}start(){this.reset(),this._state=1}put(i,e,t){if(this._state!==3){if(this._state===1)for(;e0&&this._put(i,e,t)}}end(i,e=!0){if(this._state!==0){if(this._state!==3)if(this._state===1&&this._start(),!this._active.length)this._handlerFb(this._id,"END",i);else{let t=!1,r=this._active.length-1,s=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,t=e,s=this._stack.fallThrough,this._stack.paused=!1),!s&&t===!1){for(;r>=0&&(t=this._active[r].end(i),t!==!0);r--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,t;r--}for(;r>=0;r--)if(t=this._active[r].end(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,t}this._active=ni,this._id=-1,this._state=0}}},hr=class hr{constructor(i){this._handler=i;this._data=new We(hr._payloadLimit);this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(i,e,t){this._hitLimit||this._data.append(ye(i,e,t))&&(this._hitLimit=!0)}end(i){let e=!1;if(this._hitLimit)e=!1;else if(i&&(e=this._handler(this._data.toString()),e instanceof Promise))return e.then(t=>(this._data.reset(),this._hitLimit=!1,t));return this._data.reset(),this._hitLimit=!1,e}};hr._payloadLimit=1e7;var ne=hr;var oi=[],dr=class{constructor(){this._handlers=Object.create(null);this._active=oi;this._ident=0;this._handlerFb=()=>{};this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=oi}registerHandler(i,e){this._handlers[i]??=[];let t=this._handlers[i];return t.push(e),{dispose:()=>{let r=t.indexOf(e);r!==-1&&t.splice(r,1)}}}clearHandler(i){this._handlers[i]&&delete this._handlers[i]}setHandlerFallback(i){this._handlerFb=i}reset(){if(this._active.length)for(let i=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;i>=0;--i)this._active[i].unhook(!1);this._stack.paused=!1,this._active=oi,this._ident=0}hook(i,e){if(this.reset(),this._ident=i,this._active=this._handlers[i]||oi,!this._active.length)this._handlerFb(this._ident,"HOOK",e);else for(let t=this._active.length-1;t>=0;t--)this._active[t].hook(e)}put(i,e,t){if(!this._active.length)this._handlerFb(this._ident,"PUT",ye(i,e,t));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(i,e,t)}unhook(i,e=!0){if(!this._active.length)this._handlerFb(this._ident,"UNHOOK",i);else{let t=!1,r=this._active.length-1,s=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,t=e,s=this._stack.fallThrough,this._stack.paused=!1),!s&&t===!1){for(;r>=0&&(t=this._active[r].unhook(i),t!==!0);r--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,t;r--}for(;r>=0;r--)if(t=this._active[r].unhook(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,t}this._active=oi,this._ident=0}},ai=new At;ai.addParam(0);var ur=class ur{constructor(i){this._handler=i;this._data=new We(ur._payloadLimit);this._params=ai;this._hitLimit=!1}hook(i){this._params=i.length>1||i.params[0]?i.clone():ai,this._data.reset(),this._hitLimit=!1}put(i,e,t){this._hitLimit||this._data.append(ye(i,e,t))&&(this._hitLimit=!0)}unhook(i){let e=!1;if(this._hitLimit)e=!1;else if(i&&(e=this._handler(this._data.toString(),this._params),e instanceof Promise))return e.then(t=>(this._params=ai,this._data.reset(),this._hitLimit=!1,t));return this._params=ai,this._data.reset(),this._hitLimit=!1,e}};ur._payloadLimit=1e7;var li=ur;var ci=[],fr=class{constructor(){this._handlers=Object.create(null);this._active=ci;this._ident=0;this._handlerFb=()=>{};this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(i,e){this._handlers[i]??=[];let t=this._handlers[i];return t.push(e),{dispose:()=>{let r=t.indexOf(e);r!==-1&&t.splice(r,1)}}}clearHandler(i){this._handlers[i]&&delete this._handlers[i]}setHandlerFallback(i){this._handlerFb=i}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=ci}reset(){if(this._active.length)for(let i=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;i>=0;--i)this._active[i].end(!1);this._stack.paused=!1,this._active=ci,this._ident=0}start(i){if(this.reset(),this._ident=i,this._active=this._handlers[i]||ci,!this._active.length)this._handlerFb(this._ident,"START");else for(let e=this._active.length-1;e>=0;e--)this._active[e].start()}put(i,e,t){if(!this._active.length)this._handlerFb(this._ident,"PUT",ye(i,e,t));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(i,e,t)}end(i,e=!0){if(!this._active.length)this._handlerFb(this._ident,"END",i);else{let t=!1,r=this._active.length-1,s=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,t=e,s=this._stack.fallThrough,this._stack.paused=!1),!s&&t===!1){for(;r>=0&&(t=this._active[r].end(i),t!==!0);r--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,t;r--}for(;r>=0;r--)if(t=this._active[r].end(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,t}this._active=ci,this._ident=0}},pr=class pr{constructor(i){this._handler=i;this._data=new We(pr._payloadLimit);this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(i,e,t){this._hitLimit||this._data.append(ye(i,e,t))&&(this._hitLimit=!0)}end(i){let e=!1;if(this._hitLimit)e=!1;else if(i&&(e=this._handler(this._data.toString()),e instanceof Promise))return e.then(t=>(this._data.reset(),this._hitLimit=!1,t));return this._data.reset(),this._hitLimit=!1,e}};pr._payloadLimit=1e7;var _r=pr;var Cs=class{constructor(i){this.table=new Uint16Array(i)}setDefault(i,e){this.table.fill(i<<8|e)}add(i,e,t,r){this.table[e<<8|i]=t<<8|r}addMany(i,e,t,r){for(let s=0;sl),t=(a,l)=>e.slice(a,l),r=t(32,127),s=t(0,24);s.push(25),s.push.apply(s,t(28,32));let o=t(0,17);n.setDefault(1,0),n.addMany(r,0,2,0);for(let a of o)n.addMany([24,26,153,154],a,3,0),n.addMany(t(128,144),a,3,0),n.addMany(t(144,152),a,3,0),n.add(156,a,0,0),n.add(27,a,11,1),n.add(157,a,4,8),n.addMany([152,158],a,0,7),n.add(159,a,11,14),n.add(155,a,11,3),n.add(144,a,11,9);return n.addMany(s,0,3,0),n.addMany(s,1,3,1),n.add(127,1,0,1),n.addMany(s,8,0,8),n.addMany(s,3,3,3),n.add(127,3,0,3),n.addMany(s,4,3,4),n.add(127,4,0,4),n.addMany(s,6,3,6),n.addMany(s,5,3,5),n.add(127,5,0,5),n.addMany(s,2,3,2),n.add(127,2,0,2),n.add(93,1,4,8),n.addMany(r,8,5,8),n.add(127,8,5,8),n.addMany([156,27,24,26,7],8,6,0),n.addMany(t(28,32),8,0,8),n.addMany([88,94],1,0,7),n.addMany(r,7,0,7),n.addMany(s,7,0,7),n.add(156,7,0,0),n.add(127,7,0,7),n.add(95,1,11,14),n.addMany(s,14,0,14),n.add(127,14,0,14),n.addMany(t(32,48),14,9,15),n.addMany(t(48,127),14,15,16),n.addMany(t(48,127),15,15,16),n.addMany(s,15,0,15),n.addMany(t(32,48),15,9,15),n.add(127,15,0,15),n.addMany(r,16,16,16),n.addMany(s,16,0,16),n.addMany(t(8,14),16,16,16),n.add(127,16,0,16),n.addMany([27,156,24,26],16,17,0),n.add(91,1,11,3),n.addMany(t(64,127),3,7,0),n.addMany(t(48,60),3,8,4),n.addMany([60,61,62,63],3,9,4),n.addMany(t(48,60),4,8,4),n.addMany(t(64,127),4,7,0),n.addMany([60,61,62,63],4,0,6),n.addMany(t(32,64),6,0,6),n.add(127,6,0,6),n.addMany(t(64,127),6,0,0),n.addMany(t(32,48),3,9,5),n.addMany(t(32,48),5,9,5),n.addMany(t(48,64),5,0,6),n.addMany(t(64,127),5,7,0),n.addMany(t(32,48),4,9,5),n.addMany(t(32,48),1,9,2),n.addMany(t(32,48),2,9,2),n.addMany(t(48,127),2,10,0),n.addMany(t(48,80),1,10,0),n.addMany(t(81,88),1,10,0),n.addMany([89,90,92],1,10,0),n.addMany(t(96,127),1,10,0),n.add(80,1,11,9),n.addMany(s,9,0,9),n.add(127,9,0,9),n.addMany(t(32,48),9,9,12),n.addMany(t(48,60),9,8,10),n.addMany([60,61,62,63],9,9,10),n.addMany(s,11,0,11),n.addMany(t(32,128),11,0,11),n.addMany(s,10,0,10),n.add(127,10,0,10),n.addMany(t(48,60),10,8,10),n.addMany([60,61,62,63],10,0,11),n.addMany(t(32,48),10,9,12),n.addMany(s,12,0,12),n.add(127,12,0,12),n.addMany(t(32,48),12,9,12),n.addMany(t(48,64),12,0,11),n.addMany(t(64,127),12,12,13),n.addMany(t(64,127),10,12,13),n.addMany(t(64,127),9,12,13),n.addMany(s,13,13,13),n.addMany(r,13,13,13),n.add(127,13,0,13),n.addMany([27,156,24,26],13,14,0),n.add(oe,0,2,0),n.add(oe,8,5,8),n.add(oe,6,0,6),n.add(oe,11,0,11),n.add(oe,13,13,13),n.add(oe,16,16,16),n})(),mr=class extends g{constructor(e=So){super();this._transitions=e;this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0};this.initialState=0,this.currentState=this.initialState,this._params=new At,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(t,r,s)=>{},this._executeHandlerFb=t=>{},this._csiHandlerFb=(t,r)=>{},this._escHandlerFb=t=>{},this._errorHandlerFb=t=>t,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register(E(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new cr),this._dcsParser=this._register(new dr),this._apcParser=this._register(new fr),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let r=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(r=e.prefix.charCodeAt(0),r<60||r>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let o=0;oa||a>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");r<<=8,r|=a}}if(e.final.length!==1)throw new Error("final must be a single byte");let s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return r<<=8,r|=s,r}identToString(e){let t=[];for(;e;)t.push(String.fromCharCode(e&255)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){let r=this._identifier(e,[48,126]);this._escHandlers[r]??=[];let s=this._escHandlers[r];return s.push(t),{dispose:()=>{let o=s.indexOf(t);o!==-1&&s.splice(o,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){let r=e.charCodeAt(0);this._executeHandlers[r]=t,r<24&&(this._executeHandlersArr[r]=t)}clearExecuteHandler(e){let t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){let r=this._identifier(e);this._csiHandlers[r]??=[];let s=this._csiHandlers[r];return s.push(t),{dispose:()=>{let o=s.indexOf(t);o!==-1&&s.splice(o,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,this._parseStack.state!==0&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,r,s,o){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=r,this._parseStack.transition=s,this._parseStack.chunkPos=o}parse(e,t,r){let s,o,a=0,l;if(this._parseStack.state)if(this._parseStack.state===2)this._parseStack.state=0,a=this._parseStack.chunkPos+1;else{if(r===void 0||this._parseStack.state===1)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");let h=this._parseStack.handlers,d=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(r===!1&&d>-1){for(;d>=0&&(l=h[d](this._params),l!==!0);d--)if(l instanceof Promise)return this._parseStack.handlerPos=d,l}this._parseStack.handlers=[];break;case 4:if(r===!1&&d>-1){for(;d>=0&&(l=h[d](),l!==!0);d--)if(l instanceof Promise)return this._parseStack.handlerPos=d,l}this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],l=this._dcsParser.unhook(s!==24&&s!==26,r),l)return l;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],l=this._oscParser.end(s!==24&&s!==26,r),l)return l;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],l=this._apcParser.end(s!==24&&s!==26,r),l)return l;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break}this._parseStack.state=0,a=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=this._parseStack.transition&255}for(let h=a;h=60&&c<=63&&(this._collect=c,d++);let u=!1;for(;d=48&&c<=57)this._params.addDigit(c-48);else if(c===59)this._params.addParam(0);else if(c===58)this._params.addSubParam(-1);else if(c>=64&&c<=126){let _=this._csiHandlers[this._collect<<8|c],p=_?_.length-1:-1;for(;p>=0&&(l=_[p](this._params),l!==!0);p--)if(l instanceof Promise)return o=1792,this._preserveStack(3,_,p,o,d),l;p<0&&this._csiHandlerFb(this._collect<<8|c,this._params),this.precedingJoinState=0,h=d,this.currentState=0,u=!0;break}else break;u||(h=d-1,this.currentState=4);continue}switch(o=this._transitions.table[this.currentState<<8|(s>8){case 2:let d=h,c=t-4;for(;d=32&&(e[d]<=126||e[d]>=oe)&&e[++d]>=32&&(e[d]<=126||e[d]>=oe)&&e[++d]>=32&&(e[d]<=126||e[d]>=oe)&&e[++d]>=32&&(e[d]<=126||e[d]>=oe););if(d>=c)for(;d=32&&(e[d]<=126||e[d]>=oe);)d++;this._printHandler(e,h,d),h=d-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:h,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:let _=this._csiHandlers[this._collect<<8|s],p=_?_.length-1:-1;for(;p>=0&&(l=_[p](this._params),l!==!0);p--)if(l instanceof Promise)return this._preserveStack(3,_,p,o,h),l;p<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}while(++h47&&s<60);h--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:let v=this._escHandlers[this._collect<<8|s],f=v?v.length-1:-1;for(;f>=0&&(l=v[f](),l!==!0);f--)if(l instanceof Promise)return this._preserveStack(4,v,f,o,h),l;f<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let S=h+1;;++S)if(S>=t||(s=e[S])===24||s===26||s===27||s>127&&s=t||(s=e[S])<32||s>127&&s=32&&e[S]<127||e[S]>=8&&e[S]<14||e[S]>=oe))){this._apcParser.put(e,h,S),h=S-1;break}break;case 17:if(l=this._apcParser.end(s!==24&&s!==26),l)return this._preserveStack(7,[],0,o,h),l;s===27&&(o|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0;break}this.currentState=o&255}}};var go=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,Io=/^[\da-f]+$/;function ys(n){if(!n)return;let i=n.toLowerCase();if(i.startsWith("rgb:")){i=i.slice(4);let e=go.exec(i);if(e){let t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(i.startsWith("#")&&(i=i.slice(1),Io.exec(i)&&[3,6,9,12].includes(i.length))){let e=i.length/3,t=[0,0,0];for(let r=0;r<3;++r){let s=parseInt(i.slice(e*r,e*r+e),16);t[r]=e===1?s<<4:e===2?s:e===3?s>>4:s>>8}return t}}function Es(n,i){let e=n.toString(16),t=e.length<2?"0"+e:e;switch(i){case 4:return e[0];case 8:return t;case 12:return(t+t).slice(0,3);default:return t+t}}function En(n,i=16){let[e,t,r]=n;return`rgb:${Es(e,i)}/${Es(t,i)}/${Es(r,i)}`}var yn="6.1.0-beta.287";var Eo={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function xn(n,i){if(n>24)return i.setWinLines||!1;switch(n){case 1:return!!i.restoreWin;case 2:return!!i.minimizeWin;case 3:return!!i.setWinPosition;case 4:return!!i.setWinSizePixels;case 5:return!!i.raiseWin;case 6:return!!i.lowerWin;case 7:return!!i.refreshWin;case 8:return!!i.setWinSizeChars;case 9:return!!i.maximizeWin;case 10:return!!i.fullscreenWin;case 11:return!!i.getWinState;case 13:return!!i.getWinPosition;case 14:return!!i.getWinSizePixels;case 15:return!!i.getScreenSizePixels;case 16:return!!i.getCellSizePixels;case 18:return!!i.getWinSizeChars;case 19:return!!i.getScreenSizeChars;case 20:return!!i.getIconTitle;case 21:return!!i.getWinTitle;case 22:return!!i.pushTitle;case 23:return!!i.popTitle;case 24:return!!i.setWinLines}return!1}var wn=0,br=class extends g{constructor(e,t,r,s,o,a,l,h,d=new mr){super();this._bufferService=e;this._charsetService=t;this._coreService=r;this._logService=s;this._optionsService=o;this._oscLinkService=a;this._mouseStateService=l;this._unicodeService=h;this._parser=d;this._parseBuffer=new Uint32Array(4096);this._stringDecoder=new mi;this._utf8Decoder=new bi;this._windowTitle="";this._iconName="";this._windowTitleStack=[];this._iconNameStack=[];this._curAttrData=U.clone();this._eraseAttrDataInternal=U.clone();this._onRequestBell=this._register(new b);this.onRequestBell=this._onRequestBell.event;this._onRequestRefreshRows=this._register(new b);this.onRequestRefreshRows=this._onRequestRefreshRows.event;this._onRequestReset=this._register(new b);this.onRequestReset=this._onRequestReset.event;this._onRequestSendFocus=this._register(new b);this.onRequestSendFocus=this._onRequestSendFocus.event;this._onRequestSyncScrollBar=this._register(new b);this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event;this._onRequestWindowsOptionsReport=this._register(new b);this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event;this._onA11yChar=this._register(new b);this.onA11yChar=this._onA11yChar.event;this._onA11yTab=this._register(new b);this.onA11yTab=this._onA11yTab.event;this._onCursorMove=this._register(new b);this.onCursorMove=this._onCursorMove.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;this._onTitleChange=this._register(new b);this.onTitleChange=this._onTitleChange.event;this._onColor=this._register(new b);this.onColor=this._onColor.event;this._onRequestColorSchemeQuery=this._register(new b);this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event;this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0};this._specialColors=[256,257,258];this._register(this._parser),this._dirtyRowTracker=new hi(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(c=>this._activeBuffer=c.activeBuffer)),this._parser.setCsiHandlerFallback((c,u)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(c),params:u.toArray()})}),this._parser.setEscHandlerFallback(c=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(c)})}),this._parser.setExecuteHandlerFallback(c=>{this._logService.debug("Unknown EXECUTE code: ",{code:c})}),this._parser.setOscHandlerFallback((c,u,_)=>{this._logService.debug("Unknown OSC code: ",{identifier:c,action:u,data:_})}),this._parser.setDcsHandlerFallback((c,u,_)=>{u==="HOOK"&&(_=_.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(c),action:u,payload:_})}),this._parser.setApcHandlerFallback((c,u,_)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(c),action:u,payload:_})}),this._parser.setPrintHandler((c,u,_)=>this.print(c,u,_)),this._parser.registerCsiHandler({final:"@"},c=>this.insertChars(c)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},c=>this.scrollLeft(c)),this._parser.registerCsiHandler({final:"A"},c=>this.cursorUp(c)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},c=>this.scrollRight(c)),this._parser.registerCsiHandler({final:"B"},c=>this.cursorDown(c)),this._parser.registerCsiHandler({final:"C"},c=>this.cursorForward(c)),this._parser.registerCsiHandler({final:"D"},c=>this.cursorBackward(c)),this._parser.registerCsiHandler({final:"E"},c=>this.cursorNextLine(c)),this._parser.registerCsiHandler({final:"F"},c=>this.cursorPrecedingLine(c)),this._parser.registerCsiHandler({final:"G"},c=>this.cursorCharAbsolute(c)),this._parser.registerCsiHandler({final:"H"},c=>this.cursorPosition(c)),this._parser.registerCsiHandler({final:"I"},c=>this.cursorForwardTab(c)),this._parser.registerCsiHandler({final:"J"},c=>this.eraseInDisplay(c,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},c=>this.eraseInDisplay(c,!0)),this._parser.registerCsiHandler({final:"K"},c=>this.eraseInLine(c,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},c=>this.eraseInLine(c,!0)),this._parser.registerCsiHandler({final:"L"},c=>this.insertLines(c)),this._parser.registerCsiHandler({final:"M"},c=>this.deleteLines(c)),this._parser.registerCsiHandler({final:"P"},c=>this.deleteChars(c)),this._parser.registerCsiHandler({final:"S"},c=>this.scrollUp(c)),this._parser.registerCsiHandler({final:"T"},c=>this.scrollDown(c)),this._parser.registerCsiHandler({final:"X"},c=>this.eraseChars(c)),this._parser.registerCsiHandler({final:"Z"},c=>this.cursorBackwardTab(c)),this._parser.registerCsiHandler({final:"^"},c=>this.scrollDown(c)),this._parser.registerCsiHandler({final:"`"},c=>this.charPosAbsolute(c)),this._parser.registerCsiHandler({final:"a"},c=>this.hPositionRelative(c)),this._parser.registerCsiHandler({final:"b"},c=>this.repeatPrecedingCharacter(c)),this._parser.registerCsiHandler({final:"c"},c=>this.sendDeviceAttributesPrimary(c)),this._parser.registerCsiHandler({prefix:">",final:"c"},c=>this.sendDeviceAttributesSecondary(c)),this._parser.registerCsiHandler({final:"d"},c=>this.linePosAbsolute(c)),this._parser.registerCsiHandler({final:"e"},c=>this.vPositionRelative(c)),this._parser.registerCsiHandler({final:"f"},c=>this.hVPosition(c)),this._parser.registerCsiHandler({final:"g"},c=>this.tabClear(c)),this._parser.registerCsiHandler({final:"h"},c=>this.setMode(c)),this._parser.registerCsiHandler({prefix:"?",final:"h"},c=>this.setModePrivate(c)),this._parser.registerCsiHandler({final:"l"},c=>this.resetMode(c)),this._parser.registerCsiHandler({prefix:"?",final:"l"},c=>this.resetModePrivate(c)),this._parser.registerCsiHandler({final:"m"},c=>this.charAttributes(c)),this._parser.registerCsiHandler({final:"n"},c=>this.deviceStatus(c)),this._parser.registerCsiHandler({prefix:"?",final:"n"},c=>this.deviceStatusPrivate(c)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},c=>this.softReset(c)),this._parser.registerCsiHandler({prefix:">",final:"q"},c=>this.sendXtVersion(c)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},c=>this.setCursorStyle(c)),this._parser.registerCsiHandler({final:"r"},c=>this.setScrollRegion(c)),this._parser.registerCsiHandler({final:"s"},c=>this.saveCursor(c)),this._parser.registerCsiHandler({final:"t"},c=>this.windowOptions(c)),this._parser.registerCsiHandler({final:"u"},c=>this.restoreCursor(c)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},c=>this.insertColumns(c)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},c=>this.deleteColumns(c)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},c=>this.selectProtected(c)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},c=>this.requestMode(c,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},c=>this.requestMode(c,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},c=>this.kittyKeyboardSet(c)),this._parser.registerCsiHandler({prefix:"?",final:"u"},c=>this.kittyKeyboardQuery(c)),this._parser.registerCsiHandler({prefix:">",final:"u"},c=>this.kittyKeyboardPush(c)),this._parser.registerCsiHandler({prefix:"<",final:"u"},c=>this.kittyKeyboardPop(c)),this._parser.setExecuteHandler("\x07",()=>this.bell()),this._parser.setExecuteHandler(` - `,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new ne(c=>(this.setTitle(c),this.setIconName(c),!0))),this._parser.registerOscHandler(1,new ne(c=>this.setIconName(c))),this._parser.registerOscHandler(2,new ne(c=>this.setTitle(c))),this._parser.registerOscHandler(4,new ne(c=>this.setOrReportIndexedColor(c))),this._parser.registerOscHandler(8,new ne(c=>this.setHyperlink(c))),this._parser.registerOscHandler(10,new ne(c=>this.setOrReportFgColor(c))),this._parser.registerOscHandler(11,new ne(c=>this.setOrReportBgColor(c))),this._parser.registerOscHandler(12,new ne(c=>this.setOrReportCursorColor(c))),this._parser.registerOscHandler(104,new ne(c=>this.restoreIndexedColor(c))),this._parser.registerOscHandler(110,new ne(c=>this.restoreFgColor(c))),this._parser.registerOscHandler(111,new ne(c=>this.restoreBgColor(c))),this._parser.registerOscHandler(112,new ne(c=>this.restoreCursorColor(c))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let c in q)this._parser.registerEscHandler({intermediates:"(",final:c},()=>this.selectCharset("("+c)),this._parser.registerEscHandler({intermediates:")",final:c},()=>this.selectCharset(")"+c)),this._parser.registerEscHandler({intermediates:"*",final:c},()=>this.selectCharset("*"+c)),this._parser.registerEscHandler({intermediates:"+",final:c},()=>this.selectCharset("+"+c)),this._parser.registerEscHandler({intermediates:"-",final:c},()=>this.selectCharset("-"+c)),this._parser.registerEscHandler({intermediates:".",final:c},()=>this.selectCharset("."+c)),this._parser.registerEscHandler({intermediates:"/",final:c},()=>this.selectCharset("/"+c));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(c=>(this._logService.error("Parsing error: ",c),c)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new li((c,u)=>this.requestStatusString(c,u)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,r,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=r,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,r=new Promise((s,o)=>{t=setTimeout(()=>o("#SLOW_TIMEOUT"),5e3)});Promise.race([e,r]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let r,s=this._activeBuffer.x,o=this._activeBuffer.y,a=0,l=this._parseStack.paused;if(l){if(r=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(r),r;s=this._parseStack.cursorStartX,o=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(a=this._parseStack.position+131072)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,c=>String.fromCharCode(c)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(c=>c.charCodeAt(0)):e),this._parseBuffer.length131072)for(let c=a;c0&&_.getWidth(this._activeBuffer.x-1)===2&&_.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let p=this._parser.precedingJoinState;for(let v=t;vh){if(d){let L=_,T=this._activeBuffer.x-I;if(this._activeBuffer.x=I,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),_=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!_)return;for(I>0&&_ instanceof De&&_.copyCellsFrom(L,T,0,I,!1);T=0;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(c&&(_.insertCells(this._activeBuffer.x,o-I,this._activeBuffer.getNullCell(u)),_.getWidth(h-1)===2&&_.setCellFromCodepoint(h-1,0,1,u)),_.setCellFromCodepoint(this._activeBuffer.x++,s,o,u),o>0)for(;--o;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=p,this._activeBuffer.x0&&_.getWidth(this._activeBuffer.x)===0&&!_.hasContent(this._activeBuffer.x)&&_.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,r=>xn(r.params[0],this._optionsService.rawOptions.windowOptions)?t(r):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new li(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new ne(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new _r(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,r,s=!1,o=!1){let a=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);a&&(a.replaceCells(t,r,this._activeBuffer.getNullCell(this._eraseAttrData()),o),s&&(a.isWrapped=!1))}_resetBufferLine(e,t=!1){let r=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);r&&(r.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),r.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let r;switch(e.params[0]){case 0:for(r=this._activeBuffer.y,this._dirtyRowTracker.markDirty(r),this._eraseInBufferLine(r++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);r=this._bufferService.cols){let o=this._activeBuffer.lines.get(r+1);o&&(o.isWrapped=!1)}for(;r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(r=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,r-1);r--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+r)?.getTrimmedLength(););for(;r>=0;r--)this._bufferService.scroll(this._eraseAttrData())}else{for(r=this._bufferService.rows,this._dirtyRowTracker.markDirty(r-1);r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let c=d;for(let u=1;u0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${yn})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(te[te.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",te[te.SET=1]="SET",te[te.RESET=2]="RESET",te[te.PERMANENTLY_SET=3]="PERMANENTLY_SET",te[te.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(r||={});let s=this._coreService.decPrivateModes,{activeProtocol:o,activeEncoding:a}=this._mouseStateService,l=this._coreService,{buffers:h,cols:d}=this._bufferService,{active:c,alt:u}=h,_=this._optionsService.rawOptions,p=(S,I)=>(l.triggerDataEvent(`\x1B[${t?"":"?"}${S};${I}$y`),!0),v=S=>S?1:2,f=e.params[0];return t?f===2?p(f,4):f===4?p(f,v(l.modes.insertMode)):f===12?p(f,3):f===20?p(f,v(_.convertEol)):p(f,0):f===1?p(f,v(s.applicationCursorKeys)):f===3?p(f,_.windowOptions.setWinLines?d===80?2:d===132?1:0:0):f===6?p(f,v(s.origin)):f===7?p(f,v(s.wraparound)):f===8?p(f,3):f===9?p(f,v(o==="X10")):f===12?p(f,v(_.cursorBlink)):f===25?p(f,v(!l.isCursorHidden)):f===45?p(f,v(s.reverseWraparound)):f===66?p(f,v(s.applicationKeypad)):f===67?p(f,4):f===1e3?p(f,v(o==="VT200")):f===1002?p(f,v(o==="DRAG")):f===1003?p(f,v(o==="ANY")):f===1004?p(f,v(s.sendFocus)):f===1005?p(f,4):f===1006?p(f,v(a==="SGR")):f===1015?p(f,4):f===1016?p(f,v(a==="SGR_PIXELS")):f===1048?p(f,1):f===47||f===1047||f===1049?p(f,v(c===u)):f===2004?p(f,v(s.bracketedPasteMode)):f===2026?p(f,v(s.synchronizedOutput)):f===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?p(f,v(s.win32InputMode)):p(f,0)}_updateAttrColor(e,t,r,s,o){return t===2?(e|=50331648,e&=-16777216,e|=ue.fromColorRGB([r,s,o])):t===5&&(e&=-67108864,e|=33554432|r&255),e}_extractColor(e,t,r){let s=[0,0,-1,0,0,0],o=0,a=0;do{if(s[a+o]=e.params[t+a],e.hasSubParams(t+a)){let l=e.getSubParams(t+a),h=0;do s[1]===5&&(o=1),s[a+h+1+o]=l[h];while(++h=2||s[1]===2&&a+o>=5)break;s[1]&&(o=1)}while(++a+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=U.fg,e.bg=U.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,r,s=this._curAttrData;for(let o=0;o=30&&r<=37?(s.fg&=-67108864,s.fg|=16777216|r-30):r>=40&&r<=47?(s.bg&=-67108864,s.bg|=16777216|r-40):r>=90&&r<=97?(s.fg&=-67108864,s.fg|=16777216|r-90|8):r>=100&&r<=107?(s.bg&=-67108864,s.bg|=16777216|r-100|8):r===0?this._processSGR0(s):r===1?s.fg|=134217728:r===3?s.bg|=67108864:r===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(o)?e.getSubParams(o)[0]:1,s)):r===5?s.fg|=536870912:r===7?s.fg|=67108864:r===8?s.fg|=1073741824:r===9?s.fg|=2147483648:r===2?s.bg|=134217728:r===21?this._processUnderline(2,s):r===22?(s.fg&=-134217729,s.bg&=-134217729):r===23?s.bg&=-67108865:r===24?(s.fg&=-268435457,this._processUnderline(0,s)):r===25?s.fg&=-536870913:r===27?s.fg&=-67108865:r===28?s.fg&=-1073741825:r===29?s.fg&=2147483647:r===39?(s.fg&=-67108864,s.fg|=U.fg&16777215):r===49?(s.bg&=-67108864,s.bg|=U.bg&16777215):r===38||r===48||r===58?o+=this._extractColor(e,o,s):r===53?s.bg|=1073741824:r===55?s.bg&=-1073741825:r===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:r===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:r===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",r);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${r}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${r}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=U.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let r=t%2===1;this._coreService.decPrivateModes.cursorBlink=r}return!0}setScrollRegion(e){let t=e.params[0]||1,r;return(e.length<2||(r=e.params[1])>this._bufferService.rows||r===0)&&(r=this._bufferService.rows),r>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=r-1,this._setCursor(0,0)),!0}windowOptions(e){if(!xn(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t1;){let s=r.shift(),o=r.shift();if(/^\d+$/.exec(s)){let a=parseInt(s,10);if(Tn(a))if(o==="?")t.push({type:0,index:a});else{let l=ys(o);l&&t.push({type:1,index:a,color:l})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let r=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(r,s):r.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let r=e.split(":"),s,o=r.findIndex(a=>a.startsWith("id="));return o!==-1&&(s=r[o].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let r=e.split(";");for(let s=0;s=this._specialColors.length);++s,++t)if(r[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let o=ys(r[s]);o&&this._onColor.fire([{type:1,index:this._specialColors[t],color:o}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],r=e.split(";");for(let s=0;s=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=U.clone(),this._eraseAttrDataInternal=U.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new F;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`\x1B${l}\x1B\\`),!0),s=this._bufferService.buffer,o=this._optionsService.rawOptions,a={block:2,underline:4,bar:6};return r(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${a[o.cursorStyle]-(o.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(r){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;return o.length>=16&&o.shift(),o.push(r.flags),r.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;for(let a=0;a0;a++)r.flags=o.pop();return o.length===0&&t>0&&(r.flags=0),!0}},hi=class{constructor(i){this._bufferService=i;this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(i){ithis.end&&(this.end=i)}markRangeDirty(i,e){i>e&&(wn=i,i=e,e=wn),ithis.end&&(this.end=e)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};hi=y([m(0,D)],hi);function Tn(n){return 0<=n&&n<256}var vr=class extends g{constructor(e){super();this._action=e;this._writeBuffer=[];this._callbacks=[];this._pendingData=0;this._bufferOffset=0;this._isSyncWriting=!1;this._syncCalls=0;this._didUserInput=!1;this._innerWriteTimer=this._register(new Ie);this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._register(E(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed||this._isSyncWriting)return;this._isSyncWriting=!0;let e,t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);let r=this._callbacks.shift();r&&r()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let r;for(;r=this._writeBuffer.shift();){this._action(r);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;let r=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],o=this._action(s,t);if(o){let l=h=>{this._store.isDisposed||(performance.now()-r>=12?this._scheduleInnerWrite(0,h):this._innerWrite(r,h))};o.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(l);return}let a=this._callbacks[this._bufferOffset];if(a&&a(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-r>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}};var kt=class{constructor(i){this._bufferService=i;this._nextId=1;this._entriesWithId=new Map;this._dataByLinkId=new Map}registerLink(i){let e=this._bufferService.buffer;if(i.id===void 0){let l=e.addMarker(e.ybase+e.y),h={data:i,id:this._nextId++,lines:[l]};return l.onDispose(()=>this._removeMarkerFromLink(h,l)),this._dataByLinkId.set(h.id,h),h.id}let t=i,r=this._getEntryIdKey(t),s=this._entriesWithId.get(r);if(s)return this.addLineToLink(s.id,e.ybase+e.y),s.id;let o=e.addMarker(e.ybase+e.y),a={id:this._nextId++,key:this._getEntryIdKey(t),data:t,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(a,o)),this._entriesWithId.set(a.key,a),this._dataByLinkId.set(a.id,a),a.id}addLineToLink(i,e){let t=this._dataByLinkId.get(i);if(t&&t.lines.every(r=>r.line!==e)){let r=this._bufferService.buffer.addMarker(e);t.lines.push(r),r.onDispose(()=>this._removeMarkerFromLink(t,r))}}getLinkData(i){return this._dataByLinkId.get(i)?.data}_getEntryIdKey(i){return`${i.id};;${i.uri}`}_removeMarkerFromLink(i,e){let t=i.lines.indexOf(e);t!==-1&&(i.lines.splice(t,1),i.lines.length===0&&(i.data.id!==void 0&&this._entriesWithId.delete(i.key),this._dataByLinkId.delete(i.id)))}};kt=y([m(0,D)],kt);var Dn=!1,Sr=class extends g{constructor(e){super();this._windowsWrappingHeuristics=this._register(new P);this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onData=this._register(new b);this.onData=this._onData.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._onScroll=this._register(new b);this._instantiationService=new Ji,this.optionsService=this._register(new nr(e)),this._instantiationService.setService(R,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(wt)),this._instantiationService.setService(fe,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(Dt)),this._instantiationService.setService(D,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(Lt)),this._instantiationService.setService(Y,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(or)),this._instantiationService.setService(Me,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(me)),this.unicodeService.register(new ar),this._instantiationService.setService(Us,this.unicodeService),this._charsetService=this._instantiationService.createInstance(lr),this._instantiationService.setService(Ws,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(kt),this._instantiationService.setService(vi,this._oscLinkService),this._inputHandler=this._register(new br(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(j.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(j.forward(this._bufferService.onResize,this._onResize)),this._register(j.forward(this.coreService.onData,this._onData)),this._register(j.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new vr((t,r)=>this._inputHandler.parse(t,r))),this._register(j.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new b),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Dn&&(this._logService.warn("writeSync is unreliable and will be removed soon."),Dn=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.backend!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Is.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(Is(this._bufferService),!1))),this._windowsWrappingHeuristics.value=E(()=>{for(let t of e)t.dispose()})}}};var z=0,gr=class{constructor(i,e){this._getKey=i;this._array=[];this._insertedValues=[];this._isFlushingInserted=!1;this._deletedIndices=[];this._isFlushingDeleted=!1;this._flushInsertedTask=new It(e),this._flushDeletedTask=new It(e)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(i){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(i)}_flushInserted(){let i=this._insertedValues.sort((s,o)=>this._getKey(s)-this._getKey(o)),e=0,t=0,r=new Array(this._array.length+this._insertedValues.length);for(let s=0;s=this._array.length||this._getKey(i[e])<=this._getKey(this._array[t])?(r[s]=i[e],e++):r[s]=this._array[t++];this._array=r,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(i){if(this._flushCleanupInserted(),this._array.length===0)return!1;let e=this._getKey(i);if(e===void 0||(z=this._search(e),z===-1)||this._getKey(this._array[z])!==e)return!1;do if(this._array[z]===i)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(z),!0;while(++zs-o),e=0,t=new Array(this._array.length-i.length),r=0;for(let s=0;s0&&this._flushDeletedTask.flush()}*getKeyIterator(i){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(z=this._search(i),!(z<0||z>=this._array.length)&&this._getKey(this._array[z])===i))do yield this._array[z];while(++z=this._array.length)&&this._getKey(this._array[z])===i))do e(this._array[z]);while(++z=e;){let r=e+t>>1,s=this._getKey(this._array[r]);if(s>i)t=r-1;else if(s0&&this._getKey(this._array[r-1])===i;)r--;return r}}return e}};var Mt=0,Ir=0,Bt=class extends g{constructor(e,t){super();this._logService=e;this._bufferService=t;this._lineCache=this._register(new xs);this._onDecorationRegistered=this._register(new b);this.onDecorationRegistered=this._onDecorationRegistered.event;this._onDecorationRemoved=this._register(new b);this.onDecorationRemoved=this._onDecorationRemoved.event;this._decorations=new gr(r=>r?.marker.line,this._logService),this._register(E(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new ws(e);if(t){let r=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),r.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,r){let s=this._lineCache.getDecorationsOnLine(t);if(s)for(let o of s)Mt=o.options.x??0,Ir=Mt+(o.options.width??1),e>=Mt&&e=Mt&&ethis._handleBufferLinesTrim(r))),t.add(e.onInsert(r=>this._handleBufferLinesInsert(r))),t.add(e.onDelete(r=>this._handleBufferLinesDelete(r)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){let t=e.marker.line;if(t<0)return;e._indexedStartLine=t;let r=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{let t=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(let r of t)r()})}_handleBufferLinesTrim(e){if(e<=0)return;let t=new Map;for(let[r,s]of this._decorationsByLine){let o=r-e;o<0||this._mergeLineBucket(t,o,s)}this._decorationsByLine.clear();for(let[r,s]of t)this._decorationsByLine.set(r,s);for(let r of this._decorations)r.marker.isDisposed||(r._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,r){let s=e.get(t);if(s)for(let o=0,a=r.length;ot&&(s.push(a),this._removeFromLineBuckets(a))}let o=new Map;for(let[a,l]of this._decorationsByLine){let h=a>=t?a+r:a;this._mergeLineBucket(o,h,l)}this._decorationsByLine.clear();for(let[a,l]of o)this._decorationsByLine.set(a,l);for(let a of this._decorations)a.marker.isDisposed||a._indexedStartLine>=t&&(a._indexedStartLine=a.marker.line);for(let a of s)this._addToLineBuckets(a)}_applyBufferLinesDelete(e){let t=e.index+e.amount,r=new Map;for(let[o,a]of this._decorationsByLine){if(o>=e.index&&o=t?o-e.amount:o;this._mergeLineBucket(r,l,a)}this._decorationsByLine.clear();for(let[o,a]of r)this._decorationsByLine.set(o,a);let s=[];for(let o of this._decorations){if(o.marker.isDisposed)continue;let a=o._indexedStartLine,l=this._getDecorationHeight(o);a>=t?o._indexedStartLine=o.marker.line:at&&s.push(o)}for(let o of s)this._reindexDecoration(o)}},ws=class extends pe{constructor(e){super();this.options=e;this.onRenderEmitter=this.add(new b);this.onRender=this.onRenderEmitter.event;this._onDispose=this.add(new b);this.onDispose=this._onDispose.event;this._cachedBg=null;this._cachedFg=null;this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}get backgroundColorRGB(){return this._cachedBg===null&&(this.options.backgroundColor?this._cachedBg=B.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return this._cachedFg===null&&(this.options.foregroundColor?this._cachedFg=B.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}dispose(){this._onDispose.fire(),super.dispose()}};var yo=1e3,Cr=class{constructor(i,e=yo){this._renderCallback=i;this._debounceThresholdMS=e;this._lastRefreshMs=0;this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e;let r=performance.now();if(r-this._lastRefreshMs>=this._debounceThresholdMS)this._refreshTimeoutID!==void 0&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=r,this._innerRefresh();else if(!this._additionalRefreshRequested){let s=r-this._lastRefreshMs,o=this._debounceThresholdMS-s;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},o)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e)}};var Rn=!1,Ye=class extends g{constructor(e,t,r,s){super();this._terminal=e;this._coreBrowserService=r;this._renderService=s;this._rowColumns=new WeakMap;this._liveRegionLineCount=0;this._charsToConsume=[];this._charsToAnnounce="";let o=this._coreBrowserService.mainDocument;this._accessibilityContainer=o.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=o.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let a=0;athis._handleBoundaryFocus(a,0),this._bottomBoundaryFocusListener=a=>this._handleBoundaryFocus(a,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=o.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Cr(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");Rn?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=o.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(o.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(o.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(a=>this._handleResize(a.rows))),this._register(this._terminal.onRender(a=>this._refreshRows(a.start,a.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(a=>this._handleChar(a))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` +-`,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new ne(c=>(this.setTitle(c),this.setIconName(c),!0))),this._parser.registerOscHandler(1,new ne(c=>this.setIconName(c))),this._parser.registerOscHandler(2,new ne(c=>this.setTitle(c))),this._parser.registerOscHandler(4,new ne(c=>this.setOrReportIndexedColor(c))),this._parser.registerOscHandler(8,new ne(c=>this.setHyperlink(c))),this._parser.registerOscHandler(10,new ne(c=>this.setOrReportFgColor(c))),this._parser.registerOscHandler(11,new ne(c=>this.setOrReportBgColor(c))),this._parser.registerOscHandler(12,new ne(c=>this.setOrReportCursorColor(c))),this._parser.registerOscHandler(104,new ne(c=>this.restoreIndexedColor(c))),this._parser.registerOscHandler(110,new ne(c=>this.restoreFgColor(c))),this._parser.registerOscHandler(111,new ne(c=>this.restoreBgColor(c))),this._parser.registerOscHandler(112,new ne(c=>this.restoreCursorColor(c))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let c in q)this._parser.registerEscHandler({intermediates:"(",final:c},()=>this.selectCharset("("+c)),this._parser.registerEscHandler({intermediates:")",final:c},()=>this.selectCharset(")"+c)),this._parser.registerEscHandler({intermediates:"*",final:c},()=>this.selectCharset("*"+c)),this._parser.registerEscHandler({intermediates:"+",final:c},()=>this.selectCharset("+"+c)),this._parser.registerEscHandler({intermediates:"-",final:c},()=>this.selectCharset("-"+c)),this._parser.registerEscHandler({intermediates:".",final:c},()=>this.selectCharset("."+c)),this._parser.registerEscHandler({intermediates:"/",final:c},()=>this.selectCharset("/"+c));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(c=>(this._logService.error("Parsing error: ",c),c)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new li((c,u)=>this.requestStatusString(c,u)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,r,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=r,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,r=new Promise((s,o)=>{t=setTimeout(()=>o("#SLOW_TIMEOUT"),5e3)});Promise.race([e,r]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let r,s=this._activeBuffer.x,o=this._activeBuffer.y,a=0,l=this._parseStack.paused;if(l){if(r=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(r),r;s=this._parseStack.cursorStartX,o=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(a=this._parseStack.position+131072)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,c=>String.fromCharCode(c)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(c=>c.charCodeAt(0)):e),this._parseBuffer.length131072)for(let c=a;c0&&_.getWidth(this._activeBuffer.x-1)===2&&_.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let p=this._parser.precedingJoinState;for(let v=t;vh){if(d){let L=_,T=this._activeBuffer.x-I;if(this._activeBuffer.x=I,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),_=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!_)return;for(I>0&&_ instanceof De&&_.copyCellsFrom(L,T,0,I,!1);T=0;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(c&&(_.insertCells(this._activeBuffer.x,o-I,this._activeBuffer.getNullCell(u)),_.getWidth(h-1)===2&&_.setCellFromCodepoint(h-1,0,1,u)),_.setCellFromCodepoint(this._activeBuffer.x++,s,o,u),o>0)for(;--o;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=p,this._activeBuffer.x0&&_.getWidth(this._activeBuffer.x)===0&&!_.hasContent(this._activeBuffer.x)&&_.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,r=>xn(r.params[0],this._optionsService.rawOptions.windowOptions)?t(r):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new li(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new ne(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new _r(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,r,s=!1,o=!1){let a=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);a&&(a.replaceCells(t,r,this._activeBuffer.getNullCell(this._eraseAttrData()),o),s&&(a.isWrapped=!1))}_resetBufferLine(e,t=!1){let r=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);r&&(r.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),r.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let r;switch(e.params[0]){case 0:for(r=this._activeBuffer.y,this._dirtyRowTracker.markDirty(r),this._eraseInBufferLine(r++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);r=this._bufferService.cols){let o=this._activeBuffer.lines.get(r+1);o&&(o.isWrapped=!1)}for(;r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(r=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,r-1);r--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+r)?.getTrimmedLength(););for(;r>=0;r--)this._bufferService.scroll(this._eraseAttrData())}else{for(r=this._bufferService.rows,this._dirtyRowTracker.markDirty(r-1);r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let c=d;for(let u=1;u0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${yn})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(te[te.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",te[te.SET=1]="SET",te[te.RESET=2]="RESET",te[te.PERMANENTLY_SET=3]="PERMANENTLY_SET",te[te.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(r||={});let s=this._coreService.decPrivateModes,{activeProtocol:o,activeEncoding:a}=this._mouseStateService,l=this._coreService,{buffers:h,cols:d}=this._bufferService,{active:c,alt:u}=h,_=this._optionsService.rawOptions,p=(S,I)=>(l.triggerDataEvent(`\x1B[${t?"":"?"}${S};${I}$y`),!0),v=S=>S?1:2,f=e.params[0];return t?f===2?p(f,4):f===4?p(f,v(l.modes.insertMode)):f===12?p(f,3):f===20?p(f,v(_.convertEol)):p(f,0):f===1?p(f,v(s.applicationCursorKeys)):f===3?p(f,_.windowOptions.setWinLines?d===80?2:d===132?1:0:0):f===6?p(f,v(s.origin)):f===7?p(f,v(s.wraparound)):f===8?p(f,3):f===9?p(f,v(o==="X10")):f===12?p(f,v(_.cursorBlink)):f===25?p(f,v(!l.isCursorHidden)):f===45?p(f,v(s.reverseWraparound)):f===66?p(f,v(s.applicationKeypad)):f===67?p(f,4):f===1e3?p(f,v(o==="VT200")):f===1002?p(f,v(o==="DRAG")):f===1003?p(f,v(o==="ANY")):f===1004?p(f,v(s.sendFocus)):f===1005?p(f,4):f===1006?p(f,v(a==="SGR")):f===1015?p(f,4):f===1016?p(f,v(a==="SGR_PIXELS")):f===1048?p(f,1):f===47||f===1047||f===1049?p(f,v(c===u)):f===2004?p(f,v(s.bracketedPasteMode)):f===2026?p(f,v(s.synchronizedOutput)):f===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?p(f,v(s.win32InputMode)):p(f,0)}_updateAttrColor(e,t,r,s,o){return t===2?(e|=50331648,e&=-16777216,e|=ue.fromColorRGB([r,s,o])):t===5&&(e&=-67108864,e|=33554432|r&255),e}_extractColor(e,t,r){let s=[0,0,-1,0,0,0],o=0,a=0;do{if(s[a+o]=e.params[t+a],e.hasSubParams(t+a)){let l=e.getSubParams(t+a),h=0;do s[1]===5&&(o=1),s[a+h+1+o]=l[h];while(++h=2||s[1]===2&&a+o>=5)break;s[1]&&(o=1)}while(++a+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=U.fg,e.bg=U.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,r,s=this._curAttrData;for(let o=0;o=30&&r<=37?(s.fg&=-67108864,s.fg|=16777216|r-30):r>=40&&r<=47?(s.bg&=-67108864,s.bg|=16777216|r-40):r>=90&&r<=97?(s.fg&=-67108864,s.fg|=16777216|r-90|8):r>=100&&r<=107?(s.bg&=-67108864,s.bg|=16777216|r-100|8):r===0?this._processSGR0(s):r===1?s.fg|=134217728:r===3?s.bg|=67108864:r===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(o)?e.getSubParams(o)[0]:1,s)):r===5?s.fg|=536870912:r===7?s.fg|=67108864:r===8?s.fg|=1073741824:r===9?s.fg|=2147483648:r===2?s.bg|=134217728:r===21?this._processUnderline(2,s):r===22?(s.fg&=-134217729,s.bg&=-134217729):r===23?s.bg&=-67108865:r===24?(s.fg&=-268435457,this._processUnderline(0,s)):r===25?s.fg&=-536870913:r===27?s.fg&=-67108865:r===28?s.fg&=-1073741825:r===29?s.fg&=2147483647:r===39?(s.fg&=-67108864,s.fg|=U.fg&16777215):r===49?(s.bg&=-67108864,s.bg|=U.bg&16777215):r===38||r===48||r===58?o+=this._extractColor(e,o,s):r===53?s.bg|=1073741824:r===55?s.bg&=-1073741825:r===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:r===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:r===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",r);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${r}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${r}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=U.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let r=t%2===1;this._coreService.decPrivateModes.cursorBlink=r}return!0}setScrollRegion(e){let t=e.params[0]||1,r;return(e.length<2||(r=e.params[1])>this._bufferService.rows||r===0)&&(r=this._bufferService.rows),r>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=r-1,this._setCursor(0,0)),!0}windowOptions(e){if(!xn(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t1;){let s=r.shift(),o=r.shift();if(/^\d+$/.exec(s)){let a=parseInt(s,10);if(Tn(a))if(o==="?")t.push({type:0,index:a});else{let l=ys(o);l&&t.push({type:1,index:a,color:l})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let r=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(r,s):r.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let r=e.split(":"),s,o=r.findIndex(a=>a.startsWith("id="));return o!==-1&&(s=r[o].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let r=e.split(";");for(let s=0;s=this._specialColors.length);++s,++t)if(r[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let o=ys(r[s]);o&&this._onColor.fire([{type:1,index:this._specialColors[t],color:o}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],r=e.split(";");for(let s=0;s=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=U.clone(),this._eraseAttrDataInternal=U.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new F;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`\x1B${l}\x1B\\`),!0),s=this._bufferService.buffer,o=this._optionsService.rawOptions,a={block:2,underline:4,bar:6};return r(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${a[o.cursorStyle]-(o.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(r){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;return o.length>=16&&o.shift(),o.push(r.flags),r.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;for(let a=0;a0;a++)r.flags=o.pop();return o.length===0&&t>0&&(r.flags=0),!0}},hi=class{constructor(i){this._bufferService=i;this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(i){ithis.end&&(this.end=i)}markRangeDirty(i,e){i>e&&(wn=i,i=e,e=wn),ithis.end&&(this.end=e)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};hi=y([m(0,D)],hi);function Tn(n){return 0<=n&&n<256}var vr=class extends g{constructor(e){super();this._action=e;this._writeBuffer=[];this._callbacks=[];this._pendingData=0;this._bufferOffset=0;this._isSyncWriting=!1;this._syncCalls=0;this._didUserInput=!1;this._innerWriteTimer=this._register(new Ie);this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._register(E(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed||this._isSyncWriting)return;this._isSyncWriting=!0;let e,t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);let r=this._callbacks.shift();r&&r()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let r;for(;r=this._writeBuffer.shift();){this._action(r);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;let r=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],o=this._action(s,t);if(o){let l=h=>{this._store.isDisposed||(performance.now()-r>=12?this._scheduleInnerWrite(0,h):this._innerWrite(r,h))};o.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(l);return}let a=this._callbacks[this._bufferOffset];if(a&&a(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-r>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}};var kt=class{constructor(i){this._bufferService=i;this._nextId=1;this._entriesWithId=new Map;this._dataByLinkId=new Map}registerLink(i){let e=this._bufferService.buffer;if(i.id===void 0){let l=e.addMarker(e.ybase+e.y),h={data:i,id:this._nextId++,lines:[l]};return l.onDispose(()=>this._removeMarkerFromLink(h,l)),this._dataByLinkId.set(h.id,h),h.id}let t=i,r=this._getEntryIdKey(t),s=this._entriesWithId.get(r);if(s)return this.addLineToLink(s.id,e.ybase+e.y),s.id;let o=e.addMarker(e.ybase+e.y),a={id:this._nextId++,key:this._getEntryIdKey(t),data:t,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(a,o)),this._entriesWithId.set(a.key,a),this._dataByLinkId.set(a.id,a),a.id}addLineToLink(i,e){let t=this._dataByLinkId.get(i);if(t&&t.lines.every(r=>r.line!==e)){let r=this._bufferService.buffer.addMarker(e);t.lines.push(r),r.onDispose(()=>this._removeMarkerFromLink(t,r))}}getLinkData(i){return this._dataByLinkId.get(i)?.data}_getEntryIdKey(i){return`${i.id};;${i.uri}`}_removeMarkerFromLink(i,e){let t=i.lines.indexOf(e);t!==-1&&(i.lines.splice(t,1),i.lines.length===0&&(i.data.id!==void 0&&this._entriesWithId.delete(i.key),this._dataByLinkId.delete(i.id)))}};kt=y([m(0,D)],kt);var Dn=!1,Sr=class extends g{constructor(e){super();this._windowsWrappingHeuristics=this._register(new P);this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onData=this._register(new b);this.onData=this._onData.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._onScroll=this._register(new b);this._instantiationService=new Ji,this.optionsService=this._register(new nr(e)),this._instantiationService.setService(R,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(wt)),this._instantiationService.setService(fe,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(Dt)),this._instantiationService.setService(D,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(Lt)),this._instantiationService.setService(Y,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(or)),this._instantiationService.setService(Me,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(me)),this.unicodeService.register(new ar),this._instantiationService.setService(Us,this.unicodeService),this._charsetService=this._instantiationService.createInstance(lr),this._instantiationService.setService(Ws,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(kt),this._instantiationService.setService(vi,this._oscLinkService),this._inputHandler=this._register(new br(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(j.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(j.forward(this._bufferService.onResize,this._onResize)),this._register(j.forward(this.coreService.onData,this._onData)),this._register(j.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new vr((t,r)=>this._inputHandler.parse(t,r))),this._register(j.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new b),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Dn&&(this._logService.warn("writeSync is unreliable and will be removed soon."),Dn=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.backend!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Is.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(Is(this._bufferService),!1))),this._windowsWrappingHeuristics.value=E(()=>{for(let t of e)t.dispose()})}}};var z=0,gr=class{constructor(i,e){this._getKey=i;this._array=[];this._insertedValues=[];this._isFlushingInserted=!1;this._deletedIndices=[];this._isFlushingDeleted=!1;this._flushInsertedTask=new It(e),this._flushDeletedTask=new It(e)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(i){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(i)}_flushInserted(){let i=this._insertedValues.sort((s,o)=>this._getKey(s)-this._getKey(o)),e=0,t=0,r=new Array(this._array.length+this._insertedValues.length);for(let s=0;s=this._array.length||this._getKey(i[e])<=this._getKey(this._array[t])?(r[s]=i[e],e++):r[s]=this._array[t++];this._array=r,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(i){if(this._flushCleanupInserted(),this._array.length===0)return!1;let e=this._getKey(i);if(e===void 0||(z=this._search(e),z===-1)||this._getKey(this._array[z])!==e)return!1;do if(this._array[z]===i)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(z),!0;while(++zs-o),e=0,t=new Array(this._array.length-i.length),r=0;for(let s=0;s0&&this._flushDeletedTask.flush()}*getKeyIterator(i){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(z=this._search(i),!(z<0||z>=this._array.length)&&this._getKey(this._array[z])===i))do yield this._array[z];while(++z=this._array.length)&&this._getKey(this._array[z])===i))do e(this._array[z]);while(++z=e;){let r=e+t>>1,s=this._getKey(this._array[r]);if(s>i)t=r-1;else if(s0&&this._getKey(this._array[r-1])===i;)r--;return r}}return e}};var Mt=0,Ir=0,Bt=class extends g{constructor(e,t){super();this._logService=e;this._bufferService=t;this._lineCache=this._register(new xs);this._onDecorationRegistered=this._register(new b);this.onDecorationRegistered=this._onDecorationRegistered.event;this._onDecorationRemoved=this._register(new b);this.onDecorationRemoved=this._onDecorationRemoved.event;this._decorations=new gr(r=>r?.marker.line,this._logService),this._register(E(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new ws(e);if(t){let r=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),r.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,r){let s=this._lineCache.getDecorationsOnLine(t);if(s)for(let o of s)Mt=o.options.x??0,Ir=Mt+(o.options.width??1),e>=Mt&&e=Mt&&ethis._handleBufferLinesTrim(r))),t.add(e.onInsert(r=>this._handleBufferLinesInsert(r))),t.add(e.onDelete(r=>this._handleBufferLinesDelete(r)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){let t=e.marker.line;if(t<0)return;e._indexedStartLine=t;let r=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{let t=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(let r of t)r()})}_handleBufferLinesTrim(e){if(e<=0)return;let t=new Map;for(let[r,s]of this._decorationsByLine){let o=r-e;o<0||this._mergeLineBucket(t,o,s)}this._decorationsByLine.clear();for(let[r,s]of t)this._decorationsByLine.set(r,s);for(let r of this._decorations)r.marker.isDisposed||(r._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,r){let s=e.get(t);if(s)for(let o=0,a=r.length;ot&&(s.push(a),this._removeFromLineBuckets(a))}let o=new Map;for(let[a,l]of this._decorationsByLine){let h=a>=t?a+r:a;this._mergeLineBucket(o,h,l)}this._decorationsByLine.clear();for(let[a,l]of o)this._decorationsByLine.set(a,l);for(let a of this._decorations)a.marker.isDisposed||a._indexedStartLine>=t&&(a._indexedStartLine=a.marker.line);for(let a of s)this._addToLineBuckets(a)}_applyBufferLinesDelete(e){let t=e.index+e.amount,r=new Map;for(let[o,a]of this._decorationsByLine){if(o>=e.index&&o=t?o-e.amount:o;this._mergeLineBucket(r,l,a)}this._decorationsByLine.clear();for(let[o,a]of r)this._decorationsByLine.set(o,a);let s=[];for(let o of this._decorations){if(o.marker.isDisposed)continue;let a=o._indexedStartLine,l=this._getDecorationHeight(o);a>=t?o._indexedStartLine=o.marker.line:at&&s.push(o)}for(let o of s)this._reindexDecoration(o)}},ws=class extends pe{constructor(e){super();this.options=e;this.onRenderEmitter=this.add(new b);this.onRender=this.onRenderEmitter.event;this._onDispose=this.add(new b);this.onDispose=this._onDispose.event;this._cachedBg=null;this._cachedFg=null;this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}get backgroundColorRGB(){return this._cachedBg===null&&(this.options.backgroundColor?this._cachedBg=B.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return this._cachedFg===null&&(this.options.foregroundColor?this._cachedFg=B.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}dispose(){this._onDispose.fire(),super.dispose()}};var yo=1e3,Cr=class{constructor(i,e=yo){this._renderCallback=i;this._debounceThresholdMS=e;this._lastRefreshMs=0;this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e;let r=performance.now();if(r-this._lastRefreshMs>=this._debounceThresholdMS)this._refreshTimeoutID!==void 0&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=r,this._innerRefresh();else if(!this._additionalRefreshRequested){let s=r-this._lastRefreshMs,o=this._debounceThresholdMS-s;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},o)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e)}};var Rn=!1,Ye=class extends g{constructor(e,t,r,s){super();this._terminal=e;this._coreBrowserService=r;this._renderService=s;this._rowColumns=new WeakMap;this._liveRegionLineCount=0;this._charsToConsume=[];this._charsToAnnounce="";let o=this._coreBrowserService.mainDocument;this._accessibilityContainer=o.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=o.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let a=0;athis._handleBoundaryFocus(a,0),this._bottomBoundaryFocusListener=a=>this._handleBoundaryFocus(a,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=o.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Cr(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");Rn?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=o.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(o.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(o.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(a=>this._handleResize(a.rows))),this._register(this._terminal.onRender(a=>this._refreshRows(a.start,a.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(a=>this._handleChar(a))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` ++`,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new ne(c=>(this.setTitle(c),this.setIconName(c),!0))),this._parser.registerOscHandler(1,new ne(c=>this.setIconName(c))),this._parser.registerOscHandler(2,new ne(c=>this.setTitle(c))),this._parser.registerOscHandler(4,new ne(c=>this.setOrReportIndexedColor(c))),this._parser.registerOscHandler(8,new ne(c=>this.setHyperlink(c))),this._parser.registerOscHandler(10,new ne(c=>this.setOrReportFgColor(c))),this._parser.registerOscHandler(11,new ne(c=>this.setOrReportBgColor(c))),this._parser.registerOscHandler(12,new ne(c=>this.setOrReportCursorColor(c))),this._parser.registerOscHandler(104,new ne(c=>this.restoreIndexedColor(c))),this._parser.registerOscHandler(110,new ne(c=>this.restoreFgColor(c))),this._parser.registerOscHandler(111,new ne(c=>this.restoreBgColor(c))),this._parser.registerOscHandler(112,new ne(c=>this.restoreCursorColor(c))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let c in q)this._parser.registerEscHandler({intermediates:"(",final:c},()=>this.selectCharset("("+c)),this._parser.registerEscHandler({intermediates:")",final:c},()=>this.selectCharset(")"+c)),this._parser.registerEscHandler({intermediates:"*",final:c},()=>this.selectCharset("*"+c)),this._parser.registerEscHandler({intermediates:"+",final:c},()=>this.selectCharset("+"+c)),this._parser.registerEscHandler({intermediates:"-",final:c},()=>this.selectCharset("-"+c)),this._parser.registerEscHandler({intermediates:".",final:c},()=>this.selectCharset("."+c)),this._parser.registerEscHandler({intermediates:"/",final:c},()=>this.selectCharset("/"+c));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(c=>(this._logService.error("Parsing error: ",c),c)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new li((c,u)=>this.requestStatusString(c,u)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,r,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=r,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,r=new Promise((s,o)=>{t=setTimeout(()=>o("#SLOW_TIMEOUT"),5e3)});Promise.race([e,r]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let r,s=this._activeBuffer.x,o=this._activeBuffer.y,a=0,l=this._parseStack.paused;if(l){if(r=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(r),r;s=this._parseStack.cursorStartX,o=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(a=this._parseStack.position+131072)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,c=>String.fromCharCode(c)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(c=>c.charCodeAt(0)):e),this._parseBuffer.length131072)for(let c=a;c0&&_.getWidth(this._activeBuffer.x-1)===2&&_.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let p=this._parser.precedingJoinState;for(let v=t;vh){if(d){let L=_,T=this._activeBuffer.x-I;if(this._activeBuffer.x=I,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),_=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!_)return;for(I>0&&_ instanceof De&&_.copyCellsFrom(L,T,0,I,!1);T=0;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(c&&(_.insertCells(this._activeBuffer.x,o-I,this._activeBuffer.getNullCell(u)),_.getWidth(h-1)===2&&_.setCellFromCodepoint(h-1,0,1,u)),_.setCellFromCodepoint(this._activeBuffer.x++,s,o,u),o>0)for(;--o;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=p,this._activeBuffer.x0&&_.getWidth(this._activeBuffer.x)===0&&!_.hasContent(this._activeBuffer.x)&&_.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,r=>xn(r.params[0],this._optionsService.rawOptions.windowOptions)?t(r):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new li(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new ne(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new _r(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,r,s=!1,o=!1){let a=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);a&&(a.replaceCells(t,r,this._activeBuffer.getNullCell(this._eraseAttrData()),o),s&&(a.isWrapped=!1))}_resetBufferLine(e,t=!1){let r=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);r&&(r.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),r.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let r;switch(e.params[0]){case 0:for(r=this._activeBuffer.y,this._dirtyRowTracker.markDirty(r),this._eraseInBufferLine(r++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);r=this._bufferService.cols){let o=this._activeBuffer.lines.get(r+1);o&&(o.isWrapped=!1)}for(;r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(r=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,r-1);r--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+r)?.getTrimmedLength(););for(;r>=0;r--)this._bufferService.scroll(this._eraseAttrData())}else{for(r=this._bufferService.rows,this._dirtyRowTracker.markDirty(r-1);r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let c=d;for(let u=1;u0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${yn})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(te[te.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",te[te.SET=1]="SET",te[te.RESET=2]="RESET",te[te.PERMANENTLY_SET=3]="PERMANENTLY_SET",te[te.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(r||={});let s=this._coreService.decPrivateModes,{activeProtocol:o,activeEncoding:a}=this._mouseStateService,l=this._coreService,{buffers:h,cols:d}=this._bufferService,{active:c,alt:u}=h,_=this._optionsService.rawOptions,p=(S,I)=>(l.triggerDataEvent(`\x1B[${t?"":"?"}${S};${I}$y`),!0),v=S=>S?1:2,f=e.params[0];return t?f===2?p(f,4):f===4?p(f,v(l.modes.insertMode)):f===12?p(f,3):f===20?p(f,v(_.convertEol)):p(f,0):f===1?p(f,v(s.applicationCursorKeys)):f===3?p(f,_.windowOptions.setWinLines?d===80?2:d===132?1:0:0):f===6?p(f,v(s.origin)):f===7?p(f,v(s.wraparound)):f===8?p(f,3):f===9?p(f,v(o==="X10")):f===12?p(f,v(_.cursorBlink)):f===25?p(f,v(!l.isCursorHidden)):f===45?p(f,v(s.reverseWraparound)):f===66?p(f,v(s.applicationKeypad)):f===67?p(f,4):f===1e3?p(f,v(o==="VT200")):f===1002?p(f,v(o==="DRAG")):f===1003?p(f,v(o==="ANY")):f===1004?p(f,v(s.sendFocus)):f===1005?p(f,4):f===1006?p(f,v(a==="SGR")):f===1015?p(f,4):f===1016?p(f,v(a==="SGR_PIXELS")):f===1048?p(f,1):f===47||f===1047||f===1049?p(f,v(c===u)):f===2004?p(f,v(s.bracketedPasteMode)):f===2026?p(f,v(s.synchronizedOutput)):f===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?p(f,v(s.win32InputMode)):p(f,0)}_updateAttrColor(e,t,r,s,o){return t===2?(e|=50331648,e&=-16777216,e|=ue.fromColorRGB([r,s,o])):t===5&&(e&=-67108864,e|=33554432|r&255),e}_extractColor(e,t,r){let s=[0,0,-1,0,0,0],o=0,a=0;do{if(s[a+o]=e.params[t+a],e.hasSubParams(t+a)){let l=e.getSubParams(t+a),h=0;do s[1]===5&&(o=1),s[a+h+1+o]=l[h];while(++h=2||s[1]===2&&a+o>=5)break;s[1]&&(o=1)}while(++a+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=U.fg,e.bg=U.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,r,s=this._curAttrData;for(let o=0;o=30&&r<=37?(s.fg&=-67108864,s.fg|=16777216|r-30):r>=40&&r<=47?(s.bg&=-67108864,s.bg|=16777216|r-40):r>=90&&r<=97?(s.fg&=-67108864,s.fg|=16777216|r-90|8):r>=100&&r<=107?(s.bg&=-67108864,s.bg|=16777216|r-100|8):r===0?this._processSGR0(s):r===1?s.fg|=134217728:r===3?s.bg|=67108864:r===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(o)?e.getSubParams(o)[0]:1,s)):r===5?s.fg|=536870912:r===7?s.fg|=67108864:r===8?s.fg|=1073741824:r===9?s.fg|=2147483648:r===2?s.bg|=134217728:r===21?this._processUnderline(2,s):r===22?(s.fg&=-134217729,s.bg&=-134217729):r===23?s.bg&=-67108865:r===24?(s.fg&=-268435457,this._processUnderline(0,s)):r===25?s.fg&=-536870913:r===27?s.fg&=-67108865:r===28?s.fg&=-1073741825:r===29?s.fg&=2147483647:r===39?(s.fg&=-67108864,s.fg|=U.fg&16777215):r===49?(s.bg&=-67108864,s.bg|=U.bg&16777215):r===38||r===48||r===58?o+=this._extractColor(e,o,s):r===53?s.bg|=1073741824:r===55?s.bg&=-1073741825:r===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:r===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:r===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",r);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${r}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${r}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=U.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let r=t%2===1;this._coreService.decPrivateModes.cursorBlink=r}return!0}setScrollRegion(e){let t=e.params[0]||1,r;return(e.length<2||(r=e.params[1])>this._bufferService.rows||r===0)&&(r=this._bufferService.rows),r>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=r-1,this._setCursor(0,0)),!0}windowOptions(e){if(!xn(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t1;){let s=r.shift(),o=r.shift();if(/^\d+$/.exec(s)){let a=parseInt(s,10);if(Tn(a))if(o==="?")t.push({type:0,index:a});else{let l=ys(o);l&&t.push({type:1,index:a,color:l})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let r=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(r,s):r.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let r=e.split(":"),s,o=r.findIndex(a=>a.startsWith("id="));return o!==-1&&(s=r[o].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let r=e.split(";");for(let s=0;s=this._specialColors.length);++s,++t)if(r[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let o=ys(r[s]);o&&this._onColor.fire([{type:1,index:this._specialColors[t],color:o}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],r=e.split(";");for(let s=0;s=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=U.clone(),this._eraseAttrDataInternal=U.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new F;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`\x1B${l}\x1B\\`),!0),s=this._bufferService.buffer,o=this._optionsService.rawOptions,a={block:2,underline:4,bar:6};return r(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${a[o.cursorStyle]-(o.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(r){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;return o.length>=16&&o.shift(),o.push(r.flags),r.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;for(let a=0;a0;a++)r.flags=o.pop();return o.length===0&&t>0&&(r.flags=0),!0}},hi=class{constructor(i){this._bufferService=i;this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(i){ithis.end&&(this.end=i)}markRangeDirty(i,e){i>e&&(wn=i,i=e,e=wn),ithis.end&&(this.end=e)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};hi=y([m(0,D)],hi);function Tn(n){return 0<=n&&n<256}var vr=class extends g{constructor(e){super();this._action=e;this._writeBuffer=[];this._callbacks=[];this._pendingData=0;this._bufferOffset=0;this._isSyncWriting=!1;this._syncCalls=0;this._didUserInput=!1;this._innerWriteTimer=this._register(new Ie);this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._register(E(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed||this._isSyncWriting)return;this._isSyncWriting=!0;let e,t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);let r=this._callbacks.shift();r&&r()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let r;for(;r=this._writeBuffer.shift();){this._action(r);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;let r=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],o=this._action(s,t);if(o){let l=h=>{this._store.isDisposed||(performance.now()-r>=12?this._scheduleInnerWrite(0,h):this._innerWrite(r,h))};o.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(l);return}let a=this._callbacks[this._bufferOffset];if(a&&a(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-r>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}};var kt=class{constructor(i){this._bufferService=i;this._nextId=1;this._entriesWithId=new Map;this._dataByLinkId=new Map}registerLink(i){let e=this._bufferService.buffer;if(i.id===void 0){let l=e.addMarker(e.ybase+e.y),h={data:i,id:this._nextId++,lines:[l]};return l.onDispose(()=>this._removeMarkerFromLink(h,l)),this._dataByLinkId.set(h.id,h),h.id}let t=i,r=this._getEntryIdKey(t),s=this._entriesWithId.get(r);if(s)return this.addLineToLink(s.id,e.ybase+e.y),s.id;let o=e.addMarker(e.ybase+e.y),a={id:this._nextId++,key:this._getEntryIdKey(t),data:t,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(a,o)),this._entriesWithId.set(a.key,a),this._dataByLinkId.set(a.id,a),a.id}addLineToLink(i,e){let t=this._dataByLinkId.get(i);if(t&&t.lines.every(r=>r.line!==e)){let r=this._bufferService.buffer.addMarker(e);t.lines.push(r),r.onDispose(()=>this._removeMarkerFromLink(t,r))}}getLinkData(i){return this._dataByLinkId.get(i)?.data}_getEntryIdKey(i){return`${i.id};;${i.uri}`}_removeMarkerFromLink(i,e){let t=i.lines.indexOf(e);t!==-1&&(i.lines.splice(t,1),i.lines.length===0&&(i.data.id!==void 0&&this._entriesWithId.delete(i.key),this._dataByLinkId.delete(i.id)))}};kt=y([m(0,D)],kt);var Dn=!1,Sr=class extends g{constructor(e){super();this._windowsWrappingHeuristics=this._register(new P);this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onData=this._register(new b);this.onData=this._onData.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._onScroll=this._register(new b);this._instantiationService=new Ji,this.optionsService=this._register(new nr(e)),this._instantiationService.setService(R,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(wt)),this._instantiationService.setService(fe,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(Dt)),this._instantiationService.setService(D,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(Lt)),this._instantiationService.setService(Y,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(or)),this._instantiationService.setService(Me,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(me)),this.unicodeService.register(new ar),this._instantiationService.setService(Us,this.unicodeService),this._charsetService=this._instantiationService.createInstance(lr),this._instantiationService.setService(Ws,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(kt),this._instantiationService.setService(vi,this._oscLinkService),this._inputHandler=this._register(new br(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(j.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(j.forward(this._bufferService.onResize,this._onResize)),this._register(j.forward(this.coreService.onData,this._onData)),this._register(j.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new vr((t,r)=>this._inputHandler.parse(t,r))),this._register(j.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new b),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Dn&&(this._logService.warn("writeSync is unreliable and will be removed soon."),Dn=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.backend!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Is.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(Is(this._bufferService),!1))),this._windowsWrappingHeuristics.value=E(()=>{for(let t of e)t.dispose()})}}};var z=0,gr=class{constructor(i,e){this._getKey=i;this._array=[];this._insertedValues=[];this._isFlushingInserted=!1;this._deletedIndices=[];this._isFlushingDeleted=!1;this._flushInsertedTask=new It(e),this._flushDeletedTask=new It(e)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(i){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(i)}_flushInserted(){let i=this._insertedValues.sort((s,o)=>this._getKey(s)-this._getKey(o)),e=0,t=0,r=new Array(this._array.length+this._insertedValues.length);for(let s=0;s=this._array.length||this._getKey(i[e])<=this._getKey(this._array[t])?(r[s]=i[e],e++):r[s]=this._array[t++];this._array=r,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(i){if(this._flushCleanupInserted(),this._array.length===0)return!1;let e=this._getKey(i);if(e===void 0)return!1;if(this._deleteAtKey(i,e))return!0;if(this._deletedIndices.length===0)return!1;return this._flushCleanupDeleted(),this._deleteAtKey(i,e)}_deleteAtKey(i,e){if((z=this._search(e),z===-1)||this._getKey(this._array[z])!==e)return!1;do if(this._array[z]===i)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(z),!0;while(++zs-o),e=0,t=new Array(this._array.length-i.length),r=0;for(let s=0;s0&&this._flushDeletedTask.flush()}*getKeyIterator(i){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(z=this._search(i),!(z<0||z>=this._array.length)&&this._getKey(this._array[z])===i))do yield this._array[z];while(++z=this._array.length)&&this._getKey(this._array[z])===i))do e(this._array[z]);while(++z=e;){let r=e+t>>1,s=this._getKey(this._array[r]);if(s>i)t=r-1;else if(s0&&this._getKey(this._array[r-1])===i;)r--;return r}}return e}};var Mt=0,Ir=0,Bt=class extends g{constructor(e,t){super();this._logService=e;this._bufferService=t;this._lineCache=this._register(new xs);this._onDecorationRegistered=this._register(new b);this.onDecorationRegistered=this._onDecorationRegistered.event;this._onDecorationRemoved=this._register(new b);this.onDecorationRemoved=this._onDecorationRemoved.event;this._decorations=new gr(r=>r?.marker.line,this._logService),this._register(E(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new ws(e);if(t){let r=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),r.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,r){let s=this._lineCache.getDecorationsOnLine(t);if(s)for(let o of s)Mt=o.options.x??0,Ir=Mt+(o.options.width??1),e>=Mt&&e=Mt&&ethis._handleBufferLinesTrim(r))),t.add(e.onInsert(r=>this._handleBufferLinesInsert(r))),t.add(e.onDelete(r=>this._handleBufferLinesDelete(r)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){let t=e.marker.line;if(t<0)return;e._indexedStartLine=t;let r=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{let t=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(let r of t)r()})}_handleBufferLinesTrim(e){if(e<=0)return;let t=new Map;for(let[r,s]of this._decorationsByLine){let o=r-e;o<0||this._mergeLineBucket(t,o,s)}this._decorationsByLine.clear();for(let[r,s]of t)this._decorationsByLine.set(r,s);for(let r of this._decorations)r.marker.isDisposed||(r._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,r){let s=e.get(t);if(s)for(let o=0,a=r.length;ot&&(s.push(a),this._removeFromLineBuckets(a))}let o=new Map;for(let[a,l]of this._decorationsByLine){let h=a>=t?a+r:a;this._mergeLineBucket(o,h,l)}this._decorationsByLine.clear();for(let[a,l]of o)this._decorationsByLine.set(a,l);for(let a of this._decorations)a.marker.isDisposed||a._indexedStartLine>=t&&(a._indexedStartLine=a.marker.line);for(let a of s)this._addToLineBuckets(a)}_applyBufferLinesDelete(e){let t=e.index+e.amount,r=new Map;for(let[o,a]of this._decorationsByLine){if(o>=e.index&&o=t?o-e.amount:o;this._mergeLineBucket(r,l,a)}this._decorationsByLine.clear();for(let[o,a]of r)this._decorationsByLine.set(o,a);let s=[];for(let o of this._decorations){if(o.marker.isDisposed)continue;let a=o._indexedStartLine,l=this._getDecorationHeight(o);a>=t?o._indexedStartLine=o.marker.line:at&&s.push(o)}for(let o of s)this._reindexDecoration(o)}},ws=class extends pe{constructor(e){super();this.options=e;this.onRenderEmitter=this.add(new b);this.onRender=this.onRenderEmitter.event;this._onDispose=this.add(new b);this.onDispose=this._onDispose.event;this._cachedBg=null;this._cachedFg=null;this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}get backgroundColorRGB(){return this._cachedBg===null&&(this.options.backgroundColor?this._cachedBg=B.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return this._cachedFg===null&&(this.options.foregroundColor?this._cachedFg=B.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}dispose(){this._onDispose.fire(),super.dispose()}};var yo=1e3,Cr=class{constructor(i,e=yo){this._renderCallback=i;this._debounceThresholdMS=e;this._lastRefreshMs=0;this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e;let r=performance.now();if(r-this._lastRefreshMs>=this._debounceThresholdMS)this._refreshTimeoutID!==void 0&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=r,this._innerRefresh();else if(!this._additionalRefreshRequested){let s=r-this._lastRefreshMs,o=this._debounceThresholdMS-s;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},o)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e)}};var Rn=!1,Ye=class extends g{constructor(e,t,r,s){super();this._terminal=e;this._coreBrowserService=r;this._renderService=s;this._rowColumns=new WeakMap;this._liveRegionLineCount=0;this._charsToConsume=[];this._charsToAnnounce="";let o=this._coreBrowserService.mainDocument;this._accessibilityContainer=o.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=o.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let a=0;athis._handleBoundaryFocus(a,0),this._bottomBoundaryFocusListener=a=>this._handleBoundaryFocus(a,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=o.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Cr(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");Rn?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=o.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(o.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(o.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(a=>this._handleResize(a.rows))),this._register(this._terminal.onRender(a=>this._refreshRows(a.start,a.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(a=>this._handleChar(a))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` `))),this._register(this._terminal.onA11yTab(a=>this._handleTab(a))),this._register(this._terminal.onKey(a=>this._handleKey(a.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register(C(o,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register(E(()=>{Rn?this._debugRootContainer.remove():this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` -`&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent=Ze.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){let r=this._terminal.buffer,s=r.lines.length.toString();for(let o=e;o<=t;o++){let a=r.lines.get(r.ydisp+o),l=[],h=a?.translateToString(!0,void 0,void 0,l)||"",d=(r.ydisp+o+1).toString(),c=this._rowElements[o];c&&(h.length===0?(c.textContent="\xA0",this._rowColumns.set(c,[0,1])):(c.textContent=h,this._rowColumns.set(c,l)),c.setAttribute("aria-posinset",d),c.setAttribute("aria-setsize",s),this._alignRowWidth(c))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent===Ze.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){let r=e.target,s=this._rowElements[t===0?1:this._rowElements.length-2],o=r.getAttribute("aria-posinset"),a=t===0?"1":`${this._terminal.buffer.lines.length}`;if(o===a||e.relatedTarget!==s)return;let l,h;if(t===0?(l=r,h=this._rowElements.pop(),this._rowContainer.removeChild(h)):(l=this._rowElements.shift(),h=r,this._rowContainer.removeChild(l)),l.removeEventListener("focus",this._topBoundaryFocusListener),h.removeEventListener("focus",this._bottomBoundaryFocusListener),t===0){let d=this._createAccessibilityTreeNode();this._rowElements.unshift(d),this._rowContainer.insertAdjacentElement("afterbegin",d)}else{let d=this._createAccessibilityTreeNode();this._rowElements.push(d),this._rowContainer.appendChild(d)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(t===0?-1:1),this._rowElements[t===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;let e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed){this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection();return}if(!e.anchorNode||!e.focusNode){console.error("anchorNode and/or focusNode are null");return}let t={node:e.anchorNode,offset:e.anchorOffset},r={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(r.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===r.node&&t.offset>r.offset)&&([t,r]=[r,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;let s=this._rowElements.slice(-1)[0];if(r.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(r={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(r.node))return;let o=({node:h,offset:d})=>{let c=h instanceof Text?h.parentNode:h,u=parseInt(c?.getAttribute("aria-posinset"),10)-1;if(isNaN(u))return console.warn("row is invalid. Race condition?"),null;let _=this._rowColumns.get(c);if(!_)return console.warn("columns is null. Race condition?"),null;let p=d<_.length?_[d]:_.slice(-1)[0]+1;return p>=this._terminal.cols&&(++u,p=0),{row:u,column:p}},a=o(t),l=o(r);if(!(!a||!l)){if(a.row>l.row||a.row===l.row&&a.column>=l.column)throw new Error("invalid range");this._terminal.select(a.column,a.row,(l.row-a.row)*this._terminal.cols-a.column+l.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let t=this._rowContainer.children.length;te;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){let e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register(C(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register(C(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register(C(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register(C(this._element,"mouseup",this._handleMouseUp.bind(this)))}get currentLink(){return this._currentLink}_handleMouseMove(e){this._lastMouseEvent=e;let t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;let r=e.composedPath();for(let s=0;s{s?.forEach(o=>{o.link.dispose&&o.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(let[s,o]of this._linkProviderService.linkProviders.entries())t?this._activeProviderReplies?.get(s)&&(r=this._checkLinkProviderResult(s,e,r)):o.provideLinks(e.y,a=>{if(this._isMouseOut)return;let l=a?.map(h=>({link:h}));this._activeProviderReplies?.set(s,l),r=this._checkLinkProviderResult(s,e,r),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){let r=new Set;for(let s=0;se?this._bufferService.cols:l.link.range.end.x;for(let c=h;c<=d;c++){if(r.has(c)){o.splice(a--,1);break}r.add(c)}}}}_checkLinkProviderResult(e,t,r){if(!this._activeProviderReplies)return r;let s=this._activeProviderReplies.get(e),o=!1;for(let a=0;athis._linkAtPosition(l.link,t));a&&(r=!0,this._handleNewLink(a))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!r)for(let a=0;athis._linkAtPosition(h.link,t));if(l){r=!0,this._handleNewLink(l);break}}return r}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;let t=this._positionFromMouseEvent(e,this._element);t&&this._mouseDownLink&&xo(this._mouseDownLink.link,this._currentLink.link)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){!this._currentLink||!this._lastMouseEvent||(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;let t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:e.link.decorations===void 0?!0:e.link.decorations.underline,pointerCursor:e.link.decorations===void 0?!0:e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:r=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==r&&(this._currentLink.state.decorations.pointerCursor=r,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",r))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:r=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==r&&(this._currentLink.state.decorations.underline=r,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,r))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(r=>{if(!this._currentLink)return;let s=r.start===0?0:r.start+1+this._bufferService.buffer.ydisp,o=this._bufferService.buffer.ydisp+1+r.end;if(this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=o&&(this._clearCurrentLink(s,o),this._lastMouseEvent)){let a=this._positionFromMouseEvent(this._lastMouseEvent,this._element);a&&this._askForLink(a,!1)}})))}_linkHover(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(r,t.text)}_fireUnderlineEvent(e,t){let r=e.range,s=this._bufferService.buffer.ydisp,o=this._createLinkUnderlineEvent(r.start.x-1,r.start.y-s-1,r.end.x,r.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(o)}_linkLeave(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(r,t.text)}_linkAtPosition(e,t){let r=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,o=t.y*this._bufferService.cols+t.x;return r<=o&&o<=s}_positionFromMouseEvent(e,t){let r=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(r)return{x:r[0],y:r[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,r,s,o){return{x1:e,y1:t,x2:r,y2:s,cols:this._bufferService.cols,fg:o}}};Pt=y([m(1,Pe),m(2,V),m(3,D),m(4,Ii)],Pt);function xo(n,i){return n.text===i.text&&n.range.start.x===i.range.start.x&&n.range.start.y===i.range.start.y&&n.range.end.x===i.range.end.x&&n.range.end.y===i.range.end.y}var Er=class extends Sr{constructor(e={}){super(e);this._linkifier=this._register(new P);this.browser=Ke;this._keyDownHandled=!1;this._keyDownSeen=!1;this._keyPressHandled=!1;this._unprocessedDeadKey=!1;this._accessibilityManager=this._register(new P);this._onCursorMove=this._register(new b);this.onCursorMove=this._onCursorMove.event;this._onKey=this._register(new b);this.onKey=this._onKey.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onTitleChange=this._register(new b);this.onTitleChange=this._onTitleChange.event;this._onBell=this._register(new b);this.onBell=this._onBell.event;this._onFocus=this._register(new b);this._onBlur=this._register(new b);this._onA11yCharEmitter=this._register(new b);this._onA11yTabEmitter=this._register(new b);this._onWillOpen=this._register(new b);this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._setup(),this._decorationService=this._instantiationService.createInstance(Bt),this._instantiationService.setService(ge,this._decorationService),this._keyboardService=this._instantiationService.createInstance(xt),this._instantiationService.setService(zs,this._keyboardService),this._linkProviderService=this._instantiationService.createInstance(zi),this._instantiationService.setService(Ii,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(et)),this._register(this._inputHandler.onRequestBell(()=>this._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(t=>this._reportWindowsOptions(t))),this._register(this._inputHandler.onColor(t=>this._handleColorEvent(t))),this._register(j.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(j.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(j.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(j.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(t=>this._afterResize(t.cols,t.rows))),this._register(E(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}get dimensions(){if(!this._renderService)return;let e=this._renderService.dimensions;return{css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}}_handleColorEvent(e){if(this._themeService)for(let t of e){let r,s;switch(t.index){case 256:r="foreground",s="10";break;case 257:r="background",s="11";break;case 258:r="cursor",s="12";break;default:r="ansi",s="4;"+t.index}switch(t.type){case 0:let o=k.toColorRGB(r==="ansi"?this._themeService.colors.ansi[t.index]:this._themeService.colors[r]);this.coreService.triggerDataEvent(`\x1B]${s};${En(o)}\x1B\\`);break;case 1:if(r==="ansi")this._themeService.modifyColors(a=>a.ansi[t.index]=O.toColor(...t.color));else{let a=r;this._themeService.modifyColors(l=>l[a]=O.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index);break}}}_reportColorScheme(){if(!this._themeService)return;let e=Z.relativeLuminance(this._themeService.colors.background.rgba>>8),t=Z.relativeLuminance(this._themeService.colors.foreground.rgba>>8),r=e{this.hasSelection()&&Os(t,this._selectionService)}));let e=t=>Ns(t,this.textarea,this.coreService,this.optionsService);this._register(C(this.textarea,"paste",e)),this._register(C(this.element,"paste",e)),nt?this._register(C(this.element,"mousedown",t=>{t.button===2&&Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register(C(this.element,"contextmenu",t=>{Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),zt&&this._register(C(this.element,"auxclick",t=>{t.button===1&&Fr(t,this.textarea,this.screenElement)}))}_bindKeys(){this._register(C(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register(C(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register(C(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register(C(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register(C(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register(C(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register(C(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService){this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView);return}this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",l=>this.element.classList.toggle("allow-transparency",l))),e.appendChild(this.element);let t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register(C(this.screenElement,"mousemove",l=>this.updateCursorStyle(l))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);let r=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",Ut.get()),Yr||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>r.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(Ki,this.textarea,e.ownerDocument.defaultView??window,this._document??(typeof window<"u"?window.document:null))),this._instantiationService.setService(G,this._coreBrowserService),this._register(C(this.textarea,"focus",l=>this._handleTextAreaFocus(l))),this._register(C(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(bt,this._document,this._helperContainer),this._instantiationService.setService(Be,this._charSizeService),this._themeService=this._instantiationService.createInstance(yt),this._instantiationService.setService(_e,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(He),this._instantiationService.setService(gi,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(Ct,this.rows,this.screenElement)),this._instantiationService.setService(V,this._renderService),this._register(this._renderService.onRenderedViewportChange(l=>this._onRender.fire(l))),this._register(this._renderService.onDimensionsChange(l=>this._onDimensionsChange.fire({css:{canvas:{...l.css.canvas},cell:{...l.css.cell}},device:{canvas:{...l.device.canvas},cell:{...l.device.cell},char:{...l.device.char}}}))),this.onResize(l=>this._renderService.resize(l.cols,l.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(ft,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(vt),this._instantiationService.setService(Pe,this._mouseCoordsService);let s=this._linkifier.value=this._register(this._instantiationService.createInstance(Pt,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(l){this._logService.error("onWillOpen handler threw an exception",l)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(dt,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(l=>{super.scrollLines(l,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(Et,this.element,this.screenElement,s)),this._instantiationService.setService(Si,this._selectionService),this._mouseService=this._instantiationService.createInstance(gt),this._instantiationService.setService(Ks,this._mouseService),this._register(this._selectionService.onRequestScrollLines(l=>this.scrollLines(l.amount,l.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(l=>this._renderService.handleSelectionChanged(l.start,l.end,l.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(l=>{this.textarea.value=l,this.textarea.focus(),this.textarea.select()})),this._register(j.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(ut,this.screenElement)),this._register(C(this.element,"mousedown",l=>this._selectionService.handleMouseDown(l))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(Ye,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",l=>this._handleScreenReaderModeOptionChange(l)));let o=this.options.scrollbar?.showScrollbar??!0,a=this.options.scrollbar?.width;o&&a&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",l=>{let h=(l?.showScrollbar??!0)&&!!l?.width;!this._overviewRulerRenderer&&h&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:l=>this._viewport?.handleTouchScroll(l)},l=>this._register(l),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(mt,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,r=!1){this._renderService?.refreshRows(e,t,r)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}paste(e){Nr(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");let t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return this._selectionService?this._selectionService.hasSelection:!1}select(e,t,r){this._selectionService.setSelection(e,t,r)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(!(!this._selectionService||!this._selectionService.hasSelection))return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;let t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;!t&&(e.key==="Dead"||e.key==="AltGraph")&&(this._unprocessedDeadKey=!0);let r=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),r.type===3||r.type===2){let o=this.rows-1;return this.scrollLines(r.type===2?-o:o),e.preventDefault(),e.stopPropagation(),!1}if(r.type===1&&this.selectAll(),this._isThirdLevelShift(this.browser,e)||(r.cancel&&(e.preventDefault(),e.stopPropagation()),!r.key)||!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;(r.key===""||r.key==="\r")&&(this.textarea.value="");let s=this._keyboardService.useWin32InputMode&&Ts(e);if(this._onKey.fire({key:r.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(r.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){let r=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return t.type==="keypress"?r:r&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return;Ts(e)||this.focus();let t=this._keyboardService.evaluateKeyUp(e);if(t?.key){let r=this._keyboardService.useWin32InputMode&&Ts(e);this.coreService.triggerDataEvent(t.key,!r)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;if(e.charCode)t=e.charCode;else if(e.which===null||e.which===void 0)t=e.keyCode;else if(e.which!==0&&e.charCode!==0)t=e.which;else return!1;return!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)?!1:(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,!0)}_inputEvent(e){if(e.data&&e.inputType==="insertText"&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;let t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){if(e===this.cols&&t===this.rows){this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure();return}super.resize(e,t)}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;i--)this._addons[i].instance.dispose()}loadAddon(i,e){let t={instance:e,dispose:e.dispose,isDisposed:!1};this._addons.push(t),e.dispose=()=>this._wrappedAddonDispose(t),e.activate(i)}_wrappedAddonDispose(i){if(i.isDisposed)return;let e=-1;for(let t=0;t=this._line.length))return e?(this._line.loadCell(i,e),e):this._line.loadCell(i,new F)}translateToString(i,e,t){return this._line.translateToString(i,e,t)}};var di=class{constructor(i,e){this._buffer=i;this.type=e}init(i){return this._buffer=i,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(i){let e=this._buffer.lines.get(i);if(e)return new xr(e)}getNullCell(){return new F}};var wr=class extends g{constructor(e){super();this._core=e;this._onBufferChange=this._register(new b);this.onBufferChange=this._onBufferChange.event;this._normal=new di(this._core.buffers.normal,"normal"),this._alternate=new di(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}};var Tr=class{constructor(i){this._core=i}registerCsiHandler(i,e){return this._core.registerCsiHandler(i,t=>e(t.toArray()))}addCsiHandler(i,e){return this.registerCsiHandler(i,e)}registerDcsHandler(i,e){return this._core.registerDcsHandler(i,(t,r)=>e(t,r.toArray()))}addDcsHandler(i,e){return this.registerDcsHandler(i,e)}registerEscHandler(i,e){return this._core.registerEscHandler(i,e)}addEscHandler(i,e){return this.registerEscHandler(i,e)}registerOscHandler(i,e){return this._core.registerOscHandler(i,e)}addOscHandler(i,e){return this.registerOscHandler(i,e)}registerApcHandler(i,e){return this._core.registerApcHandler(i,e)}};var Dr=class{constructor(i){this._core=i}register(i){this._core.unicodeService.register(i)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(i){this._core.unicodeService.activeVersion=i}};var wo=["cols","rows"],Ee=0,Ln=class extends g{constructor(i){super(),this._core=this._register(new Er(i)),this._addonManager=this._register(new yr),this._publicOptions={...this._core.options};let e=r=>this._core.options[r],t=(r,s)=>{this._checkReadonlyOptions(r),this._core.options[r]=s};for(let r in this._core.options){let s={get:e.bind(this,r),set:t.bind(this,r)};Object.defineProperty(this._publicOptions,r,s)}}_checkReadonlyOptions(i){if(wo.includes(i))throw new Error(`Option "${i}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new Tr(this._core)}get unicode(){return this._checkProposedApi(),new Dr(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new wr(this._core))}get markers(){return this._core.markers}get modes(){let i=this._core.coreService.decPrivateModes,e="none";switch(this._core.mouseStateService.activeProtocol){case"X10":e="x10";break;case"VT200":e="vt200";break;case"DRAG":e="drag";break;case"ANY":e="any";break}return{applicationCursorKeysMode:i.applicationCursorKeys,applicationKeypadMode:i.applicationKeypad,bracketedPasteMode:i.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:e,originMode:i.origin,reverseWraparoundMode:i.reverseWraparound,sendFocusMode:i.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:i.synchronizedOutput,win32InputMode:i.win32InputMode,wraparoundMode:i.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(i){for(let e in i)this._publicOptions[e]=i[e]}blur(){this._core.blur()}focus(){this._core.focus()}input(i,e=!0){this._core.input(i,e)}resize(i,e){this._verifyIntegers(i,e),this._core.resize(i,e)}open(i){this._core.open(i)}attachCustomKeyEventHandler(i){this._core.attachCustomKeyEventHandler(i)}attachCustomWheelEventHandler(i){this._core.attachCustomWheelEventHandler(i)}registerLinkProvider(i){return this._core.registerLinkProvider(i)}registerCharacterJoiner(i){return this._core.registerCharacterJoiner(i)}deregisterCharacterJoiner(i){this._core.deregisterCharacterJoiner(i)}registerMarker(i=0){return this._verifyIntegers(i),this._core.registerMarker(i)}registerDecoration(i){return this._verifyPositiveIntegers(i.x??0,i.width??0,i.height??0),this._core.registerDecoration(i)}hasSelection(){return this._core.hasSelection()}select(i,e,t){this._verifyIntegers(i,e,t),this._core.select(i,e,t)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(i,e){this._verifyIntegers(i,e),this._core.selectLines(i,e)}dispose(){super.dispose()}scrollLines(i){this._verifyIntegers(i),this._core.scrollLines(i)}scrollPages(i){this._verifyIntegers(i),this._core.scrollPages(i)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(i){this._verifyIntegers(i),this._core.scrollToLine(i)}clear(){this._core.clear()}write(i,e){this._core.write(i,e)}writeln(i,e){this._core.write(i),this._core.write(`\r +`&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent=Ze.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){let r=this._terminal.buffer,s=r.lines.length.toString();for(let o=e;o<=t;o++){let a=r.lines.get(r.ydisp+o),l=[],h=a?.translateToString(!0,void 0,void 0,l)||"",d=(r.ydisp+o+1).toString(),c=this._rowElements[o];c&&(h.length===0?(c.textContent="\xA0",this._rowColumns.set(c,[0,1])):(c.textContent=h,this._rowColumns.set(c,l)),c.setAttribute("aria-posinset",d),c.setAttribute("aria-setsize",s),this._alignRowWidth(c))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent===Ze.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){let r=e.target,s=this._rowElements[t===0?1:this._rowElements.length-2],o=r.getAttribute("aria-posinset"),a=t===0?"1":`${this._terminal.buffer.lines.length}`;if(o===a||e.relatedTarget!==s)return;let l,h;if(t===0?(l=r,h=this._rowElements.pop(),this._rowContainer.removeChild(h)):(l=this._rowElements.shift(),h=r,this._rowContainer.removeChild(l)),l.removeEventListener("focus",this._topBoundaryFocusListener),h.removeEventListener("focus",this._bottomBoundaryFocusListener),t===0){let d=this._createAccessibilityTreeNode();this._rowElements.unshift(d),this._rowContainer.insertAdjacentElement("afterbegin",d)}else{let d=this._createAccessibilityTreeNode();this._rowElements.push(d),this._rowContainer.appendChild(d)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(t===0?-1:1),this._rowElements[t===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;let e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed){this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection();return}if(!e.anchorNode||!e.focusNode){console.error("anchorNode and/or focusNode are null");return}let t={node:e.anchorNode,offset:e.anchorOffset},r={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(r.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===r.node&&t.offset>r.offset)&&([t,r]=[r,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;let s=this._rowElements.slice(-1)[0];if(r.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(r={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(r.node))return;let o=({node:h,offset:d})=>{let c=h instanceof Text?h.parentNode:h,u=parseInt(c?.getAttribute("aria-posinset"),10)-1;if(isNaN(u))return console.warn("row is invalid. Race condition?"),null;let _=this._rowColumns.get(c);if(!_)return console.warn("columns is null. Race condition?"),null;let p=d<_.length?_[d]:_.slice(-1)[0]+1;return p>=this._terminal.cols&&(++u,p=0),{row:u,column:p}},a=o(t),l=o(r);if(!(!a||!l)){if(a.row>l.row||a.row===l.row&&a.column>=l.column)throw new Error("invalid range");this._terminal.select(a.column,a.row,(l.row-a.row)*this._terminal.cols-a.column+l.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let t=this._rowContainer.children.length;te;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){let e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register(C(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register(C(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register(C(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register(C(this._element,"mouseup",this._handleMouseUp.bind(this)))}get currentLink(){return this._currentLink}_handleMouseMove(e){this._lastMouseEvent=e;let t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;let r=e.composedPath();for(let s=0;s{s?.forEach(o=>{o.link.dispose&&o.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(let[s,o]of this._linkProviderService.linkProviders.entries())t?this._activeProviderReplies?.get(s)&&(r=this._checkLinkProviderResult(s,e,r)):o.provideLinks(e.y,a=>{if(this._isMouseOut)return;let l=a?.map(h=>({link:h}));this._activeProviderReplies?.set(s,l),r=this._checkLinkProviderResult(s,e,r),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){let r=new Set;for(let s=0;se?this._bufferService.cols:l.link.range.end.x;for(let c=h;c<=d;c++){if(r.has(c)){o.splice(a--,1);break}r.add(c)}}}}_checkLinkProviderResult(e,t,r){if(!this._activeProviderReplies)return r;let s=this._activeProviderReplies.get(e),o=!1;for(let a=0;athis._linkAtPosition(l.link,t));a&&(r=!0,this._handleNewLink(a))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!r)for(let a=0;athis._linkAtPosition(h.link,t));if(l){r=!0,this._handleNewLink(l);break}}return r}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;let t=this._positionFromMouseEvent(e,this._element);t&&this._mouseDownLink&&xo(this._mouseDownLink.link,this._currentLink.link)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){!this._currentLink||!this._lastMouseEvent||(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;let t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:e.link.decorations===void 0?!0:e.link.decorations.underline,pointerCursor:e.link.decorations===void 0?!0:e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:r=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==r&&(this._currentLink.state.decorations.pointerCursor=r,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",r))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:r=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==r&&(this._currentLink.state.decorations.underline=r,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,r))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(r=>{if(!this._currentLink)return;let s=r.start===0?0:r.start+1+this._bufferService.buffer.ydisp,o=this._bufferService.buffer.ydisp+1+r.end;if(this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=o&&(this._clearCurrentLink(s,o),this._lastMouseEvent)){let a=this._positionFromMouseEvent(this._lastMouseEvent,this._element);a&&this._askForLink(a,!1)}})))}_linkHover(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(r,t.text)}_fireUnderlineEvent(e,t){let r=e.range,s=this._bufferService.buffer.ydisp,o=this._createLinkUnderlineEvent(r.start.x-1,r.start.y-s-1,r.end.x,r.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(o)}_linkLeave(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(r,t.text)}_linkAtPosition(e,t){let r=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,o=t.y*this._bufferService.cols+t.x;return r<=o&&o<=s}_positionFromMouseEvent(e,t){let r=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(r)return{x:r[0],y:r[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,r,s,o){return{x1:e,y1:t,x2:r,y2:s,cols:this._bufferService.cols,fg:o}}};Pt=y([m(1,Pe),m(2,V),m(3,D),m(4,Ii)],Pt);function xo(n,i){return n.text===i.text&&n.range.start.x===i.range.start.x&&n.range.start.y===i.range.start.y&&n.range.end.x===i.range.end.x&&n.range.end.y===i.range.end.y}var Er=class extends Sr{constructor(e={}){super(e);this._linkifier=this._register(new P);this.browser=Ke;this._keyDownHandled=!1;this._keyDownSeen=!1;this._keyPressHandled=!1;this._unprocessedDeadKey=!1;this._accessibilityManager=this._register(new P);this._onCursorMove=this._register(new b);this.onCursorMove=this._onCursorMove.event;this._onKey=this._register(new b);this.onKey=this._onKey.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onTitleChange=this._register(new b);this.onTitleChange=this._onTitleChange.event;this._onBell=this._register(new b);this.onBell=this._onBell.event;this._onFocus=this._register(new b);this._onBlur=this._register(new b);this._onA11yCharEmitter=this._register(new b);this._onA11yTabEmitter=this._register(new b);this._onWillOpen=this._register(new b);this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._setup(),this._decorationService=this._instantiationService.createInstance(Bt),this._instantiationService.setService(ge,this._decorationService),this._keyboardService=this._instantiationService.createInstance(xt),this._instantiationService.setService(zs,this._keyboardService),this._linkProviderService=this._instantiationService.createInstance(zi),this._instantiationService.setService(Ii,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(et)),this._register(this._inputHandler.onRequestBell(()=>this._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(t=>this._reportWindowsOptions(t))),this._register(this._inputHandler.onColor(t=>this._handleColorEvent(t))),this._register(j.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(j.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(j.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(j.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(t=>this._afterResize(t.cols,t.rows))),this._register(E(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}get dimensions(){if(!this._renderService)return;let e=this._renderService.dimensions;return{css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}}_handleColorEvent(e){if(this._themeService)for(let t of e){let r,s;switch(t.index){case 256:r="foreground",s="10";break;case 257:r="background",s="11";break;case 258:r="cursor",s="12";break;default:r="ansi",s="4;"+t.index}switch(t.type){case 0:let o=k.toColorRGB(r==="ansi"?this._themeService.colors.ansi[t.index]:this._themeService.colors[r]);this.coreService.triggerDataEvent(`\x1B]${s};${En(o)}\x1B\\`);break;case 1:if(r==="ansi")this._themeService.modifyColors(a=>a.ansi[t.index]=O.toColor(...t.color));else{let a=r;this._themeService.modifyColors(l=>l[a]=O.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index);break}}}_reportColorScheme(){if(!this._themeService)return;let e=Z.relativeLuminance(this._themeService.colors.background.rgba>>8),t=Z.relativeLuminance(this._themeService.colors.foreground.rgba>>8),r=e{this.hasSelection()&&Os(t,this._selectionService)}));let e=t=>Ns(t,this.textarea,this.coreService,this.optionsService);this._register(C(this.textarea,"paste",e)),this._register(C(this.element,"paste",e)),nt?this._register(C(this.element,"mousedown",t=>{t.button===2&&Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register(C(this.element,"contextmenu",t=>{Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),zt&&this._register(C(this.element,"auxclick",t=>{t.button===1&&Fr(t,this.textarea,this.screenElement)}))}_bindKeys(){this._register(C(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register(C(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register(C(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register(C(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register(C(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register(C(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register(C(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService){this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView);return}this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",l=>this.element.classList.toggle("allow-transparency",l))),e.appendChild(this.element);let t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register(C(this.screenElement,"mousemove",l=>this.updateCursorStyle(l))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);let r=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",Ut.get()),Yr||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>r.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(Ki,this.textarea,e.ownerDocument.defaultView??window,this._document??(typeof window<"u"?window.document:null))),this._instantiationService.setService(G,this._coreBrowserService),this._register(C(this.textarea,"focus",l=>this._handleTextAreaFocus(l))),this._register(C(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(bt,this._document,this._helperContainer),this._instantiationService.setService(Be,this._charSizeService),this._themeService=this._instantiationService.createInstance(yt),this._instantiationService.setService(_e,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(He),this._instantiationService.setService(gi,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(Ct,this.rows,this.screenElement)),this._instantiationService.setService(V,this._renderService),this._register(this._renderService.onRenderedViewportChange(l=>this._onRender.fire(l))),this._register(this._renderService.onDimensionsChange(l=>this._onDimensionsChange.fire({css:{canvas:{...l.css.canvas},cell:{...l.css.cell}},device:{canvas:{...l.device.canvas},cell:{...l.device.cell},char:{...l.device.char}}}))),this.onResize(l=>this._renderService.resize(l.cols,l.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(ft,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(vt),this._instantiationService.setService(Pe,this._mouseCoordsService);let s=this._linkifier.value=this._register(this._instantiationService.createInstance(Pt,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(l){this._logService.error("onWillOpen handler threw an exception",l)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(dt,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(l=>{super.scrollLines(l,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(Et,this.element,this.screenElement,s)),this._instantiationService.setService(Si,this._selectionService),this._mouseService=this._instantiationService.createInstance(gt),this._instantiationService.setService(Ks,this._mouseService),this._register(this._selectionService.onRequestScrollLines(l=>this.scrollLines(l.amount,l.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(l=>this._renderService.handleSelectionChanged(l.start,l.end,l.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(l=>{this.textarea.value=l,this.textarea.focus(),this.textarea.select()})),this._register(j.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(ut,this.screenElement)),this._register(C(this.element,"mousedown",l=>this._selectionService.handleMouseDown(l))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(Ye,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",l=>this._handleScreenReaderModeOptionChange(l)));let o=this.options.scrollbar?.showScrollbar??!0,a=this.options.scrollbar?.width;o&&a&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",l=>{let h=(l?.showScrollbar??!0)&&!!l?.width;!this._overviewRulerRenderer&&h&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:l=>this._viewport?.handleTouchScroll(l)},l=>this._register(l),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(mt,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,r=!1){this._renderService?.refreshRows(e,t,r)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}paste(e){Nr(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");let t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return this._selectionService?this._selectionService.hasSelection:!1}select(e,t,r){this._selectionService.setSelection(e,t,r)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(!(!this._selectionService||!this._selectionService.hasSelection))return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;let t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;!t&&(e.key==="Dead"||e.key==="AltGraph")&&(this._unprocessedDeadKey=!0);let r=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),r.type===3||r.type===2){let o=this.rows-1;return this.scrollLines(r.type===2?-o:o),e.preventDefault(),e.stopPropagation(),!1}if(r.type===1&&this.selectAll(),this._isThirdLevelShift(this.browser,e)||(r.cancel&&(e.preventDefault(),e.stopPropagation()),!r.key)||!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;(r.key===""||r.key==="\r")&&(this.textarea.value="");let s=this._keyboardService.useWin32InputMode&&Ts(e);if(this._onKey.fire({key:r.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(r.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){let r=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return t.type==="keypress"?r:r&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return;Ts(e)||this.focus();let t=this._keyboardService.evaluateKeyUp(e);if(t?.key){let r=this._keyboardService.useWin32InputMode&&Ts(e);this.coreService.triggerDataEvent(t.key,!r)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;if(e.charCode)t=e.charCode;else if(e.which===null||e.which===void 0)t=e.keyCode;else if(e.which!==0&&e.charCode!==0)t=e.which;else return!1;return!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)?!1:(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this._compositionHelper.keypress(t)||this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,!0)}_inputEvent(e){if(e.data&&e.inputType==="insertText"&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;let t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){if(e===this.cols&&t===this.rows){this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure();return}super.resize(e,t)}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;i--)this._addons[i].instance.dispose()}loadAddon(i,e){let t={instance:e,dispose:e.dispose,isDisposed:!1};this._addons.push(t),e.dispose=()=>this._wrappedAddonDispose(t),e.activate(i)}_wrappedAddonDispose(i){if(i.isDisposed)return;let e=-1;for(let t=0;t=this._line.length))return e?(this._line.loadCell(i,e),e):this._line.loadCell(i,new F)}translateToString(i,e,t){return this._line.translateToString(i,e,t)}};var di=class{constructor(i,e){this._buffer=i;this.type=e}init(i){return this._buffer=i,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(i){let e=this._buffer.lines.get(i);if(e)return new xr(e)}getNullCell(){return new F}};var wr=class extends g{constructor(e){super();this._core=e;this._onBufferChange=this._register(new b);this.onBufferChange=this._onBufferChange.event;this._normal=new di(this._core.buffers.normal,"normal"),this._alternate=new di(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}};var Tr=class{constructor(i){this._core=i}registerCsiHandler(i,e){return this._core.registerCsiHandler(i,t=>e(t.toArray()))}addCsiHandler(i,e){return this.registerCsiHandler(i,e)}registerDcsHandler(i,e){return this._core.registerDcsHandler(i,(t,r)=>e(t,r.toArray()))}addDcsHandler(i,e){return this.registerDcsHandler(i,e)}registerEscHandler(i,e){return this._core.registerEscHandler(i,e)}addEscHandler(i,e){return this.registerEscHandler(i,e)}registerOscHandler(i,e){return this._core.registerOscHandler(i,e)}addOscHandler(i,e){return this.registerOscHandler(i,e)}registerApcHandler(i,e){return this._core.registerApcHandler(i,e)}};var Dr=class{constructor(i){this._core=i}register(i){this._core.unicodeService.register(i)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(i){this._core.unicodeService.activeVersion=i}};var wo=["cols","rows"],Ee=0,Ln=class extends g{constructor(i){super(),this._core=this._register(new Er(i)),this._addonManager=this._register(new yr),this._publicOptions={...this._core.options};let e=r=>this._core.options[r],t=(r,s)=>{this._checkReadonlyOptions(r),this._core.options[r]=s};for(let r in this._core.options){let s={get:e.bind(this,r),set:t.bind(this,r)};Object.defineProperty(this._publicOptions,r,s)}}_checkReadonlyOptions(i){if(wo.includes(i))throw new Error(`Option "${i}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new Tr(this._core)}get unicode(){return this._checkProposedApi(),new Dr(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new wr(this._core))}get markers(){return this._core.markers}get modes(){let i=this._core.coreService.decPrivateModes,e="none";switch(this._core.mouseStateService.activeProtocol){case"X10":e="x10";break;case"VT200":e="vt200";break;case"DRAG":e="drag";break;case"ANY":e="any";break}return{applicationCursorKeysMode:i.applicationCursorKeys,applicationKeypadMode:i.applicationKeypad,bracketedPasteMode:i.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:e,originMode:i.origin,reverseWraparoundMode:i.reverseWraparound,sendFocusMode:i.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:i.synchronizedOutput,win32InputMode:i.win32InputMode,wraparoundMode:i.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(i){for(let e in i)this._publicOptions[e]=i[e]}blur(){this._core.blur()}focus(){this._core.focus()}input(i,e=!0){this._core.input(i,e)}resize(i,e){this._verifyIntegers(i,e),this._core.resize(i,e)}open(i){this._core.open(i)}attachCustomKeyEventHandler(i){this._core.attachCustomKeyEventHandler(i)}attachCustomWheelEventHandler(i){this._core.attachCustomWheelEventHandler(i)}registerLinkProvider(i){return this._core.registerLinkProvider(i)}registerCharacterJoiner(i){return this._core.registerCharacterJoiner(i)}deregisterCharacterJoiner(i){this._core.deregisterCharacterJoiner(i)}registerMarker(i=0){return this._verifyIntegers(i),this._core.registerMarker(i)}registerDecoration(i){return this._verifyPositiveIntegers(i.x??0,i.width??0,i.height??0),this._core.registerDecoration(i)}hasSelection(){return this._core.hasSelection()}select(i,e,t){this._verifyIntegers(i,e,t),this._core.select(i,e,t)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(i,e){this._verifyIntegers(i,e),this._core.selectLines(i,e)}dispose(){super.dispose()}scrollLines(i){this._verifyIntegers(i),this._core.scrollLines(i)}scrollPages(i){this._verifyIntegers(i),this._core.scrollPages(i)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(i){this._verifyIntegers(i),this._core.scrollToLine(i)}clear(){this._core.clear()}write(i,e){this._core.write(i,e)}writeln(i,e){this._core.write(i),this._core.write(`\r @@ -30,16 +31,16 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..5c60ecbf14aaa8ecec5e5beb2a3e4790 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -1008,7 +1008,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { - + this._onKey.fire({ key, domEvent: ev }); this._showCursor(); - this.coreService.triggerDataEvent(key, true); + if (!this._compositionHelper!.keypress(key)) { + this.coreService.triggerDataEvent(key, true); + } - + this._keyPressHandled = true; - + diff --git a/src/browser/Types.ts b/src/browser/Types.ts index 497afcf535f3eaca00889525a77e15eb633ccd96..c9fc2cf1c06d86cf5459eae1fadc8dce6b4c753b 100644 --- a/src/browser/Types.ts @@ -50,16 +51,16 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..c9fc2cf1c06d86cf5459eae1fadc8dce keydown(ev: KeyboardEvent): boolean; + keypress(text: string): boolean; } - + export interface IBrowser { diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts -index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07ea430c3f2 100644 +index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..8a6db2f530bada4fd69a614287aadd499d611c95 100644 --- a/src/browser/input/CompositionHelper.ts +++ b/src/browser/input/CompositionHelper.ts @@ -47,6 +47,11 @@ export class CompositionHelper { */ private _dataAlreadySent: string; - + + /** + * Keypress text waiting to be reconciled with the textarea composition candidate. + */ @@ -74,12 +75,12 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e this._dataAlreadySent = ''; + this._pendingKeypressData = ''; } - + /** @@ -138,6 +144,18 @@ export class CompositionHelper { return true; } - + + /** + * Defers keypress text while a composition finalizer is pending so all input is emitted once + * after reconciliation with the final textarea candidate. @@ -109,7 +110,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e }; const currentCompositionSuffix = this._compositionSuffix; + this._pendingKeypressData = ''; - + // Since composition* events happen before the changes take place in the textarea on most // browsers, use a setTimeout with 0ms time to allow the native compositionend event to @@ -195,14 +214,39 @@ export class CompositionHelper { @@ -124,7 +125,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e }, 0); } } - + + private _sendCompositionInput(input: string): void { + const keypress = this._pendingKeypressData; + // Why: Chromium may copy keypress text anywhere into the candidate, or expose only an @@ -155,3 +156,32 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e /** * Apply any changes made to the textarea after the current event chain is allowed to complete. * This should be called when not currently composing but a keydown event with the "composition +diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts +index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644 +--- a/src/common/SortedList.ts ++++ b/src/common/SortedList.ts +@@ -87,6 +87,24 @@ export class SortedList { + if (key === undefined) { + return false; + } ++ if (this._deleteAtKey(value, key)) { ++ return true; ++ } ++ // A pending deletion whose key mutated after `delete()` (disposing a marker ++ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of ++ // order, so the binary search above can miss a value that is present. ++ // Compacting those entries out restores the order; retry before reporting ++ // the value absent, else its `onDecorationRemoved` never fires and the ++ // decoration paints forever. Miss path only, so the common bulk delete ++ // keeps its O(log n) search and deferred-compaction batching. ++ if (this._deletedIndices.length === 0) { ++ return false; ++ } ++ this._flushCleanupDeleted(); ++ return this._deleteAtKey(value, key); ++ } ++ ++ private _deleteAtKey(value: T, key: number): boolean { + i = this._search(key); + if (i === -1) { + return false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea1672e8937..8fa75b204fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ patchedDependencies: hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch '@xterm/xterm@6.1.0-beta.287': - hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c + hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3 path: config/patches/@xterm__xterm@6.1.0-beta.287.patch node-pty@1.1.0: hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8 @@ -46,7 +46,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.287 - version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/headless': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -206,25 +206,25 @@ importers: version: 5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@xterm/addon-fit': specifier: 0.12.0-beta.287 - version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-ligatures': specifier: 0.11.0-beta.287 - version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-search': specifier: 0.17.0-beta.287 - version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-unicode11': specifier: 0.10.0-beta.287 - version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-web-links': specifier: 0.13.0-beta.287 - version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-webgl': specifier: 0.20.0-beta.286 - version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/xterm': specifier: 6.1.0-beta.287 - version: 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -9224,39 +9224,39 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) lru-cache: 11.5.1 opentype.js: 2.0.0 - '@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) '@xterm/headless@6.1.0-beta.287': {} - '@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)': {} + '@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {} abbrev@4.0.0: {} diff --git a/src/renderer/src/components/terminal-search-decoration-leak.test.ts b/src/renderer/src/components/terminal-search-decoration-leak.test.ts new file mode 100644 index 00000000000..54c241bd8e0 --- /dev/null +++ b/src/renderer/src/components/terminal-search-decoration-leak.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { SearchAddon } from '@xterm/addon-search' +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Regression for STA-2707: search highlights stayed painted after the find bar + * closed, until the window was minimized and restored. + * + * Mechanism, in @xterm/xterm's SortedList (patched in + * config/patches/@xterm__xterm@6.1.0-beta.287.patch): + * + * - DecorationService keys its SortedList on `decoration.marker.line`. + * - `delete()` only records an index and defers the array compaction, while + * `Marker.dispose()` sets `line = -1` — mutating that very sort key. + * - So after the first disposal the array is no longer sorted, and the binary + * search inside `delete()` can miss a decoration that IS present. `delete()` + * returns false, `onDecorationRemoved` never fires, and the decoration keeps + * reporting itself at its cells forever. + * + * `clearDecorations()` disposes the active (top-layer) match first and the + * match highlights after, which is exactly the order that trips this — so + * closing search left one match highlighted. Repaints don't help: the leaked + * decoration is still live, so every repaint faithfully re-paints it. + * + * These tests drive the REAL Terminal + SearchAddon and assert through + * `forEachDecorationAtCell` — the same lookup both renderers use to decide a + * cell's background — so a regression fails here regardless of renderer. + */ + +type LeakProbeDecoration = { marker: { line: number } } + +type DecorationServiceInternals = { + decorations: Iterable + forEachDecorationAtCell: ( + x: number, + line: number, + layer: string | undefined, + callback: (decoration: LeakProbeDecoration) => void + ) => void +} + +const SEARCH_DECORATIONS = { + matchBackground: '#5c4a00', + matchBorder: '#5c4a00', + matchOverviewRuler: '#ffcc00', + activeMatchBackground: '#c4580e', + activeMatchBorder: '#ffcf6b', + activeMatchColorOverviewRuler: '#ff9900' +} as const + +const PROBE_ROWS = 30 +const PROBE_COLS = 80 + +function settle(ms = 20): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +type SearchHarness = { + terminal: Terminal + search: SearchAddon + /** Cells whose background a renderer would still paint from a decoration. */ + highlightedCellCount: () => number + /** Closes the find bar exactly the way TerminalSearch does. */ + closeSearch: () => void +} + +function openTerminalWithSearch(): SearchHarness { + const container = document.createElement('div') + document.body.appendChild(container) + const terminal = new Terminal() + terminal.open(container) + const search = new SearchAddon() + terminal.loadAddon(search) + + const decorationService = ( + terminal as never as { _core: { _decorationService: DecorationServiceInternals } } + )._core._decorationService + + return { + terminal, + search, + highlightedCellCount: () => { + let count = 0 + for (let line = 0; line < PROBE_ROWS; line++) { + for (let x = 0; x < PROBE_COLS; x++) { + decorationService.forEachDecorationAtCell(x, line, undefined, () => count++) + } + } + return count + }, + closeSearch: () => { + search.clearDecorations() + search.findNext('') + } + } +} + +/** + * Content shapes that produce different match/marker layouts. The leak only + * showed up for some of them, so the regression has to sweep rather than pin + * one lucky case. + */ +const CONTENT_SHAPES: readonly (readonly [string, string])[] = [ + ['matches on two lines', 'needle one\r\nneedle two\r\n'], + ['matches on three lines', 'needle one\r\nneedle two\r\nneedle three\r\n'], + ['matches on four lines', 'needle a\r\nneedle b\r\nneedle c\r\nneedle d\r\n'], + ['matches separated by gaps', 'needle a\r\nplain\r\nneedle b\r\nplain\r\nneedle c\r\n'], + ['two matches on one line', 'needle needle\r\nplain\r\n'] +] + +describe('terminal search decoration cleanup (STA-2707)', () => { + beforeEach(() => { + // happy-dom has no canvas text metrics; xterm measures glyphs on open(). + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it.each(CONTENT_SHAPES)( + 'leaves no highlighted cells after closing search (%s)', + async (_name, content) => { + // Sweeping the match-navigation count matters: which decoration is the + // active one decides whether the stale sort key hides a later delete. + for (const navigations of [0, 1, 2, 3]) { + const harness = openTerminalWithSearch() + await write(harness.terminal, content) + + harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS }) + await settle() + for (let i = 0; i < navigations; i++) { + harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS }) + await settle(5) + } + await settle() + expect( + harness.highlightedCellCount(), + `search should highlight matches while open (navigations=${navigations})` + ).toBeGreaterThan(0) + + harness.closeSearch() + await settle() + + expect( + harness.highlightedCellCount(), + `closing search must leave no highlighted cells (navigations=${navigations})` + ).toBe(0) + + document.body.replaceChildren() + } + } + ) + + it('leaves no highlighted cells after typing a query character by character', async () => { + const harness = openTerminalWithSearch() + await write(harness.terminal, 'needle one\r\nneedle two\r\nneedle three\r\n') + + // The find bar re-searches on every keystroke, so each character disposes + // the previous run's decorations — the same path that leaked. + for (const query of ['n', 'ne', 'nee', 'need', 'needl', 'needle']) { + harness.search.findNext(query, { decorations: SEARCH_DECORATIONS, incremental: true }) + await settle(10) + } + await settle() + + harness.closeSearch() + await settle() + + expect(harness.highlightedCellCount()).toBe(0) + }) + + it('does not accumulate highlights across repeated open/close cycles', async () => { + const harness = openTerminalWithSearch() + await write(harness.terminal, 'needle one\r\nneedle two\r\nneedle three\r\n') + + for (let cycle = 0; cycle < 5; cycle++) { + harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS }) + await settle(10) + harness.closeSearch() + await settle(10) + } + + expect(harness.highlightedCellCount()).toBe(0) + }) + + it('keeps highlights painted while search is still open', async () => { + const harness = openTerminalWithSearch() + await write(harness.terminal, 'needle one\r\nneedle two\r\n') + + harness.search.findNext('needle', { decorations: SEARCH_DECORATIONS }) + await settle() + + expect(harness.highlightedCellCount()).toBeGreaterThan(0) + }) +}) From 81eeb40ada495826b9dbdef106bde2692ebc2252 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:01:25 -0700 Subject: [PATCH 128/150] perf(cli): load only the handler group a command dispatches into (#10883) Co-authored-by: Orca --- src/cli/browser-handler-groups.ts | 119 ++++++++++++++ src/cli/dispatch.ts | 90 ++++------- src/cli/handler-group-manifest.test.ts | 121 ++++++++++++++ src/cli/handler-group-manifest.ts | 214 +++++++++++++++++++++++++ 4 files changed, 482 insertions(+), 62 deletions(-) create mode 100644 src/cli/browser-handler-groups.ts create mode 100644 src/cli/handler-group-manifest.test.ts create mode 100644 src/cli/handler-group-manifest.ts diff --git a/src/cli/browser-handler-groups.ts b/src/cli/browser-handler-groups.ts new file mode 100644 index 00000000000..0a7de2fa573 --- /dev/null +++ b/src/cli/browser-handler-groups.ts @@ -0,0 +1,119 @@ +import type { HandlerGroup } from './handler-group-manifest' + +// Why split out: the browser command surface is a third of the CLI's groups and +// changes as a unit, so it keeps handler-group-manifest.ts readable at a glance. +export const BROWSER_HANDLER_GROUPS: readonly HandlerGroup[] = [ + { + name: 'browser-nav', + keys: [ + 'snapshot', + 'screenshot', + 'goto', + 'back', + 'reload', + 'forward', + 'eval', + 'scroll', + 'wait', + 'pdf', + 'full-screenshot' + ], + load: async () => (await import('./handlers/browser-nav.js')).BROWSER_NAV_HANDLERS + }, + { + name: 'browser-interact', + keys: [ + 'click', + 'dblclick', + 'fill', + 'type', + 'select', + 'check', + 'uncheck', + 'focus', + 'clear', + 'select-all', + 'keypress', + 'hover', + 'drag', + 'upload', + 'scrollintoview', + 'get', + 'is', + 'inserttext', + 'mouse move', + 'mouse down', + 'mouse up', + 'mouse wheel', + 'find', + 'download', + 'highlight' + ], + load: async () => (await import('./handlers/browser-interact.js')).BROWSER_INTERACT_HANDLERS + }, + { + name: 'browser-tab', + keys: ['tab list', 'tab show', 'tab current', 'tab switch', 'tab create', 'tab close', 'exec'], + load: async () => (await import('./handlers/browser-tab.js')).BROWSER_TAB_HANDLERS + }, + { + name: 'browser-profile', + keys: [ + 'tab profile list', + 'tab profile create', + 'tab profile delete', + 'tab profile set', + 'tab profile show', + 'tab profile use-default', + 'tab profile clone' + ], + load: async () => (await import('./handlers/browser-profile.js')).BROWSER_PROFILE_HANDLERS + }, + { + name: 'browser-cookie', + keys: ['cookie get', 'cookie set', 'cookie delete'], + load: async () => (await import('./handlers/browser-cookie.js')).BROWSER_COOKIE_HANDLERS + }, + { + name: 'browser-capture', + keys: [ + 'intercept enable', + 'intercept disable', + 'intercept list', + 'capture start', + 'capture stop', + 'console', + 'network' + ], + load: async () => (await import('./handlers/browser-capture.js')).BROWSER_CAPTURE_HANDLERS + }, + { + name: 'browser-env', + keys: [ + 'viewport', + 'geolocation', + 'set device', + 'set offline', + 'set headers', + 'set credentials', + 'set media', + 'clipboard read', + 'clipboard write', + 'dialog accept', + 'dialog dismiss' + ], + load: async () => (await import('./handlers/browser-env.js')).BROWSER_ENV_HANDLERS + }, + { + name: 'browser-storage', + keys: [ + 'storage local get', + 'storage local set', + 'storage local clear', + 'storage session get', + 'storage session set', + 'storage session clear' + ], + load: async () => (await import('./handlers/browser-storage.js')).BROWSER_STORAGE_HANDLERS + } +] diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b5996261416..f4e30166d72 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,30 +1,6 @@ import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' -import { CORE_HANDLERS } from './handlers/core' -import { AUTOMATION_HANDLERS } from './handlers/automations' -import { PROJECT_HANDLERS } from './handlers/project' -import { REPO_HANDLERS } from './handlers/repo' -import { WORKTREE_HANDLERS } from './handlers/worktree' -import { FILE_HANDLERS } from './handlers/file' -import { TERMINAL_HANDLERS } from './handlers/terminal' -import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav' -import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact' -import { BROWSER_TAB_HANDLERS } from './handlers/browser-tab' -import { BROWSER_PROFILE_HANDLERS } from './handlers/browser-profile' -import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie' -import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture' -import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' -import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' -import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' -import { COMPUTER_HANDLERS } from './handlers/computer' -import { ENVIRONMENT_HANDLERS } from './handlers/environment' -import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks' -import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics' -import { INTROSPECTION_HANDLERS } from './handlers/introspection' -import { EMULATOR_HANDLERS } from './handlers/emulator' -import { LINEAR_HANDLERS } from './handlers/linear' -import { VM_HANDLERS } from './handlers/vm' -import { SKILL_HANDLERS } from './handlers/skills' +import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' export type HandlerContext = { flags: Map @@ -36,56 +12,46 @@ export type HandlerContext = { export type CommandHandler = (ctx: HandlerContext) => Promise -function buildHandlers(): Map { - const table = new Map() - const groups = [ - CORE_HANDLERS, - AUTOMATION_HANDLERS, - PROJECT_HANDLERS, - REPO_HANDLERS, - WORKTREE_HANDLERS, - FILE_HANDLERS, - TERMINAL_HANDLERS, - BROWSER_NAV_HANDLERS, - BROWSER_INTERACT_HANDLERS, - BROWSER_TAB_HANDLERS, - BROWSER_PROFILE_HANDLERS, - BROWSER_COOKIE_HANDLERS, - BROWSER_CAPTURE_HANDLERS, - BROWSER_ENV_HANDLERS, - BROWSER_STORAGE_HANDLERS, - ORCHESTRATION_HANDLERS, - EMULATOR_HANDLERS, - COMPUTER_HANDLERS, - AGENT_HOOK_HANDLERS, - DIAGNOSTICS_HANDLERS, - INTROSPECTION_HANDLERS, - ENVIRONMENT_HANDLERS, - LINEAR_HANDLERS, - VM_HANDLERS, - SKILL_HANDLERS - ] +// Why: routing only needs key→group, so every CLI invocation can skip the +// transitive module graph of the 24 groups it does not dispatch into. +function buildRoutes(groups: readonly HandlerGroup[]): Map { + const table = new Map() for (const group of groups) { - for (const [key, handler] of Object.entries(group)) { - if (table.has(key)) { - throw new Error(`Duplicate CLI handler registration for "${key}"`) + for (const key of group.keys) { + const owner = table.get(key) + if (owner) { + throw new Error( + `Duplicate CLI handler registration for "${key}" (${owner.name} and ${group.name})` + ) } - table.set(key, handler) + table.set(key, group) } } return table } -const HANDLERS = buildHandlers() +const ROUTES = buildRoutes(HANDLER_GROUPS) // Why: exposes only the canonical command keys (not the handler internals) so the // registry-parity guard can check specs↔handlers without rebuilding the table. -export const HANDLER_COMMAND_KEYS: ReadonlySet = new Set(HANDLERS.keys()) +export const HANDLER_COMMAND_KEYS: ReadonlySet = new Set(ROUTES.keys()) export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise { - const handler = HANDLERS.get(commandPath.join(' ')) + const key = commandPath.join(' ') + const group = ROUTES.get(key) + if (!group) { + throw new RuntimeClientError('invalid_argument', `Unknown command: ${key}`) + } + const handler = (await group.load())[key] + // Why: the manifest key list is verified against the real exports in CI, so a + // miss here means the group changed without the manifest — fail loudly. if (!handler) { - throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`) + throw new RuntimeClientError( + 'invalid_argument', + `CLI handler group "${group.name}" does not export "${key}"` + ) } await handler(ctx) } + +export { buildRoutes as buildHandlerRoutes } diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts new file mode 100644 index 00000000000..d17c3446db1 --- /dev/null +++ b/src/cli/handler-group-manifest.test.ts @@ -0,0 +1,121 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' +import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' + +// Why: dispatch trusts the manifest's eager key lists to route without loading a +// group. These tests are the only thing standing between that trust and a +// silently unreachable command, so they load every group for real. + +describe('handler group manifest', () => { + it('lists a loadable group for every entry', async () => { + for (const group of HANDLER_GROUPS) { + const loaded = await group.load() + expect(loaded, `${group.name} resolved to a non-record`).toBeTypeOf('object') + } + }) + + it('matches each group export key-for-key', async () => { + const drift: string[] = [] + for (const group of HANDLER_GROUPS) { + const actual = Object.keys(await group.load()).sort() + const declared = [...group.keys].sort() + if (JSON.stringify(actual) !== JSON.stringify(declared)) { + drift.push( + `${group.name}: manifest ${JSON.stringify(declared)} !== export ${JSON.stringify(actual)}` + ) + } + } + expect(drift).toEqual([]) + }) + + it('exposes every declared key as a callable handler', async () => { + const notCallable: string[] = [] + for (const group of HANDLER_GROUPS) { + const loaded = await group.load() + for (const key of group.keys) { + if (typeof loaded[key] !== 'function') { + notCallable.push(`${group.name}/${key}`) + } + } + } + expect(notCallable).toEqual([]) + }) + + it('reaches every group through dispatch routing', () => { + const routes = buildHandlerRoutes(HANDLER_GROUPS) + const reached = new Set([...routes.values()].map((group) => group.name)) + const unreachable = HANDLER_GROUPS.filter((group) => !reached.has(group.name)).map( + (group) => group.name + ) + expect(unreachable).toEqual([]) + }) + + // Why: dropping a group from the manifest silently unregisters its commands — + // scan the directory so a new or forgotten handler file fails here, not in prod. + it('registers every handler module that exports a handler group', async () => { + // Why: __dirname works under both Vitest and the CommonJS tsc emit that + // build:cli type-checks this file against; import.meta.dirname does not. + const dir = join(__dirname, 'handlers') + const modules = readdirSync(dir).filter( + (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') + ) + const registered = new Set(HANDLER_GROUPS.map((group) => group.name)) + const missing: string[] = [] + for (const file of modules) { + const name = file.slice(0, -'.ts'.length) + const exports: Record = await import(join(dir, file)) + const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS')) + if (exportsGroup && !registered.has(name)) { + missing.push(name) + } + } + expect(missing).toEqual([]) + }) +}) + +describe('duplicate command keys', () => { + const group = (name: string, keys: string[]): HandlerGroup => ({ + name, + keys, + load: async () => ({}) + }) + + it('rejects the same key claimed by two groups', () => { + expect(() => + buildHandlerRoutes([group('alpha', ['ship it']), group('beta', ['ship it'])]) + ).toThrow('Duplicate CLI handler registration for "ship it" (alpha and beta)') + }) + + it('rejects a key duplicated inside one group list', () => { + expect(() => buildHandlerRoutes([group('alpha', ['ship it', 'ship it'])])).toThrow( + 'Duplicate CLI handler registration for "ship it"' + ) + }) + + it('accepts distinct keys across groups', () => { + const routes = buildHandlerRoutes([group('alpha', ['a']), group('beta', ['b'])]) + expect([...routes.keys()]).toEqual(['a', 'b']) + }) + + it('holds for the live manifest', () => { + expect(() => buildHandlerRoutes(HANDLER_GROUPS)).not.toThrow() + }) +}) + +describe('dispatch errors', () => { + const ctx = { + flags: new Map(), + cwd: '/tmp', + json: false + } as unknown as HandlerContext + + it('reports an unknown command with the joined path', async () => { + await expect(dispatch(['not', 'a', 'command'], ctx)).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Unknown command: not a command' + }) + }) +}) diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts new file mode 100644 index 00000000000..4d54c3aba21 --- /dev/null +++ b/src/cli/handler-group-manifest.ts @@ -0,0 +1,214 @@ +import type { CommandHandler } from './dispatch' +import { BROWSER_HANDLER_GROUPS } from './browser-handler-groups' + +export type HandlerGroup = { + name: string + // Why: eager string keys let dispatch build (and duplicate-check) the whole + // command table without loading any group's transitive module graph. + keys: readonly string[] + load: () => Promise> +} + +// Why: `keys` mirrors each group's exported record and is verified against the +// real exports by handler-group-manifest.test.ts, so drift fails CI, not dispatch. +export const HANDLER_GROUPS: readonly HandlerGroup[] = [ + { + name: 'core', + keys: ['claude-teams', 'open', 'serve', 'status'], + load: async () => (await import('./handlers/core.js')).CORE_HANDLERS + }, + { + name: 'automations', + keys: [ + 'automations list', + 'automations show', + 'automations create', + 'automations edit', + 'automations remove', + 'automations run', + 'automations runs' + ], + load: async () => (await import('./handlers/automations.js')).AUTOMATION_HANDLERS + }, + { + name: 'project', + keys: [ + 'project list', + 'project setups', + 'project setup-existing-folder', + 'project setup-clone', + 'project setup-create', + 'project setup-update', + 'project setup-delete' + ], + load: async () => (await import('./handlers/project.js')).PROJECT_HANDLERS + }, + { + name: 'repo', + keys: ['repo list', 'repo add', 'repo show', 'repo set-base-ref', 'repo search-refs'], + load: async () => (await import('./handlers/repo.js')).REPO_HANDLERS + }, + { + name: 'worktree', + keys: [ + 'worktree ps', + 'worktree list', + 'worktree show', + 'worktree current', + 'worktree create', + 'worktree set', + 'worktree rm' + ], + load: async () => (await import('./handlers/worktree.js')).WORKTREE_HANDLERS + }, + { + name: 'file', + keys: ['file open', 'file diff', 'file open-changed'], + load: async () => (await import('./handlers/file.js')).FILE_HANDLERS + }, + { + name: 'terminal', + keys: [ + 'terminal list', + 'terminal show', + 'terminal read', + 'terminal send', + 'terminal wait', + 'terminal stop', + 'terminal rename', + 'terminal create', + 'terminal switch', + 'terminal close', + 'terminal split' + ], + load: async () => (await import('./handlers/terminal.js')).TERMINAL_HANDLERS + }, + ...BROWSER_HANDLER_GROUPS, + { + name: 'orchestration', + keys: [ + 'orchestration send', + 'orchestration check', + 'orchestration reply', + 'orchestration inbox', + 'orchestration task-create', + 'orchestration task-list', + 'orchestration task-update', + 'orchestration dispatch', + 'orchestration ask', + 'orchestration dispatch-show', + 'orchestration run', + 'orchestration run-stop', + 'orchestration gate-create', + 'orchestration gate-resolve', + 'orchestration gate-list', + 'orchestration reset' + ], + load: async () => (await import('./handlers/orchestration.js')).ORCHESTRATION_HANDLERS + }, + { + name: 'emulator', + keys: [ + 'emulator list', + 'emulator devices', + 'emulator attach', + 'emulator tap', + 'emulator type', + 'emulator gesture', + 'emulator button', + 'emulator rotate', + 'emulator exec', + 'emulator kill', + 'emulator shutdown', + 'emulator install', + 'emulator launch', + 'emulator permissions', + 'emulator ax', + 'emulator logcat' + ], + load: async () => (await import('./handlers/emulator.js')).EMULATOR_HANDLERS + }, + { + name: 'computer', + keys: [ + 'computer capabilities', + 'computer list-apps', + 'computer permissions', + 'computer list-windows', + 'computer get-app-state', + 'computer click', + 'computer perform-secondary-action', + 'computer scroll', + 'computer drag', + 'computer type-text', + 'computer press-key', + 'computer hotkey', + 'computer paste-text', + 'computer set-value' + ], + load: async () => (await import('./handlers/computer.js')).COMPUTER_HANDLERS + }, + { + name: 'agent-hooks', + keys: ['agent hooks status', 'agent hooks off', 'agent hooks on'], + load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS + }, + { + name: 'diagnostics', + keys: ['diagnostics memory'], + load: async () => (await import('./handlers/diagnostics.js')).DIAGNOSTICS_HANDLERS + }, + { + name: 'introspection', + keys: ['agent-context'], + load: async () => (await import('./handlers/introspection.js')).INTROSPECTION_HANDLERS + }, + { + name: 'environment', + keys: ['environment add', 'environment list', 'environment show', 'environment rm'], + load: async () => (await import('./handlers/environment.js')).ENVIRONMENT_HANDLERS + }, + { + name: 'linear', + keys: [ + 'linear save-issue', + 'linear list-issues', + 'linear relation add', + 'linear relation remove', + 'linear issue', + 'linear search', + 'linear team list', + 'linear team members', + 'linear team states', + 'linear team labels', + 'linear project list', + 'linear list', + 'linear status set', + 'linear assignee set', + 'linear assignee clear', + 'linear priority set', + 'linear priority clear', + 'linear estimate set', + 'linear estimate clear', + 'linear due-date set', + 'linear due-date clear', + 'linear label add', + 'linear label remove', + 'linear label set', + 'linear comment add', + 'linear attach', + 'linear create' + ], + load: async () => (await import('./handlers/linear.js')).LINEAR_HANDLERS + }, + { + name: 'vm', + keys: ['vm recipe doctor'], + load: async () => (await import('./handlers/vm.js')).VM_HANDLERS + }, + { + name: 'skills', + keys: ['skills list', 'skills get'], + load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS + } +] From 7dab1e86e227e5e441f374fddc64ee2c685b1fc2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:02:48 -0700 Subject: [PATCH 129/150] perf(ssh): normalize watch event paths once per fs.changed batch (#10881) Co-authored-by: Orca --- config/scripts/ssh-watch-fanout-benchmark.mjs | 190 ++++++++++++++++++ ...ssh-filesystem-watch-notifications.test.ts | 172 ++++++++++++++++ .../ssh-filesystem-watch-notifications.ts | 18 +- src/shared/cross-platform-path.ts | 25 ++- 4 files changed, 395 insertions(+), 10 deletions(-) create mode 100644 config/scripts/ssh-watch-fanout-benchmark.mjs create mode 100644 src/main/providers/ssh-filesystem-watch-notifications.test.ts diff --git a/config/scripts/ssh-watch-fanout-benchmark.mjs b/config/scripts/ssh-watch-fanout-benchmark.mjs new file mode 100644 index 00000000000..c2285846194 --- /dev/null +++ b/config/scripts/ssh-watch-fanout-benchmark.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Benchmark: routing one `fs.changed` relay notification to SSH watch registrations. +// +// routeSshFilesystemWatchNotification called isPathInsideOrEqual(root, event.path) +// for every (registration x event) pair. That helper NFC-normalizes BOTH sides, so +// each event path was re-normalized once per watch root, and each root was +// re-normalized once per event -- O(roots * events) normalizations for what is +// O(roots + events) distinct work. +// +// The fix normalizes each event path once up front and builds one pre-normalized +// matcher per root, leaving only string compare in the inner loop. +// +// This is a hot path on SSH: the relay watcher batches up to MAX_BATCHED_WATCHER_EVENTS +// per notify, and a single `git checkout` or `pnpm install` on the remote host emits +// thousands of paths through it. +// +// Both arms are run against the same inputs and their outputs are compared before +// timing, so a matcher that changed which events route where cannot be reported as +// a win. The normalizer is imported from the real module (via tsx) rather than +// re-modelled here, so folding-rule drift cannot silently invalidate the result. +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const ITERATIONS = Number(process.env.ORCA_SSH_WATCH_BENCH_ITERATIONS ?? '200') +const WARMUP = Number(process.env.ORCA_SSH_WATCH_BENCH_WARMUP ?? '30') + +for (const [name, value] of [ + ['ORCA_SSH_WATCH_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_SSH_WATCH_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +// Why re-read the source: this benchmark's whole claim is that the normalizer is +// the expensive part. If someone makes it cheap (or drops the NFC fold), the +// numbers below stop meaning what the header says, so fail loudly instead. +const PATH_SOURCE = readFileSync( + new URL('../../src/shared/cross-platform-path.ts', import.meta.url), + 'utf8' +) +for (const marker of ['normalize(', 'createNormalizedPathInsideOrEqualMatcher']) { + if (!PATH_SOURCE.includes(marker)) { + throw new Error(`cross-platform-path.ts no longer contains ${marker}; this benchmark is stale`) + } +} + +// Import the real normalizer so both arms fold paths exactly as production does. +const { + normalizeRuntimePathForComparison, + isPathInsideOrEqual, + createNormalizedPathInsideOrEqualMatcher +} = await import(new URL('../../src/shared/cross-platform-path.ts', import.meta.url).href) + +// Pre-fix: mirrors the original routeSshFilesystemWatchNotification inner loop. +function routeBefore(roots, events, sink) { + for (const rootPath of roots) { + const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath)) + if (matching.length > 0) { + sink(rootPath, matching) + } + } +} + +// Post-fix: mirrors the current implementation. +function routeAfter(roots, events, sink) { + const normalizedEvents = events.map((event) => ({ + event, + normalizedPath: normalizeRuntimePathForComparison(event.absolutePath) + })) + for (const rootPath of roots) { + const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(rootPath) + const matching = normalizedEvents + .filter(({ normalizedPath }) => isInsideRoot(normalizedPath)) + .map(({ event }) => event) + if (matching.length > 0) { + sink(rootPath, matching) + } + } +} + +// Why real repo paths: path length and segment count drive normalization cost, and +// a synthetic `/a/b/c` fixture would understate it against real source trees. +const REPO_PATHS = execFileSync('git', ['ls-files'], { + cwd: REPO_ROOT, + maxBuffer: 256 * 1024 * 1024 +}) + .toString() + .split('\n') + .filter(Boolean) + +// A remote host running several worktrees: each is its own watch root, and the +// file explorer plus the worktree-base-directory watcher both register. +function makeRoots(count) { + return Array.from({ length: count }, (_, index) => `/home/dev/worktrees/orca-${index}`) +} + +function makeEvents(roots, count) { + const events = [] + for (let index = 0; index < count; index += 1) { + // Spread events across roots so most roots match some events, as a real + // multi-worktree checkout does. Paths outside any root also occur (node_modules + // of a sibling checkout), so include a slice of those too. + const root = index % 11 === 0 ? '/home/dev/other-checkout' : roots[index % roots.length] + events.push({ + kind: 'update', + absolutePath: `${root}/${REPO_PATHS[index % REPO_PATHS.length]}` + }) + } + return events +} + +function collect(roots, events, route) { + const seen = [] + route(roots, events, (rootPath, matching) => + seen.push(`${rootPath} ${matching.map((event) => event.absolutePath).join(',')}`) + ) + return seen.join('\n') +} + +// Why interleaved: running one arm's whole batch before the other's lets CPU +// frequency drift and background load correlate with the arm being measured. On a +// loaded machine that alone swung the 12x200 row between 6.7x and 23.3x. Alternating +// per round and taking per-arm medians keeps the drift common to both. +function measureInterleaved(roots, events) { + const noop = () => undefined + for (let index = 0; index < WARMUP; index += 1) { + routeBefore(roots, events, noop) + routeAfter(roots, events, noop) + } + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < 5; round += 1) { + let start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + routeBefore(roots, events, noop) + } + beforeSamples.push((performance.now() - start) / ITERATIONS) + + start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + routeAfter(roots, events, noop) + } + afterSamples.push((performance.now() - start) / ITERATIONS) + } + beforeSamples.sort((a, b) => a - b) + afterSamples.sort((a, b) => a - b) + return { beforeMs: beforeSamples[2], afterMs: afterSamples[2] } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('SSH fs.changed fan-out, per relay notification. Lower is better.') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log( + `${pad('roots', 6)} ${pad('events', 7)} ${pad('per-pair', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}` +) + +// roots x events: 3x20 is a typical few-worktree session with a small save; the +// larger rows are a remote `git checkout` or `pnpm install` storm, which the relay +// batches up to MAX_BATCHED_WATCHER_EVENTS (5,000) per notification. +for (const [rootCount, eventCount] of [ + [3, 20], + [6, 50], + [12, 200], + [25, 500], + [25, 5000] +]) { + const roots = makeRoots(rootCount) + const events = makeEvents(roots, eventCount) + const before = collect(roots, events, routeBefore) + const after = collect(roots, events, routeAfter) + if (before !== after) { + throw new Error(`routing differs at ${rootCount} roots x ${eventCount} events`) + } + if (!before.includes(' ')) { + throw new Error(`fixture routed nothing at ${rootCount} roots x ${eventCount} events`) + } + const { beforeMs, afterMs } = measureInterleaved(roots, events) + console.log( + `${pad(rootCount, 6)} ${pad(eventCount, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\nThis times routing only. The saving scales with roots x events, so it is small\nfor a single-worktree session and largest during a remote checkout storm, which\nis exactly when the main process is already busy.' +) diff --git a/src/main/providers/ssh-filesystem-watch-notifications.test.ts b/src/main/providers/ssh-filesystem-watch-notifications.test.ts new file mode 100644 index 00000000000..3380fe42e89 --- /dev/null +++ b/src/main/providers/ssh-filesystem-watch-notifications.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../shared/types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' +import type { WatchRegistration } from './ssh-filesystem-provider-watch' + +function registration(rootPath: string, ...callbacks: ((events: FsChangeEvent[]) => void)[]) { + return { + rootPath, + callbacks: new Set(callbacks), + terminalCallbacks: new Map(), + remoteWatchId: 1, + ready: true, + stopping: false, + unwatchSent: false + } as unknown as WatchRegistration +} + +function changed(...paths: string[]): FsChangeEvent[] { + return paths.map((absolutePath) => ({ kind: 'update', absolutePath }) as FsChangeEvent) +} + +function route(registrations: Map, events: FsChangeEvent[]): void { + routeSshFilesystemWatchNotification(registrations, 'fs.changed', { events }) +} + +// Reference: the pre-change routing, which called isPathInsideOrEqual per pair and +// so re-normalized the candidate for every root. Not circular for what this asserts +// -- the change under test is that one shared normalization of each candidate still +// matches every root the per-pair normalization did. +function referenceRoute(roots: string[], events: FsChangeEvent[]): Record { + const result: Record = {} + for (const rootPath of roots) { + const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath)) + if (matching.length > 0) { + result[rootPath] = matching.map((event) => event.absolutePath) + } + } + return result +} + +describe('routeSshFilesystemWatchNotification fs.changed fan-out', () => { + it('delivers only the events inside each root', () => { + const alpha = vi.fn() + const beta = vi.fn() + const registrations = new Map([ + ['/repo/alpha', registration('/repo/alpha', alpha)], + ['/repo/beta', registration('/repo/beta', beta)] + ]) + + route( + registrations, + changed( + '/repo/alpha/src/a.ts', + '/repo/beta/src/b.ts', + '/repo/alpha/src/c.ts', + '/elsewhere/d.ts' + ) + ) + + expect(alpha).toHaveBeenCalledTimes(1) + expect(alpha.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ + '/repo/alpha/src/a.ts', + '/repo/alpha/src/c.ts' + ]) + expect(beta.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ + '/repo/beta/src/b.ts' + ]) + }) + + // Why: the fix hoists normalization out of the inner loop, so the risk is that a + // shared pre-normalized candidate stops matching a root it used to match. + it('routes identically to per-pair normalization', () => { + const roots = ['/repo/alpha', '/repo/alpha-extra', '/repo/beta/', '/repo'] + const events = changed( + '/repo/alpha/src/a.ts', + '/repo/alpha-extra/src/b.ts', + '/repo/beta/src/c.ts', + '/repo/alpha', + '/repo//alpha//src//d.ts', + '/repo/gamma/e.ts', + '/elsewhere/f.ts' + ) + const seen: Record = {} + const registrations = new Map( + roots.map((rootPath) => [ + rootPath, + registration(rootPath, (delivered) => { + seen[rootPath] = delivered.map((event) => event.absolutePath) + }) + ]) + ) + + route(registrations, events) + + expect(seen).toEqual(referenceRoute(roots, events)) + }) + + // Why: normalizeRuntimePathForComparison is not idempotent for WSL UNC paths, so + // a candidate normalized once up front must still match a root normalized once. + it('matches WSL UNC roots whose case survives only a single fold', () => { + const received = vi.fn() + const registrations = new Map([ + ['wsl', registration('\\\\wsl.localhost\\Ubuntu\\home\\User\\Repo', received)] + ]) + + route(registrations, changed('//wsl$/UBUNTU/home/User/Repo/src/a.ts')) + + expect(received).toHaveBeenCalledTimes(1) + expect(received.mock.calls[0][0][0].absolutePath).toBe('//wsl$/UBUNTU/home/User/Repo/src/a.ts') + }) + + // Why: macOS emits NFD names while stored roots are often NFC; both spell the + // same directory, and the shared candidate must still fold into the root. + it('matches a root recorded in NFC against NFD event paths', () => { + const received = vi.fn() + const registrations = new Map([['nfc', registration('/repo/café'.normalize('NFC'), received)]]) + + route(registrations, changed('/repo/café/src/a.ts'.normalize('NFD'))) + + expect(received).toHaveBeenCalledTimes(1) + }) + + it('does not treat a sibling with a shared prefix as inside the root', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + route(registrations, changed('/repo/alphabet/src/a.ts')) + + expect(received).not.toHaveBeenCalled() + }) + + it('delivers the root itself to a watcher on that root', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + route(registrations, changed('/repo/alpha')) + + expect(received).toHaveBeenCalledTimes(1) + }) + + it('notifies every callback registered on a shared root', () => { + const first = vi.fn() + const second = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', first, second)]]) + + route(registrations, changed('/repo/alpha/src/a.ts')) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + }) + + it('skips callbacks entirely when no event is inside the root', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + route(registrations, changed('/elsewhere/a.ts', '/repo/beta/b.ts')) + + expect(received).not.toHaveBeenCalled() + }) + + it('ignores methods other than fs.changed and fs.watchFailed', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + routeSshFilesystemWatchNotification(registrations, 'pty.data', { + events: changed('/repo/alpha/src/a.ts') + }) + + expect(received).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/ssh-filesystem-watch-notifications.ts b/src/main/providers/ssh-filesystem-watch-notifications.ts index 97e3a3a4cdb..c08afb21095 100644 --- a/src/main/providers/ssh-filesystem-watch-notifications.ts +++ b/src/main/providers/ssh-filesystem-watch-notifications.ts @@ -1,5 +1,8 @@ import type { FsChangeEvent } from '../../shared/types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { + createNormalizedPathInsideOrEqualMatcher, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' import { failSshFilesystemWatchRegistration, type WatchRegistration @@ -12,10 +15,17 @@ export function routeSshFilesystemWatchNotification( ): void { if (method === 'fs.changed') { const events = params.events as FsChangeEvent[] + // Why normalize once: isPathInsideOrEqual NFC-normalizes both sides, so the + // nested fan-out re-normalized every event path once per watch root. + const normalizedEvents = events.map((event) => ({ + event, + normalizedPath: normalizeRuntimePathForComparison(event.absolutePath) + })) for (const registration of registrations.values()) { - const matching = events.filter((event) => - isPathInsideOrEqual(registration.rootPath, event.absolutePath) - ) + const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(registration.rootPath) + const matching = normalizedEvents + .filter(({ normalizedPath }) => isInsideRoot(normalizedPath)) + .map(({ event }) => event) if (matching.length > 0) { for (const callback of registration.callbacks) { callback(matching) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index 5e5b5432d6e..de59442d990 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -70,15 +70,28 @@ export function getRuntimePathBasename(value: string): string { return trimmed.split(/[\\/]/).findLast(Boolean) ?? '' } -export function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean { +/** + * Pre-normalizes the root so a fan-out normalizes it once, not once per candidate. + * + * Why the name says "normalized": candidates must already be run through + * `normalizeRuntimePathForComparison`. That function is not idempotent for WSL UNC + * paths (`//wsl.localhost/Ubuntu/A` folds to `//wsl/ubuntu/A`, which a second pass + * lowercases further), so a raw candidate here would silently fail to match. + */ +export function createNormalizedPathInsideOrEqualMatcher( + rootPath: string +): (normalizedCandidate: string) => boolean { const root = normalizeRuntimePathForComparison(rootPath) - const candidate = normalizeRuntimePathForComparison(candidatePath) - if (candidate === root) { - return true - } const rootWithBoundary = root === '/' || /^[a-z]:\/$/i.test(root) ? root : `${root.replace(/\/+$/, '')}/` - return candidate.startsWith(rootWithBoundary) + return (normalizedCandidate) => + normalizedCandidate === root || normalizedCandidate.startsWith(rootWithBoundary) +} + +export function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean { + return createNormalizedPathInsideOrEqualMatcher(rootPath)( + normalizeRuntimePathForComparison(candidatePath) + ) } export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null { From 97e4776dfe54ee87f3acc0e3e2d898d9e410955f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:14:33 -0700 Subject: [PATCH 130/150] =?UTF-8?q?feat(plugins):=20Orca=20plugin=20system?= =?UTF-8?q?=20=E2=80=94=20kernel,=20content=20packs,=20panels,=20workers,?= =?UTF-8?q?=20marketplace=20v0=20(experimental)=20(#8549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): Orca plugin system — kernel, content packs, panels, workers, marketplace v0 (experimental) Adds Orca's experimental plugin system behind a settings flag: a supervised kernel, declarative content packs (VM recipes, commands and keybindings, language packs), sandboxed iframe panels, forked worker hosts, and a Git-backed marketplace v0 with consent, provenance and kill-list enforcement. Theme, icon-theme and terminal-theme contributions are deferred to a follow-up pass. * fix(plugins): make unsupported marketplace listings unreachable by key findPlugin() backs preview/install/previewInstalledUpdate via requireListing(), so filtering only listPlugins() hid the catalog card while leaving the dead install path reachable one click later. * fix(plugins): fan Pi session-only status out to plugin subscribers The providerSessionOnly early-return in applyNormalizedStatus emitted to onAgentStatus (main-window fanout) but skipped enrichedStatusListeners, so plugins subscribed to agent.status.changed silently missed every Pi session_start event. Route both emit sites through one helper so a future early return cannot drop the plugin tap again. Co-authored-by: Orca * plugins: drop dead code and hoist duplicated trust-boundary patterns Cleanup pass over the P1 diff, no behavior change: - Delete `readPluginTreeSnapshot`/`readSnapshotFile` and their types, plus the now-vestigial `directories`/`signal` plumbing in `collectFiles`. - Delete `resolveContainedPluginDirectory` (no callers). - Delete `plugin-content-load-pool.ts`; it reimplemented the existing `mapWithConcurrency`, whose index arg also removes the pairing wrapper in `buildPluginList`. - Hoist `PLUGIN_CONTENT_HASH_PATTERN` and `PLUGIN_COMMIT_PATTERN` into the install-lockfile module; 11 sites hand-rolled these identically. - Point the new reliability gate at the PR instead of gitignored docs paths, matching every other gate's link form. * fix(plugins): retry plugin state renames on Windows AV/EPERM locks Six plugin write paths (lockfile, provenance, current pointer, kill list, marketplace cache, staged install dir) did a plain rename, so an antivirus or indexer holding the target open surfaced as a failed install. The repo already retries this hazard for issue #1507, but only through a sync helper; these paths are all async. Adds one bounded async retry + atomic write used by all six, and trims a consent-provenance header that restated its own JSX. * test(plugins): cover the Windows rename retry path The retry loop shipped untested: both existing cases hit the non-retry path, and the temp-cleanup test passed identically with the `finally` removed. Mock `rename` to queue errno codes so CI can exercise locks it cannot provoke. Co-authored-by: Orca * fix(plugins): pin bundled plugin resources to LF Windows CI checks out with autocrlf, so the byte-hashed launch tree arrived as CRLF and verify-packaged-plugin-resources rejected it — the packaged build could never pass on Windows. Reproduced locally: CRLF yields the exact CI error, LF verifies clean. Files are already LF, so nothing renormalizes. Co-authored-by: Orca * test: guard the bundled-plugin LF pin against a CRLF checkout The byte-hash mismatch only surfaced in Windows packaging CI. Assert the .gitattributes pin and that a CRLF tree is rejected, so a regression fails on any platform instead of waiting for a packaged Windows build. Co-authored-by: Orca * ci: trigger packaged-build check on bundled plugin resource changes The launch tree is byte-hashed during packaging, but no trigger path covered it — so the CRLF fix for that check would not have re-run the check. Add the resources, verifier and .gitattributes paths that can break packaging. Co-authored-by: Orca * perf(plugins): rebuild the panel frame only when its baked theme values change The revision keys the panel iframe, so every bump destroys the sandboxed frame and its in-panel state. It counted root attribute mutations, but --workspace-sidebar-live-width is written every rAF of a sidebar drag, so dragging with a panel open blanked it ~60x/sec. Compare the two values the shell actually bakes in instead. Co-authored-by: Orca * test: stop pinning a plugin name in the CRLF guard The CRLF case rewrites every launch file, so the reported mismatch is whichever plugin sorts first. P2 adds theme plugins that sort ahead of orca-navigation-shortcuts, which broke the assertion there. Co-authored-by: Orca * style: drop stray blank lines left by the rebase resolutions Both sides of the agent-hooks and orca-runtime conflicts contributed a trailing blank, which oxfmt rejects. Whitespace only. Co-authored-by: Orca * test(plugins): stop the startup budget failing on machine load P95 runs 16-34ms idle but exceeds the 50ms bound under full-suite parallelism, so the gate flaked. Widen it to catch an order-of-magnitude regression instead; the no-worker/no-plugin-code assertions are the real guarantee. Verified a 400ms regression still fails. Co-authored-by: Orca --------- Co-authored-by: Orca --- .gitattributes | 2 + .github/workflows/win-crash-survival-e2e.yml | 4 + build-plugins/plain-node-entry-guard.js | 104 ++++ config/electron-builder.config.cjs | 16 +- config/reliability-gates.jsonc | 106 ++++ .../scripts/electron-builder-config.test.mjs | 17 +- .../scripts/verify-localization-catalog.mjs | 68 ++- .../verify-localization-catalog.test.mjs | 32 +- .../verify-packaged-plugin-resources.cjs | 111 ++++ .../verify-packaged-plugin-resources.test.mjs | 77 +++ electron.vite.config.ts | 1 + examples/plugins/hello-orca/main.mjs | 24 + examples/plugins/hello-orca/orca-plugin.json | 23 + examples/plugins/hello-orca/panel.html | 125 ++++ .../plugins/hostile-panel/orca-plugin.json | 16 + examples/plugins/hostile-panel/panel.html | 206 +++++++ resources/plugins/launch/bundled-plugins.json | 10 + .../plugins/launch/orca-marketplace.json | 36 ++ .../orca-plugin.json | 15 + .../recipes/ubuntu-lts.json | 10 + .../orca-plugin.json | 34 ++ .../locales/pt-BR.json | 36 ++ .../stablyai.orca-portuguese/orca-plugin.json | 15 + src/main/agent-hooks/server.test.ts | 32 ++ src/main/agent-hooks/server.ts | 29 +- .../runtime-home-service.test.ts | 4 + src/main/codex-accounts/service.test.ts | 4 + src/main/ephemeral-vm-runtime-service.test.ts | 4 + src/main/ephemeral-vm-runtime-service.ts | 1 + src/main/i18n/main-i18n-lazy-locale.test.ts | 29 +- src/main/i18n/main-i18n.ts | 35 +- src/main/index.ts | 184 +++++- src/main/ipc/ephemeral-vm-recipe-context.ts | 47 +- src/main/ipc/ephemeral-vm-runtime-handlers.ts | 28 +- src/main/ipc/ephemeral-vm.test.ts | 136 +++++ src/main/ipc/ephemeral-vm.ts | 35 +- src/main/ipc/keybindings.test.ts | 17 + src/main/ipc/keybindings.ts | 8 +- src/main/ipc/plugin-marketplaces.test.ts | 172 ++++++ src/main/ipc/plugin-marketplaces.ts | 76 +++ src/main/ipc/plugins.test.ts | 159 +++++ src/main/ipc/plugins.ts | 257 +++++++++ src/main/ipc/register-core-handlers.test.ts | 2 +- src/main/ipc/register-core-handlers.ts | 16 +- src/main/ipc/settings.test.ts | 21 + src/main/ipc/settings.ts | 4 + src/main/ipc/worktrees.ts | 20 +- src/main/keybindings/keybinding-file.test.ts | 26 + .../persistence-right-sidebar-tab.test.ts | 49 ++ src/main/persistence.ts | 9 +- src/main/plugins/plugin-activation-policy.ts | 26 + .../plugins/plugin-approved-vm-recipes.ts | 12 + .../plugins/plugin-artifact-validation.ts | 193 +++++++ .../plugins/plugin-atomic-file-write.test.ts | 187 ++++++ src/main/plugins/plugin-atomic-file-write.ts | 55 ++ src/main/plugins/plugin-audit-log.test.ts | 38 ++ src/main/plugins/plugin-audit-log.ts | 86 +++ ...ugin-bundled-bootstrap-coordinator.test.ts | 74 +++ .../plugin-bundled-bootstrap-coordinator.ts | 48 ++ .../plugins/plugin-bundled-bootstrap.test.ts | 134 +++++ src/main/plugins/plugin-bundled-bootstrap.ts | 154 +++++ src/main/plugins/plugin-command-invocation.ts | 13 + .../plugins/plugin-command-registry.test.ts | 180 ++++++ src/main/plugins/plugin-command-registry.ts | 167 ++++++ src/main/plugins/plugin-content-hash.ts | 129 +++++ src/main/plugins/plugin-content-integrity.ts | 58 ++ .../plugin-content-pack-registry.test.ts | 108 ++++ .../plugins/plugin-content-pack-registry.ts | 95 +++ .../plugins/plugin-content-safety.test.ts | 305 ++++++++++ src/main/plugins/plugin-current-pointer.ts | 49 ++ src/main/plugins/plugin-dev-watcher.test.ts | 64 +++ src/main/plugins/plugin-dev-watcher.ts | 108 ++++ src/main/plugins/plugin-discovery.test.ts | 129 +++++ src/main/plugins/plugin-discovery.ts | 254 ++++++++ src/main/plugins/plugin-enablement.test.ts | 132 +++++ src/main/plugins/plugin-enablement.ts | 87 +++ src/main/plugins/plugin-event-bus.ts | 42 ++ src/main/plugins/plugin-event-delivery.ts | 48 ++ src/main/plugins/plugin-git-repository.ts | 57 ++ src/main/plugins/plugin-host-call-adapter.ts | 57 ++ .../plugins/plugin-host-conformance.test.ts | 366 ++++++++++++ src/main/plugins/plugin-host-entry.ts | 41 ++ .../plugins/plugin-host-method-bindings.ts | 181 ++++++ src/main/plugins/plugin-host-methods.test.ts | 232 ++++++++ src/main/plugins/plugin-host-methods.ts | 144 +++++ src/main/plugins/plugin-host-process.test.ts | 135 +++++ src/main/plugins/plugin-host-process.ts | 332 +++++++++++ src/main/plugins/plugin-host-runtime.test.ts | 76 +++ src/main/plugins/plugin-host-runtime.ts | 210 +++++++ .../plugins/plugin-host-service-bindings.ts | 84 +++ .../plugins/plugin-install-lockfile-store.ts | 83 +++ src/main/plugins/plugin-install-provenance.ts | 118 ++++ .../plugins/plugin-install-publication.ts | 94 +++ src/main/plugins/plugin-install-staging.ts | 253 ++++++++ src/main/plugins/plugin-install-trust.test.ts | 115 ++++ src/main/plugins/plugin-install-trust.ts | 27 + src/main/plugins/plugin-install.test.ts | 531 +++++++++++++++++ src/main/plugins/plugin-install.ts | 319 +++++++++++ .../plugin-instructional-content-integrity.ts | 30 + .../plugins/plugin-kill-list-service.test.ts | 111 ++++ src/main/plugins/plugin-kill-list-service.ts | 140 +++++ src/main/plugins/plugin-kill-list-store.ts | 46 ++ .../plugin-language-pack-registry.test.ts | 78 +++ .../plugins/plugin-language-pack-registry.ts | 96 ++++ .../plugins/plugin-launch-content.test.ts | 113 ++++ .../plugins/plugin-list-projection.test.ts | 224 ++++++++ src/main/plugins/plugin-list-projection.ts | 231 ++++++++ src/main/plugins/plugin-log-buffer.ts | 20 + src/main/plugins/plugin-manifest-file.ts | 22 + .../plugin-marketplace-error-message.ts | 3 + src/main/plugins/plugin-marketplace-fetch.ts | 63 ++ .../plugin-marketplace-installer.test.ts | 198 +++++++ .../plugins/plugin-marketplace-installer.ts | 164 ++++++ .../plugins/plugin-marketplace-projection.ts | 33 ++ .../plugins/plugin-marketplace-provenance.ts | 27 + .../plugin-marketplace-service.test.ts | 336 +++++++++++ .../plugins/plugin-marketplace-service.ts | 320 +++++++++++ .../plugins/plugin-marketplace-store.test.ts | 85 +++ src/main/plugins/plugin-marketplace-store.ts | 212 +++++++ .../plugins/plugin-panel-controller.test.ts | 179 ++++++ src/main/plugins/plugin-panel-controller.ts | 170 ++++++ .../plugin-panel-navigation-guard.test.ts | 41 ++ .../plugins/plugin-panel-navigation-guard.ts | 75 +++ .../plugin-panel-owner-lifecycle.test.ts | 33 ++ .../plugins/plugin-panel-owner-lifecycle.ts | 42 ++ .../plugins/plugin-panel-sessions.test.ts | 38 ++ src/main/plugins/plugin-panel-sessions.ts | 84 +++ .../plugin-private-marketplace-ssh-shim.cjs | 12 + ...in-private-marketplace.integration.test.ts | 153 +++++ src/main/plugins/plugin-refresh-settlement.ts | 11 + src/main/plugins/plugin-secrets-store.test.ts | 117 ++++ src/main/plugins/plugin-secrets-store.ts | 108 ++++ .../plugins/plugin-service-housekeeping.ts | 47 ++ .../plugins/plugin-service-integrity.test.ts | 125 ++++ src/main/plugins/plugin-service-options.ts | 18 + .../plugin-service-reconciliation.test.ts | 462 +++++++++++++++ src/main/plugins/plugin-service.ts | 336 +++++++++++ .../plugins/plugin-startup-budget.test.ts | 137 +++++ src/main/plugins/plugin-storage-store.test.ts | 27 + src/main/plugins/plugin-storage-store.ts | 102 ++++ src/main/plugins/plugin-supervisor.ts | 98 ++++ .../plugins/plugin-vm-recipe-registry.test.ts | 169 ++++++ src/main/plugins/plugin-vm-recipe-registry.ts | 127 ++++ .../plugins/plugin-worker-controller.test.ts | 166 ++++++ src/main/plugins/plugin-worker-controller.ts | 171 ++++++ src/main/plugins/plugin-worker-env.test.ts | 21 + src/main/plugins/plugin-worker-env.ts | 52 ++ .../plugins/plugin-worker-manager.test.ts | 375 ++++++++++++ src/main/plugins/plugin-worker-manager.ts | 309 ++++++++++ .../plugin-worker-output-buffer.test.ts | 22 + .../plugins/plugin-worker-output-buffer.ts | 70 +++ .../plugins/plugin-worker-reconciliation.ts | 22 + .../plugins/plugin-worker-restart-loop.ts | 50 ++ src/main/plugins/plugin-worker-slot-pool.ts | 102 ++++ src/main/plugins/plugin-worker-spawn-spec.ts | 41 ++ src/main/plugins/plugin-worker-startup.ts | 99 ++++ ...gin-worker-supervision.integration.test.ts | 177 ++++++ src/main/runtime/orca-runtime.ts | 117 +++- src/main/runtime/rpc/methods/index.ts | 2 + src/main/runtime/rpc/methods/plugins.test.ts | 74 +++ src/main/runtime/rpc/methods/plugins.ts | 154 +++++ .../window/attach-main-window-services.ts | 7 +- src/main/window/createMainWindow.ts | 4 + src/preload/api-types.ts | 193 +++++++ src/preload/index.ts | 65 ++- src/relay/plugin-host-call-handler.ts | 69 +++ src/relay/relay.ts | 9 + src/renderer/src/App.tsx | 359 +++++++----- src/renderer/src/assets/main.css | 56 ++ .../src/components/WorktreeJumpPalette.tsx | 47 +- .../cmd-j/plugin-quick-actions.test.ts | 42 ++ .../components/cmd-j/plugin-quick-actions.ts | 34 ++ .../plugin-catalog/PluginCatalogAvatar.tsx | 25 + .../PluginCatalogEmptyState.tsx | 62 ++ .../plugin-catalog/PluginCatalogLayout.tsx | 85 +++ .../plugin-catalog/plugin-display-name.ts | 26 + .../right-sidebar/PluginPanel.test.tsx | 357 ++++++++++++ .../components/right-sidebar/PluginPanel.tsx | 236 ++++++++ .../activity-bar-buttons.test.tsx | 47 ++ .../right-sidebar/activity-bar-buttons.tsx | 43 +- .../src/components/right-sidebar/index.tsx | 49 +- .../plugin-panel-activity-items.test.ts | 24 + .../plugin-panel-activity-items.ts | 90 +++ .../plugin-panel-bridge-host.test.ts | 205 +++++++ .../right-sidebar/plugin-panel-bridge-host.ts | 160 ++++++ .../plugin-panel-design-token-css.ts | 23 + .../plugin-panel-watchdog.test.ts | 50 ++ .../right-sidebar/plugin-panel-watchdog.ts | 97 ++++ .../right-sidebar-activity-visibility.test.ts | 23 +- .../right-sidebar-effective-tab.test.ts | 22 + .../right-sidebar-panel-content.tsx | 10 + ...alled-plugin-route-reconciliation.test.tsx | 91 +++ ...e-installed-plugin-route-reconciliation.ts | 25 + .../use-plugin-panel-theme-revision.test.tsx | 106 ++++ .../use-plugin-panel-theme-revision.ts | 33 ++ .../settings/AppearanceInterfaceSection.tsx | 7 + .../settings/EphemeralVmsPane.test.tsx | 75 +++ .../components/settings/EphemeralVmsPane.tsx | 23 +- .../settings/PluginConsentDialog.test.tsx | 272 +++++++++ .../settings/PluginConsentDialog.tsx | 255 +++++++++ .../settings/PluginConsentProvenance.tsx | 129 +++++ .../settings/PluginDevelopmentSection.tsx | 129 +++++ .../settings/PluginInstallDialog.test.tsx | 85 +++ .../settings/PluginInstallDialog.tsx | 206 +++++++ .../PluginKeybindingConsentPreview.test.ts | 11 + .../PluginKeybindingConsentPreview.tsx | 99 ++++ .../PluginMarketplaceBrowser.test.tsx | 307 ++++++++++ .../settings/PluginMarketplaceBrowser.tsx | 378 ++++++++++++ .../settings/PluginMarketplaceListingRow.tsx | 129 +++++ .../PluginMarketplacePreviewDialog.tsx | 297 ++++++++++ .../PluginMarketplaceSourceDialog.test.tsx | 117 ++++ .../PluginMarketplaceSourceDialog.tsx | 331 +++++++++++ .../settings/PluginRemoveDialog.tsx | 65 +++ .../settings/PluginRollbackDialog.tsx | 68 +++ .../settings/PluginSettingsOverview.tsx | 175 ++++++ .../settings/PluginSettingsRow.test.tsx | 85 +++ .../components/settings/PluginSettingsRow.tsx | 334 +++++++++++ .../settings/PluginVmRecipeConsentPreview.tsx | 73 +++ .../PluginsSettingsSection.lifecycle.test.tsx | 541 ++++++++++++++++++ .../settings/PluginsSettingsSection.tsx | 365 ++++++++++++ .../src/components/settings/Settings.tsx | 10 + .../settings/SettingsSidebar.test.tsx | 15 +- .../src/components/settings/ShortcutsPane.tsx | 85 +-- .../plugin-capability-presentation.ts | 43 ++ .../settings/plugin-error-presentation.ts | 95 +++ .../settings/plugin-install-source.test.ts | 51 ++ .../settings/plugin-install-source.ts | 40 ++ .../src/components/settings/plugins-search.ts | 34 ++ .../settings/shortcut-definition-catalog.ts | 62 ++ .../settings/shortcut-groups.test.ts | 54 ++ .../components/settings/shortcut-groups.ts | 7 +- .../components/settings/use-plugin-logs.ts | 87 +++ .../use-plugin-marketplace-lifecycle.ts | 110 ++++ ...poserState-host-context-boundaries.test.ts | 12 +- src/renderer/src/hooks/useComposerState.ts | 79 +-- .../useEphemeralVmRecipeOptions.test.tsx | 148 +++++ .../src/hooks/useEphemeralVmRecipeOptions.ts | 100 ++++ .../useSettingsNavigationMetadata.test.ts | 14 + .../hooks/useSettingsNavigationMetadata.ts | 16 + src/renderer/src/i18n/I18nProvider.tsx | 19 +- src/renderer/src/i18n/i18n.ts | 31 +- src/renderer/src/i18n/lazy-locale.test.ts | 25 +- src/renderer/src/i18n/locales/en.json | 324 ++++++++++- src/renderer/src/i18n/locales/es.json | 324 ++++++++++- src/renderer/src/i18n/locales/ja.json | 324 ++++++++++- src/renderer/src/i18n/locales/ko.json | 324 ++++++++++- src/renderer/src/i18n/locales/zh.json | 324 ++++++++++- src/renderer/src/i18n/supported-languages.ts | 13 +- .../src/lib/app-command-dispatch.test.ts | 29 + src/renderer/src/lib/app-command-dispatch.ts | 24 + .../src/lib/plugin-command-execution.test.ts | 54 ++ .../src/lib/plugin-command-execution.ts | 18 + .../lib/plugin-command-keybindings.test.ts | 115 ++++ .../src/lib/plugin-command-keybindings.ts | 75 +++ .../src/lib/settings-navigation-types.ts | 1 + .../src/store/plugin-language-packs.ts | 58 ++ src/renderer/src/store/plugin-panels.test.ts | 154 +++++ src/renderer/src/store/plugin-panels.ts | 224 ++++++++ .../src/store/right-sidebar-route.test.ts | 33 ++ src/renderer/src/store/right-sidebar-route.ts | 21 +- .../src/store/slices/settings.test.ts | 64 +++ src/renderer/src/store/slices/settings.ts | 135 +++-- src/shared/constants.ts | 4 + src/shared/ephemeral-vm-runtimes.ts | 16 + src/shared/keybindings.test.ts | 20 + src/shared/keybindings.ts | 68 ++- src/shared/plugins/plugin-capabilities.ts | 67 +++ src/shared/plugins/plugin-capability-gate.ts | 63 ++ src/shared/plugins/plugin-change-event.ts | 4 + src/shared/plugins/plugin-command-actions.ts | 36 ++ .../plugin-consent-fingerprint.test.ts | 173 ++++++ .../plugins/plugin-consent-fingerprint.ts | 45 ++ .../plugins/plugin-consent-request.test.ts | 42 ++ src/shared/plugins/plugin-consent-request.ts | 16 + src/shared/plugins/plugin-consent-state.ts | 70 +++ .../plugin-content-pack-contributions.test.ts | 155 +++++ .../plugin-content-pack-contributions.ts | 55 ++ .../plugins/plugin-demo-fixture.test.ts | 20 + src/shared/plugins/plugin-events.ts | 43 ++ .../plugins/plugin-extension-registry.ts | 98 ++++ src/shared/plugins/plugin-host-api.ts | 269 +++++++++ src/shared/plugins/plugin-host-protocol.ts | 114 ++++ .../plugins/plugin-hostile-fixture.test.ts | 29 + .../plugins/plugin-install-lockfile.test.ts | 49 ++ src/shared/plugins/plugin-install-lockfile.ts | 165 ++++++ src/shared/plugins/plugin-kill-list.test.ts | 89 +++ src/shared/plugins/plugin-kill-list.ts | 50 ++ .../plugin-language-pack-artifact.test.ts | 76 +++ .../plugins/plugin-language-pack-artifact.ts | 118 ++++ ...plugin-manifest-contribution-validation.ts | 144 +++++ src/shared/plugins/plugin-manifest-fields.ts | 47 ++ src/shared/plugins/plugin-manifest.test.ts | 90 +++ src/shared/plugins/plugin-manifest.ts | 214 +++++++ src/shared/plugins/plugin-marketplace.test.ts | 188 ++++++ src/shared/plugins/plugin-marketplace.ts | 207 +++++++ src/shared/plugins/plugin-panel-bridge.ts | 150 +++++ .../plugin-panel-call-admission.test.ts | 32 ++ .../plugins/plugin-panel-call-admission.ts | 63 ++ .../plugin-panel-message-budget.test.ts | 37 ++ .../plugins/plugin-panel-message-budget.ts | 181 ++++++ src/shared/plugins/plugin-panel-shell.test.ts | 53 ++ src/shared/plugins/plugin-panel-shell.ts | 101 ++++ src/shared/plugins/plugin-path-safety.test.ts | 48 ++ src/shared/plugins/plugin-path-safety.ts | 42 ++ .../plugins/plugin-vm-recipe-artifact.test.ts | 79 +++ .../plugins/plugin-vm-recipe-artifact.ts | 65 +++ src/shared/terminal-custom-themes.ts | 2 +- src/shared/types.ts | 16 + src/shared/ui-language.ts | 21 +- src/shared/ui-locale.test.ts | 6 + src/shared/ui-locale.ts | 8 +- tests/e2e/helpers/orca-restart.ts | 4 +- tests/e2e/plugin-demo.spec.ts | 180 ++++++ tests/e2e/plugin-marketplace-content.spec.ts | 285 +++++++++ tests/e2e/plugin-panel-containment.spec.ts | 344 +++++++++++ tests/e2e/plugin-startup-budget.spec.ts | 155 +++++ 316 files changed, 31575 insertions(+), 455 deletions(-) create mode 100644 build-plugins/plain-node-entry-guard.js create mode 100644 config/scripts/verify-packaged-plugin-resources.cjs create mode 100644 config/scripts/verify-packaged-plugin-resources.test.mjs create mode 100644 examples/plugins/hello-orca/main.mjs create mode 100644 examples/plugins/hello-orca/orca-plugin.json create mode 100644 examples/plugins/hello-orca/panel.html create mode 100644 examples/plugins/hostile-panel/orca-plugin.json create mode 100644 examples/plugins/hostile-panel/panel.html create mode 100644 resources/plugins/launch/bundled-plugins.json create mode 100644 resources/plugins/launch/orca-marketplace.json create mode 100644 resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json create mode 100644 resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json create mode 100644 resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json create mode 100644 resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json create mode 100644 resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json create mode 100644 src/main/ipc/plugin-marketplaces.test.ts create mode 100644 src/main/ipc/plugin-marketplaces.ts create mode 100644 src/main/ipc/plugins.test.ts create mode 100644 src/main/ipc/plugins.ts create mode 100644 src/main/persistence-right-sidebar-tab.test.ts create mode 100644 src/main/plugins/plugin-activation-policy.ts create mode 100644 src/main/plugins/plugin-approved-vm-recipes.ts create mode 100644 src/main/plugins/plugin-artifact-validation.ts create mode 100644 src/main/plugins/plugin-atomic-file-write.test.ts create mode 100644 src/main/plugins/plugin-atomic-file-write.ts create mode 100644 src/main/plugins/plugin-audit-log.test.ts create mode 100644 src/main/plugins/plugin-audit-log.ts create mode 100644 src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts create mode 100644 src/main/plugins/plugin-bundled-bootstrap-coordinator.ts create mode 100644 src/main/plugins/plugin-bundled-bootstrap.test.ts create mode 100644 src/main/plugins/plugin-bundled-bootstrap.ts create mode 100644 src/main/plugins/plugin-command-invocation.ts create mode 100644 src/main/plugins/plugin-command-registry.test.ts create mode 100644 src/main/plugins/plugin-command-registry.ts create mode 100644 src/main/plugins/plugin-content-hash.ts create mode 100644 src/main/plugins/plugin-content-integrity.ts create mode 100644 src/main/plugins/plugin-content-pack-registry.test.ts create mode 100644 src/main/plugins/plugin-content-pack-registry.ts create mode 100644 src/main/plugins/plugin-content-safety.test.ts create mode 100644 src/main/plugins/plugin-current-pointer.ts create mode 100644 src/main/plugins/plugin-dev-watcher.test.ts create mode 100644 src/main/plugins/plugin-dev-watcher.ts create mode 100644 src/main/plugins/plugin-discovery.test.ts create mode 100644 src/main/plugins/plugin-discovery.ts create mode 100644 src/main/plugins/plugin-enablement.test.ts create mode 100644 src/main/plugins/plugin-enablement.ts create mode 100644 src/main/plugins/plugin-event-bus.ts create mode 100644 src/main/plugins/plugin-event-delivery.ts create mode 100644 src/main/plugins/plugin-git-repository.ts create mode 100644 src/main/plugins/plugin-host-call-adapter.ts create mode 100644 src/main/plugins/plugin-host-conformance.test.ts create mode 100644 src/main/plugins/plugin-host-entry.ts create mode 100644 src/main/plugins/plugin-host-method-bindings.ts create mode 100644 src/main/plugins/plugin-host-methods.test.ts create mode 100644 src/main/plugins/plugin-host-methods.ts create mode 100644 src/main/plugins/plugin-host-process.test.ts create mode 100644 src/main/plugins/plugin-host-process.ts create mode 100644 src/main/plugins/plugin-host-runtime.test.ts create mode 100644 src/main/plugins/plugin-host-runtime.ts create mode 100644 src/main/plugins/plugin-host-service-bindings.ts create mode 100644 src/main/plugins/plugin-install-lockfile-store.ts create mode 100644 src/main/plugins/plugin-install-provenance.ts create mode 100644 src/main/plugins/plugin-install-publication.ts create mode 100644 src/main/plugins/plugin-install-staging.ts create mode 100644 src/main/plugins/plugin-install-trust.test.ts create mode 100644 src/main/plugins/plugin-install-trust.ts create mode 100644 src/main/plugins/plugin-install.test.ts create mode 100644 src/main/plugins/plugin-install.ts create mode 100644 src/main/plugins/plugin-instructional-content-integrity.ts create mode 100644 src/main/plugins/plugin-kill-list-service.test.ts create mode 100644 src/main/plugins/plugin-kill-list-service.ts create mode 100644 src/main/plugins/plugin-kill-list-store.ts create mode 100644 src/main/plugins/plugin-language-pack-registry.test.ts create mode 100644 src/main/plugins/plugin-language-pack-registry.ts create mode 100644 src/main/plugins/plugin-launch-content.test.ts create mode 100644 src/main/plugins/plugin-list-projection.test.ts create mode 100644 src/main/plugins/plugin-list-projection.ts create mode 100644 src/main/plugins/plugin-log-buffer.ts create mode 100644 src/main/plugins/plugin-manifest-file.ts create mode 100644 src/main/plugins/plugin-marketplace-error-message.ts create mode 100644 src/main/plugins/plugin-marketplace-fetch.ts create mode 100644 src/main/plugins/plugin-marketplace-installer.test.ts create mode 100644 src/main/plugins/plugin-marketplace-installer.ts create mode 100644 src/main/plugins/plugin-marketplace-projection.ts create mode 100644 src/main/plugins/plugin-marketplace-provenance.ts create mode 100644 src/main/plugins/plugin-marketplace-service.test.ts create mode 100644 src/main/plugins/plugin-marketplace-service.ts create mode 100644 src/main/plugins/plugin-marketplace-store.test.ts create mode 100644 src/main/plugins/plugin-marketplace-store.ts create mode 100644 src/main/plugins/plugin-panel-controller.test.ts create mode 100644 src/main/plugins/plugin-panel-controller.ts create mode 100644 src/main/plugins/plugin-panel-navigation-guard.test.ts create mode 100644 src/main/plugins/plugin-panel-navigation-guard.ts create mode 100644 src/main/plugins/plugin-panel-owner-lifecycle.test.ts create mode 100644 src/main/plugins/plugin-panel-owner-lifecycle.ts create mode 100644 src/main/plugins/plugin-panel-sessions.test.ts create mode 100644 src/main/plugins/plugin-panel-sessions.ts create mode 100644 src/main/plugins/plugin-private-marketplace-ssh-shim.cjs create mode 100644 src/main/plugins/plugin-private-marketplace.integration.test.ts create mode 100644 src/main/plugins/plugin-refresh-settlement.ts create mode 100644 src/main/plugins/plugin-secrets-store.test.ts create mode 100644 src/main/plugins/plugin-secrets-store.ts create mode 100644 src/main/plugins/plugin-service-housekeeping.ts create mode 100644 src/main/plugins/plugin-service-integrity.test.ts create mode 100644 src/main/plugins/plugin-service-options.ts create mode 100644 src/main/plugins/plugin-service-reconciliation.test.ts create mode 100644 src/main/plugins/plugin-service.ts create mode 100644 src/main/plugins/plugin-startup-budget.test.ts create mode 100644 src/main/plugins/plugin-storage-store.test.ts create mode 100644 src/main/plugins/plugin-storage-store.ts create mode 100644 src/main/plugins/plugin-supervisor.ts create mode 100644 src/main/plugins/plugin-vm-recipe-registry.test.ts create mode 100644 src/main/plugins/plugin-vm-recipe-registry.ts create mode 100644 src/main/plugins/plugin-worker-controller.test.ts create mode 100644 src/main/plugins/plugin-worker-controller.ts create mode 100644 src/main/plugins/plugin-worker-env.test.ts create mode 100644 src/main/plugins/plugin-worker-env.ts create mode 100644 src/main/plugins/plugin-worker-manager.test.ts create mode 100644 src/main/plugins/plugin-worker-manager.ts create mode 100644 src/main/plugins/plugin-worker-output-buffer.test.ts create mode 100644 src/main/plugins/plugin-worker-output-buffer.ts create mode 100644 src/main/plugins/plugin-worker-reconciliation.ts create mode 100644 src/main/plugins/plugin-worker-restart-loop.ts create mode 100644 src/main/plugins/plugin-worker-slot-pool.ts create mode 100644 src/main/plugins/plugin-worker-spawn-spec.ts create mode 100644 src/main/plugins/plugin-worker-startup.ts create mode 100644 src/main/plugins/plugin-worker-supervision.integration.test.ts create mode 100644 src/main/runtime/rpc/methods/plugins.test.ts create mode 100644 src/main/runtime/rpc/methods/plugins.ts create mode 100644 src/relay/plugin-host-call-handler.ts create mode 100644 src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts create mode 100644 src/renderer/src/components/cmd-j/plugin-quick-actions.ts create mode 100644 src/renderer/src/components/plugin-catalog/PluginCatalogAvatar.tsx create mode 100644 src/renderer/src/components/plugin-catalog/PluginCatalogEmptyState.tsx create mode 100644 src/renderer/src/components/plugin-catalog/PluginCatalogLayout.tsx create mode 100644 src/renderer/src/components/plugin-catalog/plugin-display-name.ts create mode 100644 src/renderer/src/components/right-sidebar/PluginPanel.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/PluginPanel.tsx create mode 100644 src/renderer/src/components/right-sidebar/activity-bar-buttons.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts create mode 100644 src/renderer/src/components/right-sidebar/plugin-panel-activity-items.ts create mode 100644 src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts create mode 100644 src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.ts create mode 100644 src/renderer/src/components/right-sidebar/plugin-panel-design-token-css.ts create mode 100644 src/renderer/src/components/right-sidebar/plugin-panel-watchdog.test.ts create mode 100644 src/renderer/src/components/right-sidebar/plugin-panel-watchdog.ts create mode 100644 src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/use-installed-plugin-route-reconciliation.ts create mode 100644 src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/use-plugin-panel-theme-revision.ts create mode 100644 src/renderer/src/components/settings/PluginConsentDialog.test.tsx create mode 100644 src/renderer/src/components/settings/PluginConsentDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginConsentProvenance.tsx create mode 100644 src/renderer/src/components/settings/PluginDevelopmentSection.tsx create mode 100644 src/renderer/src/components/settings/PluginInstallDialog.test.tsx create mode 100644 src/renderer/src/components/settings/PluginInstallDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts create mode 100644 src/renderer/src/components/settings/PluginKeybindingConsentPreview.tsx create mode 100644 src/renderer/src/components/settings/PluginMarketplaceBrowser.test.tsx create mode 100644 src/renderer/src/components/settings/PluginMarketplaceBrowser.tsx create mode 100644 src/renderer/src/components/settings/PluginMarketplaceListingRow.tsx create mode 100644 src/renderer/src/components/settings/PluginMarketplacePreviewDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginMarketplaceSourceDialog.test.tsx create mode 100644 src/renderer/src/components/settings/PluginMarketplaceSourceDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginRemoveDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginRollbackDialog.tsx create mode 100644 src/renderer/src/components/settings/PluginSettingsOverview.tsx create mode 100644 src/renderer/src/components/settings/PluginSettingsRow.test.tsx create mode 100644 src/renderer/src/components/settings/PluginSettingsRow.tsx create mode 100644 src/renderer/src/components/settings/PluginVmRecipeConsentPreview.tsx create mode 100644 src/renderer/src/components/settings/PluginsSettingsSection.lifecycle.test.tsx create mode 100644 src/renderer/src/components/settings/PluginsSettingsSection.tsx create mode 100644 src/renderer/src/components/settings/plugin-capability-presentation.ts create mode 100644 src/renderer/src/components/settings/plugin-error-presentation.ts create mode 100644 src/renderer/src/components/settings/plugin-install-source.test.ts create mode 100644 src/renderer/src/components/settings/plugin-install-source.ts create mode 100644 src/renderer/src/components/settings/plugins-search.ts create mode 100644 src/renderer/src/components/settings/shortcut-definition-catalog.ts create mode 100644 src/renderer/src/components/settings/shortcut-groups.test.ts create mode 100644 src/renderer/src/components/settings/use-plugin-logs.ts create mode 100644 src/renderer/src/components/settings/use-plugin-marketplace-lifecycle.ts create mode 100644 src/renderer/src/hooks/useEphemeralVmRecipeOptions.test.tsx create mode 100644 src/renderer/src/hooks/useEphemeralVmRecipeOptions.ts create mode 100644 src/renderer/src/lib/app-command-dispatch.test.ts create mode 100644 src/renderer/src/lib/app-command-dispatch.ts create mode 100644 src/renderer/src/lib/plugin-command-execution.test.ts create mode 100644 src/renderer/src/lib/plugin-command-execution.ts create mode 100644 src/renderer/src/lib/plugin-command-keybindings.test.ts create mode 100644 src/renderer/src/lib/plugin-command-keybindings.ts create mode 100644 src/renderer/src/store/plugin-language-packs.ts create mode 100644 src/renderer/src/store/plugin-panels.test.ts create mode 100644 src/renderer/src/store/plugin-panels.ts create mode 100644 src/shared/plugins/plugin-capabilities.ts create mode 100644 src/shared/plugins/plugin-capability-gate.ts create mode 100644 src/shared/plugins/plugin-change-event.ts create mode 100644 src/shared/plugins/plugin-command-actions.ts create mode 100644 src/shared/plugins/plugin-consent-fingerprint.test.ts create mode 100644 src/shared/plugins/plugin-consent-fingerprint.ts create mode 100644 src/shared/plugins/plugin-consent-request.test.ts create mode 100644 src/shared/plugins/plugin-consent-request.ts create mode 100644 src/shared/plugins/plugin-consent-state.ts create mode 100644 src/shared/plugins/plugin-content-pack-contributions.test.ts create mode 100644 src/shared/plugins/plugin-content-pack-contributions.ts create mode 100644 src/shared/plugins/plugin-demo-fixture.test.ts create mode 100644 src/shared/plugins/plugin-events.ts create mode 100644 src/shared/plugins/plugin-extension-registry.ts create mode 100644 src/shared/plugins/plugin-host-api.ts create mode 100644 src/shared/plugins/plugin-host-protocol.ts create mode 100644 src/shared/plugins/plugin-hostile-fixture.test.ts create mode 100644 src/shared/plugins/plugin-install-lockfile.test.ts create mode 100644 src/shared/plugins/plugin-install-lockfile.ts create mode 100644 src/shared/plugins/plugin-kill-list.test.ts create mode 100644 src/shared/plugins/plugin-kill-list.ts create mode 100644 src/shared/plugins/plugin-language-pack-artifact.test.ts create mode 100644 src/shared/plugins/plugin-language-pack-artifact.ts create mode 100644 src/shared/plugins/plugin-manifest-contribution-validation.ts create mode 100644 src/shared/plugins/plugin-manifest-fields.ts create mode 100644 src/shared/plugins/plugin-manifest.test.ts create mode 100644 src/shared/plugins/plugin-manifest.ts create mode 100644 src/shared/plugins/plugin-marketplace.test.ts create mode 100644 src/shared/plugins/plugin-marketplace.ts create mode 100644 src/shared/plugins/plugin-panel-bridge.ts create mode 100644 src/shared/plugins/plugin-panel-call-admission.test.ts create mode 100644 src/shared/plugins/plugin-panel-call-admission.ts create mode 100644 src/shared/plugins/plugin-panel-message-budget.test.ts create mode 100644 src/shared/plugins/plugin-panel-message-budget.ts create mode 100644 src/shared/plugins/plugin-panel-shell.test.ts create mode 100644 src/shared/plugins/plugin-panel-shell.ts create mode 100644 src/shared/plugins/plugin-path-safety.test.ts create mode 100644 src/shared/plugins/plugin-path-safety.ts create mode 100644 src/shared/plugins/plugin-vm-recipe-artifact.test.ts create mode 100644 src/shared/plugins/plugin-vm-recipe-artifact.ts create mode 100644 tests/e2e/plugin-demo.spec.ts create mode 100644 tests/e2e/plugin-marketplace-content.spec.ts create mode 100644 tests/e2e/plugin-panel-containment.spec.ts create mode 100644 tests/e2e/plugin-startup-budget.spec.ts diff --git a/.gitattributes b/.gitattributes index b1dacdd5456..565a0287996 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,5 @@ /skill-stubs/*.md text eol=lf /skills/*/SKILL.md text eol=lf /src/cli/bundled-skill-guides.ts text eol=lf +# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash. +/resources/plugins/** text eol=lf diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index e4c388ceb18..7dc5f0e219b 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -27,7 +27,11 @@ on: - 'config/patches/**' - 'config/scripts/ensure-native-runtime.mjs' - 'config/scripts/rebuild-native-deps.mjs' + - 'config/scripts/verify-packaged-plugin-resources.cjs' - 'native/**' + # Byte-hashed at package time, and CRLF-sensitive on Windows. + - '.gitattributes' + - 'resources/plugins/**' - 'resources/win32/**' - 'src/main/daemon/**' - 'src/main/index.ts' diff --git a/build-plugins/plain-node-entry-guard.js b/build-plugins/plain-node-entry-guard.js new file mode 100644 index 00000000000..ba8c0e8e4cc --- /dev/null +++ b/build-plugins/plain-node-entry-guard.js @@ -0,0 +1,104 @@ +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main +// chunk gained `require("electron")` (an import edge added in #7642), and the +// daemon is forked as a plain-Node process where electron cannot be required. +// Nothing in CI executes the built daemon-entry under plain Node, so the leak +// stayed invisible until an adopted old daemon died. This guard fails the +// build when any chunk reachable from a plain-Node fork entry requires +// electron, and smoke-loads daemon-entry under plain Node to prove its module +// graph still resolves. +// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime): +// forked daemon, parcel-watcher and computer sidecars, and the CLI-run +// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them. +const PLAIN_NODE_ENTRY_NAMES = [ + 'daemon-entry', + 'parcel-watcher-process-entry', + 'computer-sidecar', + 'agent-hooks/managed-agent-hook-controls', + 'codex/codex-app-server-grant-entry' +]; +const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/; +function collectReachableChunks(entry, byFileName) { + const seen = new Set(); + const reachable = []; + const stack = [entry.fileName]; + while (stack.length > 0) { + const fileName = stack.pop(); + if (seen.has(fileName)) { + continue; + } + seen.add(fileName); + const chunk = byFileName.get(fileName); + if (!chunk) { + continue; + } + reachable.push(chunk); + for (const imported of [...chunk.imports, ...chunk.dynamicImports]) { + stack.push(imported); + } + } + return reachable; +} +function assertNoElectronRequire(entryName, entry, byFileName) { + for (const chunk of collectReachableChunks(entry, byFileName)) { + if (ELECTRON_REQUIRE_RE.test(chunk.code)) { + throw new Error(`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + + `requires electron. "${entryName}" runs as a plain-Node process, where ` + + `require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` + + `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`); + } + } +} +// Why: proves the whole daemon-entry graph resolves under plain Node (no +// unresolved requires). require("electron") does not throw in a dev tree with +// node_modules present, so the static scan above — not this smoke — is the +// electron regression guard; this only catches gross load failures. +function smokeLoadDaemonEntry(outputDir) { + const entryPath = join(outputDir, 'daemon-entry.js'); + const result = spawnSync(process.execPath, [entryPath], { + encoding: 'utf8', + timeout: 15_000 + }); + if (result.error) { + throw new Error(`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` + + `${result.error.message}`); + } + const stderr = result.stderr ?? ''; + if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) { + throw new Error(`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`); + } + if (!stderr.includes('Usage: daemon-entry')) { + throw new Error(`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` + + `(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`); + } +} +export function createPlainNodeEntryGuardPlugin() { + return { + name: 'orca-plain-node-entry-guard', + writeBundle(options, bundle) { + // Why: skip in `electron-vite dev` watch mode — the smoke would respawn on + // every rebuild, and the guard only needs to gate produced builds. + if (this.meta.watchMode) { + return; + } + const chunks = Object.values(bundle).filter((item) => item.type === 'chunk'); + const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk])); + const entryByName = new Map(); + for (const chunk of chunks) { + if (chunk.isEntry && chunk.name) { + entryByName.set(chunk.name, chunk); + } + } + for (const entryName of PLAIN_NODE_ENTRY_NAMES) { + const entry = entryByName.get(entryName); + if (entry) { + assertNoElectronRequire(entryName, entry, byFileName); + } + } + if (entryByName.has('daemon-entry') && options.dir) { + smokeLoadDaemonEntry(options.dir); + } + } + }; +} diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 1f6246ec531..9a385066196 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -12,6 +12,7 @@ const { verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') +const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' @@ -32,11 +33,17 @@ const relayExtraResource = { from: 'out/relay', to: 'relay' } +// Why: bundled plugins are immutable install inputs and must remain ordinary +// directories so the startup bootstrap can verify and publish exact bytes. +const bundledPluginResources = { + from: 'resources/plugins/launch', + to: 'plugins/launch' +} // Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute // from package directories where pnpm's symlink farm is absent. Copy the exact // runtime dependency closure to Resources/node_modules so bare require() calls // do not fall through to a developer checkout's node_modules. -const commonExtraResources = [relayExtraResource, skillFreshnessResources] +const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -87,6 +94,9 @@ module.exports = { // it from process.resourcesPath; exclude the source copy from app.asar. '!resources/onboarding/feature-wall/**', '!resources/skills/**', + // Why: bundled plugins ship via extraResources to resources/plugins/launch; + // packing the source tree into app.asar would duplicate those exact bytes. + '!resources/plugins/launch/**', // Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd // (beside the native resources/bin/orca.exe). Packing the source tree into // app.asar too lets asarUnpack:['resources/**'] extract a second copy at @@ -129,6 +139,7 @@ module.exports = { 'out/main/hermes/**', 'out/main/win32-utils.js', 'out/main/daemon-entry.js', + 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', 'out/main/chunks/**', @@ -178,6 +189,9 @@ module.exports = { `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})` ) } + // Why: inspect electron-builder's real output so a broken extraResources + // mapping fails packaging before bundled content reaches users. + verifyPackagedPluginResources(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f5200abb1a5..53c89e2007c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -6570,6 +6570,112 @@ "Windows native runtime watches bypass this child path; WSL reservation/release is deterministic-contract tested but not live fault-injected, and SSH registration ownership is not live-relay fault-injected." ], "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds one runtime watcher child, if total physical operation exceeds eight children including retiring generations, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process." + }, + { + "id": "terminal-input.plugin-explicit-worktree-routing", + "title": "Plugin terminal input stays inside the freshly resolved worktree", + "maturity": "experimental", + "protection": "partial", + "owner": "plugin-platform", + "layer": "main-relay-contract", + "surfaces": [ + "plugin host API terminal input", + "active worktree resolution", + "provider terminal inventory", + "relay capability enforcement" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime", + "mobile-relay" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "ssh" + ], + "coverageNotes": "Deterministic macOS contract evidence covers opaque local- and SSH-shaped terminal ids, one bounded worktree listing, mismatch rejection, and the main/relay host-call adapter matrix. It does not launch a live PTY or provision a relay-hosted plugin.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/8549" + ], + "invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.", + "oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts" + ], + "testFiles": [ + "src/main/plugins/plugin-host-methods.test.ts", + "src/main/plugins/plugin-host-conformance.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/plugins/plugin-host-methods.test.ts", + "assertions": [ + "a terminal outside the freshly resolved worktree performs one capped list and zero sends", + "matching local- and SSH-shaped opaque ids each perform one capped list and one exact send", + "workspace.readContext drops provider paths, path-bearing internal worktree ids, and terminal titles while capping its terminal projection" + ] + }, + { + "file": "src/main/plugins/plugin-host-conformance.test.ts", + "assertions": [ + "all 13 v0 methods succeed with the required consented capability through desktop-main and relay adapters", + "missing consent, missing capability, unknown method, malformed params, panel-forbidden access, malformed results, and mutation-audit failure return identical codes", + "malformed qualified keys, client-supplied grants, and client-supplied transport flags are rejected before host policy resolution" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts", + "result": "passed", + "durationSeconds": 0.18, + "summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "plugin host main/relay contract tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic focused suite passed locally once and needs CI and soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Exact mismatch/send counts and adapter error parity are asserted; intentional-break and saved CI evidence are still missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Each plugin send resolves once, performs exactly one list capped at 50 terminals, and performs at most one send. The path adds no polling, subprocesses, provider fanout, renderer work, or startup await." + }, + "promotionCriteria": [ + "Run for at least 100 consecutive passes or 14 days across required CI platforms.", + "Attach intentional-break evidence for the worktree membership check and relay transport binding.", + "Exercise live local and SSH provider terminals, including mismatch rejection and successful input echo.", + "Keep relay-hosted plugin provisioning behind a separate reviewed policy before replacing the fail-closed registration." + ], + "knownGaps": [ + "Linux and Windows execution evidence is not recorded.", + "Daemon, WSL, remote-runtime, and mobile-relay providers have no live input evidence.", + "Local and SSH coverage is contract-level over opaque ids, not a live PTY input/echo run.", + "The bounded 50-terminal inventory intentionally rejects a target not present in the capped result; scale behavior above that cap needs a targeted membership API before expansion.", + "Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default." + ], + "demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge." } ] } diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 50104b2cd75..4d492d68cd0 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -35,17 +35,25 @@ describe('electron-builder config', () => { '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', - '!out/**/*.test.js' + '!out/**/*.test.js', + '!resources/plugins/launch/**' ]) ) }) it('keeps runtime resources available through extraResources', () => { + const bundledPluginResources = expect.objectContaining({ + from: 'resources/plugins/launch', + to: 'plugins/launch' + }) for (const platform of ['mac', 'linux', 'win']) { expect(electronBuilderConfig[platform].extraResources).toContainEqual({ from: 'resources/skills', to: 'skills' }) + expect(electronBuilderConfig[platform].extraResources).toEqual( + expect.arrayContaining([bundledPluginResources]) + ) } expect(electronBuilderConfig.mac.extraResources).toEqual( expect.arrayContaining([ @@ -386,6 +394,11 @@ describe('electron-builder config', () => { const resourcesDir = join(root, 'linux-unpacked', 'resources') const launcherPath = join(resourcesDir, 'bin', 'orca-ide') await mkdir(join(resourcesDir, 'bin'), { recursive: true }) + await cp( + join(process.cwd(), 'resources', 'plugins', 'launch'), + join(resourcesDir, 'plugins', 'launch'), + { recursive: true } + ) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) // Why: afterPack now fails hard when the unpacked daemon entry is // missing, so the fixture must carry one like a real package layout. diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index 23126da730f..1c16279a295 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -385,15 +385,79 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) { } function parseArgs(argv) { - return { - fix: argv.includes('--fix') + const pluginCatalogs = [] + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (argument === '--plugin-catalog') { + const catalogPath = argv[index + 1] + if (!catalogPath || catalogPath.startsWith('--')) { + throw new Error('--plugin-catalog requires a JSON catalog path') + } + pluginCatalogs.push(catalogPath) + index += 1 + } else if (argument.startsWith('--plugin-catalog=')) { + pluginCatalogs.push(argument.slice('--plugin-catalog='.length)) + } } + return { + fix: argv.includes('--fix'), + pluginCatalogs + } +} + +async function reportPluginCatalog(root, catalog, pluginCatalogPath) { + const resolvedPath = path.resolve(root, pluginCatalogPath) + let pluginCatalog + try { + pluginCatalog = JSON.parse(await fs.readFile(resolvedPath, 'utf8')) + } catch (error) { + console.error( + `Could not read plugin catalog ${normalizePath(root, resolvedPath)}: ${error instanceof Error ? error.message : String(error)}` + ) + return 1 + } + const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } = + collectLocaleParityIssues(catalog, pluginCatalog) + const translated = enEntries.size - missingInLocale.length - interpolationMismatches.length + const coverage = enEntries.size === 0 ? 100 : (translated / enEntries.size) * 100 + console.log( + `Plugin catalog ${normalizePath(root, resolvedPath)}: ${translated}/${enEntries.size} core keys (${coverage.toFixed(1)}% coverage), ${localeEntries.size} catalog entries.` + ) + if (missingInLocale.length > 0) { + console.log(formatMissingKeys('missing', missingInLocale.slice(0, 20))) + if (missingInLocale.length > 20) { + console.log(`...and ${missingInLocale.length - 20} more missing keys`) + } + } + if (extraInLocale.length > 0) { + console.log(formatMissingKeys('extra', extraInLocale.slice(0, 20))) + } + if (interpolationMismatches.length > 0) { + console.log(formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20))) + } + // Why: absent plugin translations safely fall back to English, but a present + // value with different variables can render broken or misleading UI. + return interpolationMismatches.length > 0 ? 1 : 0 } export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) { const localesDir = path.join(root, LOCALES_RELATIVE_DIR) const catalogPath = path.join(localesDir, 'en.json') const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8')) + const pluginCatalogs = options.pluginCatalogs ?? [] + if (pluginCatalogs.length > 0) { + if (options.fix) { + console.error('--fix cannot be combined with --plugin-catalog') + return 1 + } + for (const pluginCatalogPath of pluginCatalogs) { + const result = await reportPluginCatalog(root, catalog, pluginCatalogPath) + if (result !== 0) { + return result + } + } + return 0 + } let catalogKeys = new Set(flattenCatalogKeys(catalog)) const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot)) const references = [] diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs index ce148c7890f..14a41fcef66 100644 --- a/config/scripts/verify-localization-catalog.test.mjs +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs' @@ -79,4 +79,34 @@ describe('verify-localization-catalog', () => { await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1) expect(readJson(path.join(localesDir, 'en.json'))).toEqual({}) }) + + it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => { + const { root } = makeProject({ + sourceText: 'export {}\n', + enCatalog: { + auto: { first: 'First {{name}}', second: 'Second' } + }, + esCatalog: { + auto: { first: 'Primero {{name}}', second: 'Segundo' } + } + }) + const pluginCatalogPath = path.join(root, 'plugin-locale.json') + writeJson(pluginCatalogPath, { + auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' } + }) + const report = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + try { + await expect( + verifyLocalizationCatalog(root, { + fix: false, + pluginCatalogs: [pluginCatalogPath] + }) + ).resolves.toBe(1) + expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys')) + expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch')) + } finally { + report.mockRestore() + } + }) }) diff --git a/config/scripts/verify-packaged-plugin-resources.cjs b/config/scripts/verify-packaged-plugin-resources.cjs new file mode 100644 index 00000000000..701c5e73214 --- /dev/null +++ b/config/scripts/verify-packaged-plugin-resources.cjs @@ -0,0 +1,111 @@ +const { createHash } = require('node:crypto') +const { lstatSync, readFileSync, readdirSync, statSync } = require('node:fs') +const { isAbsolute, join, relative, resolve, sep } = require('node:path') + +const MAX_PLUGIN_FILES = 2_000 +const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 + +function hashLength(hash, length) { + const framedLength = Buffer.allocUnsafe(8) + framedLength.writeBigUInt64BE(BigInt(length)) + hash.update(framedLength) +} + +function hashPackagedPluginTree(root) { + const files = [] + let entriesVisited = 0 + let totalBytes = 0 + const visit = (directory) => { + const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0 + ) + for (const entry of entries) { + if (directory === root && entry.name === '.git') { + continue + } + const entryPath = join(directory, entry.name) + const metadata = lstatSync(entryPath) + entriesVisited += 1 + if (entriesVisited > MAX_PLUGIN_FILES) { + throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`) + } + if (metadata.isSymbolicLink()) { + throw new Error(`packaged plugin contains a symlink: ${relative(root, entryPath)}`) + } + if (metadata.isDirectory()) { + visit(entryPath) + } else if (metadata.isFile()) { + totalBytes += metadata.size + if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) { + throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`) + } + files.push({ path: entryPath, size: metadata.size }) + } else { + throw new Error(`packaged plugin contains an unsupported entry: ${entryPath}`) + } + } + } + visit(root) + const hash = createHash('sha256').update('orca-plugin-tree-v1\0') + for (const file of files) { + const relativePath = relative(root, file.path).replaceAll('\\', '/') + hashLength(hash, Buffer.byteLength(relativePath, 'utf8')) + hash.update(relativePath, 'utf8') + hashLength(hash, file.size) + hash.update(readFileSync(file.path)) + } + return hash.digest('hex') +} + +function readJsonFile(path, label) { + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `[verify-packaged-plugin-resources] invalid ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function verifyPackagedPluginResources(resourcesDir) { + const launchRoot = join(resourcesDir, 'plugins', 'launch') + if (!statSync(launchRoot).isDirectory()) { + throw new Error(`[verify-packaged-plugin-resources] missing launch directory at ${launchRoot}`) + } + const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index') + readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index') + if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) { + throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid') + } + const resolvedRoot = resolve(launchRoot) + for (const entry of index.plugins) { + if ( + typeof entry?.pluginKey !== 'string' || + typeof entry.path !== 'string' || + !/^[0-9a-f]{64}$/.test(entry.contentHash) + ) { + throw new Error('[verify-packaged-plugin-resources] bundled plugin entry is invalid') + } + const pluginRoot = resolve(launchRoot, entry.path) + const fromRoot = relative(resolvedRoot, pluginRoot) + if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error('[verify-packaged-plugin-resources] bundled plugin path escapes launch root') + } + const manifest = readJsonFile(join(pluginRoot, 'orca-plugin.json'), 'plugin manifest') + if (`${manifest.publisher}.${manifest.id}` !== entry.pluginKey) { + throw new Error( + `[verify-packaged-plugin-resources] manifest identity does not match ${entry.pluginKey}` + ) + } + if (hashPackagedPluginTree(pluginRoot) !== entry.contentHash) { + throw new Error( + `[verify-packaged-plugin-resources] packaged bytes do not match ${entry.pluginKey}` + ) + } + } + console.log( + `[verify-packaged-plugin-resources] OK — verified ${index.plugins.length} bundled plugin(s)` + ) +} + +module.exports = { verifyPackagedPluginResources } diff --git a/config/scripts/verify-packaged-plugin-resources.test.mjs b/config/scripts/verify-packaged-plugin-resources.test.mjs new file mode 100644 index 00000000000..79dda1f4866 --- /dev/null +++ b/config/scripts/verify-packaged-plugin-resources.test.mjs @@ -0,0 +1,77 @@ +import { cp, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs') + +describe('verify packaged plugin resources', () => { + it('accepts exact launch bytes copied into a packaged resources directory', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) + try { + await cp( + join(process.cwd(), 'resources', 'plugins', 'launch'), + join(resourcesDir, 'plugins', 'launch'), + { recursive: true } + ) + + expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow() + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('rejects mutated bytes in the packaged output', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) + try { + const launchRoot = join(resourcesDir, 'plugins', 'launch') + await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { + recursive: true + }) + await writeFile( + join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'), + '{"mutated":true}\n' + ) + + expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow( + 'packaged bytes do not match stablyai.orca-navigation-shortcuts' + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + // The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the + // pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it. + it('pins the launch tree to LF so Windows checkouts hash identically', async () => { + const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8') + expect(attributes).toContain('/resources/plugins/** text eol=lf') + }) + + it('rejects a CRLF checkout of the launch tree', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) + try { + const launchRoot = join(resourcesDir, 'plugins', 'launch') + await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { + recursive: true + }) + for (const entry of await readdir(launchRoot, { recursive: true })) { + const path = join(launchRoot, entry) + if (!(await stat(path)).isFile()) { + continue + } + await writeFile(path, (await readFile(path, 'utf8')).replace(/\r?\n/g, '\r\n')) + } + + // Every file is rewritten, so the first mismatch is whichever plugin sorts + // first — don't pin a name a later branch can reorder. + expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow( + /packaged bytes do not match stablyai\./ + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 956349c70cb..02262dd8274 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -179,6 +179,7 @@ export default defineConfig({ input: { index: resolve('src/main/index.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), + 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), 'stt-worker': resolve('src/main/speech/stt-worker.ts'), 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'), diff --git a/examples/plugins/hello-orca/main.mjs b/examples/plugins/hello-orca/main.mjs new file mode 100644 index 00000000000..17dacd63722 --- /dev/null +++ b/examples/plugins/hello-orca/main.mjs @@ -0,0 +1,24 @@ +// Sample Orca plugin worker entry. Runs inside the out-of-process plugin +// worker (plain Node, no Electron), forked lazily on the first trigger. The +// default export receives the `orca` API: command registration, event +// handlers, and the capability-gated host API. +export default function activate(orca) { + orca.commands.register('hello-ping', async (args) => { + const stored = await orca.host.call('storage.get', { key: 'pings' }) + const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1 + await orca.host.call('storage.set', { key: 'pings', value: count }) + return { pong: true, count, args: args ?? null } + }) + + orca.events.on('worktree.created', async (payload) => { + orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`) + await orca.host.call('notifications.show', { + title: 'Worktree created', + body: payload.path + }) + }) + + orca.events.on('agent.status.changed', (payload) => { + orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`) + }) +} diff --git a/examples/plugins/hello-orca/orca-plugin.json b/examples/plugins/hello-orca/orca-plugin.json new file mode 100644 index 00000000000..da41d0f7e72 --- /dev/null +++ b/examples/plugins/hello-orca/orca-plugin.json @@ -0,0 +1,23 @@ +{ + "manifestVersion": 1, + "id": "hello-orca", + "publisher": "orca-samples", + "name": "Hello Orca", + "version": "1.0.0", + "description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "main": "main.mjs", + "contributes": { + "panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }], + "commands": [{ "id": "hello-ping", "title": "Hello: Ping" }], + "events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }] + }, + "capabilities": [ + { "kind": "workspace:read" }, + { "kind": "terminal:send" }, + { "kind": "notifications:show" }, + { "kind": "storage" }, + { "kind": "events:subscribe" } + ] +} diff --git a/examples/plugins/hello-orca/panel.html b/examples/plugins/hello-orca/panel.html new file mode 100644 index 00000000000..6c7b5cf8181 --- /dev/null +++ b/examples/plugins/hello-orca/panel.html @@ -0,0 +1,125 @@ + + + + + + + +

Hello Orca 👋

+

Panel + worker command + events, gated by consent.

+ + + + +

+ + + diff --git a/examples/plugins/hostile-panel/orca-plugin.json b/examples/plugins/hostile-panel/orca-plugin.json new file mode 100644 index 00000000000..cf3a2f90a94 --- /dev/null +++ b/examples/plugins/hostile-panel/orca-plugin.json @@ -0,0 +1,16 @@ +{ + "manifestVersion": 1, + "id": "hostile-panel", + "publisher": "orca-samples", + "name": "Hostile Panel (security fixture)", + "version": "1.0.0", + "description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "panels": [ + { "id": "hostile", "title": "Hostile Fixture", "icon": "bug", "entry": "panel.html" } + ] + }, + "capabilities": [] +} diff --git a/examples/plugins/hostile-panel/panel.html b/examples/plugins/hostile-panel/panel.html new file mode 100644 index 00000000000..d4f779333c6 --- /dev/null +++ b/examples/plugins/hostile-panel/panel.html @@ -0,0 +1,206 @@ + + + + + + + +

Hostile panel fixture

+
    + + + + + + + + + diff --git a/resources/plugins/launch/bundled-plugins.json b/resources/plugins/launch/bundled-plugins.json new file mode 100644 index 00000000000..3bc0d4e1d12 --- /dev/null +++ b/resources/plugins/launch/bundled-plugins.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "plugins": [ + { + "pluginKey": "stablyai.orca-navigation-shortcuts", + "path": "stablyai.orca-navigation-shortcuts", + "contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77" + } + ] +} diff --git a/resources/plugins/launch/orca-marketplace.json b/resources/plugins/launch/orca-marketplace.json new file mode 100644 index 00000000000..1088bdcd22f --- /dev/null +++ b/resources/plugins/launch/orca-marketplace.json @@ -0,0 +1,36 @@ +{ + "name": "Orca Official Plugins", + "owner": "stablyai", + "plugins": [ + { + "id": "stablyai.orca-portuguese", + "source": { + "kind": "git", + "url": "https://github.com/stablyai/orca-portuguese.git", + "ref": "v1.0.0" + }, + "description": "Brazilian Portuguese translations for common Orca navigation.", + "categories": ["languages", "official"] + }, + { + "id": "stablyai.orca-multipass-recipes", + "source": { + "kind": "git", + "url": "https://github.com/stablyai/orca-multipass-recipes.git", + "ref": "v1.0.0" + }, + "description": "A reviewed starter lifecycle for disposable Multipass workspaces.", + "categories": ["vm-recipes", "official"] + }, + { + "id": "stablyai.orca-navigation-shortcuts", + "source": { + "kind": "git", + "url": "https://github.com/stablyai/orca-navigation-shortcuts.git", + "ref": "v1.0.0" + }, + "description": "Command aliases and optional shortcuts for frequent Orca views.", + "categories": ["keybindings", "official"] + } + ] +} diff --git a/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json b/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json new file mode 100644 index 00000000000..53dd14d721a --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json @@ -0,0 +1,15 @@ +{ + "manifestVersion": 1, + "id": "orca-multipass-recipes", + "publisher": "stablyai", + "name": "Multipass VM Recipes", + "version": "1.0.0", + "description": "A reviewed starter lifecycle for disposable Multipass workspaces.", + "repository": "https://github.com/stablyai/orca-multipass-recipes", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "vmRecipes": [{ "path": "recipes/ubuntu-lts.json" }] + }, + "capabilities": [] +} diff --git a/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json b/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json new file mode 100644 index 00000000000..b2c60c798c6 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "id": "multipass-ubuntu-lts", + "name": "Multipass Ubuntu LTS", + "description": "Creates a four-CPU disposable Ubuntu workspace with Multipass.", + "create": "multipass launch 24.04 --name \"$ORCA_VM_NAME\" --cpus 4 --memory 8G --disk 40G", + "suspend": "multipass suspend \"$ORCA_VM_NAME\"", + "resume": "multipass start \"$ORCA_VM_NAME\"", + "destroy": "multipass delete \"$ORCA_VM_NAME\" --purge" +} diff --git a/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json b/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json new file mode 100644 index 00000000000..6310d4e5476 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json @@ -0,0 +1,34 @@ +{ + "manifestVersion": 1, + "id": "orca-navigation-shortcuts", + "publisher": "stablyai", + "name": "Orca Navigation Shortcuts", + "version": "1.0.0", + "description": "Command aliases and optional shortcuts for frequent Orca views.", + "repository": "https://github.com/stablyai/orca-navigation-shortcuts", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "commands": [ + { "id": "open-tasks", "title": "Open Tasks", "context": "global", "action": "view.tasks" }, + { + "id": "toggle-search", + "title": "Toggle Search", + "context": "global", + "action": "sidebar.search.toggle" + }, + { + "id": "toggle-source-control", + "title": "Toggle Source Control", + "context": "global", + "action": "sidebar.sourceControl.toggle" + } + ], + "keybindings": [ + { "command": "open-tasks", "key": "Mod+Alt+T", "when": "global" }, + { "command": "toggle-search", "key": "Mod+Alt+F", "when": "global" }, + { "command": "toggle-source-control", "key": "Mod+Alt+G", "when": "global" } + ] + }, + "capabilities": [] +} diff --git a/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json b/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json new file mode 100644 index 00000000000..27945d1be25 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json @@ -0,0 +1,36 @@ +{ + "settings": { + "appearance": { + "language": { + "title": "Idioma", + "description": "Escolha o idioma usado na interface do Orca.", + "system": "Sistema", + "english": "Inglês", + "chinese": "Chinês simplificado", + "korean": "Coreano", + "japanese": "Japonês", + "spanish": "Espanhol" + } + } + }, + "menu": { + "checkForUpdates": "Verificar atualizações...", + "settings": "Configurações", + "file": "Arquivo", + "exit": "Sair", + "edit": "Editar", + "appearance": "Aparência", + "view": "Visualizar", + "reload": "Recarregar", + "window": "Janela", + "help": "Ajuda", + "paste": "Colar" + }, + "tray": { + "openOrca": "Abrir o Orca", + "quit": "Encerrar", + "minimizeNotice": { + "body": "O Orca continua em execução na bandeja do sistema" + } + } +} diff --git a/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json b/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json new file mode 100644 index 00000000000..d32be4b38d9 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json @@ -0,0 +1,15 @@ +{ + "manifestVersion": 1, + "id": "orca-portuguese", + "publisher": "stablyai", + "name": "Português do Brasil", + "version": "1.0.0", + "description": "Brazilian Portuguese translations for common Orca navigation.", + "repository": "https://github.com/stablyai/orca-portuguese", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "languagePacks": [{ "locale": "pt-BR", "path": "locales/pt-BR.json" }] + }, + "capabilities": [] +} diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 351960dd79e..e05e0c17312 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -7083,6 +7083,38 @@ describe('AgentHookServer ingestRemote', () => { } }) + it('fans a Pi session-only status out to plugins, not just the renderer', () => { + const server = new AgentHookServer() + const rendererListener = vi.fn() + const pluginListener = vi.fn() + server.setListener(rendererListener) + server.subscribeEnrichedStatus(pluginListener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + 'conn-1' + ) + + // The session-only path returns early, so it must not skip the plugin tap. + expect(rendererListener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) + ) + expect(pluginListener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) + ) + }) + it('rejects invalid remote metadata-only session envelopes', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 1aeb253a191..a9395cf44de 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -465,6 +465,10 @@ export class AgentHookServer { private onPaneStatusCleared: PaneStatusClearListener | null = null private statusChangeListeners = new Set() private providerSessionChangeListeners = new Set() + // Why: setListener is a single slot owned by the main-window fanout; the + // plugin event bus (and future consumers) need an additive subscription + // that also works in headless serve, where no window listener exists. + private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). private endpointDir: string | null = null private endpointFilePathCache: string | null = null @@ -525,6 +529,14 @@ export class AgentHookServer { } } + /** Multi-subscriber tap on every enriched status change (no replay). */ + subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { + this.enrichedStatusListeners.add(listener) + return () => { + this.enrichedStatusListeners.delete(listener) + } + } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { this.onPaneStatusCleared = listener } @@ -890,7 +902,7 @@ export class AgentHookServer { this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() - this.onAgentStatus?.(enriched) + this.emitEnrichedStatus(enriched) return enriched } const stateReconciledPayload = @@ -1002,10 +1014,23 @@ export class AgentHookServer { this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() - this.onAgentStatus?.(enriched) + this.emitEnrichedStatus(enriched) return enriched } + // Why: every status emit must reach plugins too, so a new early-return path + // upstream cannot silently leave the plugin tap behind the main-window fanout. + private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + this.onAgentStatus?.(enriched) + for (const listener of this.enrichedStatusListeners) { + try { + listener(enriched) + } catch (err) { + console.error('[agent-hooks] enriched status listener threw', err) + } + } + } + private clearAssistantMessageRetry(paneKey: string): void { const timer = this.assistantMessageRetryTimers.get(paneKey) if (!timer) { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index e94067c4265..9546c2f251f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -140,6 +140,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, disabledTuiAgents: [], + pluginSystemEnabled: false, + disabledPlugins: [], + pluginConsents: {}, + devPluginPaths: [], skipDeleteWorktreeConfirm: false, skipCloseTerminalWithRunningProcessConfirm: false, skipDeleteAutomationConfirm: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index cb6ba306f2a..c2ae74b8911 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -148,6 +148,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, disabledTuiAgents: [], + pluginSystemEnabled: false, + disabledPlugins: [], + pluginConsents: {}, + devPluginPaths: [], skipDeleteWorktreeConfirm: false, skipCloseTerminalWithRunningProcessConfirm: false, skipDeleteAutomationConfirm: false, diff --git a/src/main/ephemeral-vm-runtime-service.test.ts b/src/main/ephemeral-vm-runtime-service.test.ts index 6f31e7a755d..61fc991ad83 100644 --- a/src/main/ephemeral-vm-runtime-service.test.ts +++ b/src/main/ephemeral-vm-runtime-service.test.ts @@ -83,6 +83,9 @@ describe('ephemeral VM runtime service', () => { const recipe: OrcaVmRecipe = { id: 'cloud-sandbox', name: 'Cloud Sandbox', + // Repo-owned recipes predate plugin bounds; snapshotting must not fail + // after create has already provisioned external resources. + description: 'x'.repeat(2_048), create: nodeCommand(startPath), destroy: nodeCommand(cleanupPath) } @@ -104,6 +107,7 @@ describe('ephemeral VM runtime service', () => { expect(provisioned.runtime).toMatchObject({ id: provisioned.start.context.instanceId, recipeId: 'cloud-sandbox', + recipe, repoId: 'repo-1', projectId: 'project-1', workspaceName: 'Fix Login Race', diff --git a/src/main/ephemeral-vm-runtime-service.ts b/src/main/ephemeral-vm-runtime-service.ts index 58ad107a346..90eb216c440 100644 --- a/src/main/ephemeral-vm-runtime-service.ts +++ b/src/main/ephemeral-vm-runtime-service.ts @@ -120,6 +120,7 @@ export async function provisionEphemeralVmRuntime( const runtime = upsertEphemeralVmRuntime(args.userDataPath, { id: start.context.instanceId ?? start.context.recipeId, recipeId: args.recipe.id, + recipe: args.recipe, ...(args.repoId ? { repoId: args.repoId } : {}), ...(args.projectId ? { projectId: args.projectId } : {}), ...(args.workspaceId ? { workspaceId: args.workspaceId } : {}), diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts index 877c36c47e4..30d11f54977 100644 --- a/src/main/i18n/main-i18n-lazy-locale.test.ts +++ b/src/main/i18n/main-i18n-lazy-locale.test.ts @@ -17,11 +17,18 @@ import { UI_LANGUAGE_KOREAN, UI_LANGUAGE_SPANISH } from '../../shared/ui-language' -import { ensureMainI18n, setMainUiLanguage, translateMain } from './main-i18n' +import { + ensureMainI18n, + setMainPluginLanguagePacks, + setMainUiLanguage, + translateMain +} from './main-i18n' +import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact' describe('main-i18n lazy locale loading', () => { beforeEach(async () => { await ensureMainI18n() + setMainPluginLanguagePacks([]) await setMainUiLanguage(UI_LANGUAGE_ENGLISH) }) @@ -58,4 +65,24 @@ describe('main-i18n lazy locale loading', () => { await setMainUiLanguage(UI_LANGUAGE_ENGLISH) expect(translateMain('menu.file', 'File')).toBe('File') }) + + it('loads a contributed catalog for native menus and dialogs', async () => { + const id = 'plugin:orca-samples.portuguese/pt-BR' as const + setMainPluginLanguagePacks([ + { + id, + resourceLanguage: pluginLanguageResourceId(id), + pluginKey: 'orca-samples.portuguese', + locale: 'pt-BR', + catalog: { menu: { file: 'Arquivo Orca' } } + } + ]) + + await setMainUiLanguage(id) + expect(translateMain('menu.file', 'File')).toBe('Arquivo Orca') + + setMainPluginLanguagePacks([]) + expect(await setMainUiLanguage(id)).toBe('en') + expect(translateMain('menu.file', 'File')).toBe('File') + }) }) diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index 4904260db5c..8ebe21e0545 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -9,10 +9,13 @@ import i18next, { import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/pseudo-localization' import { DEFAULT_UI_LOCALE, resolveUiLocale, type SupportedUiLocale } from '../../shared/ui-locale' import { UI_LANGUAGE_SYSTEM, type UiLanguage } from '../../shared/ui-language' +import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact' export const mainI18n: I18nInstance = i18next.createInstance() let initialized = false +let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] +const registeredPluginLanguages = new Set() // Why: main-process callers pass English fallbacks to translateMain(), so the // main bundle does not need to parse any locale catalog at cold start. Only @@ -72,16 +75,20 @@ export async function ensureMainI18n(): Promise { } }) initialized = true + applyMainPluginLanguagePacks() } return mainI18n } -export async function setMainUiLanguage(language: UiLanguage): Promise { +export async function setMainUiLanguage(language: UiLanguage): Promise { await ensureMainI18n() - const locale = resolveUiLocale( + const selectedLocale = resolveUiLocale( language, language === UI_LANGUAGE_SYSTEM ? getMainSystemLocale() : DEFAULT_UI_LOCALE ) + const locale = + pluginLanguagePacks.find((pack) => pack.id === selectedLocale)?.resourceLanguage ?? + (selectedLocale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : selectedLocale) if (mainI18n.language !== locale) { // changeLanguage triggers the lazy backend load for non-English locales and // resolves once the catalog is in memory, so callers that await this have @@ -91,6 +98,30 @@ export async function setMainUiLanguage(language: UiLanguage): Promise void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null +let pluginService: PluginService | null = null +let pluginKillListService: PluginKillListService | null = null +let pluginMarketplaceService: PluginMarketplaceService | null = null +let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null let keybindings: KeybindingService | null = null + +function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + pluginService?.emitEvent( + event.kind === 'created' ? 'worktree.created' : 'worktree.removed', + event.kind === 'created' + ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } + : { worktreeId: event.worktreeId, path: event.path } + ) +} // Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() @@ -1177,7 +1204,11 @@ function openMainWindow(): BrowserWindow { }, onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(), onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow() - } + }, + pluginService ?? undefined, + pluginMarketplaceService && pluginMarketplaceInstaller + ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller } + : undefined ) automations.setWebContents(window.webContents) automations.start() @@ -1201,7 +1232,8 @@ function openMainWindow(): BrowserWindow { isRecoveryReloadInFlight, onBeforeUpdateQuit: () => preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), - updateInstallMode: resolveUpdateInstallMode(isServeMode) + updateInstallMode: resolveUpdateInstallMode(isServeMode), + onWorktreeLifecycle: emitPluginWorktreeLifecycle } ) rateLimits.attach(window) @@ -2229,6 +2261,137 @@ app.whenReady().then(async () => { prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) }) + const pluginSystemStartupStartedAt = performance.now() + pluginKillListService = new PluginKillListService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')) + }) + await pluginKillListService.initialize() + pluginMarketplaceService = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')), + getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null + }) + const requestOfficialMarketplaceSeed = (): void => { + if (store?.getSettings().pluginSystemEnabled !== true) { + return + } + void pluginMarketplaceService?.seedOfficialSource().catch((error) => { + console.warn('[plugins] failed to configure the official marketplace:', error) + }) + } + pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace: pluginMarketplaceService, + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null + }) + pluginService = new PluginService({ + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + // Feature flag: with the setting off, discovery returns nothing and no + // plugin code path runs at all. + isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true, + getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins), + getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents), + getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths), + getKeybindings: () => keybindings?.getOverrides() ?? {}, + getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, + hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) + }) + const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ + root: resolveBundledPluginRoot({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }), + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isEnabled: () => store?.getSettings().pluginSystemEnabled === true, + blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, + refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() + }) + const requestBundledPluginBootstrap = (): void => { + void bundledPluginBootstrap + .request() + .then((result) => { + for (const failure of result?.errors ?? []) { + console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) + } + }) + .catch((error) => { + console.warn('[plugins] failed to bootstrap bundled plugins:', error) + }) + } + pluginKillListService.onChanged(() => { + void pluginService?.reconcileActivationState().catch((error) => { + console.warn('[plugins] failed to apply plugin safety-list refresh:', error) + }) + }) + store.onSettingsChanged((updates) => { + if (updates.pluginSystemEnabled === true) { + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + } + if (app.isPackaged && updates.pluginSystemEnabled === true) { + void pluginKillListService?.refresh().catch((error) => { + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + }) + } + }) + // Why: headless `orca serve` clients reach plugins through the runtime RPC + // methods, which resolve the service via this module-level setter. Consent + // over RPC uses the same hash-keyed write path as the desktop dialog. + setPluginServiceForRpc(pluginService, { + applyConsent: (request) => + applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }), + applyEnablement: (pluginKey, enabled) => + applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled }) + }) + // Lazy kernel: initialize() only discovers manifests — no worker forks, no + // panel reads. Zero plugin code runs before an explicit trigger. + void pluginService + .initialize() + .then(() => { + logStartupMilestone('plugin-system-initialized', { + durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), + installedPlugins: pluginService?.getDiscovered().length ?? 0 + }) + }) + .catch((error) => { + console.warn('[plugins] failed to initialize plugin service:', error) + }) + if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) { + void pluginKillListService.refresh().catch((error) => { + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + }) + } + pluginService.onChanged((event) => { + if ( + event.contentPacksChanged && + setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) + ) { + void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('plugins:changed', event) + } + } + }) + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + // v0 plugin event seams: agent status (hook pipeline tap) + worktree + // lifecycle (runtime tap). Server-side filtered per plugin subscription. + agentHookServer.subscribeEnrichedStatus((enriched) => { + pluginService?.emitEvent('agent.status.changed', { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt + }) + }) + runtimeService.onWorktreeLifecycle((event) => { + emitPluginWorktreeLifecycle(event) + }) starNag = new StarNagService(store, stats) starNag.start() starNag.registerIpcHandlers() @@ -2577,6 +2740,16 @@ app.on('will-quit', (e) => { // Why: stats.flush() must precede killAllPty() so still-running agents emit synthetic agent_stop events (killAllPty skips runtime.onPtyExit()). starNag?.stop() automations?.stop() + // Why: plugin hosts are forked children; dispose sends shutdown and + // escalates to SIGKILL so they cannot outlive the app. The promise joins + // the teardown barrier below — quitting before it resolves would let + // Electron exit first and orphan the hosts. + setPluginServiceForRpc(null) + pluginKillListService = null + pluginMarketplaceService = null + pluginMarketplaceInstaller = null + const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() + pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. @@ -2623,7 +2796,8 @@ app.on('will-quit', (e) => { { name: 'daemon', promise: daemonTeardown }, { name: 'runtime-rpc', promise: rpcStopAndClear }, { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown } + { name: 'emulator', promise: emulatorShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown } ]) .then((pendingTeardowns) => { if (pendingTeardowns.length > 0) { diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index aba6c730755..c922d6f13b1 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -26,7 +26,11 @@ export type RecipeRepoResult = | { ok: true; repo: Exclude, null | undefined> } | { ok: false; message: string; doctor: (recipeId: string) => EphemeralVmRecipeDoctorResult } -export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeListResult { +export function listRecipes( + store: Store, + repoId: string, + pluginRecipes: readonly OrcaVmRecipe[] = [] +): EphemeralVmRecipeListResult { const repo = store.getRepo(repoId) if (!repo || isFolderRepo(repo)) { return { @@ -50,12 +54,15 @@ export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeList return { status: 'ok', repoPath: repo.path, - recipes: hooks?.environmentRecipes ?? [], + recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes), diagnostics: hooks?.environmentRecipeDiagnostics ?? [] } } -export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[] { +export function listRecipeCatalog( + store: Store, + pluginRecipes: readonly OrcaVmRecipe[] = [] +): EphemeralVmRecipeCatalogEntry[] { return store .getRepos() .filter((repo) => isGitRepoKind(repo) && !isFolderRepo(repo) && !repo.connectionId) @@ -65,7 +72,7 @@ export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[] repoId: repo.id, repoName: repo.displayName, repoPath: repo.path, - recipes: hooks?.environmentRecipes ?? [], + recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes), diagnostics: hooks?.environmentRecipeDiagnostics ?? [] } }) @@ -103,15 +110,41 @@ export function getRuntimeRecipeContext( if (!repo.ok) { throw new Error(repo.message) } - const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( - (entry) => entry.id === runtime.recipeId - ) + // Pre-snapshot runtimes can only be attributed to repo-owned recipes. Never + // substitute a later same-id plugin recipe for an older runtime lifecycle. + const recipe = + runtime.recipe ?? + (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( + (entry) => entry.id === runtime.recipeId + ) if (!recipe) { throw new Error(`Recipe not found: ${runtime.recipeId}`) } return { runtime, repo, recipe } } +export function resolveRecipeForRepo( + repoPath: string, + recipeId: string, + pluginRecipes: readonly OrcaVmRecipe[] = [] +): OrcaVmRecipe | null { + return ( + combineEphemeralVmRecipes(loadHooks(repoPath)?.environmentRecipes ?? [], pluginRecipes).find( + (recipe) => recipe.id === recipeId + ) ?? null + ) +} + +/** Project-owned recipes are authoritative for their repository and shadow + * same-id global plugin recipes without disabling the rest of the pack. */ +export function combineEphemeralVmRecipes( + repoRecipes: readonly OrcaVmRecipe[], + pluginRecipes: readonly OrcaVmRecipe[] +): OrcaVmRecipe[] { + const repoIds = new Set(repoRecipes.map((recipe) => recipe.id)) + return [...repoRecipes, ...pluginRecipes.filter((recipe) => !repoIds.has(recipe.id))] +} + function failedRecipeRepo(repoPath: string | null, message: string): RecipeRepoResult { return { ok: false, diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index f3ce3caec94..dea6d811706 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -1,6 +1,5 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' -import { loadHooks } from '../hooks' import { listEphemeralVmRuntimes, updateEphemeralVmRuntimeStatus @@ -28,7 +27,7 @@ import { disconnectRuntimeOwnedSshTarget, removeRuntimeOwnedSshTarget } from '../ephemeral-vm-runtime-ssh' -import { getRecipeRepo, getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' +import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' import { invalidateRuntimeEnvironmentTransport } from './runtime-environments' export type EphemeralVmCleanupCommandResult = { @@ -74,30 +73,21 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { if (!runtime.repoId) { throw new Error(`Ephemeral VM runtime has no repo id: ${args.runtimeId}`) } - const repo = getRecipeRepo(store, runtime.repoId) - if (!repo.ok) { + let resolved: ReturnType + try { + resolved = getRuntimeRecipeContext(store, userDataPath, runtime.id) + } catch (error) { return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, { status: 'cleanup_failed', cleanupStatus: 'failed', cleanupLastAttemptAt: Date.now(), - cleanupLastError: repo.message - }) - } - const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( - (entry) => entry.id === runtime.recipeId - ) - if (!recipe) { - return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, { - status: 'cleanup_failed', - cleanupStatus: 'failed', - cleanupLastAttemptAt: Date.now(), - cleanupLastError: `Recipe not found: ${runtime.recipeId}` + cleanupLastError: error instanceof Error ? error.message : String(error) }) } const result = await cleanupEphemeralVmRuntime({ userDataPath, - repoPath: repo.repo.path, - recipe, + repoPath: resolved.repo.repo.path, + recipe: resolved.recipe, runtimeId: runtime.id }) if (result.ok && runtime.runtimeEnvironmentId) { @@ -217,7 +207,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { ipcMain.handle( 'ephemeralVm:getCleanupCommand', - (_event, args: { runtimeId: string }): EphemeralVmCleanupCommandResult => { + async (_event, args: { runtimeId: string }): Promise => { const userDataPath = app.getPath('userData') const resolved = getRuntimeRecipeContext(store, userDataPath, args.runtimeId) const payload = buildEphemeralVmRecipeCleanupPayload({ diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 45f980bad9a..8df189a4abb 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { listEnvironments } from '../../shared/runtime-environment-store' +import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store' const handlers = new Map Promise | unknown>() const { @@ -92,6 +93,15 @@ function nodeCommand(scriptPath: string): string { return `"${process.execPath}" "${scriptPath}"` } +function pluginServiceWithRecipes( + recipes: { pluginKey: string; recipe: Record }[] +) { + return { + whenReady: vi.fn().mockResolvedValue(undefined), + contentPacks: { vmRecipes: { list: vi.fn(() => recipes) } } + } +} + describe('registerEphemeralVmHandlers', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -198,6 +208,132 @@ describe('registerEphemeralVmHandlers', () => { ]) }) + it('merges approved plugin recipes while repository recipes shadow matching ids', async () => { + const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') + writeFileSync( + join(repoPath, 'orca.yaml'), + [ + 'environmentRecipes:', + ' - id: shared', + ' name: Repository Recipe', + ' create: repo-create' + ].join('\n') + ) + const pluginService = pluginServiceWithRecipes([ + { + pluginKey: 'orca-samples.recipes', + recipe: { id: 'shared', name: 'Plugin Shared', create: 'plugin-shared' } + }, + { + pluginKey: 'orca-samples.recipes', + recipe: { id: 'global', name: 'Plugin Global', create: 'plugin-global' } + } + ]) + + registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never) + const result = (await handlers.get('ephemeralVm:listRecipes')?.(null, { + repoId: 'repo-1' + } as never)) as { recipes: { id: string; name: string }[] } + + expect(pluginService.whenReady).toHaveBeenCalled() + expect(result.recipes).toMatchObject([ + { id: 'shared', name: 'Repository Recipe' }, + { id: 'global', name: 'Plugin Global' } + ]) + }) + + it('uses an immutable plugin recipe snapshot after the plugin is removed', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') + getPathMock.mockReturnValue(userDataPath) + const startPath = join(repoPath, 'start.js') + const destroyPath = join(repoPath, 'destroy.js') + writeFileSync( + startPath, + `console.log(${JSON.stringify( + JSON.stringify({ + schemaVersion: 1, + pairingCode: makePairingCode(), + projectRoot: '/workspace/repo' + }) + )})` + ) + writeFileSync(destroyPath, "require('fs').writeFileSync('plugin-cleaned.txt', 'yes')") + const registrations = [ + { + pluginKey: 'orca-samples.recipes', + recipe: { + id: 'plugin-cloud', + name: 'Plugin Cloud', + create: nodeCommand(startPath), + destroy: nodeCommand(destroyPath) + } + } + ] + const pluginService = pluginServiceWithRecipes(registrations) + registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never) + + const provisioned = (await handlers.get('ephemeralVm:provision')?.(null, { + repoId: 'repo-1', + recipeId: 'plugin-cloud' + } as never)) as { ok: true; runtime: { id: string; recipe?: { id: string } } } + registrations.splice(0) + const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, { + runtimeId: provisioned.runtime.id + } as never) + + expect(provisioned.runtime.recipe).toMatchObject({ id: 'plugin-cloud' }) + expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' })) + expect(readFileSync(join(repoPath, 'plugin-cleaned.txt'), 'utf8')).toBe('yes') + }) + + it('never substitutes a later same-id plugin recipe for a legacy runtime', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') + getPathMock.mockReturnValue(userDataPath) + const pluginDestroyPath = join(repoPath, 'plugin-destroy.js') + writeFileSync( + pluginDestroyPath, + "require('fs').writeFileSync('plugin-destroy-ran.txt', 'unsafe')" + ) + upsertEphemeralVmRuntime(userDataPath, { + id: 'legacy-runtime', + recipeId: 'shared-id', + repoId: 'repo-1', + status: 'running', + cleanupStatus: 'not_started', + createdAt: 1, + updatedAt: 1, + recipeResult: { + schemaVersion: 1, + pairingCode: makePairingCode(), + projectRoot: '/workspace/repo' + } + }) + const pluginService = pluginServiceWithRecipes([ + { + pluginKey: 'orca-samples.recipes', + recipe: { + id: 'shared-id', + name: 'Later Plugin Recipe', + create: 'create', + destroy: nodeCommand(pluginDestroyPath) + } + } + ]) + registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never) + + const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, { + runtimeId: 'legacy-runtime' + } as never) + + expect(cleaned).toMatchObject({ + status: 'cleanup_failed', + cleanupLastError: 'Recipe not found: shared-id' + }) + expect(existsSync(join(repoPath, 'plugin-destroy-ran.txt'))).toBe(false) + }) + it('provisions a recipe and persists the ephemeral runtime', async () => { const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-') const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 9d071989c85..f75c9c77d39 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -1,6 +1,5 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' -import { loadHooks } from '../hooks' import { getEphemeralVmRecipeResultConnection, getEphemeralVmRecipeResultWarnings, @@ -27,9 +26,12 @@ import { getRecipeRepo, listRecipeCatalog, listRecipes, + resolveRecipeForRepo, type EphemeralVmRecipeCatalogEntry } from './ephemeral-vm-recipe-context' import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers' +import type { PluginService } from '../plugins/plugin-service' +import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes' const activeProvisionControllers = new Map() @@ -57,7 +59,7 @@ export type EphemeralVmProvisionIpcResult = stdout: string } -export function registerEphemeralVmHandlers(store: Store): void { +export function registerEphemeralVmHandlers(store: Store, pluginService?: PluginService): void { ipcMain.removeHandler('ephemeralVm:listRecipes') ipcMain.removeHandler('ephemeralVm:listRecipeCatalog') ipcMain.removeHandler('ephemeralVm:doctor') @@ -65,25 +67,32 @@ export function registerEphemeralVmHandlers(store: Store): void { ipcMain.removeHandler('ephemeralVm:cancelProvision') registerEphemeralVmRuntimeHandlers(store) - ipcMain.handle('ephemeralVm:listRecipes', (_event, args: { repoId: string }) => { - return listRecipes(store, args.repoId) - }) - - ipcMain.handle('ephemeralVm:listRecipeCatalog', (): EphemeralVmRecipeCatalogEntry[] => { - return listRecipeCatalog(store) + ipcMain.handle('ephemeralVm:listRecipes', async (_event, args: { repoId: string }) => { + return listRecipes(store, args.repoId, await getApprovedPluginVmRecipes(pluginService)) }) + ipcMain.handle( + 'ephemeralVm:listRecipeCatalog', + async (): Promise => { + return listRecipeCatalog(store, await getApprovedPluginVmRecipes(pluginService)) + } + ) + ipcMain.handle( 'ephemeralVm:doctor', - (_event, args: { repoId: string; recipeId: string }): EphemeralVmRecipeDoctorResult => { + async ( + _event, + args: { repoId: string; recipeId: string } + ): Promise => { const repo = getRecipeRepo(store, args.repoId) if (!repo.ok) { return repo.doctor(args.recipeId) } + const pluginRecipes = await getApprovedPluginVmRecipes(pluginService) return doctorEphemeralVmRecipe({ repoPath: repo.repo.path, recipeId: args.recipeId, - recipes: loadHooks(repo.repo.path)?.environmentRecipes ?? [], + recipes: listRecipes(store, args.repoId, pluginRecipes).recipes, localExecutionSupported: true }) } @@ -106,8 +115,10 @@ export function registerEphemeralVmHandlers(store: Store): void { if (!repo.ok) { return { ok: false, error: repo.message, stdout: '', stderr: '' } } - const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( - (entry) => entry.id === args.recipeId + const recipe = resolveRecipeForRepo( + repo.repo.path, + args.recipeId, + await getApprovedPluginVmRecipes(pluginService) ) if (!recipe) { return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' } diff --git a/src/main/ipc/keybindings.test.ts b/src/main/ipc/keybindings.test.ts index 3f0aa37aa28..ec8016287d5 100644 --- a/src/main/ipc/keybindings.test.ts +++ b/src/main/ipc/keybindings.test.ts @@ -75,6 +75,23 @@ describe('registerKeybindingHandlers', () => { expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path) }) + it('reconciles plugin command conflicts after a shortcut edit', () => { + const onChanged = vi.fn() + const setActionBindings = vi.fn(() => snapshot) + registerKeybindingHandlers({ setActionBindings } as never, onChanged) + + expect( + getHandler('keybindings:setAction')( + {}, + { + actionId: 'plugin:orca-samples.tasks/open', + bindings: ['Mod+Shift+T'] + } + ) + ).toBe(snapshot) + expect(onChanged).toHaveBeenCalledOnce() + }) + it('authorizes the keybindings file before opening it outside Orca', async () => { openPathMock.mockResolvedValue('') registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never) diff --git a/src/main/ipc/keybindings.ts b/src/main/ipc/keybindings.ts index ae73c2b15ac..7a71f3116c0 100644 --- a/src/main/ipc/keybindings.ts +++ b/src/main/ipc/keybindings.ts @@ -13,7 +13,10 @@ function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void { rebuildAppMenu() } -export function registerKeybindingHandlers(service: KeybindingService): void { +export function registerKeybindingHandlers( + service: KeybindingService, + onChanged?: () => void +): void { ipcMain.handle('keybindings:get', () => service.getSnapshot()) ipcMain.handle('keybindings:ensureFile', () => { @@ -22,6 +25,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void { // workspace. Opening it in the editor still needs normal fs IPC access. authorizeExternalPath(snapshot.path) broadcastKeybindingsChanged(snapshot) + onChanged?.() return snapshot }) @@ -30,6 +34,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void { (_event, args: { actionId: KeybindingActionId; bindings: string[] | null }) => { const snapshot = service.setActionBindings(args.actionId, args.bindings) broadcastKeybindingsChanged(snapshot) + onChanged?.() return snapshot } ) @@ -37,6 +42,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void { ipcMain.handle('keybindings:reload', () => { const snapshot = service.reload() broadcastKeybindingsChanged(snapshot) + onChanged?.() return snapshot }) diff --git a/src/main/ipc/plugin-marketplaces.test.ts b/src/main/ipc/plugin-marketplaces.test.ts new file mode 100644 index 00000000000..b1badba09c4 --- /dev/null +++ b/src/main/ipc/plugin-marketplaces.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginService } from '../plugins/plugin-service' + +type IpcHandler = (event: unknown, args?: unknown) => unknown + +const electronMocks = vi.hoisted(() => ({ handle: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { handle: electronMocks.handle } })) + +import { + registerPluginMarketplaceHandlers, + type PluginMarketplaceHandlerServices +} from './plugin-marketplaces' + +const SOURCE_ID = 'a'.repeat(32) +const MARKETPLACE_COMMIT = 'b'.repeat(40) +const PLUGIN_COMMIT = 'c'.repeat(40) +const PLUGIN_KEY = 'orca-samples.demo' + +let handlers: Map + +function createServices(): PluginMarketplaceHandlerServices { + return { + marketplace: { + listSources: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), + addSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), + removeSource: vi.fn().mockResolvedValue(true), + refreshSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), + refreshAll: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), + listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }]) + } as unknown as PluginMarketplaceService, + installer: { + preview: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }), + install: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY }), + previewInstalledUpdate: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }), + rollback: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY }) + } as unknown as PluginMarketplaceInstaller + } +} + +function createPluginService(): PluginService { + return { + deactivatePlugin: vi.fn().mockResolvedValue(undefined), + refresh: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginService +} + +async function invoke(channel: string, args?: unknown): Promise { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`missing IPC handler: ${channel}`) + } + return handler({}, args) +} + +beforeEach(() => { + handlers = new Map() + electronMocks.handle.mockReset() + electronMocks.handle.mockImplementation((channel: string, handler: IpcHandler) => { + handlers.set(channel, handler) + }) +}) + +describe('plugin marketplace IPC authority', () => { + it('validates every mutating or plugin-selecting request strictly', async () => { + registerPluginMarketplaceHandlers(createPluginService(), createServices()) + + await expect( + invoke('plugins:addMarketplace', { + kind: 'git', + url: 'https://example.com/marketplace.git', + ref: 'main', + unexpected: true + }) + ).rejects.toThrow() + await expect( + invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID, unexpected: true }) + ).rejects.toThrow() + await expect( + invoke('plugins:refreshMarketplaces', { sourceId: 'not-a-source' }) + ).rejects.toThrow() + await expect( + invoke('plugins:previewMarketplacePlugin', { + marketplaceSourceId: SOURCE_ID, + pluginKey: '__proto__.demo' + }) + ).rejects.toThrow() + await expect( + invoke('plugins:installMarketplacePlugin', { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: 'moving-ref', + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + }) + ).rejects.toThrow() + await expect( + invoke('plugins:previewMarketplaceUpdate', { + pluginKey: PLUGIN_KEY, + unexpected: true + }) + ).rejects.toThrow() + await expect( + invoke('plugins:rollbackMarketplacePlugin', { pluginKey: 'bare-id' }) + ).rejects.toThrow() + }) + + it('dispatches source listing, add, removal, and refresh operations', async () => { + const services = createServices() + registerPluginMarketplaceHandlers(createPluginService(), services) + const source = { + kind: 'git' as const, + url: 'https://example.com/marketplace.git', + ref: 'main' + } + + await invoke('plugins:listMarketplaces') + await invoke('plugins:addMarketplace', source) + await invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID }) + await invoke('plugins:refreshMarketplaces', { sourceId: SOURCE_ID }) + await invoke('plugins:refreshMarketplaces', {}) + await invoke('plugins:listMarketplacePlugins') + + expect(services.marketplace.listSources).toHaveBeenCalledTimes(2) + expect(services.marketplace.addSource).toHaveBeenCalledWith(source) + expect(services.marketplace.removeSource).toHaveBeenCalledWith(SOURCE_ID) + expect(services.marketplace.refreshSource).toHaveBeenCalledWith(SOURCE_ID) + expect(services.marketplace.refreshAll).toHaveBeenCalledOnce() + expect(services.marketplace.listPlugins).toHaveBeenCalledOnce() + }) + + it('refreshes discovery only after a successful install', async () => { + const services = createServices() + const pluginService = createPluginService() + registerPluginMarketplaceHandlers(pluginService, services) + const preview = { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: MARKETPLACE_COMMIT, + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + } + + await invoke('plugins:installMarketplacePlugin', preview) + expect(services.installer.install).toHaveBeenCalledWith(preview) + expect(pluginService.refresh).toHaveBeenCalledOnce() + + vi.mocked(pluginService.refresh).mockClear() + vi.mocked(services.installer.install).mockResolvedValueOnce({ ok: false, error: 'failed' }) + await invoke('plugins:installMarketplacePlugin', preview) + expect(pluginService.refresh).not.toHaveBeenCalled() + }) + + it('deactivates before rollback and refreshes discovery only on success', async () => { + const services = createServices() + const pluginService = createPluginService() + registerPluginMarketplaceHandlers(pluginService, services) + + await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) + + expect(pluginService.deactivatePlugin).toHaveBeenCalledWith(PLUGIN_KEY) + expect(services.installer.rollback).toHaveBeenCalledWith(PLUGIN_KEY) + expect(vi.mocked(pluginService.deactivatePlugin).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(services.installer.rollback).mock.invocationCallOrder[0] + ) + expect(pluginService.refresh).toHaveBeenCalledOnce() + + vi.mocked(pluginService.refresh).mockClear() + vi.mocked(services.installer.rollback).mockResolvedValueOnce({ ok: false, error: 'failed' }) + await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) + expect(pluginService.refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/plugin-marketplaces.ts b/src/main/ipc/plugin-marketplaces.ts new file mode 100644 index 00000000000..8e27ec24911 --- /dev/null +++ b/src/main/ipc/plugin-marketplaces.ts @@ -0,0 +1,76 @@ +import { ipcMain } from 'electron' +import { z } from 'zod' +import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { pluginMarketplaceGitSourceSchema } from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN } from '../plugins/plugin-marketplace-store' +import type { PluginService } from '../plugins/plugin-service' + +export type PluginMarketplaceHandlerServices = { + marketplace: PluginMarketplaceService + installer: PluginMarketplaceInstaller +} + +const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN) +const removeMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema }) +const refreshMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema.optional() }) +const marketplacePluginSchema = z.strictObject({ + marketplaceSourceId: sourceIdSchema, + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) +const installMarketplacePluginSchema = marketplacePluginSchema.extend({ + marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN), + resolvedCommit: z.string().regex(PLUGIN_COMMIT_PATTERN) +}) +const installedPluginSchema = z.strictObject({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) + +export function registerPluginMarketplaceHandlers( + pluginService: PluginService, + services: PluginMarketplaceHandlerServices +): void { + ipcMain.handle('plugins:listMarketplaces', () => services.marketplace.listSources()) + ipcMain.handle('plugins:addMarketplace', async (_event, args: unknown) => { + const source = pluginMarketplaceGitSourceSchema.parse(args) + return services.marketplace.addSource(source) + }) + ipcMain.handle('plugins:removeMarketplace', async (_event, args: unknown) => { + const { sourceId } = removeMarketplaceSchema.parse(args) + await services.marketplace.removeSource(sourceId) + return services.marketplace.listSources() + }) + ipcMain.handle('plugins:refreshMarketplaces', async (_event, args: unknown) => { + const { sourceId } = refreshMarketplaceSchema.parse(args ?? {}) + return sourceId + ? [await services.marketplace.refreshSource(sourceId)] + : services.marketplace.refreshAll() + }) + ipcMain.handle('plugins:listMarketplacePlugins', () => services.marketplace.listPlugins()) + ipcMain.handle('plugins:previewMarketplacePlugin', async (_event, args: unknown) => { + const parsed = marketplacePluginSchema.parse(args) + return services.installer.preview(parsed.marketplaceSourceId, parsed.pluginKey) + }) + ipcMain.handle('plugins:installMarketplacePlugin', async (_event, args: unknown) => { + const result = await services.installer.install(installMarketplacePluginSchema.parse(args)) + if (result.ok) { + await pluginService.refresh() + } + return result + }) + ipcMain.handle('plugins:previewMarketplaceUpdate', async (_event, args: unknown) => { + const { pluginKey } = installedPluginSchema.parse(args) + return services.installer.previewInstalledUpdate(pluginKey) + }) + ipcMain.handle('plugins:rollbackMarketplacePlugin', async (_event, args: unknown) => { + const { pluginKey } = installedPluginSchema.parse(args) + await pluginService.deactivatePlugin(pluginKey) + const result = await services.installer.rollback(pluginKey) + if (result.ok) { + await pluginService.refresh() + } + return result + }) +} diff --git a/src/main/ipc/plugins.test.ts b/src/main/ipc/plugins.test.ts new file mode 100644 index 00000000000..cdbc8082e6f --- /dev/null +++ b/src/main/ipc/plugins.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile' +import type { PluginService } from '../plugins/plugin-service' +import type { Store } from '../persistence' + +const electronMocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn() })) +vi.mock('electron', () => ({ + ipcMain: { handle: electronMocks.handle, on: electronMocks.on } +})) + +import { + canRemoveInstalledPlugin, + parsePluginConsentArgs, + parsePluginInstallArgs, + registerPluginHandlers +} from './plugins' + +beforeEach(() => { + electronMocks.handle.mockReset() + electronMocks.on.mockReset() +}) + +describe('plugin consent IPC schema', () => { + it('requires the fingerprint reviewed by the caller', () => { + expect(() => + parsePluginConsentArgs({ pluginKey: 'orca-samples.demo', decision: 'approve' }) + ).toThrow() + }) + + it('accepts an explicit reviewed fingerprint', () => { + expect( + parsePluginConsentArgs({ + pluginKey: 'orca-samples.demo', + reviewedFingerprint: 'sha256-reviewed', + decision: 'approve' + }) + ).toEqual({ + pluginKey: 'orca-samples.demo', + reviewedFingerprint: 'sha256-reviewed', + decision: 'approve' + }) + }) +}) + +describe('plugin install IPC schema', () => { + it('requires a non-empty git ref', () => { + expect(() => + parsePluginInstallArgs({ kind: 'git', url: 'https://example.com/plugin.git' }) + ).toThrow() + expect(() => + parsePluginInstallArgs({ kind: 'git', url: 'https://example.com/plugin.git', ref: ' ' }) + ).toThrow() + }) + + it('accepts an explicit git ref', () => { + expect( + parsePluginInstallArgs({ + kind: 'git', + url: 'https://example.com/plugin.git', + ref: ' v1.2.3 ' + }) + ).toEqual({ kind: 'git', url: 'https://example.com/plugin.git', ref: 'v1.2.3' }) + }) + + it('accepts HTTPS and SSH git transports', () => { + expect( + parsePluginInstallArgs({ + kind: 'git', + url: 'ssh://git@example.com/acme/plugin.git', + ref: 'main' + }) + ).toEqual({ + kind: 'git', + url: 'ssh://git@example.com/acme/plugin.git', + ref: 'main' + }) + expect( + parsePluginInstallArgs({ + kind: 'git', + url: 'git@example.com:acme/plugin.git', + ref: 'main' + }) + ).toEqual({ kind: 'git', url: 'git@example.com:acme/plugin.git', ref: 'main' }) + }) + + it('rejects executable helpers and embedded HTTPS credentials', () => { + expect(() => + parsePluginInstallArgs({ kind: 'git', url: 'ext::sh -c calc', ref: 'main' }) + ).toThrow() + expect(() => + parsePluginInstallArgs({ + kind: 'git', + url: 'https://user@example.com/plugin.git', + ref: 'main' + }) + ).toThrow() + }) +}) + +describe('plugin removal authority', () => { + it('allows installed rows but refuses dev overrides and unknown keys', () => { + const service = { + getDiscovered: () => [ + { pluginKey: 'orca-samples.installed', isDev: false }, + { pluginKey: 'orca-samples.dev', isDev: true } + ] + } as unknown as PluginService + + expect(canRemoveInstalledPlugin(service, 'orca-samples.installed')).toBe(true) + expect(canRemoveInstalledPlugin(service, 'orca-samples.dev')).toBe(false) + expect(canRemoveInstalledPlugin(service, 'orca-samples.unknown')).toBe(false) + }) + + it('refuses bundled installs because startup would restore them', () => { + const service = { + getDiscovered: () => [{ pluginKey: 'stablyai.orca-theme', isDev: false }] + } as unknown as PluginService + const lock = { + version: 1, + plugins: { + 'stablyai.orca-theme': { + pluginKey: 'stablyai.orca-theme', + version: '1.0.0', + source: { kind: 'bundled', bundleId: 'stablyai.orca-theme' }, + resolvedCommit: null, + contentHash: 'a'.repeat(64), + consentFingerprint: 'reviewed', + installedAt: 1 + } + } + } satisfies PluginLockfile + + expect(canRemoveInstalledPlugin(service, 'stablyai.orca-theme', lock)).toBe(false) + }) +}) + +describe('plugin settings lifecycle authority', () => { + it('refreshes from the main-process settings listener without renderer follow-up', () => { + let settingsListener!: (updates: { + pluginSystemEnabled?: boolean + devPluginPaths?: string[] + }) => void + const store = { + onSettingsChanged: vi.fn((listener) => { + settingsListener = listener + return vi.fn() + }) + } as unknown as Store + const service = { + setRuntimeDelegate: vi.fn(), + refresh: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginService + registerPluginHandlers(store, service, null) + + settingsListener({ pluginSystemEnabled: false }) + + expect(service.refresh).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts new file mode 100644 index 00000000000..29a78812353 --- /dev/null +++ b/src/main/ipc/plugins.ts @@ -0,0 +1,257 @@ +import { ipcMain } from 'electron' +import { z } from 'zod' +import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import { getUserPluginsDir, getPluginsDataDir } from '../plugins/plugin-discovery' +import { + installPluginFromGit, + installPluginFromLocalPath, + readPluginLockfile, + removeInstalledPlugin +} from '../plugins/plugin-install' +import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' +import { buildPluginList, type PluginListEntry } from '../plugins/plugin-list-projection' +import type { PluginService } from '../plugins/plugin-service' +import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { pluginConsentRequestSchema } from '../../shared/plugins/plugin-consent-request' +import { normalizePluginIdList } from '../../shared/plugins/plugin-consent-state' +import { + isAllowedPluginGitUrl, + type PluginLockfile +} from '../../shared/plugins/plugin-install-lockfile' +import { + registerPluginMarketplaceHandlers, + type PluginMarketplaceHandlerServices +} from './plugin-marketplaces' + +export function parsePluginConsentArgs(args: unknown): z.infer { + return pluginConsentRequestSchema.parse(args) +} + +const setEnabledArgsSchema = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + enabled: z.boolean() +}) + +const readPanelEntryArgsSchema = z.object({ + pluginKey: z.string().min(1), + panelId: z.string().min(1) +}) + +const invokeCommandArgsSchema = z.object({ + pluginKey: z.string().min(1), + commandId: z.string().min(1), + args: z.unknown().optional() +}) + +const installArgsSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('local-path'), path: z.string().min(1) }), + z.object({ + kind: z.literal('git'), + url: z.string().trim().min(1).refine(isAllowedPluginGitUrl, 'git URL must use HTTPS or SSH'), + // Why: installs must stay reproducible even when callers bypass renderer validation. + ref: z.string().trim().min(1) + }) +]) + +export function parsePluginInstallArgs(args: unknown): z.infer { + return installArgsSchema.parse(args) +} + +const removeArgsSchema = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) +const logsArgsSchema = z.object({ pluginKey: z.string().min(1) }) + +export async function listPluginsForClients( + pluginService: PluginService +): Promise { + await pluginService.whenReady() + const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath)) + return buildPluginList(pluginService, lock) +} + +export function canRemoveInstalledPlugin( + pluginService: PluginService, + pluginKey: string, + lock?: PluginLockfile +): boolean { + return ( + lock?.plugins[pluginKey]?.source.kind !== 'bundled' && + pluginService.getDiscovered().some((plugin) => plugin.pluginKey === pluginKey && !plugin.isDev) + ) +} + +function rendererPanelOwner(webContentsId: number): string { + return `renderer:${webContentsId}` +} + +export function registerPluginHandlers( + store: Store, + pluginService: PluginService, + runtime: OrcaRuntimeService | null, + marketplaceServices?: PluginMarketplaceHandlerServices +): void { + // The runtime IS the delegate: the structural PluginRuntimeDelegate type + // keeps the facade electron-free while main binds the real service. + if (runtime) { + pluginService.setRuntimeDelegate(runtime) + } + + store.onSettingsChanged((updates) => { + if ('pluginSystemEnabled' in updates || 'devPluginPaths' in updates) { + // Main owns plugin lifecycle. Renderer follow-up refreshes are UX only; + // a crashed or remote caller must not leave old workers authoritative. + void pluginService.refresh().catch((error) => { + console.warn('[plugins] failed to apply plugin settings change:', error) + }) + } + }) + + // Why: startup discovery is fire-and-forget; every handler awaits it so an + // early renderer fetch can't observe the empty pre-discovery list. + ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService)) + ipcMain.handle('plugins:listLanguagePacks', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.languagePacks.list() + }) + ipcMain.handle('plugins:consent', async (event, args: unknown) => { + await pluginService.whenReady() + const parsed = parsePluginConsentArgs(args) + await applyPluginConsent({ + store, + pluginService, + pluginKey: parsed.pluginKey, + reviewedFingerprint: parsed.reviewedFingerprint, + decision: parsed.decision, + originWebContentsId: event.sender.id + }) + return listPluginsForClients(pluginService) + }) + + ipcMain.handle('plugins:setEnabled', async (event, args: unknown) => { + await pluginService.whenReady() + const parsed = setEnabledArgsSchema.parse(args) + await applyPluginEnablement({ + store, + pluginService, + pluginKey: parsed.pluginKey, + enabled: parsed.enabled, + originWebContentsId: event.sender.id + }) + return listPluginsForClients(pluginService) + }) + + // Why: the renderer renders panel HTML via a sandboxed iframe srcdoc, so it + // needs (CSP-wrapped) file contents — never a file:// path — across IPC. + ipcMain.handle( + 'plugins:readPanelEntry', + async (event, args: unknown): Promise => { + const ownerKey = rendererPanelOwner(event.sender.id) + const ownerLease = bindPluginPanelOwnerLifecycle(event.sender, () => + pluginService.panels.revokeOwner(ownerKey) + ) + await pluginService.whenReady() + const parsed = readPanelEntryArgsSchema.parse(args) + const entry = await pluginService.panels.open(ownerKey, parsed.pluginKey, parsed.panelId) + if (!ownerLease.isCurrent()) { + pluginService.panels.revokeOwner(ownerKey) + return null + } + return entry + } + ) + + // Panel-originated actions relayed by the renderer's postMessage bridge + // host. Capability enforcement happens in main, never in the renderer. + ipcMain.handle( + 'plugins:panelAction', + async (event, args: unknown): Promise => { + await pluginService.whenReady() + return pluginService.panels.execute(rendererPanelOwner(event.sender.id), args) + } + ) + + ipcMain.handle('plugins:invokeCommand', async (_event, args: unknown) => { + await pluginService.whenReady() + const parsed = invokeCommandArgsSchema.parse(args) + return pluginService.invokeCommand(parsed.pluginKey, parsed.commandId, parsed.args) + }) + + ipcMain.handle('plugins:install', async (_event, args: unknown) => { + await pluginService.whenReady() + const parsed = parsePluginInstallArgs(args) + const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath) + const hostVersion = pluginService.options.hostVersion + const blockedPluginReason = (pluginKey: string): string | null => + pluginService.options.getPluginKillListEntry?.(pluginKey)?.reason ?? null + const result = + parsed.kind === 'local-path' + ? await installPluginFromLocalPath({ + pluginsDir, + sourcePath: parsed.path, + hostVersion, + blockedPluginReason + }) + : await installPluginFromGit({ + pluginsDir, + url: parsed.url, + ref: parsed.ref, + hostVersion, + blockedPluginReason + }) + if (result.ok) { + await pluginService.refresh() + } + return result + }) + + ipcMain.handle('plugins:remove', async (event, args: unknown) => { + await pluginService.whenReady() + const parsed = removeArgsSchema.parse(args) + const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath) + const lock = await readPluginLockfile(pluginsDir) + if (!canRemoveInstalledPlugin(pluginService, parsed.pluginKey, lock)) { + throw new Error(`cannot remove protected or non-installed plugin ${parsed.pluginKey}`) + } + await pluginService.deactivatePlugin(parsed.pluginKey) + await removeInstalledPlugin({ + pluginsDir, + pluginsDataDir: getPluginsDataDir(pluginService.options.userDataPath), + pluginKey: parsed.pluginKey + }) + // Drop the stale consent so a later reinstall re-prompts from scratch. + const settings = store.getSettings() + const consents = { ...settings.pluginConsents } + delete consents[parsed.pluginKey] + const disabledPlugins = normalizePluginIdList(settings.disabledPlugins).filter( + (pluginKey) => pluginKey !== parsed.pluginKey + ) + store.updateSettings( + { pluginConsents: consents, disabledPlugins }, + { notifyListeners: true, originWebContentsId: event.sender.id } + ) + await pluginService.refresh() + return listPluginsForClients(pluginService) + }) + + ipcMain.handle('plugins:getLogs', async (_event, args: unknown) => { + const parsed = logsArgsSchema.parse(args) + return pluginService.getLogs(parsed.pluginKey) + }) + + // Re-discover after settings edits (feature flag, dev paths) — the + // renderer calls this right after updating those settings. + ipcMain.handle('plugins:refresh', async () => { + await pluginService.refresh() + return listPluginsForClients(pluginService) + }) + if (marketplaceServices) { + registerPluginMarketplaceHandlers(pluginService, marketplaceServices) + } +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index d2b36bdf08c..51a417f7ad7 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -532,7 +532,7 @@ describe('registerCoreHandlers', () => { expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store) - expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store) + expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store, undefined) expect(registerAiVaultHandlersMock).toHaveBeenCalledWith( expect.objectContaining({ getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 2c834255eab..25ddf067727 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -48,6 +48,7 @@ import { registerTelemetryHandlers } from './telemetry' import { registerBrowserHandlers } from './browser' import { registerShellHandlers } from './shell' import { registerPetHandlers } from './pet' +import { registerPluginHandlers } from './plugins' import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui' import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream' @@ -86,6 +87,8 @@ import { prepareRuntimeAiVaultSessionResume, scanRuntimeAiVaultSessions } from '../ai-vault/runtime-session-scanner' +import type { PluginService } from '../plugins/plugin-service' +import type { PluginMarketplaceHandlerServices } from './plugin-marketplaces' let registered = false @@ -115,7 +118,9 @@ export function registerCoreHandlers( agentAwakeService?: AgentAwakeService, crashReports?: CrashReportStore, keybindings?: KeybindingService, - lifecycleOptions: CoreHandlerLifecycleOptions = {} + lifecycleOptions: CoreHandlerLifecycleOptions = {}, + pluginService?: PluginService, + marketplaceServices?: PluginMarketplaceHandlerServices ): void { // Why: on macOS the app can stay alive after all windows close, then // openMainWindow() is called again on 'activate'. ipcMain.handle() throws @@ -175,7 +180,12 @@ export function registerCoreHandlers( registerAutomationHandlers(store, automations) } if (keybindings) { - registerKeybindingHandlers(keybindings) + registerKeybindingHandlers(keybindings, () => { + void pluginService?.reconcileActivationState() + }) + } + if (pluginService) { + registerPluginHandlers(store, pluginService, runtime, marketplaceServices) } registerTelemetryHandlers(store) registerOrcaProfileHandlers(store, { @@ -201,7 +211,7 @@ export function registerCoreHandlers( registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers(store) - registerEphemeralVmHandlers(store) + registerEphemeralVmHandlers(store, pluginService) registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 01bbcbd12b4..71ca362f08e 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -384,6 +384,27 @@ describe('registerSettingsHandlers', () => { ) }) + it('does not accept plugin authority grants from generic renderer settings IPC', async () => { + store.getSettings.mockReturnValue({ pluginConsents: {}, disabledPlugins: [] }) + store.updateSettings.mockReturnValue({ pluginConsents: {}, disabledPlugins: [] }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { + pluginConsents: { 'orca-samples.demo': 'sha256-forged' }, + disabledPlugins: ['orca-samples.demo'] + }) + + expect(store.updateSettings).toHaveBeenCalledWith( + {}, + { notifyListeners: true, originWebContentsId: 1 } + ) + }) + it('normalizes terminal scrollback row updates and drops legacy byte updates', async () => { store.getSettings.mockReturnValue({ terminalScrollbackRows: 5_000 }) store.updateSettings.mockReturnValue({ terminalScrollbackRows: 50_000 }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index c4e37b9c01c..0166f4ad5ee 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -37,6 +37,10 @@ function sanitizeRendererSettingsUpdate(args: Partial): Partial< const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedArgs } = args as LegacyTerminalScrollbackSettingsUpdate void _legacyScrollbackBytes + // Plugin consent and enablement are main-owned authority state. Renderer + // writes must pass the dedicated reviewed-fingerprint handlers. + delete sanitizedArgs.pluginConsents + delete sanitizedArgs.disabledPlugins return sanitizedArgs } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index d41a92f90ff..43a0fbfbf55 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -95,7 +95,7 @@ import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' @@ -987,7 +987,8 @@ function buildDisconnectedDetectedWorktrees( export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, - runtime: OrcaRuntimeService + runtime: OrcaRuntimeService, + options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { // Remove previously registered handlers so re-register works when macOS re-activates and creates a new window. ipcMain.removeHandler('worktrees:listAll') @@ -1285,6 +1286,13 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repo.id) } + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + return result }) } @@ -1902,7 +1910,13 @@ export function registerWorktreeHandlers( })() worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) try { - return await removal + const result = await removal + options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: parseWorktreeId(args.worktreeId).worktreePath + }) + return result } finally { if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { worktreeRemovalsInFlight.delete(inFlightKey) diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index 4fd295d4c50..a722e2c5ed7 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -92,6 +92,32 @@ describe('keybinding-file', () => { }) }) + it('preserves valid plugin overrides while rejecting malformed plugin action IDs', () => { + writeFileSync( + filePath, + JSON.stringify({ + keybindings: { + 'plugin:orca-samples.tasks/open': 'Mod+Shift+T', + 'plugin:tasks/open': 'Mod+Alt+T' + } + }), + 'utf8' + ) + + const snapshot = readKeybindingFile(filePath, 'linux') + expect(snapshot.overrides).toEqual({ + 'plugin:orca-samples.tasks/open': ['Mod+Shift+T'] + }) + expect(snapshot.diagnostics).toMatchObject([ + { severity: 'warning', actionId: 'plugin:tasks/open' } + ]) + + writeKeybindingOverride(filePath, 'linux', 'plugin:orca-samples.tasks/open', []) + expect(readKeybindingFile(filePath, 'linux').overrides).toEqual({ + 'plugin:orca-samples.tasks/open': [] + }) + }) + it('ignores invalid, unknown, and conflicting manual edits', () => { writeFileSync( filePath, diff --git a/src/main/persistence-right-sidebar-tab.test.ts b/src/main/persistence-right-sidebar-tab.test.ts new file mode 100644 index 00000000000..fcaf48afc4e --- /dev/null +++ b/src/main/persistence-right-sidebar-tab.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' + +// Why: persistence.ts touches electron at import time; a minimal stub keeps +// this normalizer test focused instead of booting the full Store fixture. +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-persistence-right-sidebar-tab-test' + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +import { normalizeRightSidebarTab } from './persistence' + +describe('normalizeRightSidebarTab', () => { + it.each(['explorer', 'search', 'vault', 'workspaces', 'source-control', 'checks', 'ports'])( + 'preserves the built-in %s tab', + (tab) => { + expect(normalizeRightSidebarTab(tab)).toBe(tab) + } + ) + + // Regression: pr-checks was missing from the allow-list, so the folder + // PR Checks tab silently reset to Explorer on every app restart. + it('preserves the folder-only pr-checks tab across restarts', () => { + expect(normalizeRightSidebarTab('pr-checks')).toBe('pr-checks') + }) + + it('preserves well-formed plugin panel tabs', () => { + expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/dashboard')).toBe( + 'plugin:orca-samples.my-plugin/dashboard' + ) + }) + + it('normalizes malformed plugin tabs to the default tab', () => { + expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin')).toBe('explorer') + expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/panel/extra')).toBe('explorer') + expect(normalizeRightSidebarTab('plugin:My_Plugin/Panel!')).toBe('explorer') + }) + + it('normalizes unknown values to the default tab', () => { + expect(normalizeRightSidebarTab('bogus')).toBe('explorer') + expect(normalizeRightSidebarTab(undefined)).toBe('explorer') + expect(normalizeRightSidebarTab(42)).toBe('explorer') + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 48495b7a1e0..593ba050b0b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -72,6 +72,7 @@ import { normalizeProjectRuntimePreference } from '../shared/project-execution-runtime' import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' +import { isPluginPanelTabKey } from '../shared/plugins/plugin-manifest' import type { GitRemoteIdentity } from '../shared/git-remote-identity' import { buildTaskSourceContextFromRepo, @@ -781,18 +782,24 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][ return getDefaultUIState().projectOrderBy } -function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { +export function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { if ( tab === 'explorer' || tab === 'search' || tab === 'vault' || tab === 'workspaces' || + tab === 'pr-checks' || tab === 'source-control' || tab === 'checks' || tab === 'ports' ) { return tab } + // Why: plugin tabs are open-ended `plugin:./` keys; validate the + // shape so a persisted plugin tab doesn't reset to Explorer on restart. + if (typeof tab === 'string' && isPluginPanelTabKey(tab)) { + return tab + } return getDefaultUIState().rightSidebarTab } diff --git a/src/main/plugins/plugin-activation-policy.ts b/src/main/plugins/plugin-activation-policy.ts new file mode 100644 index 00000000000..9ef9bd59b3b --- /dev/null +++ b/src/main/plugins/plugin-activation-policy.ts @@ -0,0 +1,26 @@ +import { + getPluginActivationState, + type PluginConsentLists +} from '../../shared/plugins/plugin-consent-state' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +export function snapshotPluginConsentLists(source: { + getPluginConsents: () => Record + getDisabledPlugins: () => string[] +}): PluginConsentLists { + return { + pluginConsents: source.getPluginConsents(), + disabledPlugins: source.getDisabledPlugins() + } +} + +export function isPluginApproved( + enabled: boolean, + plugin: ValidDiscoveredPlugin, + lists: PluginConsentLists +): boolean { + return ( + enabled && + getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, lists) === 'approved' + ) +} diff --git a/src/main/plugins/plugin-approved-vm-recipes.ts b/src/main/plugins/plugin-approved-vm-recipes.ts new file mode 100644 index 00000000000..ab193a5280f --- /dev/null +++ b/src/main/plugins/plugin-approved-vm-recipes.ts @@ -0,0 +1,12 @@ +import type { OrcaVmRecipe } from '../../shared/types' +import type { PluginService } from './plugin-service' + +export async function getApprovedPluginVmRecipes( + pluginService?: PluginService +): Promise { + if (!pluginService) { + return [] + } + await pluginService.whenReady() + return pluginService.contentPacks.vmRecipes.list().map(({ recipe }) => recipe) +} diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts new file mode 100644 index 00000000000..00e508b35af --- /dev/null +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -0,0 +1,193 @@ +import { createReadStream } from 'node:fs' +import { realpath, stat } from 'node:fs/promises' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact' + +export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string } + +export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024 +export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024 +const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024 +export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024 +export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024 +const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024 + +type DeclaredArtifact = + | { label: string; path: string; kind: 'file'; maxBytes: number } + | { label: string; path: string; kind: 'directory' } + +function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { + return [ + ...(manifest.icon + ? [ + { + label: 'icon', + path: manifest.icon, + kind: 'file' as const, + maxBytes: PLUGIN_ICON_MAX_BYTES + } + ] + : []), + ...(manifest.main + ? [ + { + label: 'worker entry', + path: manifest.main, + kind: 'file' as const, + maxBytes: PLUGIN_WORKER_ENTRY_MAX_BYTES + } + ] + : []), + ...manifest.contributes.panels.map((panel) => ({ + label: `panel "${panel.id}" entry`, + path: panel.entry, + kind: 'file' as const, + maxBytes: PLUGIN_PANEL_ENTRY_MAX_BYTES + })), + ...manifest.contributes.languagePacks.map((languagePack) => ({ + label: `language pack "${languagePack.locale}"`, + path: languagePack.path, + kind: 'file' as const, + maxBytes: PLUGIN_LANGUAGE_PACK_MAX_BYTES + })), + ...manifest.contributes.vmRecipes.map((recipe) => ({ + label: 'VM recipe', + path: recipe.path, + kind: 'file' as const, + maxBytes: PLUGIN_VM_RECIPE_MAX_BYTES + })), + ...manifest.contributes.agents.map((agent) => ({ + label: 'agent profile', + path: agent.path, + kind: 'file' as const, + maxBytes: PLUGIN_AGENT_PROFILE_MAX_BYTES + })) + ] +} + +export async function resolveContainedPluginArtifact( + rootDir: string, + relativePath: string, + maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES +): Promise { + const rootReal = await realpath(resolve(rootDir)) + return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes) +} + +export async function readContainedPluginArtifactText( + rootDir: string, + relativePath: string, + maxBytes: number +): Promise { + const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes) + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(artifact)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > maxBytes) { + throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) + } + chunks.push(bytes) + } + return Buffer.concat(chunks, totalBytes).toString('utf8') +} + +async function resolvePathFromRealRoot( + rootDir: string, + rootReal: string, + relativePath: string, + kind: 'file' | 'directory', + maxBytes?: number +): Promise { + const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/))) + const fromRoot = relative(rootReal, artifactReal) + if ( + fromRoot.length === 0 || + isAbsolute(fromRoot) || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) + ) { + throw new Error('resolves outside the plugin directory') + } + const artifactStat = await stat(artifactReal) + if (kind === 'file' && !artifactStat.isFile()) { + throw new Error('is not a regular file') + } + if (kind === 'directory' && !artifactStat.isDirectory()) { + throw new Error('is not a directory') + } + if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) { + throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) + } + return artifactReal +} + +/** Presence and containment checks are bounded by the manifest's declared artifacts. */ +export async function validateDeclaredPluginArtifacts( + rootDir: string, + manifest: PluginManifest +): Promise { + const artifacts = declaredArtifactPaths(manifest) + if (artifacts.length === 0) { + return { ok: true } + } + const seen = new Set() + let rootReal: string + try { + rootReal = await realpath(resolve(rootDir)) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + for (const artifact of artifacts) { + if (seen.has(artifact.path)) { + continue + } + seen.add(artifact.path) + try { + await resolvePathFromRealRoot( + rootDir, + rootReal, + artifact.path, + artifact.kind, + artifact.kind === 'file' ? artifact.maxBytes : undefined + ) + } catch (error) { + return { + ok: false, + error: `${artifact.label} ${artifact.path}: ${error instanceof Error ? error.message : String(error)}` + } + } + } + return { ok: true } +} + +/** Parses declared VM recipe artifacts at the immutable install boundary. */ +export async function validatePluginInstallContent( + rootDir: string, + manifest: PluginManifest +): Promise { + const vmRecipeIds = new Set() + for (const contribution of manifest.contributes.vmRecipes) { + try { + const recipe = parsePluginVmRecipeArtifact( + await readContainedPluginArtifactText( + rootDir, + contribution.path, + PLUGIN_VM_RECIPE_MAX_BYTES + ) + ) + if (vmRecipeIds.has(recipe.id)) { + throw new Error(`duplicate VM recipe id "${recipe.id}"`) + } + vmRecipeIds.add(recipe.id) + } catch (error) { + return { + ok: false, + error: `VM recipe ${contribution.path}: ${error instanceof Error ? error.message : String(error)}` + } + } + } + return { ok: true } +} diff --git a/src/main/plugins/plugin-atomic-file-write.test.ts b/src/main/plugins/plugin-atomic-file-write.test.ts new file mode 100644 index 00000000000..11407f7f781 --- /dev/null +++ b/src/main/plugins/plugin-atomic-file-write.test.ts @@ -0,0 +1,187 @@ +import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' +import { + renamePluginFileWithWindowsRetry, + writePluginFileAtomically +} from './plugin-atomic-file-write' + +// Windows AV/indexer locks cannot be provoked on CI, so queue the errno codes instead. +const locks = vi.hoisted(() => ({ codes: [] as string[] })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, target: string) => { + const code = locks.codes.shift() + if (!code) { + return actual.rename(source, target) + } + throw Object.assign(new Error(`simulated ${code}`), { code }) + } + } +}) + +function withPlatform(platform: string): () => void { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + return () => { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +describe('writePluginFileAtomically', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'orca-plugin-atomic-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('writes contents and leaves no temp file behind', async () => { + const target = join(dir, 'current') + await writePluginFileAtomically(target, 'abc123') + expect(await readFile(target, 'utf8')).toBe('abc123') + expect(await readdir(dir)).toEqual(['current']) + }) + + it('replaces an existing file', async () => { + const target = join(dir, 'plugins.lock.json') + await writePluginFileAtomically(target, 'first') + await writePluginFileAtomically(target, 'second') + expect(await readFile(target, 'utf8')).toBe('second') + expect(await readdir(dir)).toEqual(['plugins.lock.json']) + }) + + it('applies the requested mode', async () => { + const target = join(dir, 'provenance.json') + await writePluginFileAtomically(target, '{}', { mode: 0o600 }) + const mode = (await stat(target)).mode & 0o777 + // Windows does not model POSIX permission bits. + if (process.platform !== 'win32') { + expect(mode).toBe(0o600) + } + }) + + it('cleans up the temp file when the write fails', async () => { + await expect(writePluginFileAtomically(join(dir, 'missing', 'x'), 'v')).rejects.toThrow() + expect(await readdir(dir)).toEqual([]) + }) + + it('cleans up the temp file when the rename gives up', async () => { + const restorePlatform = withPlatform('win32') + locks.codes = Array.from({ length: 6 }, () => 'EPERM') + try { + await expect(writePluginFileAtomically(join(dir, 'current'), 'v')).rejects.toMatchObject({ + code: 'EPERM' + }) + expect(await readdir(dir)).toEqual([]) + } finally { + restorePlatform() + locks.codes = [] + } + }) + + it('runs concurrent writers to one target without leaking temp files', async () => { + const target = join(dir, 'sources.json') + await Promise.all( + Array.from({ length: 8 }, (_unused, index) => + writePluginFileAtomically(target, `value-${index}`) + ) + ) + expect(await readdir(dir)).toEqual(['sources.json']) + expect(await readFile(target, 'utf8')).toMatch(/^value-\d$/) + }) +}) + +describe('renamePluginFileWithWindowsRetry', () => { + it('renames when the source exists', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-')) + try { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + await renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + expect(await readFile(join(dir, 'to'), 'utf8')).toBe('payload') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('rethrows a non-retryable error', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-')) + try { + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'absent'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + describe('on Windows', () => { + let dir: string + let restorePlatform: () => void + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-win-')) + restorePlatform = withPlatform('win32') + }) + + afterEach(async () => { + restorePlatform() + locks.codes = [] + await rm(dir, { recursive: true, force: true }) + }) + + it.each(['EPERM', 'EACCES', 'EBUSY'])('retries past a transient %s lock', async (code) => { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + locks.codes = [code, code] + await renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + expect(await readFile(join(dir, 'to'), 'utf8')).toBe('payload') + expect(locks.codes).toEqual([]) + }) + + it('gives up after the last delay rather than looping forever', async () => { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + // One more lock than there are delays, so the loop must exit on the bound. + locks.codes = Array.from({ length: 6 }, () => 'EBUSY') + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'EBUSY' }) + expect(locks.codes).toEqual([]) + }) + + it('rethrows a non-retryable code without retrying', async () => { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + locks.codes = ['ENOSPC', 'ENOSPC'] + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'ENOSPC' }) + expect(locks.codes).toEqual(['ENOSPC']) + }) + }) + + it('does not retry off Windows', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-posix-')) + const restorePlatform = withPlatform('linux') + try { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + locks.codes = ['EPERM', 'EPERM'] + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'EPERM' }) + expect(locks.codes).toEqual(['EPERM']) + } finally { + restorePlatform() + locks.codes = [] + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/plugins/plugin-atomic-file-write.ts b/src/main/plugins/plugin-atomic-file-write.ts new file mode 100644 index 00000000000..e87b824f38c --- /dev/null +++ b/src/main/plugins/plugin-atomic-file-write.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto' +import { rename, rm, writeFile } from 'node:fs/promises' + +/** + * Atomic write for plugin state files (lockfile, provenance, pointers, caches). + * + * Why the retry: on Windows the rename can fail with EPERM/EACCES/EBUSY while + * antivirus or an indexer holds the target open (issue #1507). The repo's + * existing `renameFileWithWindowsRetry` covers the same hazard but is sync; + * every plugin write path is async, so the backoff parks on a timer instead. + */ + +const WINDOWS_RENAME_RETRY_DELAYS_MS = [50, 100, 150, 200, 250] + +export type PluginAtomicWriteOptions = { mode?: number } + +export async function writePluginFileAtomically( + target: string, + contents: string, + options?: PluginAtomicWriteOptions +): Promise { + // Unique temp name so concurrent writers in one plugins dir cannot collide. + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + try { + await writeFile(temporary, contents, { encoding: 'utf8', mode: options?.mode }) + await renamePluginFileWithWindowsRetry(temporary, target) + } finally { + await rm(temporary, { force: true }).catch(() => undefined) + } +} + +export async function renamePluginFileWithWindowsRetry( + source: string, + target: string +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await rename(source, target) + return + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + const retryable = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY' + if ( + process.platform !== 'win32' || + !retryable || + attempt >= WINDOWS_RENAME_RETRY_DELAYS_MS.length + ) { + throw error + } + await new Promise((resolve) => + setTimeout(resolve, WINDOWS_RENAME_RETRY_DELAYS_MS[attempt]) + ) + } + } +} diff --git a/src/main/plugins/plugin-audit-log.test.ts b/src/main/plugins/plugin-audit-log.test.ts new file mode 100644 index 00000000000..ab70de89da8 --- /dev/null +++ b/src/main/plugins/plugin-audit-log.test.ts @@ -0,0 +1,38 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PluginAuditLog } from './plugin-audit-log' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginAuditLog retention', () => { + it('rotates bounded segments while preserving recent entries across the boundary', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-audit-')) + roots.push(root) + const audit = new PluginAuditLog(root, { maxBytes: 240 }) + + for (let index = 0; index < 8; index += 1) { + await audit.record({ + ts: index, + actor: 'plugin:orca-samples.demo', + method: 'storage.set', + summary: `key=${index}`, + outcome: 'ok' + }) + } + + await expect(stat(join(root, 'audit.log'))).resolves.toMatchObject({ + isFile: expect.any(Function) + }) + await expect(stat(join(root, 'audit.log.1'))).resolves.toMatchObject({ + isFile: expect.any(Function) + }) + const recent = await audit.readRecent(3) + expect(recent.map((entry) => entry.ts)).toEqual([5, 6, 7]) + }) +}) diff --git a/src/main/plugins/plugin-audit-log.ts b/src/main/plugins/plugin-audit-log.ts new file mode 100644 index 00000000000..0b5f4cd3056 --- /dev/null +++ b/src/main/plugins/plugin-audit-log.ts @@ -0,0 +1,86 @@ +import { appendFile, mkdir, readFile, rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +/** + * Append-only audit trail for host-API mutations performed on a plugin's + * behalf, with actor `plugin:`. One JSONL file so support and + * enterprise policy tooling can replay exactly what plugins did through the + * gated API. (Honest scope: worker code acting through its own Node access + * bypasses this — stated in the consent UI.) Mutation intents are awaited + * before their handler runs so an API-mediated write cannot outrun the log. + */ + +export type PluginAuditEntry = { + ts: number + actor: `plugin:${string}` + method: string + /** Bounded summary — never full params (they may contain user content). */ + summary: string + outcome: 'attempt' | 'ok' | 'error' +} + +export class PluginAuditLog { + private readonly filePath: string + private readonly rotatedFilePath: string + private readonly maxBytes: number + private writeChain: Promise = Promise.resolve() + private fileBytes: number | null = null + + constructor(pluginsDataDir: string, options: { maxBytes?: number } = {}) { + this.filePath = join(pluginsDataDir, 'audit.log') + this.rotatedFilePath = join(pluginsDataDir, 'audit.log.1') + this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024 + } + + record(entry: PluginAuditEntry): Promise { + const write = this.writeChain.then(async () => { + await mkdir(dirname(this.filePath), { recursive: true }) + const line = `${JSON.stringify(entry)}\n` + if (this.fileBytes === null) { + this.fileBytes = await stat(this.filePath).then( + (file) => file.size, + () => 0 + ) + } + const lineBytes = Buffer.byteLength(line, 'utf8') + if (this.fileBytes > 0 && this.fileBytes + lineBytes > this.maxBytes) { + await rm(this.rotatedFilePath, { force: true }) + await rename(this.filePath, this.rotatedFilePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) + this.fileBytes = 0 + } + await appendFile(this.filePath, line, 'utf8') + this.fileBytes += lineBytes + }) + // Keep the serialization chain usable after a failed append while still + // exposing this write's failure to the mutation chokepoint. + this.writeChain = write.catch(() => undefined) + return write + } + + async flush(): Promise { + await this.writeChain + } + + async readRecent(limit = 200): Promise { + try { + const [rotated, current] = await Promise.all( + [this.rotatedFilePath, this.filePath].map((path) => readFile(path, 'utf8').catch(() => '')) + ) + const text = rotated + current + const lines = text.split('\n').filter((line) => line.length > 0) + return lines.slice(-limit).flatMap((line) => { + try { + return [JSON.parse(line) as PluginAuditEntry] + } catch { + return [] + } + }) + } catch { + return [] + } + } +} diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts new file mode 100644 index 00000000000..ab6543bc6b7 --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PluginBundledBootstrapResult } from './plugin-bundled-bootstrap' +import { PluginBundledBootstrapCoordinator } from './plugin-bundled-bootstrap-coordinator' + +const unchanged: PluginBundledBootstrapResult = { + installed: [], + unchanged: ['stablyai.orca-theme'], + errors: [] +} + +describe('PluginBundledBootstrapCoordinator', () => { + it('skips disabled requests and refreshes discovery only after publication', async () => { + let enabled = false + const bootstrap = vi + .fn() + .mockResolvedValueOnce(unchanged) + .mockResolvedValueOnce({ + installed: ['stablyai.orca-theme'], + unchanged: [], + errors: [] + }) + const refreshPlugins = vi.fn().mockResolvedValue(undefined) + const coordinator = new PluginBundledBootstrapCoordinator({ + root: 'resources', + userDataPath: 'user-data', + hostVersion: '1.4.0', + isEnabled: () => enabled, + refreshPlugins, + bootstrap + }) + + await expect(coordinator.request()).resolves.toBeNull() + enabled = true + await expect(coordinator.request()).resolves.toEqual(unchanged) + expect(refreshPlugins).not.toHaveBeenCalled() + await coordinator.request() + expect(refreshPlugins).toHaveBeenCalledOnce() + }) + + it('serializes overlapping startup and feature-toggle requests', async () => { + let active = 0 + let maximumActive = 0 + let releaseFirst: (() => void) | undefined + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const bootstrap = vi.fn(async (): Promise => { + active += 1 + maximumActive = Math.max(maximumActive, active) + if (bootstrap.mock.calls.length === 1) { + await firstGate + } + active -= 1 + return unchanged + }) + const coordinator = new PluginBundledBootstrapCoordinator({ + root: 'resources', + userDataPath: 'user-data', + hostVersion: '1.4.0', + isEnabled: () => true, + refreshPlugins: vi.fn().mockResolvedValue(undefined), + bootstrap + }) + + const first = coordinator.request() + const second = coordinator.request() + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledTimes(1)) + releaseFirst?.() + await Promise.all([first, second]) + + expect(bootstrap).toHaveBeenCalledTimes(2) + expect(maximumActive).toBe(1) + }) +}) diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts new file mode 100644 index 00000000000..74fae9b63e3 --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts @@ -0,0 +1,48 @@ +import { + bootstrapBundledPlugins, + type PluginBundledBootstrapResult +} from './plugin-bundled-bootstrap' + +type PluginBundledBootstrapRequest = Parameters[0] + +export class PluginBundledBootstrapCoordinator { + private readonly options: PluginBundledBootstrapRequest & { + isEnabled: () => boolean + refreshPlugins: () => Promise + bootstrap?: typeof bootstrapBundledPlugins + } + private pending: Promise = Promise.resolve() + + constructor(options: PluginBundledBootstrapCoordinator['options']) { + this.options = options + } + + request(): Promise { + const run = this.pending.then(() => this.runOnce()) + // Why: feature-toggle and startup requests can overlap; preserve their + // order even when one resource read fails. + this.pending = run.then( + () => undefined, + () => undefined + ) + return run + } + + private async runOnce(): Promise { + if (!this.options.isEnabled()) { + return null + } + const result = await (this.options.bootstrap ?? bootstrapBundledPlugins)({ + root: this.options.root, + userDataPath: this.options.userDataPath, + hostVersion: this.options.hostVersion, + ...(this.options.blockedPluginReason + ? { blockedPluginReason: this.options.blockedPluginReason } + : {}) + }) + if (result.installed.length > 0) { + await this.options.refreshPlugins() + } + return result + } +} diff --git a/src/main/plugins/plugin-bundled-bootstrap.test.ts b/src/main/plugins/plugin-bundled-bootstrap.test.ts new file mode 100644 index 00000000000..d5e2e64bd8f --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { hashPluginTree } from './plugin-content-hash' +import { readPluginLockfile } from './plugin-install' +import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap' + +const roots: string[] = [] + +async function tempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + return root +} + +async function writeBundle(root: string, name = 'Skills'): Promise<{ path: string; hash: string }> { + const path = 'stablyai.orca-skills' + const pluginRoot = join(root, path) + await mkdir(pluginRoot, { recursive: true }) + await writeFile( + join(pluginRoot, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'orca-skills', + publisher: 'stablyai', + name, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [] + }) + ) + const hashed = await hashPluginTree(pluginRoot) + if (!hashed.ok) { + throw new Error(hashed.error) + } + return { path, hash: hashed.hash } +} + +async function writeIndex(root: string, path: string, contentHash: string): Promise { + await writeFile( + join(root, 'bundled-plugins.json'), + JSON.stringify({ + version: 1, + plugins: [{ pluginKey: 'stablyai.orca-skills', path, contentHash }] + }) + ) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('bundled plugin bootstrap', () => { + it('installs release-indexed content once and keeps unchanged startup work bounded', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const bundle = await writeBundle(root) + await writeIndex(root, bundle.path, bundle.hash) + + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: [], unchanged: ['stablyai.orca-skills'], errors: [] }) + }) + + it('publishes an updated immutable bundle only when the indexed hash matches', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const first = await writeBundle(root) + await writeIndex(root, first.path, first.hash) + await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + const second = await writeBundle(root, 'Updated Skills') + await writeIndex(root, second.path, second.hash) + + const updated = await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + + expect(updated).toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + const lock = await readPluginLockfile(join(userDataPath, 'plugins')) + expect(lock.plugins['stablyai.orca-skills']?.contentHash).toBe(second.hash) + }) + + it('repairs a missing or modified bundled current version', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const bundle = await writeBundle(root) + await writeIndex(root, bundle.path, bundle.hash) + await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + const versionDir = join(userDataPath, 'plugins', 'stablyai.orca-skills', bundle.hash) + await writeFile(join(versionDir, 'orca-plugin.json'), '{}') + + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + + await rm(versionDir, { recursive: true, force: true }) + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + }) + + it('refuses mismatched release hashes before publication', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const bundle = await writeBundle(root) + await writeIndex(root, bundle.path, 'f'.repeat(64)) + + const result = await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + + expect(result.installed).toEqual([]) + expect(result.errors[0]?.error).toContain('does not match its release index') + expect((await readPluginLockfile(join(userDataPath, 'plugins'))).plugins).toEqual({}) + }) + + it('resolves packaged and development resource roots without platform separators', () => { + expect( + resolveBundledPluginRoot({ + isPackaged: true, + resourcesPath: join('app', 'resources'), + appPath: join('repo', 'app') + }) + ).toBe(join('app', 'resources', 'plugins', 'launch')) + expect( + resolveBundledPluginRoot({ + isPackaged: false, + resourcesPath: join('app', 'resources'), + appPath: join('repo', 'app') + }) + ).toBe(join('repo', 'app', 'resources', 'plugins', 'launch')) + }) +}) diff --git a/src/main/plugins/plugin-bundled-bootstrap.ts b/src/main/plugins/plugin-bundled-bootstrap.ts new file mode 100644 index 00000000000..a7cfbbc1736 --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap.ts @@ -0,0 +1,154 @@ +import { readFile, realpath, stat } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import { z } from 'zod' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { pluginRelativeDirectorySchema } from '../../shared/plugins/plugin-manifest-fields' +import { isOfficialPluginIdentity } from '../../shared/plugins/plugin-marketplace' +import { getUserPluginsDir } from './plugin-discovery' +import { installBundledPlugin, readPluginLockfile } from './plugin-install' +import { inspectPluginInstallTree } from './plugin-install-staging' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { hashPluginTree } from './plugin-content-hash' + +export const BUNDLED_PLUGIN_INDEX_FILENAME = 'bundled-plugins.json' +const BUNDLED_PLUGIN_INDEX_MAX_BYTES = 64 * 1024 + +const bundledPluginIndexSchema = z + .object({ + version: z.literal(1), + plugins: z + .array( + z + .object({ + pluginKey: z + .string() + .refine(isQualifiedPluginKey, 'invalid qualified plugin identity') + .refine(isOfficialPluginIdentity, 'bundled plugins must use an official identity'), + path: pluginRelativeDirectorySchema, + contentHash: z.string().regex(/^[0-9a-f]{64}$/) + }) + .strict() + ) + .max(32) + }) + .strict() + .superRefine((index, ctx) => { + const keys = new Set() + for (const [entryIndex, plugin] of index.plugins.entries()) { + if (keys.has(plugin.pluginKey)) { + ctx.addIssue({ + code: 'custom', + path: ['plugins', entryIndex, 'pluginKey'], + message: 'duplicate bundled plugin identity' + }) + } + keys.add(plugin.pluginKey) + } + }) + +export type PluginBundledBootstrapResult = { + installed: string[] + unchanged: string[] + errors: { pluginKey: string; error: string }[] +} + +export function resolveBundledPluginRoot(options: { + isPackaged: boolean + resourcesPath: string + appPath: string +}): string { + return options.isPackaged + ? join(options.resourcesPath, 'plugins', 'launch') + : join(options.appPath, 'resources', 'plugins', 'launch') +} + +async function readBundledPluginIndex( + root: string +): Promise> { + const indexPath = join(root, BUNDLED_PLUGIN_INDEX_FILENAME) + const metadata = await stat(indexPath) + if (!metadata.isFile() || metadata.size > BUNDLED_PLUGIN_INDEX_MAX_BYTES) { + throw new Error(`bundled plugin index exceeds ${BUNDLED_PLUGIN_INDEX_MAX_BYTES} bytes`) + } + return bundledPluginIndexSchema.parse(JSON.parse(await readFile(indexPath, 'utf8'))) +} + +async function resolveBundlePath(root: string, path: string): Promise { + const [resolvedRoot, resolvedPath] = await Promise.all([ + realpath(root), + realpath(join(root, path)) + ]) + const fromRoot = relative(resolvedRoot, resolvedPath) + if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error('bundled plugin path escapes the resource root') + } + return resolvedPath +} + +async function bundledInstallIsIntact( + pluginsDir: string, + pluginKey: string, + contentHash: string +): Promise { + const pluginDir = join(pluginsDir, pluginKey) + if ((await readPluginCurrentPointer(pluginDir).catch(() => null)) !== contentHash) { + return false + } + const hashed = await hashPluginTree(join(pluginDir, contentHash)) + return hashed.ok && hashed.hash === contentHash +} + +export async function bootstrapBundledPlugins(options: { + root: string + userDataPath: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + const index = await readBundledPluginIndex(options.root) + const pluginsDir = getUserPluginsDir(options.userDataPath) + const lock = await readPluginLockfile(pluginsDir) + const result: PluginBundledBootstrapResult = { installed: [], unchanged: [], errors: [] } + for (const entry of index.plugins) { + const locked = lock.plugins[entry.pluginKey] + if ( + locked?.source.kind === 'bundled' && + locked.source.bundleId === entry.pluginKey && + locked.contentHash === entry.contentHash && + (await bundledInstallIsIntact(pluginsDir, entry.pluginKey, entry.contentHash)) + ) { + result.unchanged.push(entry.pluginKey) + continue + } + try { + const sourcePath = await resolveBundlePath(options.root, entry.path) + const inspection = await inspectPluginInstallTree({ + rootDir: sourcePath, + hostVersion: options.hostVersion, + expectedPluginKey: entry.pluginKey + }) + if (!inspection.ok) { + throw new Error(inspection.error) + } + if (inspection.contentHash !== entry.contentHash) { + throw new Error('bundled plugin content does not match its release index') + } + const installed = await installBundledPlugin({ + pluginsDir, + sourcePath, + hostVersion: options.hostVersion, + expectedPluginKey: entry.pluginKey, + blockedPluginReason: options.blockedPluginReason + }) + if (!installed.ok) { + throw new Error(installed.error) + } + result.installed.push(entry.pluginKey) + } catch (error) { + result.errors.push({ + pluginKey: entry.pluginKey, + error: error instanceof Error ? error.message : String(error) + }) + } + } + return result +} diff --git a/src/main/plugins/plugin-command-invocation.ts b/src/main/plugins/plugin-command-invocation.ts new file mode 100644 index 00000000000..28ff762bfcc --- /dev/null +++ b/src/main/plugins/plugin-command-invocation.ts @@ -0,0 +1,13 @@ +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +export function assertPluginWorkerCommand(plugin: ValidDiscoveredPlugin, commandId: string): void { + const command = plugin.manifest.contributes.commands.find((entry) => entry.id === commandId) + if (!command) { + throw new Error(`plugin ${plugin.pluginKey} does not contribute command ${commandId}`) + } + // Declarative aliases are renderer-owned and must never cross the worker + // activation boundary, even if a compromised renderer invokes IPC directly. + if (command.action !== undefined) { + throw new Error(`plugin ${plugin.pluginKey} command ${commandId} is a built-in action alias`) + } +} diff --git a/src/main/plugins/plugin-command-registry.test.ts b/src/main/plugins/plugin-command-registry.test.ts new file mode 100644 index 00000000000..e05c6351441 --- /dev/null +++ b/src/main/plugins/plugin-command-registry.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginCommandRegistry } from './plugin-command-registry' + +function commandPlugin( + id: string, + contributes: { + commands: Record[] + keybindings?: Record[] + } +): ValidDiscoveredPlugin { + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id, + publisher: 'orca-samples', + name: id, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + ...(contributes.commands.some((command) => command.action === undefined) + ? { main: 'worker.js' } + : {}), + contributes, + capabilities: [] + }) + return { + pluginKey: `orca-samples.${id}`, + rootDir: `/plugins/${id}`, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, `content-${id}`), + consentContentHash: `content-${id}`, + contentHash: `content-${id}`, + isDev: false + } +} + +describe('PluginCommandRegistry', () => { + it('retains pending previews and exposes only approved commands', () => { + const plugin = commandPlugin('aliases', { + commands: [{ id: 'tasks', title: 'Open Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'mod+alt+t' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([plugin], () => false) + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toEqual([ + { + pluginKey: plugin.pluginKey, + id: 'tasks', + title: 'Open Tasks', + context: 'global', + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [{ key: 'Mod+Alt+T', when: 'global' }] + } + ]) + + registry.reconcile([plugin], () => true) + expect(registry.list()).toHaveLength(1) + }) + + it('projects worker commands and inherited worktree keybinding context', () => { + const plugin = commandPlugin('worker', { + commands: [{ id: 'create', title: 'Create Task', context: 'worktree' }], + keybindings: [{ command: 'create', key: 'Mod+Shift+A' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([plugin], () => true) + + expect(registry.list()).toMatchObject([ + { + context: 'worktree', + handler: { type: 'worker' }, + keybindings: [{ key: 'Mod+Shift+A', when: 'worktree' }] + } + ]) + }) + + it('errors approved plugins whose keybindings overlap', () => { + const global = commandPlugin('global', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'global' }] + }) + const worktree = commandPlugin('worktree', { + commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'worktree' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([global, worktree], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.error(global.pluginKey)).toContain('conflicts') + expect(registry.error(worktree.pluginKey)).toContain('conflicts') + }) + + it('uses saved effective bindings to recover conflicting plugins', () => { + const first = commandPlugin('first', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const second = commandPlugin('second', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile( + [first, second], + () => true, + { 'plugin:orca-samples.first/tasks': ['Mod+Shift+T'] }, + 'linux' + ) + + expect(registry.list()).toHaveLength(2) + expect(registry.error(first.pluginKey)).toBeNull() + expect(registry.error(second.pluginKey)).toBeNull() + }) + + it('rejects conflicting saved bindings within one plugin', () => { + const plugin = commandPlugin('aliases', { + commands: [ + { id: 'tasks', title: 'Tasks', action: 'view.tasks' }, + { id: 'sidebar', title: 'Sidebar', action: 'sidebar.left.toggle' } + ] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile( + [plugin], + () => true, + { + 'plugin:orca-samples.aliases/tasks': ['Mod+Alt+T'], + 'plugin:orca-samples.aliases/sidebar': ['Mod+Alt+T'] + }, + 'linux' + ) + + expect(registry.list()).toEqual([]) + expect(registry.error(plugin.pluginKey)).toContain('conflicts') + }) + + it('detects cross-platform Mod and physical Ctrl conflicts', () => { + const portable = commandPlugin('portable', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const physical = commandPlugin('physical', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Ctrl+Alt+T' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([portable, physical], () => true, {}, 'linux') + + expect(registry.list()).toEqual([]) + expect(registry.error(portable.pluginKey)).toContain('conflicts') + expect(registry.error(physical.pluginKey)).toContain('conflicts') + }) + + it('allows the same worktree-only chord after one plugin is disabled', () => { + const first = commandPlugin('first', { + commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const second = commandPlugin('second', { + commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([first, second], (plugin) => plugin === first) + + expect(registry.list()).toHaveLength(1) + expect(registry.error(first.pluginKey)).toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-command-registry.ts b/src/main/plugins/plugin-command-registry.ts new file mode 100644 index 00000000000..3b3af499ebc --- /dev/null +++ b/src/main/plugins/plugin-command-registry.ts @@ -0,0 +1,167 @@ +import { + pluginCommandKeybindingActionId, + type PluginCommandAliasActionId +} from '../../shared/plugins/plugin-command-actions' +import { getKeybindingConflictIdentity, type KeybindingOverrides } from '../../shared/keybindings' +import type { + PluginCommandContribution, + PluginManifest +} from '../../shared/plugins/plugin-manifest' +import type { PluginKeybindingContribution } from '../../shared/plugins/plugin-content-pack-contributions' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +export type PluginCommandKeybinding = { + key: string + when: 'global' | 'worktree' +} + +export type PluginCommandRegistration = { + pluginKey: string + id: string + title: string + context: 'global' | 'worktree' + handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' } + keybindings: PluginCommandKeybinding[] +} + +type CommandOwner = { + pluginKey: string + context: PluginCommandKeybinding['when'] + key: string +} + +export class PluginCommandRegistry { + private active: PluginCommandRegistration[] = [] + private readonly previews = new Map() + private readonly errors = new Map() + + list(): readonly PluginCommandRegistration[] { + return this.active + } + + preview(pluginKey: string): readonly PluginCommandRegistration[] { + return this.previews.get(pluginKey) ?? [] + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean, + overrides: KeybindingOverrides = {}, + platform: NodeJS.Platform = process.platform + ): void { + const candidates = discovered.filter( + (plugin): plugin is ValidDiscoveredPlugin => + !isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.commands.length > 0 + ) + const registrations = candidates.map((plugin) => ({ + pluginKey: plugin.pluginKey, + approved: isApproved(plugin), + commands: registrationsForManifest(plugin.pluginKey, plugin.manifest) + })) + + this.previews.clear() + this.errors.clear() + for (const plugin of registrations) { + this.previews.set(plugin.pluginKey, plugin.commands) + } + + const approved = registrations.filter((plugin) => plugin.approved) + const chordOwners = new Map() + for (const plugin of approved) { + for (const command of plugin.commands) { + for (const keybinding of effectiveCommandKeybindings(command, overrides)) { + const identity = getKeybindingConflictIdentity(keybinding.key, platform) + const owners = chordOwners.get(identity) ?? [] + owners.push({ + pluginKey: plugin.pluginKey, + context: keybinding.when, + key: keybinding.key + }) + chordOwners.set(identity, owners) + } + } + } + + const conflicted = new Set() + for (const owners of chordOwners.values()) { + for (let index = 0; index < owners.length; index += 1) { + for (let compared = index + 1; compared < owners.length; compared += 1) { + const first = owners[index]! + const second = owners[compared]! + if (!contextsOverlap(first.context, second.context)) { + continue + } + conflicted.add(first.pluginKey) + conflicted.add(second.pluginKey) + this.errors.set( + first.pluginKey, + `plugin keybinding ${first.key} conflicts with another plugin` + ) + this.errors.set( + second.pluginKey, + `plugin keybinding ${second.key} conflicts with another plugin` + ) + } + } + } + + this.active = approved + .filter((plugin) => !conflicted.has(plugin.pluginKey)) + .flatMap((plugin) => plugin.commands) + } +} + +function effectiveCommandKeybindings( + command: PluginCommandRegistration, + overrides: KeybindingOverrides +): PluginCommandKeybinding[] { + const override = overrides[pluginCommandKeybindingActionId(command.pluginKey, command.id)] + if (!Array.isArray(override)) { + return command.keybindings + } + return override.map((key) => ({ key, when: command.context })) +} + +function registrationsForManifest( + pluginKey: string, + manifest: PluginManifest +): PluginCommandRegistration[] { + return manifest.contributes.commands.map((command) => ({ + pluginKey, + id: command.id, + title: command.title, + context: command.context ?? 'global', + handler: + command.action === undefined + ? { type: 'worker' as const } + : { type: 'built-in' as const, action: command.action as PluginCommandAliasActionId }, + keybindings: keybindingsForCommand(command, manifest.contributes.keybindings) + })) +} + +function keybindingsForCommand( + command: PluginCommandContribution, + keybindings: readonly PluginKeybindingContribution[] +): PluginCommandKeybinding[] { + return keybindings + .filter((keybinding) => keybinding.command === command.id) + .map((keybinding) => ({ + key: keybinding.key, + when: keybinding.when ?? command.context ?? 'global' + })) +} + +function contextsOverlap( + first: PluginCommandKeybinding['when'], + second: PluginCommandKeybinding['when'] +): boolean { + return first === 'global' || second === 'global' || first === second +} diff --git a/src/main/plugins/plugin-content-hash.ts b/src/main/plugins/plugin-content-hash.ts new file mode 100644 index 00000000000..719c23fcb87 --- /dev/null +++ b/src/main/plugins/plugin-content-hash.ts @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { lstat, readdir } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { pluginPathSegmentError } from '../../shared/plugins/plugin-path-safety' + +/** + * Deterministic hash of a plugin's file tree. The hash names the immutable + * install directory (`/plugins/.//`), so two + * installs of identical content share a name and a mutated install is + * detectable. Hashes relative paths + file bytes in sorted order; symlinks + * are refused outright (installed trees must be self-contained). + */ + +const MAX_PLUGIN_FILES = 2_000 +const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 + +type PluginFile = { path: string; size: number } + +export type PluginTreeHashResult = + | { ok: true; hash: string; fileCount: number; totalBytes: number } + | { ok: false; error: string } + +async function collectFiles( + root: string, + dir: string, + files: PluginFile[], + counters: { entries: number; bytes: number } +): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + // Why: localeCompare ordering varies with host locale/ICU data; content + // addresses must sort identically on macOS, Linux, and Windows. + entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) + for (const entry of entries) { + if (dir === root && entry.name === '.git') { + continue + } + const segmentError = pluginPathSegmentError(entry.name) + if (segmentError) { + return `unsafe plugin path segment "${entry.name}": ${segmentError}` + } + const full = join(dir, entry.name) + const stat = await lstat(full) + counters.entries += 1 + if (counters.entries > MAX_PLUGIN_FILES) { + return `plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit` + } + if (stat.isSymbolicLink()) { + return `symlink not allowed in plugin content: ${relative(root, full)}` + } + if (stat.isDirectory()) { + const error = await collectFiles(root, full, files, counters) + if (error) { + return error + } + } else if (stat.isFile()) { + counters.bytes += stat.size + if (counters.bytes > MAX_PLUGIN_TOTAL_BYTES) { + return `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit` + } + files.push({ path: full, size: stat.size }) + } else { + return `unsupported plugin entry type: ${relative(root, full)}` + } + } + return null +} + +async function hashFileBounded( + hash: ReturnType, + file: PluginFile +): Promise { + let bytesRead = 0 + for await (const chunk of createReadStream(file.path)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytesRead += bytes.byteLength + if (bytesRead > file.size || bytesRead > MAX_PLUGIN_TOTAL_BYTES) { + throw new Error(`plugin file changed while hashing: ${file.path}`) + } + hash.update(bytes) + } + if (bytesRead !== file.size) { + throw new Error(`plugin file changed while hashing: ${file.path}`) + } + return bytesRead +} + +export async function hashPluginTree(root: string): Promise { + const files: PluginFile[] = [] + try { + const counters = { entries: 0, bytes: 0 } + const error = await collectFiles(root, root, files, counters) + if (error) { + return { ok: false, error } + } + const hash = createHash('sha256') + // Why: every record is length-framed so path/content delimiters inside a + // plugin file cannot make two different trees share one hash preimage. + hash.update('orca-plugin-tree-v1\0') + let totalBytes = 0 + for (const file of files) { + totalBytes += file.size + if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) { + return { ok: false, error: `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit` } + } + // Normalize separators so the same tree hashes identically on Windows. + const rel = relative(root, file.path).replaceAll('\\', '/') + hashLength(hash, Buffer.byteLength(rel, 'utf8')) + hash.update(rel, 'utf8') + hashLength(hash, file.size) + await hashFileBounded(hash, file) + } + // Hex (not base64) because the hash becomes a directory name. + return { + ok: true, + hash: hash.digest('hex'), + fileCount: files.length, + totalBytes + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} + +function hashLength(hash: ReturnType, length: number): void { + const framedLength = Buffer.allocUnsafe(8) + framedLength.writeBigUInt64BE(BigInt(length)) + hash.update(framedLength) +} diff --git a/src/main/plugins/plugin-content-integrity.ts b/src/main/plugins/plugin-content-integrity.ts new file mode 100644 index 00000000000..19f68cae680 --- /dev/null +++ b/src/main/plugins/plugin-content-integrity.ts @@ -0,0 +1,58 @@ +import { hashPluginTree } from './plugin-content-hash' + +export type HashAddressedPluginContent = { + rootDir: string + contentHash: string | null +} + +export type PluginContentIntegrityResult = { ok: true } | { ok: false; error: string } + +/** Dev trees are intentionally mutable; installed hash-addressed trees are not. */ +export async function verifyHashAddressedPluginContent( + plugin: HashAddressedPluginContent +): Promise { + if (plugin.contentHash === null) { + return { ok: true } + } + const actual = await hashPluginTree(plugin.rootDir) + if (!actual.ok) { + return { ok: false, error: actual.error } + } + const matchesCurrentHash = actual.hash === plugin.contentHash + // Early P0 installs used a 128-bit SHA-256 prefix as the directory name. + // Honor that existing address while all new installs use the full digest. + const matchesLegacyPrefix = + plugin.contentHash.length === 32 && actual.hash.startsWith(plugin.contentHash) + if (!matchesCurrentHash && !matchesLegacyPrefix) { + return { + ok: false, + error: `content hash mismatch (expected ${plugin.contentHash}, got ${actual.hash})` + } + } + return { ok: true } +} + +/** Deduplicates the first lazy verification for each discovered install. */ +export class PluginContentVerifier { + private readonly verifications = new Map>() + + clear(): void { + this.verifications.clear() + } + + async verify(plugin: HashAddressedPluginContent & { pluginKey: string }): Promise { + // Why: a refresh can replace one same-key install while its old hash is + // still being verified. Cache by immutable content identity, never key. + const identity = JSON.stringify([plugin.pluginKey, plugin.rootDir, plugin.contentHash]) + let verification = this.verifications.get(identity) + if (!verification) { + verification = verifyHashAddressedPluginContent(plugin) + this.verifications.set(identity, verification) + } + const result = await verification + if (!result.ok) { + this.verifications.delete(identity) + throw new Error(`plugin ${plugin.pluginKey} failed integrity verification: ${result.error}`) + } + } +} diff --git a/src/main/plugins/plugin-content-pack-registry.test.ts b/src/main/plugins/plugin-content-pack-registry.test.ts new file mode 100644 index 00000000000..fe58cd3cc1d --- /dev/null +++ b/src/main/plugins/plugin-content-pack-registry.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import { hashPluginTree } from './plugin-content-hash' +import { PluginContentPackRegistry } from './plugin-content-pack-registry' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginContentPackRegistry', () => { + it('activates all contributions from a plugin atomically', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-registry-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'locales')) + await Promise.all([ + writeFile( + join(rootDir, 'locales', 'invalid.json'), + JSON.stringify({ settings: { title: 42 } }) + ), + writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' })) + ]) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'mixed-content', + publisher: 'orca-samples', + name: 'Mixed Content', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + languagePacks: [ + { locale: 'es', path: 'locales/valid.json' }, + { locale: 'pt-BR', path: 'locales/invalid.json' } + ] + }, + capabilities: [] + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.mixed-content', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } + const registry = new PluginContentPackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + + expect(registry.error(plugin.pluginKey)).toContain('string or object') + expect(registry.languagePacks.list()).toEqual([]) + }) + + it('rolls back valid packs when a VM recipe from the same plugin is invalid', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-vm-')) + roots.push(rootDir) + await Promise.all([mkdir(join(rootDir, 'locales')), mkdir(join(rootDir, 'recipes'))]) + await Promise.all([ + writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' })), + writeFile( + join(rootDir, 'recipes', 'invalid.json'), + JSON.stringify({ schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', resume: 'up' }) + ) + ]) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'mixed-recipes', + publisher: 'orca-samples', + name: 'Mixed Recipes', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + languagePacks: [{ locale: 'es', path: 'locales/valid.json' }], + vmRecipes: [{ path: 'recipes/invalid.json' }] + }, + capabilities: [] + }) + const content = await hashPluginTree(rootDir) + if (!content.ok) { + throw new Error(content.error) + } + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.mixed-recipes', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, content.hash), + consentContentHash: content.hash, + contentHash: null, + isDev: true + } + const registry = new PluginContentPackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + + expect(registry.error(plugin.pluginKey)).toContain('suspend and resume') + expect(registry.languagePacks.list()).toEqual([]) + expect(registry.vmRecipes.list()).toEqual([]) + }) +}) diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts new file mode 100644 index 00000000000..c3901090c33 --- /dev/null +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -0,0 +1,95 @@ +import type { PluginContentVerifier } from './plugin-content-integrity' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' +import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' +import { PluginCommandRegistry } from './plugin-command-registry' +import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' +import type { KeybindingOverrides } from '../../shared/keybindings' + +export class PluginContentPackRegistry { + readonly languagePacks: PluginLanguagePackRegistry + readonly vmRecipes: PluginVmRecipeRegistry + readonly commands: PluginCommandRegistry + private readonly activationErrors = new Map() + + constructor(contentVerifier: PluginContentVerifier) { + this.languagePacks = new PluginLanguagePackRegistry(contentVerifier) + this.vmRecipes = new PluginVmRecipeRegistry() + this.commands = new PluginCommandRegistry() + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean, + keybindings: KeybindingOverrides = {} + ): Promise { + const approvedKeys = new Set( + discovered + .filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin)) + .filter(isApproved) + .map((plugin) => plugin.pluginKey) + ) + const excluded = new Set() + this.activationErrors.clear() + + await Promise.all( + discovered.map(async (plugin) => { + if ( + isInvalidDiscoveredPlugin(plugin) || + !approvedKeys.has(plugin.pluginKey) || + plugin.manifest.contributes.vmRecipes.length > 0 + ) { + return + } + try { + await verifyInstructionalPluginContent(plugin) + } catch (error) { + excluded.add(plugin.pluginKey) + this.activationErrors.set( + plugin.pluginKey, + error instanceof Error ? error.message : String(error) + ) + } + }) + ) + + while (true) { + const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean => + approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey) + await Promise.all([ + this.languagePacks.reconcile(discovered, approveAtomically), + this.vmRecipes.reconcile(discovered, approveAtomically), + this.commands.reconcile(discovered, approveAtomically, keybindings) + ]) + + let foundNewError = false + for (const pluginKey of approvedKeys) { + const error = this.registryError(pluginKey) + if (error && !excluded.has(pluginKey)) { + excluded.add(pluginKey) + this.activationErrors.set(pluginKey, error) + foundNewError = true + } + } + if (!foundNewError) { + break + } + } + } + + error(pluginKey: string): string | null { + return this.activationErrors.get(pluginKey) ?? this.registryError(pluginKey) + } + + private registryError(pluginKey: string): string | null { + return ( + this.languagePacks.error(pluginKey) ?? + this.vmRecipes.error(pluginKey) ?? + this.commands.error(pluginKey) + ) + } +} diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts new file mode 100644 index 00000000000..eedeb42ef9f --- /dev/null +++ b/src/main/plugins/plugin-content-safety.test.ts @@ -0,0 +1,305 @@ +import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import { + PLUGIN_PANEL_ENTRY_MAX_BYTES, + validateDeclaredPluginArtifacts, + validatePluginInstallContent +} from './plugin-artifact-validation' +import { hashPluginTree } from './plugin-content-hash' +import { verifyHashAddressedPluginContent } from './plugin-content-integrity' +import { PluginService } from './plugin-service' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-content-test-')) + roots.push(root) + return root +} + +type ManifestOverrides = Omit, 'contributes'> & { + contributes?: Partial +} + +function manifest(overrides: ManifestOverrides = {}): PluginManifest { + const { contributes, ...manifestOverrides } = overrides + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [], + ...manifestOverrides, + contributes + }) +} + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('declared plugin artifacts', () => { + it('validates every content-pack file and directory before enablement', async () => { + const root = await tempRoot() + await Promise.all([ + mkdir(join(root, 'locales')), + mkdir(join(root, 'recipes')), + writeFile(join(root, 'agent.json'), '{}') + ]) + await Promise.all([ + writeFile(join(root, 'locales', 'pt-BR.json'), '{}'), + writeFile(join(root, 'recipes', 'vm.json'), '{}') + ]) + const pluginManifest = manifest({ + contributes: { + languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }], + vmRecipes: [{ path: 'recipes/vm.json' }], + agents: [{ path: 'agent.json' }] + } + }) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({ + ok: true + }) + }) + + it('requires declared files to exist and be regular files', async () => { + const root = await tempRoot() + await mkdir(join(root, 'panel.html')) + + const result = await validateDeclaredPluginArtifacts( + root, + manifest({ + main: 'missing-worker.js', + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [], + events: [] + } + }) + ) + + expect(result).toMatchObject({ ok: false }) + }) + + it('refuses a panel reached through an escaping directory link or junction', async () => { + const root = await tempRoot() + const outsideDir = await tempRoot() + const userDataPath = await tempRoot() + await writeFile(join(outsideDir, 'panel.html'), '

    outside

    ') + await symlink( + outsideDir, + join(root, 'escape'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + const pluginManifest = manifest({ + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'escape/panel.html' }], + commands: [], + events: [] + } + }) + await writeFile(join(root, 'orca-plugin.json'), JSON.stringify(pluginManifest)) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({ + ok: false + }) + + const pluginKey = `${pluginManifest.publisher}.${pluginManifest.id}` + const service = new PluginService({ + userDataPath, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ + [pluginKey]: fingerprintPluginConsent(pluginManifest) + }), + getDevPluginPaths: () => [root] + }) + try { + await service.initialize() + await expect(service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull() + } finally { + await service.dispose() + } + }) + + it('rejects a panel artifact too large to mount safely in a renderer', async () => { + const root = await tempRoot() + const panelPath = join(root, 'panel.html') + await writeFile(panelPath, '') + await truncate(panelPath, PLUGIN_PANEL_ENTRY_MAX_BYTES + 1) + const pluginManifest = manifest({ + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [], + events: [] + } + }) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('artifact limit') + }) + }) + + it('parses VM recipes at the immutable install boundary', async () => { + const root = await tempRoot() + await mkdir(join(root, 'recipes')) + await writeFile( + join(root, 'recipes', 'invalid.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'cloud', + name: 'Cloud', + create: 'create', + suspend: 'suspend' + }) + ) + const pluginManifest = manifest({ + contributes: { vmRecipes: [{ path: 'recipes/invalid.json' }] } + }) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({ + ok: true + }) + await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('suspend and resume') + }) + }) + + it('rejects duplicate VM recipe ids at the immutable install boundary', async () => { + const root = await tempRoot() + await mkdir(join(root, 'recipes')) + const recipe = JSON.stringify({ + schemaVersion: 1, + id: 'cloud', + name: 'Cloud', + create: 'create' + }) + await Promise.all([ + writeFile(join(root, 'recipes', 'one.json'), recipe), + writeFile(join(root, 'recipes', 'two.json'), recipe) + ]) + const pluginManifest = manifest({ + contributes: { + vmRecipes: [{ path: 'recipes/one.json' }, { path: 'recipes/two.json' }] + } + }) + + await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('duplicate VM recipe id "cloud"') + }) + }) +}) + +describe('hash-addressed plugin content', () => { + it('uses unambiguous framing for paths and file contents', async () => { + const first = await tempRoot() + const second = await tempRoot() + await writeFile(join(first, 'a'), Buffer.from('x\0b\0y')) + await Promise.all([writeFile(join(second, 'a'), 'x'), writeFile(join(second, 'b'), 'y')]) + + const [firstHash, secondHash] = await Promise.all([ + hashPluginTree(first), + hashPluginTree(second) + ]) + + expect(firstHash).toMatchObject({ ok: true }) + expect(secondHash).toMatchObject({ ok: true }) + if (firstHash.ok && secondHash.ok) { + expect(firstHash.hash).not.toBe(secondHash.hash) + } + }) + + it('hashes and bounds nested .git directories as plugin content', async () => { + const root = await tempRoot() + const nestedGit = join(root, 'vendor', '.git') + await mkdir(nestedGit, { recursive: true }) + const entry = join(nestedGit, 'main.mjs') + await writeFile(entry, 'export default function activate() {}') + const initial = await hashPluginTree(root) + await writeFile(entry, 'export default function activate() { throw new Error("changed") }') + const changed = await hashPluginTree(root) + + expect(initial).toMatchObject({ ok: true }) + expect(changed).toMatchObject({ ok: true }) + if (initial.ok && changed.ok) { + expect(changed.hash).not.toBe(initial.hash) + } + + await truncate(entry, 50 * 1024 * 1024 + 1) + await expect(hashPluginTree(root)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('byte limit') + }) + }) + + it('does not let host locale collation change a content address', async () => { + const root = await tempRoot() + await writeFile(join(root, 'alpha.txt'), 'a') + await writeFile(join(root, 'zulu.txt'), 'z') + const expected = await hashPluginTree(root) + vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => -1) + + const withDifferentCollation = await hashPluginTree(root) + + expect(withDifferentCollation).toEqual(expected) + }) + + it.skipIf(process.platform === 'win32')( + 'rejects Windows-reserved tree entries cross-platform', + async () => { + const root = await tempRoot() + await writeFile(join(root, 'CON.txt'), 'reserved') + + await expect(hashPluginTree(root)).resolves.toMatchObject({ ok: false }) + } + ) + + it('detects content changed after its address was computed', async () => { + const root = await tempRoot() + const entry = join(root, 'panel.html') + await writeFile(entry, '

    original

    ') + const initial = await hashPluginTree(root) + expect(initial.ok).toBe(true) + if (!initial.ok) { + return + } + expect(initial.hash).toMatch(/^[0-9a-f]{64}$/) + await expect( + verifyHashAddressedPluginContent({ + rootDir: root, + contentHash: initial.hash.slice(0, 32) + }) + ).resolves.toEqual({ ok: true }) + + await writeFile(entry, '

    tampered

    ') + + await expect( + verifyHashAddressedPluginContent({ rootDir: root, contentHash: initial.hash }) + ).resolves.toMatchObject({ ok: false }) + }) + + it('rejects an oversized sparse file before reading it into memory', async () => { + const root = await tempRoot() + const oversized = join(root, 'oversized.bin') + await writeFile(oversized, '') + await truncate(oversized, 50 * 1024 * 1024 + 1) + + await expect(hashPluginTree(root)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('byte limit') + }) + }) +}) diff --git a/src/main/plugins/plugin-current-pointer.ts b/src/main/plugins/plugin-current-pointer.ts new file mode 100644 index 00000000000..c299bed982d --- /dev/null +++ b/src/main/plugins/plugin-current-pointer.ts @@ -0,0 +1,49 @@ +import { createReadStream } from 'node:fs' +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { writePluginFileAtomically } from './plugin-atomic-file-write' + +export const PLUGIN_CURRENT_POINTER_FILENAME = 'current' +export const PLUGIN_CURRENT_POINTER_MAX_BYTES = 128 + +/** Reads the tiny hash pointer through a cap so discovery cannot allocate a + * corrupt sparse file during startup. Missing pointers resolve to null. */ +export async function readPluginCurrentPointer(pluginDir: string): Promise { + const target = join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME) + const chunks: Buffer[] = [] + let totalBytes = 0 + try { + for await (const chunk of createReadStream(target)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_CURRENT_POINTER_MAX_BYTES) { + throw new Error('current-version pointer exceeds its size limit') + } + chunks.push(bytes) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + return Buffer.concat(chunks, totalBytes).toString('utf8').trim() +} + +export async function writePluginCurrentPointer( + pluginDir: string, + contentHash: string +): Promise { + await writePluginFileAtomically(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), contentHash) +} + +export async function restorePluginCurrentPointer( + pluginDir: string, + previousContentHash: string | null +): Promise { + if (previousContentHash === null) { + await rm(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), { force: true }) + return + } + await writePluginCurrentPointer(pluginDir, previousContentHash) +} diff --git a/src/main/plugins/plugin-dev-watcher.test.ts b/src/main/plugins/plugin-dev-watcher.test.ts new file mode 100644 index 00000000000..77996fe3bd8 --- /dev/null +++ b/src/main/plugins/plugin-dev-watcher.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginDevWatcher } from './plugin-dev-watcher' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('PluginDevWatcher', () => { + it('contains asynchronous watcher errors and requests a retrying refresh', async () => { + vi.useFakeTimers() + let onEvent!: (error: Error | null) => void + const unsubscribe = vi.fn().mockResolvedValue(undefined) + const subscribePath = vi.fn(async (_path, callback: typeof onEvent) => { + onEvent = callback + return { unsubscribe } + }) + const devWatcher = new PluginDevWatcher(subscribePath) + const refresh = vi.fn() + const onWatcherError = vi.fn() + devWatcher.start(['/plugins/demo'], refresh, onWatcherError) + await vi.waitFor(() => expect(subscribePath).toHaveBeenCalledOnce()) + + expect(() => onEvent(new Error('watch failed'))).not.toThrow() + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + expect(onWatcherError).toHaveBeenCalledOnce() + vi.advanceTimersByTime(300) + expect(refresh).toHaveBeenCalledOnce() + + devWatcher.dispose() + }) + + it('unsubscribes a subscription that resolves after disposal', async () => { + let resolveSubscription!: (value: { unsubscribe: () => Promise }) => void + const unsubscribe = vi.fn().mockResolvedValue(undefined) + const subscribePath = vi.fn( + () => + new Promise<{ unsubscribe: () => Promise }>((resolve) => { + resolveSubscription = resolve + }) + ) + const devWatcher = new PluginDevWatcher(subscribePath) + + devWatcher.start(['/plugins/demo'], vi.fn()) + devWatcher.dispose() + resolveSubscription({ unsubscribe }) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + }) + + it('does not spin refreshes when a missing path cannot be subscribed', async () => { + vi.useFakeTimers() + const subscribePath = vi.fn().mockRejectedValue(new Error('missing path')) + const refresh = vi.fn() + const onWatcherError = vi.fn() + const devWatcher = new PluginDevWatcher(subscribePath) + + devWatcher.start(['/plugins/missing'], refresh, onWatcherError) + await vi.waitFor(() => expect(onWatcherError).toHaveBeenCalledOnce()) + vi.advanceTimersByTime(10_000) + + expect(refresh).not.toHaveBeenCalled() + devWatcher.dispose() + }) +}) diff --git a/src/main/plugins/plugin-dev-watcher.ts b/src/main/plugins/plugin-dev-watcher.ts new file mode 100644 index 00000000000..ea77def417f --- /dev/null +++ b/src/main/plugins/plugin-dev-watcher.ts @@ -0,0 +1,108 @@ +import { + subscribeViaWatcherProcess, + type WatcherProcessSubscription +} from '../ipc/parcel-watcher-process' + +type SubscribePluginPath = ( + path: string, + onEvent: (error: Error | null) => void, + onInterruption: () => void +) => Promise + +const subscribePluginPath: SubscribePluginPath = (path, onEvent, onInterruption) => + subscribeViaWatcherProcess( + path, + (error) => onEvent(error), + {}, + { + onInterruption, + onTerminalError: onEvent + } + ) + +/** Owns debounced manifest/panel refresh watchers for mutable dev plugins. */ +export class PluginDevWatcher { + private readonly subscriptions: WatcherProcessSubscription[] = [] + private refreshTimer: ReturnType | null = null + private generation = 0 + + constructor(private readonly subscribePath: SubscribePluginPath = subscribePluginPath) {} + + start(devPaths: readonly string[], refresh: () => void, onWatcherError?: () => void): void { + const generation = ++this.generation + for (const devPath of devPaths) { + let subscription: WatcherProcessSubscription | null = null + let failedBeforeReady = false + const fail = (): void => { + if (generation !== this.generation) { + return + } + failedBeforeReady = true + if (subscription) { + this.removeSubscription(subscription) + void subscription.unsubscribe() + } + onWatcherError?.() + this.scheduleRefresh(refresh) + } + void this.subscribePath( + devPath, + (error) => { + if (error) { + fail() + } else if (generation === this.generation) { + this.scheduleRefresh(refresh) + } + }, + () => { + if (generation === this.generation) { + // The watcher process recovered, but changes during the gap were + // lost, so refresh the complete plugin projection once. + this.scheduleRefresh(refresh) + } + } + ) + .then((created) => { + subscription = created + if (generation !== this.generation || failedBeforeReady) { + void created.unsubscribe() + return + } + this.subscriptions.push(created) + }) + .catch(() => { + if (generation === this.generation) { + onWatcherError?.() + } + }) + } + } + + dispose(): void { + this.generation += 1 + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + this.refreshTimer = null + } + for (const subscription of this.subscriptions.splice(0)) { + void subscription.unsubscribe() + } + } + + private removeSubscription(subscription: WatcherProcessSubscription): void { + const index = this.subscriptions.indexOf(subscription) + if (index >= 0) { + this.subscriptions.splice(index, 1) + } + } + + private scheduleRefresh(refresh: () => void): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null + refresh() + }, 300) + } +} diff --git a/src/main/plugins/plugin-discovery.test.ts b/src/main/plugins/plugin-discovery.test.ts new file mode 100644 index 00000000000..685a622e90b --- /dev/null +++ b/src/main/plugins/plugin-discovery.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { discoverPlugins, isInvalidDiscoveredPlugin } from './plugin-discovery' +import { PLUGIN_CURRENT_POINTER_MAX_BYTES } from './plugin-current-pointer' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function tempPluginsDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-discovery-')) + roots.push(root) + return root +} + +describe('installed plugin discovery identity', () => { + it('keeps the install directory identity when a manifest is invalid or mismatched', async () => { + const pluginsDir = await tempPluginsDir() + const installedKey = 'orca-samples.expected' + const hash = 'a'.repeat(64) + const versionDir = join(pluginsDir, installedKey, hash) + await mkdir(versionDir, { recursive: true }) + await writeFile(join(pluginsDir, installedKey, 'current'), hash) + await writeFile( + join(versionDir, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'different', + publisher: 'orca-samples', + name: 'Different', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { panels: [], commands: [], events: [] }, + capabilities: [] + }) + ) + + const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) + + expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true) + expect(plugin?.pluginKey).toBe(installedKey) + expect(plugin && 'error' in plugin ? plugin.error : '').toContain('does not match') + }) + + it('keeps a removable qualified identity when the current pointer is missing', async () => { + const pluginsDir = await tempPluginsDir() + const installedKey = 'orca-samples.broken' + await mkdir(join(pluginsDir, installedKey), { recursive: true }) + + const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) + + expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true) + expect(plugin?.pluginKey).toBe(installedKey) + }) + + it('rejects an oversized current pointer without an unbounded startup read', async () => { + const pluginsDir = await tempPluginsDir() + const installedKey = 'orca-samples.broken' + const pluginDir = join(pluginsDir, installedKey) + await mkdir(pluginDir, { recursive: true }) + const pointer = join(pluginDir, 'current') + await writeFile(pointer, '') + await truncate(pointer, PLUGIN_CURRENT_POINTER_MAX_BYTES + 1) + + const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) + + expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true) + expect(plugin?.pluginKey).toBe(installedKey) + }) +}) + +describe('instructional plugin discovery identity', () => { + it('changes dev consent when a VM recipe command changes', async () => { + const pluginsDir = await tempPluginsDir() + const devRoot = await tempPluginsDir() + await mkdir(join(devRoot, 'recipes')) + await writeFile( + join(devRoot, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'recipes', + publisher: 'orca-samples', + name: 'Recipes', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] }, + capabilities: [] + }) + ) + const recipePath = join(devRoot, 'recipes', 'cloud.json') + await writeFile( + recipePath, + JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v1' }) + ) + const [first] = await discoverPlugins({ + pluginsDir, + devPluginPaths: [devRoot], + hostVersion: '1.4.0' + }) + await writeFile( + recipePath, + JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v2' }) + ) + const [second] = await discoverPlugins({ + pluginsDir, + devPluginPaths: [devRoot], + hostVersion: '1.4.0' + }) + + expect(first && !isInvalidDiscoveredPlugin(first)).toBe(true) + expect(second && !isInvalidDiscoveredPlugin(second)).toBe(true) + if ( + !first || + !second || + isInvalidDiscoveredPlugin(first) || + isInvalidDiscoveredPlugin(second) + ) { + return + } + expect(second.consentContentHash).not.toBe(first.consentContentHash) + expect(second.consentFingerprint).not.toBe(first.consentFingerprint) + }) +}) diff --git a/src/main/plugins/plugin-discovery.ts b/src/main/plugins/plugin-discovery.ts new file mode 100644 index 00000000000..bffd31ef553 --- /dev/null +++ b/src/main/plugins/plugin-discovery.ts @@ -0,0 +1,254 @@ +import { readdir } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { join } from 'node:path' +import { PLUGIN_CONTENT_HASH_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { + PLUGIN_MANIFEST_FILENAME, + isQualifiedPluginKey, + parsePluginManifest, + qualifiedPluginKey, + satisfiesOrcaEngineRange, + type PluginManifest +} from '../../shared/plugins/plugin-manifest' +import { + fingerprintPluginConsent, + hasInstructionalPluginContributions +} from '../../shared/plugins/plugin-consent-fingerprint' +import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation' +import { readPluginManifestText } from './plugin-manifest-file' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { hashPluginTree } from './plugin-content-hash' + +export { PLUGIN_CURRENT_POINTER_FILENAME } from './plugin-current-pointer' + +const INSTALLED_PLUGIN_DISCOVERY_CONCURRENCY = 8 + +/** + * Discovery over the hash-addressed install layout: + * + * /plugins/./current ← text file naming the hash + * /plugins/.// ← immutable install tree + * + * plus dev-mode plugins loaded straight from arbitrary local directories. + * Discovery reads manifests and checks only their declared artifact paths — + * never plugin bytes or whole trees. Full content hashing stays lazy so + * startup cost is bounded by installed plugins plus declared entries. + */ + +export type ValidDiscoveredPlugin = { + /** Qualified `.` key. */ + pluginKey: string + rootDir: string + manifest: PluginManifest + /** Fingerprint of the capabilities and trusted-worker execution tier. */ + consentFingerprint: string + /** Immutable tree identity included in consent for instructional packs. */ + consentContentHash?: string | null + /** Content hash the install dir is named by; null for dev plugins. */ + contentHash: string | null + isDev: boolean +} + +export type InvalidDiscoveredPlugin = { + pluginKey?: string + rootDir: string + error: string + isDev: boolean +} + +export type DiscoveredPlugin = ValidDiscoveredPlugin | InvalidDiscoveredPlugin + +export function isInvalidDiscoveredPlugin( + plugin: DiscoveredPlugin +): plugin is InvalidDiscoveredPlugin { + return 'error' in plugin +} + +export function getUserPluginsDir(userDataPath: string): string { + return join(userDataPath, 'plugins') +} + +export function getPluginsDataDir(userDataPath: string): string { + return join(userDataPath, 'plugins-data') +} + +async function readManifestDir( + rootDir: string, + hostVersion: string, + isDev: boolean, + installedContentHash?: string +): Promise { + let rawText: string + try { + rawText = await readPluginManifestText(rootDir) + } catch (error) { + return { + rootDir, + error: + error instanceof Error && error.message.includes('exceeds') + ? error.message + : `missing ${PLUGIN_MANIFEST_FILENAME}`, + isDev + } + } + let raw: unknown + try { + raw = JSON.parse(rawText) + } catch (error) { + return { + rootDir, + error: `invalid JSON in ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`, + isDev + } + } + const parsed = parsePluginManifest(raw) + if (!parsed.ok) { + return { rootDir, error: `invalid manifest: ${parsed.error}`, isDev } + } + const manifest = parsed.manifest + const pluginKey = qualifiedPluginKey(manifest) + if (!satisfiesOrcaEngineRange(hostVersion, manifest.engines.orca)) { + return { + pluginKey, + rootDir, + error: `requires Orca ${manifest.engines.orca} (this is ${hostVersion})`, + isDev + } + } + const artifacts = await validateDeclaredPluginArtifacts(rootDir, manifest) + if (!artifacts.ok) { + return { + pluginKey, + rootDir, + error: `invalid declared artifact: ${artifacts.error}`, + isDev + } + } + let consentContentIdentity: string | undefined + if (installedContentHash && hasInstructionalPluginContributions(manifest)) { + consentContentIdentity = installedContentHash + } + if (isDev && hasInstructionalPluginContributions(manifest)) { + const treeHash = await hashPluginTree(rootDir) + if (!treeHash.ok) { + return { pluginKey, rootDir, error: treeHash.error, isDev } + } + consentContentIdentity = treeHash.hash + } + return { + pluginKey, + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, consentContentIdentity), + consentContentHash: consentContentIdentity ?? null, + contentHash: null, + isDev + } +} + +async function readInstalledPlugin( + pluginDir: string, + dirName: string, + hostVersion: string +): Promise { + let contentHash: string + try { + contentHash = (await readPluginCurrentPointer(pluginDir)) ?? '' + } catch { + return { + pluginKey: dirName, + rootDir: pluginDir, + error: 'missing current-version pointer', + isDev: false + } + } + // The pointer names a sibling directory; refuse anything path-like so a + // corrupted pointer cannot address content outside the plugin dir. + if (!PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) { + return { + pluginKey: dirName, + rootDir: pluginDir, + error: 'corrupt current-version pointer', + isDev: false + } + } + const versionDir = join(pluginDir, contentHash) + const discovered = await readManifestDir(versionDir, hostVersion, false, contentHash) + if (isInvalidDiscoveredPlugin(discovered)) { + return { ...discovered, pluginKey: dirName } + } + // Why: the directory name is the install key (and the uninstall target); a + // mismatched manifest identity would let two dirs claim the same plugin. + if (discovered.pluginKey !== dirName) { + return { + pluginKey: dirName, + rootDir: versionDir, + error: `manifest identity "${discovered.pluginKey}" does not match install directory "${dirName}"`, + isDev: false + } + } + return { ...discovered, contentHash } +} + +async function readInstalledPlugins( + pluginsDir: string, + entries: readonly Dirent[], + hostVersion: string +): Promise { + const results = Array.from({ length: entries.length }) as DiscoveredPlugin[] + let nextIndex = 0 + const readers = Array.from( + { length: Math.min(INSTALLED_PLUGIN_DISCOVERY_CONCURRENCY, entries.length) }, + async () => { + while (nextIndex < entries.length) { + const index = nextIndex++ + const entry = entries[index]! + results[index] = await readInstalledPlugin( + join(pluginsDir, entry.name), + entry.name, + hostVersion + ) + } + } + ) + await Promise.all(readers) + return results +} + +export async function discoverPlugins(options: { + pluginsDir: string + devPluginPaths: readonly string[] + hostVersion: string +}): Promise { + const discovered: DiscoveredPlugin[] = [] + let entries: Dirent[] = [] + try { + entries = await readdir(options.pluginsDir, { withFileTypes: true }) + } catch { + // A missing plugins dir just means no plugins are installed yet. + } + const installedEntries = entries.filter( + (entry) => entry.isDirectory() && isQualifiedPluginKey(entry.name) + ) + // Installed manifests are independent immutable trees. Read them in + // a bounded pool so startup latency stays low without exhausting handles. + discovered.push( + ...(await readInstalledPlugins(options.pluginsDir, installedEntries, options.hostVersion)) + ) + for (const devPath of options.devPluginPaths) { + const plugin = await readManifestDir(devPath, options.hostVersion, true) + // A dev path that duplicates an installed plugin's identity wins — that + // is the point of dev mode — but two dev paths must not collide. + if (!isInvalidDiscoveredPlugin(plugin)) { + const collision = discovered.find( + (existing) => + !isInvalidDiscoveredPlugin(existing) && existing.pluginKey === plugin.pluginKey + ) + if (collision) { + discovered.splice(discovered.indexOf(collision), 1) + } + } + discovered.push(plugin) + } + return discovered +} diff --git a/src/main/plugins/plugin-enablement.test.ts b/src/main/plugins/plugin-enablement.test.ts new file mode 100644 index 00000000000..0ce3013d8a9 --- /dev/null +++ b/src/main/plugins/plugin-enablement.test.ts @@ -0,0 +1,132 @@ +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { Store } from '../persistence' +import { applyPluginConsent, applyPluginEnablement } from './plugin-enablement' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginService } from './plugin-service' + +const pluginKey = 'orca-samples.demo' +const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: {}, + capabilities: [] +}) + +function createStore(): { + store: Store + getSettings: () => GlobalSettings + updateSettings: ReturnType +} { + let settings = getDefaultSettings(tmpdir()) + const updateSettings = vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + }) + return { + store: { getSettings: () => settings, updateSettings } as unknown as Store, + getSettings: () => settings, + updateSettings + } +} + +function createPluginService( + getFingerprint: () => string, + overrides: Partial = {} +): PluginService { + return { + findValidPlugin: (requestedKey: string) => + requestedKey === pluginKey + ? { + pluginKey, + rootDir: tmpdir(), + manifest, + consentFingerprint: getFingerprint(), + consentContentHash: null, + contentHash: null, + isDev: true, + ...overrides + } + : null, + reconcileActivationState: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginService +} + +describe('applyPluginConsent', () => { + it('stores approval only for the fingerprint the user reviewed', async () => { + const harness = createStore() + const pluginService = createPluginService(() => 'sha256-reviewed') + + await applyPluginConsent({ + store: harness.store, + pluginService, + pluginKey, + reviewedFingerprint: 'sha256-reviewed', + decision: 'approve' + }) + + expect(harness.getSettings().pluginConsents[pluginKey]).toBe('sha256-reviewed') + expect(harness.getSettings().disabledPlugins).not.toContain(pluginKey) + }) + + it('rejects a stale review after a same-key plugin update without writing settings', async () => { + const harness = createStore() + let currentFingerprint = 'sha256-reviewed-v1' + const pluginService = createPluginService(() => currentFingerprint) + currentFingerprint = 'sha256-current-v2' + + await expect( + applyPluginConsent({ + store: harness.store, + pluginService, + pluginKey, + reviewedFingerprint: 'sha256-reviewed-v1', + decision: 'approve' + }) + ).rejects.toThrow('changed since its permissions were reviewed') + + expect(harness.updateSettings).not.toHaveBeenCalled() + expect(harness.getSettings().pluginConsents[pluginKey]).toBeUndefined() + }) + + it('allows a stale dialog to keep the newer plugin disabled', async () => { + const harness = createStore() + const pluginService = createPluginService(() => 'sha256-current-v2') + + await applyPluginConsent({ + store: harness.store, + pluginService, + pluginKey, + reviewedFingerprint: 'sha256-reviewed-v1', + decision: 'keep-disabled' + }) + + expect(harness.getSettings().disabledPlugins).toContain(pluginKey) + expect(pluginService.reconcileActivationState).toHaveBeenCalledOnce() + }) +}) + +describe('applyPluginEnablement', () => { + it('does not persist unknown plugin identities', async () => { + const harness = createStore() + const pluginService = createPluginService(() => 'sha256-current') + + await expect( + applyPluginEnablement({ + store: harness.store, + pluginService, + pluginKey: 'orca-samples.unknown', + enabled: false + }) + ).rejects.toThrow('unknown plugin') + + expect(harness.updateSettings).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/plugins/plugin-enablement.ts b/src/main/plugins/plugin-enablement.ts new file mode 100644 index 00000000000..44349ef087b --- /dev/null +++ b/src/main/plugins/plugin-enablement.ts @@ -0,0 +1,87 @@ +import { + normalizePluginConsents, + normalizePluginIdList +} from '../../shared/plugins/plugin-consent-state' +import type { Store } from '../persistence' +import type { PluginService } from './plugin-service' +import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' +import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' + +/** + * Single write path for consent + enablement. Consent is recorded as + * (qualified key → consent fingerprint) — never a bare id — so a capability + * expansion or addition of trusted Node code requires re-consent. The desktop + * IPC handlers and the headless RPC methods both route through here. + */ + +export type PluginConsentDecision = PluginConsentRequest['decision'] + +/** Records the user's consent-dialog answer. Approving stores the CURRENT + * consent fingerprint and clears any disable; declining disables so the plugin + * never re-prompts on later launches. */ +export async function applyPluginConsent(input: { + store: Store + pluginService: PluginService + pluginKey: PluginConsentRequest['pluginKey'] + reviewedFingerprint: PluginConsentRequest['reviewedFingerprint'] + decision: PluginConsentRequest['decision'] + originWebContentsId?: number +}): Promise { + const { store, pluginService, pluginKey } = input + const plugin = pluginService.findValidPlugin(pluginKey) + if (!plugin) { + throw new Error(`cannot record consent for unknown plugin ${pluginKey}`) + } + // Why: a same-key install can change while the dialog is open; never apply a + // decision to capabilities or a worker trust tier the user did not review. + if (input.decision === 'approve' && plugin.consentFingerprint !== input.reviewedFingerprint) { + throw new Error(`plugin ${pluginKey} changed since its permissions were reviewed`) + } + if (input.decision === 'approve') { + // Why: IPC and serve callers can bypass the renderer dialog, so main must + // prove every instructional byte is still reviewable before enabling it. + await verifyInstructionalPluginContent(plugin) + } + const settings = store.getSettings() + const disabled = new Set(normalizePluginIdList(settings.disabledPlugins)) + const consents = normalizePluginConsents(settings.pluginConsents) + if (input.decision === 'approve') { + consents[pluginKey] = plugin.consentFingerprint + disabled.delete(pluginKey) + } else { + disabled.add(pluginKey) + } + store.updateSettings( + { disabledPlugins: [...disabled], pluginConsents: consents }, + { notifyListeners: true, originWebContentsId: input.originWebContentsId } + ) + await pluginService.reconcileActivationState() +} + +/** Enables/disables an already-consented plugin. Enabling never bypasses + * consent: with missing or stale consent the plugin stays pending and the + * caller must run the consent flow instead. */ +export async function applyPluginEnablement(input: { + store: Store + pluginService: PluginService + pluginKey: string + enabled: boolean + originWebContentsId?: number +}): Promise { + const { store, pluginService, pluginKey, enabled } = input + if (!pluginService.findValidPlugin(pluginKey)) { + throw new Error(`cannot change enablement for unknown plugin ${pluginKey}`) + } + const settings = store.getSettings() + const disabled = new Set(normalizePluginIdList(settings.disabledPlugins)) + if (enabled) { + disabled.delete(pluginKey) + } else { + disabled.add(pluginKey) + } + store.updateSettings( + { disabledPlugins: [...disabled] }, + { notifyListeners: true, originWebContentsId: input.originWebContentsId } + ) + await pluginService.reconcileActivationState() +} diff --git a/src/main/plugins/plugin-event-bus.ts b/src/main/plugins/plugin-event-bus.ts new file mode 100644 index 00000000000..cee9e7825a5 --- /dev/null +++ b/src/main/plugins/plugin-event-bus.ts @@ -0,0 +1,42 @@ +import { PLUGIN_EVENT_PAYLOAD_SCHEMAS } from '../../shared/plugins/plugin-events' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' + +/** + * Server-side event filtering: plugins receive only events they subscribed + * to (manifest `contributes.events` or a runtime `events.subscribe` call) — + * never a firehose. Manifest subscriptions are durable activation triggers; + * dynamic subscriptions live only as long as the worker that made them. + */ + +export class PluginEventBus { + private readonly dynamicSubscriptions = new Map>() + + subscribe(pluginKey: string, events: PluginEventName[]): PluginEventName[] { + const existing = this.dynamicSubscriptions.get(pluginKey) ?? new Set() + for (const event of events) { + existing.add(event) + } + this.dynamicSubscriptions.set(pluginKey, existing) + return [...existing] + } + + isDynamicallySubscribed(pluginKey: string, event: PluginEventName): boolean { + return this.dynamicSubscriptions.get(pluginKey)?.has(event) ?? false + } + + /** Dynamic subscriptions die with the worker that registered them. */ + clear(pluginKey: string): void { + this.dynamicSubscriptions.delete(pluginKey) + } + + /** Validates and bounds an event payload before it reaches any plugin. */ + projectPayload( + event: PluginEventName, + payload: unknown + ): { ok: true; payload: unknown } | { ok: false; error: string } { + const parsed = PLUGIN_EVENT_PAYLOAD_SCHEMAS[event].safeParse(payload) + return parsed.success + ? { ok: true, payload: parsed.data } + : { ok: false, error: `malformed ${event} payload` } + } +} diff --git a/src/main/plugins/plugin-event-delivery.ts b/src/main/plugins/plugin-event-delivery.ts new file mode 100644 index 00000000000..15aac71b316 --- /dev/null +++ b/src/main/plugins/plugin-event-delivery.ts @@ -0,0 +1,48 @@ +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import type { PluginEventBus } from './plugin-event-bus' +import type { PluginWorkerController } from './plugin-worker-controller' + +export function deliverPluginEvent(options: { + event: PluginEventName + payload: unknown + plugins: readonly DiscoveredPlugin[] + eventBus: PluginEventBus + workerController: PluginWorkerController + isRuntimeApproved: (plugin: ValidDiscoveredPlugin) => boolean + logWarning: (pluginKey: string, line: string) => void +}): void { + const projected = options.eventBus.projectPayload(options.event, options.payload) + if (!projected.ok) { + return + } + for (const plugin of options.plugins) { + if (isInvalidDiscoveredPlugin(plugin) || !options.isRuntimeApproved(plugin)) { + continue + } + const manifestSubscribed = plugin.manifest.contributes.events.some( + (subscription) => subscription.on === options.event + ) + if (manifestSubscribed && plugin.manifest.main) { + void options.workerController + .ensure(plugin) + .then((handle) => handle.deliverEvent(options.event, projected.payload)) + .catch((error) => { + options.logWarning( + plugin.pluginKey, + `event ${options.event} dropped: ${error instanceof Error ? error.message : String(error)}` + ) + }) + } else if (options.eventBus.isDynamicallySubscribed(plugin.pluginKey, options.event)) { + options.workerController.deliverEventIfRunning( + plugin.pluginKey, + options.event, + projected.payload + ) + } + } +} diff --git a/src/main/plugins/plugin-git-repository.ts b/src/main/plugins/plugin-git-repository.ts new file mode 100644 index 00000000000..e8870f9e7fe --- /dev/null +++ b/src/main/plugins/plugin-git-repository.ts @@ -0,0 +1,57 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + isAllowedPluginGitUrl, + PLUGIN_COMMIT_PATTERN +} from '../../shared/plugins/plugin-install-lockfile' + +const execFileAsync = promisify(execFile) +const PLUGIN_GIT_TIMEOUT_MS = 120_000 + +/** Runs system Git with argv-only invocation so credential helpers and SSH + * remotes work without exposing an executable remote-helper surface. */ +export async function runPluginGit(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + timeout: PLUGIN_GIT_TIMEOUT_MS, + windowsHide: true, + env: { + ...process.env, + // Existing non-interactive helpers and SSH agents still work, but a + // background marketplace refresh can never hang on a terminal prompt. + GIT_TERMINAL_PROMPT: '0' + } + }) + return stdout.trim() +} + +/** Checks out one Git ref into an empty destination and returns exact HEAD. */ +export async function checkoutPluginGitSource(input: { + url: string + ref: string + destination: string + workingDirectory: string +}): Promise { + if (!isAllowedPluginGitUrl(input.url)) { + throw new Error('plugin Git URL must use HTTPS or SSH') + } + const ref = input.ref.trim() + if (PLUGIN_COMMIT_PATTERN.test(ref)) { + await runPluginGit(['init', '--quiet', input.destination], input.workingDirectory) + await runPluginGit(['remote', 'add', 'origin', input.url], input.destination) + await runPluginGit(['fetch', '--quiet', '--depth', '1', 'origin', ref], input.destination) + await runPluginGit(['checkout', '--quiet', 'FETCH_HEAD'], input.destination) + } else { + const args = ['clone', '--quiet', '--depth', '1'] + if (ref.length > 0) { + args.push('--branch', ref) + } + args.push('--', input.url, input.destination) + await runPluginGit(args, input.workingDirectory) + } + const resolvedCommit = await runPluginGit(['rev-parse', 'HEAD'], input.destination) + if (!PLUGIN_COMMIT_PATTERN.test(resolvedCommit)) { + throw new Error('Git resolved an invalid commit identity') + } + return resolvedCommit +} diff --git a/src/main/plugins/plugin-host-call-adapter.ts b/src/main/plugins/plugin-host-call-adapter.ts new file mode 100644 index 00000000000..39c26eabdce --- /dev/null +++ b/src/main/plugins/plugin-host-call-adapter.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { executePluginHostCall, type ExecutePluginHostCallInput } from './plugin-host-methods' + +const pluginHostCallRequestSchema = z + .object({ + method: z.string().min(1).max(128), + params: z.unknown().optional() + }) + .strict() + +export type PluginHostCallRequest = z.infer + +export function isPluginHostCallRequest(request: unknown): request is PluginHostCallRequest { + return pluginHostCallRequestSchema.safeParse(request).success +} + +export type PluginHostCallPolicy = Pick< + ExecutePluginHostCallInput, + 'grantedCapabilities' | 'services' | 'audit' +> + +export type ResolvePluginHostCallPolicy = ( + pluginKey: string +) => PluginHostCallPolicy | Promise + +/** Validates the transport envelope, resolves all authority host-side, then + * enters the one capability/schema/audit execution chokepoint. */ +export async function executePluginHostCallRequest(input: { + /** Qualified identity already authenticated by the owning transport. */ + pluginKey: string + request: unknown + viaPanel: boolean + resolvePolicy: ResolvePluginHostCallPolicy +}): Promise { + if (!isQualifiedPluginKey(input.pluginKey)) { + return { ok: false, code: 'invalid_request', error: 'invalid qualified plugin key' } + } + const parsed = pluginHostCallRequestSchema.safeParse(input.request) + if (!parsed.success) { + return { ok: false, code: 'invalid_request', error: 'malformed plugin host call request' } + } + let policy: PluginHostCallPolicy + try { + policy = await input.resolvePolicy(input.pluginKey) + } catch { + return { ok: false, code: 'unavailable', error: 'plugin host policy is not available' } + } + return executePluginHostCall({ + pluginId: input.pluginKey, + method: parsed.data.method, + params: parsed.data.params, + viaPanel: input.viaPanel, + ...policy + }) +} diff --git a/src/main/plugins/plugin-host-conformance.test.ts b/src/main/plugins/plugin-host-conformance.test.ts new file mode 100644 index 00000000000..bc43dd741de --- /dev/null +++ b/src/main/plugins/plugin-host-conformance.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it, vi } from 'vitest' +import { PLUGIN_HOST_API_V0 } from '../../shared/plugins/plugin-host-api' +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import { + admitPluginPanelCall, + createPluginPanelCallAdmission +} from '../../shared/plugins/plugin-panel-call-admission' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import type { MethodHandler } from '../../relay/dispatcher' +import { + RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, + RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, + registerRelayPluginHostCallHandlers +} from '../../relay/plugin-host-call-handler' +import { + executePluginHostCallRequest, + type PluginHostCallPolicy, + type ResolvePluginHostCallPolicy +} from './plugin-host-call-adapter' +import type { PluginHostServices } from './plugin-host-methods' + +const PLUGIN_KEY = 'orca-samples.demo' +const WORKTREE_ID = 'repo-id::/Users/private/orca' +const TERMINAL_ID = 'terminal:local:one' + +type HostCallAdapter = (request: unknown, viaPanel: boolean) => Promise + +function createServices(): PluginHostServices { + return { + resolveActiveWorktreeContext: vi.fn().mockResolvedValue({ + worktreeId: WORKTREE_ID, + branch: 'main', + displayName: 'Orca', + path: '/Users/private/orca' + }), + listWorktreeTerminals: vi + .fn() + .mockResolvedValue([{ id: TERMINAL_ID, title: '/home/private/orca' }]), + sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }), + dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }), + storage: { + get: vi.fn().mockReturnValue('stored'), + set: vi.fn().mockReturnValue({ ok: true }), + delete: vi.fn(), + keys: vi.fn().mockReturnValue(['alpha']) + }, + secrets: { + get: vi.fn().mockReturnValue({ ok: true, value: 'secret' }), + set: vi.fn().mockReturnValue({ ok: true }), + delete: vi.fn() + }, + settings: { + getAll: vi.fn().mockReturnValue({ theme: 'dark' }), + set: vi.fn().mockReturnValue({ ok: true }) + }, + subscribeEvents: vi.fn().mockImplementation((_pluginKey, events) => events) + } +} + +function createPolicy( + grantedCapabilities: readonly PluginCapabilityKind[] | null, + services: PluginHostServices = createServices(), + audit = { record: vi.fn().mockResolvedValue(undefined) } +): PluginHostCallPolicy { + return { grantedCapabilities, services, audit } +} + +function createAdapters( + resolvePolicy: ResolvePluginHostCallPolicy, + limits?: { maxBytes?: number; maxMessages?: number; perMs?: number } +): Record { + const relayHandlers = new Map() + registerRelayPluginHostCallHandlers( + { onRequest: (method, handler) => relayHandlers.set(method, handler) }, + (context) => (context.clientId === 1 ? PLUGIN_KEY : null), + resolvePolicy, + { panelAdmission: createPluginPanelCallAdmission({ limits, now: () => 0 }) } + ) + const desktopAdmission = createPluginPanelCallAdmission({ limits, now: () => 0 }) + return { + 'desktop-main': async (request, viaPanel) => { + if (viaPanel) { + const admissionRefusal = admitPluginPanelCall(desktopAdmission, PLUGIN_KEY, request) + if (admissionRefusal) { + return admissionRefusal + } + } + return executePluginHostCallRequest({ + pluginKey: PLUGIN_KEY, + request, + viaPanel, + resolvePolicy + }) + }, + relay: async (request, viaPanel) => { + const registeredMethod = viaPanel + ? RELAY_PLUGIN_PANEL_HOST_CALL_METHOD + : RELAY_PLUGIN_WORKER_HOST_CALL_METHOD + return (await relayHandlers.get(registeredMethod)!(request as Record, { + clientId: 1, + isStale: () => false + })) as PluginPanelActionOutcome + } + } +} + +const successParams: Record = { + 'workspace.readContext': {}, + 'terminal.sendText': { terminalId: TERMINAL_ID, text: 'echo hi', enter: true }, + 'notifications.show': { title: 'Hello' }, + 'storage.get': { key: 'alpha' }, + 'storage.set': { key: 'alpha', value: 1 }, + 'storage.delete': { key: 'alpha' }, + 'storage.keys': {}, + 'secrets.get': { key: 'token' }, + 'secrets.set': { key: 'token', value: 'secret' }, + 'secrets.delete': { key: 'token' }, + 'settings.get': {}, + 'settings.set': { key: 'theme', value: 'dark' }, + 'events.subscribe': { events: ['worktree.created'] } +} + +describe('plugin host main/relay conformance', () => { + it('runs a granted success through both transports for all 13 v0 methods', async () => { + expect(PLUGIN_HOST_API_V0).toHaveLength(13) + expect(Object.keys(successParams).sort()).toEqual( + PLUGIN_HOST_API_V0.map((entry) => entry.name).sort() + ) + expect(PLUGIN_HOST_API_V0.every((entry) => entry.stability === 'experimental')).toBe(true) + expect(PLUGIN_HOST_API_V0.every((entry) => entry.scope.length > 0)).toBe(true) + + for (const spec of PLUGIN_HOST_API_V0) { + const policy = createPolicy([spec.capability]) + const resolvePolicy = vi.fn().mockResolvedValue(policy) + const outcomes = await Promise.all( + Object.values(createAdapters(resolvePolicy)).map((adapter) => + adapter({ method: spec.name, params: successParams[spec.name] }, spec.panel) + ) + ) + expect(outcomes, spec.name).toHaveLength(2) + expect(outcomes[0], spec.name).toEqual(outcomes[1]) + expect(outcomes[0], spec.name).toMatchObject({ ok: true }) + } + }) + + it('projects workspace context without host paths on main and relay', async () => { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['workspace:read'])) + for (const adapter of Object.values(createAdapters(resolvePolicy))) { + const outcome = await adapter({ method: 'workspace.readContext', params: {} }, true) + expect(outcome).toEqual({ + ok: true, + value: { + branch: 'main', + displayName: 'Orca', + terminals: [{ id: TERMINAL_ID }] + } + }) + expect(outcome).not.toHaveProperty('value.path') + expect(outcome).not.toHaveProperty('value.worktreeId') + } + }) + + const deniedCases: { + name: string + request: unknown + viaPanel: boolean + policy: () => PluginHostCallPolicy + code: string + }[] = [ + { + name: 'missing or stale consent', + request: { method: 'workspace.readContext', params: {} }, + viaPanel: true, + policy: () => createPolicy(null), + code: 'consent_required' + }, + { + name: 'missing capability', + request: { method: 'workspace.readContext', params: {} }, + viaPanel: true, + policy: () => createPolicy([]), + code: 'capability_denied' + }, + { + name: 'unknown method', + request: { method: 'workspace.erase', params: {} }, + viaPanel: false, + policy: () => createPolicy(['workspace:read']), + code: 'unknown_method' + }, + { + name: 'malformed params', + request: { + method: 'terminal.sendText', + params: { terminalId: TERMINAL_ID, text: '' } + }, + viaPanel: true, + policy: () => createPolicy(['terminal:send']), + code: 'invalid_params' + }, + { + name: 'panel-forbidden method', + request: { method: 'storage.get', params: { key: 'alpha' } }, + viaPanel: true, + policy: () => createPolicy(['storage']), + code: 'panel_forbidden' + }, + { + name: 'malformed result', + request: { + method: 'notifications.show', + params: { title: 'Hello' } + }, + viaPanel: true, + policy: () => { + const services = createServices() + services.dispatchPluginNotification = vi + .fn() + .mockResolvedValue({ delivered: 'yes' } as unknown as { delivered: boolean }) + return createPolicy(['notifications:show'], services) + }, + code: 'action_failed' + }, + { + name: 'mutation audit failure', + request: { + method: 'storage.set', + params: { key: 'alpha', value: 1 } + }, + viaPanel: false, + policy: () => + createPolicy(['storage'], createServices(), { + record: vi.fn().mockRejectedValue(new Error('disk full')) + }), + code: 'action_failed' + } + ] + + it.each(deniedCases)('returns identical $code codes for $name', async (testCase) => { + const outcomes: PluginPanelActionOutcome[] = [] + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockImplementation(() => testCase.policy()) + const adapter = createAdapters(resolvePolicy)[adapterName]! + outcomes.push(await adapter(testCase.request, testCase.viaPanel)) + } + expect(outcomes[0]).toMatchObject({ ok: false, code: testCase.code }) + expect(outcomes[1]).toMatchObject({ ok: false, code: testCase.code }) + expect(outcomes[0]).toEqual(outcomes[1]) + }) + + it('enforces the same per-plugin panel budget on desktop main and relay', async () => { + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) + const adapter = createAdapters(resolvePolicy, { + maxMessages: 1, + perMs: 10_000 + })[adapterName]! + + await expect( + adapter({ method: 'notifications.show', params: { title: 'first' } }, true) + ).resolves.toMatchObject({ ok: true }) + await expect( + adapter({ method: 'notifications.show', params: { title: 'second' } }, true) + ).resolves.toEqual({ + ok: false, + code: 'rate_limited', + error: 'too many panel requests' + }) + } + }) + + it('charges malformed and oversized panel traffic before schema parsing', async () => { + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) + const adapter = createAdapters(resolvePolicy, { + maxBytes: 128, + maxMessages: 2, + perMs: 10_000 + })[adapterName]! + + await expect( + adapter({ method: 'notifications.show', unexpected: true }, true) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + await expect( + adapter( + { + method: 'notifications.show', + params: { title: 'x'.repeat(256) } + }, + true + ) + ).resolves.toEqual({ + ok: false, + code: 'invalid_request', + error: 'panel message exceeds the size limit' + }) + await expect( + adapter({ method: 'notifications.show', params: { title: 'third' } }, true) + ).resolves.toEqual({ + ok: false, + code: 'rate_limited', + error: 'too many panel requests' + }) + expect(resolvePolicy).not.toHaveBeenCalled() + } + }) + + it('binds relay plugin identity to the requesting connection', async () => { + const relayHandlers = new Map() + const services = createServices() + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'], services)) + const resolveIdentity = vi + .fn() + .mockImplementation(({ clientId }: { clientId: number }) => + clientId === 7 ? PLUGIN_KEY : null + ) + registerRelayPluginHostCallHandlers( + { onRequest: (method, handler) => relayHandlers.set(method, handler) }, + resolveIdentity, + resolvePolicy + ) + const handler = relayHandlers.get(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD)! + + await expect( + handler( + { method: 'storage.get', params: { key: 'alpha' } }, + { clientId: 7, isStale: () => false } + ) + ).resolves.toMatchObject({ ok: true }) + expect(services.storage.get).toHaveBeenCalledWith(PLUGIN_KEY, 'alpha') + + await expect( + handler( + { method: 'storage.get', params: { key: 'alpha' } }, + { clientId: 8, isStale: () => false } + ) + ).resolves.toMatchObject({ ok: false, code: 'unavailable' }) + expect(resolvePolicy).toHaveBeenCalledTimes(1) + }) + + it('rejects malformed envelopes and client-supplied authority before policy resolution', async () => { + const requests = [ + { pluginKey: '../evil', method: 'storage.get', params: { key: 'alpha' } }, + { + pluginKey: PLUGIN_KEY, + method: 'storage.get', + params: { key: 'alpha' }, + grantedCapabilities: ['storage'] + }, + { + pluginKey: PLUGIN_KEY, + method: 'storage.get', + params: { key: 'alpha' }, + viaPanel: false + } + ] + for (const request of requests) { + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'])) + const outcome = await createAdapters(resolvePolicy)[adapterName]!(request, false) + expect(outcome).toMatchObject({ ok: false, code: 'invalid_request' }) + expect(resolvePolicy).not.toHaveBeenCalled() + } + } + }) +}) diff --git a/src/main/plugins/plugin-host-entry.ts b/src/main/plugins/plugin-host-entry.ts new file mode 100644 index 00000000000..cc6d40a55a9 --- /dev/null +++ b/src/main/plugins/plugin-host-entry.ts @@ -0,0 +1,41 @@ +/** + * Child-process entry for the out-of-process plugin worker. Forked with + * ELECTRON_RUN_AS_NODE, so this file must stay plain Node — no electron + * imports (directly or transitively). All logic lives in + * `plugin-host-runtime.ts`; this file only wires the fork IPC channel. + */ +import { createPluginWorkerRuntime } from './plugin-host-runtime' +import type { PluginWorkerChildMessage } from '../../shared/plugins/plugin-host-protocol' + +function sendToParent(message: PluginWorkerChildMessage): void { + process.send?.(message) +} + +const runtime = createPluginWorkerRuntime({ send: sendToParent }) + +process.on('message', (raw: unknown) => { + void runtime.handleMessage(raw) +}) + +// Why: third-party plugin code runs here; an escaped rejection must not leave +// a zombie worker. Report the crash so the parent can supervise/restart. +function dieFatally(error: unknown): void { + try { + sendToParent({ + type: 'fatal', + error: error instanceof Error ? (error.stack ?? error.message) : String(error) + }) + } catch { + // Channel already gone; nothing left to report to. + } + process.exit(1) +} + +process.on('uncaughtException', dieFatally) +process.on('unhandledRejection', dieFatally) + +// Why: if the parent dies without sending shutdown, the IPC channel closes; +// exit instead of lingering as an orphaned Node process. +process.on('disconnect', () => { + process.exit(0) +}) diff --git a/src/main/plugins/plugin-host-method-bindings.ts b/src/main/plugins/plugin-host-method-bindings.ts new file mode 100644 index 00000000000..b1073027287 --- /dev/null +++ b/src/main/plugins/plugin-host-method-bindings.ts @@ -0,0 +1,181 @@ +import { + getPluginHostMethodSpec, + PLUGIN_HOST_API_V0, + PLUGIN_TERMINAL_ID_MAX_LENGTH, + PLUGIN_WORKSPACE_LABEL_MAX_LENGTH, + PLUGIN_WORKSPACE_TERMINAL_LIMIT, + type PluginHostMethodSpec +} from '../../shared/plugins/plugin-host-api' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' + +export type PluginWorktreeContext = { + worktreeId: string + branch: string + displayName: string +} + +/** Structural service surface the facade delegates to. Desktop main binds it + * over runtime services; relay policy and conformance tests bind fakes. */ +export type PluginHostServices = { + resolveActiveWorktreeContext(): Promise + listWorktreeTerminals(worktreeId: string): Promise<{ id: string }[]> + sendTerminalText( + terminalId: string, + action: { text: string; enter: boolean } + ): Promise<{ accepted: boolean }> + dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> + storage: { + get(pluginId: string, key: string): unknown + set(pluginId: string, key: string, value: unknown): { ok: true } | { ok: false; error: string } + delete(pluginId: string, key: string): void + keys(pluginId: string): string[] + } + secrets: { + get( + pluginId: string, + key: string + ): { ok: true; value: string | null } | { ok: false; error: string } + set(pluginId: string, key: string, value: string): { ok: true } | { ok: false; error: string } + delete(pluginId: string, key: string): void + } + settings: { + getAll(pluginId: string): Record + set(pluginId: string, key: string, value: unknown): { ok: true } | { ok: false; error: string } + } + subscribeEvents(pluginId: string, events: PluginEventName[]): PluginEventName[] +} + +export type BoundPluginHostMethod = { + spec: PluginHostMethodSpec + handler: ( + params: unknown, + ctx: { pluginId: string; services: PluginHostServices } + ) => Promise +} + +function definePluginMethod( + name: string, + handler: BoundPluginHostMethod['handler'] +): [string, BoundPluginHostMethod] { + const spec = getPluginHostMethodSpec(name) + if (!spec) { + throw new Error(`no host API spec for method ${name}`) + } + return [name, { spec, handler }] +} + +const HANDLERS = new Map([ + definePluginMethod('workspace.readContext', async (_params, { services }) => { + const context = await services.resolveActiveWorktreeContext() + if (!context) { + return null + } + const terminals = await services.listWorktreeTerminals(context.worktreeId) + // Why: Orca worktree ids embed provider paths, so the public projection + // must select safe fields instead of spreading the internal context. + return { + branch: context.branch.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), + displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), + terminals: terminals + .filter( + (terminal) => + terminal.id.length > 0 && terminal.id.length <= PLUGIN_TERMINAL_ID_MAX_LENGTH + ) + .slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT) + .map((terminal) => ({ id: terminal.id })) + } + }), + definePluginMethod('terminal.sendText', async (params, { services }) => { + const { terminalId, text, enter } = params as { + terminalId: string + text: string + enter: boolean + } + const context = await services.resolveActiveWorktreeContext() + if (!context) { + throw new Error('no active worktree is available for terminal input') + } + // Why: terminal handles are provider-owned and can outlive focus changes; + // re-list the resolved worktree immediately before routing plugin input. + const terminals = await services.listWorktreeTerminals(context.worktreeId) + if (!terminals.some((terminal) => terminal.id === terminalId)) { + throw new Error('terminal is outside the active worktree') + } + const result = await services.sendTerminalText(terminalId, { text, enter }) + return { accepted: result.accepted } + }), + definePluginMethod('notifications.show', async (params, { pluginId, services }) => { + const { title, body } = params as { title: string; body?: string } + return services.dispatchPluginNotification({ pluginId, title, body }) + }), + definePluginMethod('storage.get', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + return { value: services.storage.get(pluginId, key) ?? null } + }), + definePluginMethod('storage.set', async (params, { pluginId, services }) => { + const { key, value } = params as { key: string; value: unknown } + const result = services.storage.set(pluginId, key, value) + if (!result.ok) { + throw new Error(result.error) + } + return { ok: true } + }), + definePluginMethod('storage.delete', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + services.storage.delete(pluginId, key) + return { ok: true } + }), + definePluginMethod('storage.keys', async (_params, { pluginId, services }) => { + return { keys: services.storage.keys(pluginId) } + }), + definePluginMethod('secrets.get', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + const result = services.secrets.get(pluginId, key) + if (!result.ok) { + throw new Error(result.error) + } + return { value: result.value } + }), + definePluginMethod('secrets.set', async (params, { pluginId, services }) => { + const { key, value } = params as { key: string; value: string } + const result = services.secrets.set(pluginId, key, value) + if (!result.ok) { + throw new Error(result.error) + } + return { ok: true } + }), + definePluginMethod('secrets.delete', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + services.secrets.delete(pluginId, key) + return { ok: true } + }), + definePluginMethod('settings.get', async (_params, { pluginId, services }) => { + return { settings: services.settings.getAll(pluginId) } + }), + definePluginMethod('settings.set', async (params, { pluginId, services }) => { + const { key, value } = params as { key: string; value: unknown } + const result = services.settings.set(pluginId, key, value) + if (!result.ok) { + throw new Error(result.error) + } + return { ok: true } + }), + definePluginMethod('events.subscribe', async (params, { pluginId, services }) => { + const { events } = params as { events: PluginEventName[] } + return { subscribed: services.subscribeEvents(pluginId, events) } + }) +]) + +// Why: adding a facade schema without a binding must fail at module load, +// before a plugin can observe transport-specific behavior. +if (HANDLERS.size !== PLUGIN_HOST_API_V0.length) { + throw new Error('plugin host API spec table and handler bindings are out of sync') +} + +export function getBoundPluginHostMethod(name: string): BoundPluginHostMethod | null { + return HANDLERS.get(name) ?? null +} diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts new file mode 100644 index 00000000000..19b12f43dd9 --- /dev/null +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -0,0 +1,232 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' +import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' +import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods' + +function createServices(storageSet: PluginHostServices['storage']['set']): PluginHostServices { + return { + resolveActiveWorktreeContext: vi.fn().mockResolvedValue(null), + listWorktreeTerminals: vi.fn().mockResolvedValue([]), + sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }), + dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }), + storage: { + get: vi.fn(), + set: storageSet, + delete: vi.fn(), + keys: vi.fn().mockReturnValue([]) + }, + secrets: { + get: vi.fn().mockReturnValue({ ok: true, value: null }), + set: vi.fn().mockReturnValue({ ok: true }), + delete: vi.fn() + }, + settings: { + getAll: vi.fn().mockReturnValue({}), + set: vi.fn().mockReturnValue({ ok: true }) + }, + subscribeEvents: vi.fn().mockReturnValue([]) + } +} + +describe('executePluginHostCall mutation auditing', () => { + it('rejects prototype-sensitive storage keys before any host service call', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: '__proto__', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) + + expect(outcome).toMatchObject({ ok: false, code: 'invalid_params' }) + expect(storageSet).not.toHaveBeenCalled() + }) + + it('rejects non-JSON storage values before any host service call', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'created', value: new Date() }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) + + expect(outcome).toMatchObject({ ok: false, code: 'invalid_params' }) + expect(storageSet).not.toHaveBeenCalled() + }) + + it('fails closed before a mutation when the audit intent cannot be recorded', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'answer', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record: vi.fn().mockRejectedValue(new Error('disk full')) } + }) + + expect(outcome).toMatchObject({ ok: false, code: 'action_failed' }) + expect(storageSet).not.toHaveBeenCalled() + }) + + it('records an intent before the mutation and its outcome afterward', async () => { + const order: string[] = [] + const storageSet = vi.fn(() => { + order.push('mutation') + return { ok: true as const } + }) + const record = vi.fn(async (entry: { outcome: string }) => { + order.push(`audit:${entry.outcome}`) + }) + + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'answer', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record } + }) + + expect(outcome).toEqual({ ok: true, value: { ok: true } }) + expect(order).toEqual(['audit:attempt', 'mutation', 'audit:ok']) + }) + + it('refuses mutations when no audit writer is configured', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'answer', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet) + }) + + expect(outcome).toMatchObject({ ok: false, code: 'unavailable' }) + expect(storageSet).not.toHaveBeenCalled() + }) +}) + +function createTerminalHarness(terminalHandles: string[]): { + delegate: PluginRuntimeDelegate + services: PluginHostServices +} { + const delegate: PluginRuntimeDelegate = { + resolveActiveWorktreeContext: vi.fn().mockResolvedValue({ + worktreeId: 'worktree-1', + path: '/Users/private/repo', + branch: 'main', + displayName: 'Repo' + }), + listTerminals: vi.fn().mockResolvedValue({ + terminals: terminalHandles.map((handle) => ({ handle, title: null })) + }), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }) + } + return { + delegate, + services: bindPluginHostServices({ + delegate, + pluginsDataDir: join(tmpdir(), 'plugin-host-methods-test'), + subscribeEvents: vi.fn().mockReturnValue([]) + }) + } +} + +async function sendTerminalText( + services: PluginHostServices, + terminalId: string +): ReturnType { + return executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'terminal.sendText', + params: { terminalId, text: 'echo hi', enter: true }, + viaPanel: true, + grantedCapabilities: ['terminal:send'], + services, + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) +} + +describe('terminal.sendText explicit worktree routing', () => { + it('performs one bounded list and zero sends when the terminal is outside the worktree', async () => { + const { delegate, services } = createTerminalHarness(['terminal:local:other']) + + const outcome = await sendTerminalText(services, 'terminal:ssh:requested') + + expect(outcome).toMatchObject({ ok: false, code: 'action_failed' }) + expect(delegate.resolveActiveWorktreeContext).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledWith( + 'id:worktree-1', + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + expect(delegate.sendTerminal).not.toHaveBeenCalled() + }) + + it.each(['terminal:local:one', 'terminal:ssh:opaque-provider-id'])( + 'performs one bounded list and one send for provider-agnostic id %s', + async (terminalId) => { + const { delegate, services } = createTerminalHarness([terminalId]) + + const outcome = await sendTerminalText(services, terminalId) + + expect(outcome).toEqual({ ok: true, value: { accepted: true } }) + expect(delegate.resolveActiveWorktreeContext).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledWith( + 'id:worktree-1', + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) + expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, { + text: 'echo hi', + enter: true + }) + expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan( + vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]! + ) + } + ) + + it('bounds workspace.readContext and omits the provider path', async () => { + const handles = Array.from( + { length: PLUGIN_WORKSPACE_TERMINAL_LIMIT + 10 }, + (_, index) => `terminal:local:${index}` + ) + const { delegate, services } = createTerminalHarness(handles) + + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'workspace.readContext', + params: {}, + viaPanel: true, + grantedCapabilities: ['workspace:read'], + services + }) + + expect(outcome).toMatchObject({ + ok: true, + value: { branch: 'main', displayName: 'Repo' } + }) + expect(outcome).not.toHaveProperty('value.path') + expect(outcome).not.toHaveProperty('value.worktreeId') + expect(outcome.ok && (outcome.value as { terminals: unknown[] }).terminals).toHaveLength( + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + expect(delegate.listTerminals).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/plugins/plugin-host-methods.ts b/src/main/plugins/plugin-host-methods.ts new file mode 100644 index 00000000000..c4ffb00535f --- /dev/null +++ b/src/main/plugins/plugin-host-methods.ts @@ -0,0 +1,144 @@ +import { getBoundPluginHostMethod, type PluginHostServices } from './plugin-host-method-bindings' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { gatePluginHostCall as decidePluginHostCall } from '../../shared/plugins/plugin-capability-gate' +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import type { PluginAuditLog } from './plugin-audit-log' + +/** + * Host API v0 handler bindings — the one place plugin-originated calls + * (panel bridge, worker hostCall, serve RPC relay) execute. Handlers + * delegate to runtime services through the structural `PluginHostServices` + * interface, so this module stays electron-free and the relay conformance + * suite can run the identical chokepoint against a fake service set. + */ + +export type { PluginHostServices } from './plugin-host-method-bindings' + +export type ExecutePluginHostCallInput = { + /** Qualified plugin key, bound host-side from authenticated identity. */ + pluginId: string + method: string + params: unknown + /** True when the call arrives over the sandboxed panel bridge. */ + viaPanel: boolean + /** Consented capability kinds; null = unknown/disabled/consent-stale. */ + grantedCapabilities: readonly PluginCapabilityKind[] | null + services: PluginHostServices | null + audit?: Pick +} + +export async function executePluginHostCall( + input: ExecutePluginHostCallInput +): Promise { + if (!isQualifiedPluginKey(input.pluginId)) { + return { ok: false, code: 'invalid_request', error: 'invalid qualified plugin key' } + } + const gate = decidePluginHostCall( + { grantedCapabilities: input.grantedCapabilities, viaPanel: input.viaPanel }, + input.method + ) + if (!gate.granted) { + return { ok: false, code: gate.code, error: gate.error } + } + const bound = getBoundPluginHostMethod(input.method) + if (!bound) { + return { ok: false, code: 'unknown_method', error: `unknown host method: ${input.method}` } + } + const parsedParams = bound.spec.params.safeParse(input.params) + if (!parsedParams.success) { + const issue = parsedParams.error.issues[0] + const path = issue?.path.join('.') || '(root)' + return { + ok: false, + code: 'invalid_params', + error: `${path}: ${issue?.message ?? 'invalid params'}` + } + } + if (!input.services) { + return { ok: false, code: 'unavailable', error: 'runtime is not available' } + } + const auditMutation = async (outcome: 'attempt' | 'ok' | 'error'): Promise => { + if (bound.spec.mutation && input.audit) { + await input.audit.record({ + ts: Date.now(), + actor: `plugin:${input.pluginId}`, + method: input.method, + summary: summarizeParams(input.method, parsedParams.data), + outcome + }) + } + } + if (bound.spec.mutation) { + if (!input.audit) { + return { + ok: false, + code: 'unavailable', + error: 'mutation audit log is not available' + } + } + try { + // The intent is appended before the handler. If this write fails, the + // mutation is never attempted. + await auditMutation('attempt') + } catch { + return { + ok: false, + code: 'action_failed', + error: 'mutation audit log could not be written' + } + } + } + try { + const value = await bound.handler(parsedParams.data, { + pluginId: input.pluginId, + services: input.services + }) + const validated = bound.spec.result.safeParse(value) + if (!validated.success) { + await auditMutation('error').catch(() => undefined) + // A result-schema mismatch is a host bug; fail the call rather than + // leaking an unvalidated shape into plugin-facing transports. + return { + ok: false, + code: 'action_failed', + error: `internal: malformed ${input.method} result` + } + } + await auditMutation('ok').catch(() => undefined) + return { ok: true, value: validated.data } + } catch (error) { + await auditMutation('error').catch(() => undefined) + return { + ok: false, + code: 'action_failed', + error: error instanceof Error ? error.message : String(error) + } + } +} + +/** Bounded, content-free summaries for the audit log. */ +function summarizeParams(method: string, params: unknown): string { + const record = (typeof params === 'object' && params !== null ? params : {}) as Record< + string, + unknown + > + switch (method) { + case 'terminal.sendText': { + const text = typeof record.text === 'string' ? record.text : '' + return `terminal=${String(record.terminalId)} bytes=${Buffer.byteLength(text, 'utf8')} enter=${record.enter === true}` + } + case 'notifications.show': { + const title = typeof record.title === 'string' ? record.title : '' + return `titleChars=${title.length}` + } + case 'storage.set': + case 'storage.delete': + case 'secrets.set': + case 'secrets.delete': + case 'settings.set': + return `key=${String(record.key)}` + default: + return '' + } +} diff --git a/src/main/plugins/plugin-host-process.test.ts b/src/main/plugins/plugin-host-process.test.ts new file mode 100644 index 00000000000..2c4716f8e87 --- /dev/null +++ b/src/main/plugins/plugin-host-process.test.ts @@ -0,0 +1,135 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const processMocks = vi.hoisted(() => ({ fork: vi.fn() })) +vi.mock('node:child_process', () => ({ fork: processMocks.fork })) + +import { startPluginWorker } from './plugin-host-process' + +class FakeChild extends EventEmitter { + connected = true + stdout = new PassThrough() + stderr = new PassThrough() + send = vi.fn() + kill = vi.fn() +} + +function start(child: FakeChild, options: { eventTimeoutMs?: number } = {}) { + processMocks.fork.mockReturnValue(child) + return startPluginWorker({ + pluginId: 'orca-samples.demo', + rootDir: '/plugin', + mainEntry: 'worker.js', + entryPath: '/host.js', + grantedCapabilities: [], + executeHostCall: async () => ({ ok: true, value: null }), + log: vi.fn(), + ...options + }) +} + +beforeEach(() => { + processMocks.fork.mockReset() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('startPluginWorker', () => { + it('does not inherit Orca execArgv', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: [] }) + await pending + + expect(processMocks.fork).toHaveBeenCalledWith( + '/host.js', + [], + expect.objectContaining({ execArgv: [] }) + ) + }) + + it('replays an exit that happened before handle registration', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: ['run'] }) + const handle = await pending + child.emit('exit', 23) + const onExit = vi.fn() + + handle.onExit(onExit) + + expect(onExit).toHaveBeenCalledOnce() + expect(onExit).toHaveBeenCalledWith(23) + }) + + it('kills a live worker that disconnects its IPC channel', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: ['run'] }) + const handle = await pending + const command = handle.invokeCommand('run') + child.connected = false + + child.emit('disconnect') + + await expect(command).rejects.toThrow('disconnected') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('counts delivered events as in flight until their acknowledgement', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: [] }) + const handle = await pending + + handle.deliverEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + + expect(handle.inFlightCount()).toBe(1) + child.emit('message', { type: 'eventAck', eventId: 0 }) + expect(handle.inFlightCount()).toBe(0) + }) + + it('kills a worker whose event handler never acknowledges completion', async () => { + vi.useFakeTimers() + const child = new FakeChild() + const pending = start(child, { eventTimeoutMs: 25 }) + child.emit('message', { type: 'ready', commands: [] }) + const handle = await pending + + handle.deliverEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + await vi.advanceTimersByTimeAsync(25) + + expect(handle.inFlightCount()).toBe(0) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills a worker that exceeds the pending event cap', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: [] }) + const handle = await pending + + for (let index = 0; index < 65; index += 1) { + handle.deliverEvent('agent.status.changed', { + worktreeId: null, + paneKey: `pane-${index}`, + state: 'working', + receivedAt: Date.now() + }) + } + + expect(handle.inFlightCount()).toBe(64) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) +}) diff --git a/src/main/plugins/plugin-host-process.ts b/src/main/plugins/plugin-host-process.ts new file mode 100644 index 00000000000..0c289d502ad --- /dev/null +++ b/src/main/plugins/plugin-host-process.ts @@ -0,0 +1,332 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { + PLUGIN_WORKER_INVOKE_TIMEOUT_MS, + PLUGIN_WORKER_READY_TIMEOUT_MS, + pluginWorkerChildMessageSchema, + type PluginWorkerParentMessage +} from '../../shared/plugins/plugin-host-protocol' +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { buildPluginWorkerEnv } from './plugin-worker-env' +import { pipePluginWorkerOutput } from './plugin-worker-output-buffer' + +// Grace between the shutdown message and SIGKILL: long enough for plugin +// cleanup, short enough that disable/quit never feels stuck. +const PLUGIN_WORKER_SHUTDOWN_GRACE_MS = 2_000 +const PLUGIN_WORKER_EVENT_TIMEOUT_MS = 5 * 60_000 +const PLUGIN_WORKER_MAX_PENDING_EVENTS = 64 + +export type PluginWorkerLogSink = (level: 'info' | 'warn' | 'error', line: string) => void + +/** Executes a worker-originated host API call; the outcome is relayed back + * over the fork channel as a hostResult message. */ +export type PluginWorkerHostCallExecutor = ( + method: string, + params: unknown +) => Promise + +export type PluginWorkerHandle = { + /** Command ids the worker registered on activate (⊆ manifest commands). */ + commands: readonly string[] + invokeCommand(commandId: string, args?: unknown): Promise + deliverEvent(event: PluginEventName, payload: unknown): void + /** Milliseconds timestamp of the last completed work (for idle reap). */ + lastActivityAt(): number + inFlightCount(): number + dispose(): Promise + kill(): void + onExit(callback: (code: number | null) => void): void +} + +export type StartPluginWorkerOptions = { + pluginId: string + rootDir: string + mainEntry: string + /** Absolute path to the compiled plugin-host-entry.js, resolved by caller. */ + entryPath: string + grantedCapabilities: readonly PluginCapabilityKind[] + executeHostCall: PluginWorkerHostCallExecutor + log: PluginWorkerLogSink + readyTimeoutMs?: number + invokeTimeoutMs?: number + eventTimeoutMs?: number + signal?: AbortSignal +} + +/** + * Resolves the compiled child entry from the app path. Mirrors + * getDaemonEntryPath(): packaged apps must fork the asar-unpacked copy + * because fork() cannot execute scripts from inside app.asar. + */ +export function resolvePluginHostEntryPath(appPath: string, isPackaged: boolean): string { + const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const directEntryPath = join(basePath, 'plugin-host-entry.js') + if (existsSync(directEntryPath)) { + return directEntryPath + } + return join(basePath, 'out', 'main', 'plugin-host-entry.js') +} + +type PendingCall = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export async function startPluginWorker( + options: StartPluginWorkerOptions +): Promise { + const { pluginId, rootDir, mainEntry, entryPath, log } = options + const readyTimeoutMs = options.readyTimeoutMs ?? PLUGIN_WORKER_READY_TIMEOUT_MS + const invokeTimeoutMs = options.invokeTimeoutMs ?? PLUGIN_WORKER_INVOKE_TIMEOUT_MS + const eventTimeoutMs = options.eventTimeoutMs ?? PLUGIN_WORKER_EVENT_TIMEOUT_MS + const tag = `[plugin:${pluginId}]` + + const child: ChildProcess = fork(entryPath, [], { + // Why: ELECTRON_RUN_AS_NODE makes the forked Electron binary behave as + // plain Node. The env is a scrubbed allowlist — never ...process.env, + // which can carry shell-exported secrets into third-party code. + env: buildPluginWorkerEnv(), + // Why: inspector/loader flags from Orca's own launch must never execute + // inside third-party plugin workers. + execArgv: [], + // Why: the protocol permits structured-clone values. Node's default JSON + // fork serialization rejects BigInt, cycles, maps, and typed arrays. + serialization: 'advanced', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }) + pipePluginWorkerOutput(child.stdout, 'info', log) + pipePluginWorkerOutput(child.stderr, 'error', log) + + const pendingCommands = new Map() + const pendingEvents = new Map>() + const exitCallbacks: ((code: number | null) => void)[] = [] + let nextCallId = 0 + let nextEventId = 0 + let exited = false + let exitCode: number | null = null + let disposed = false + let lastActivityAt = Date.now() + + function sendToChild(message: PluginWorkerParentMessage): void { + if (child.connected) { + child.send(message) + } + } + + function rejectAllPending(reason: string): void { + for (const [callId, entry] of pendingCommands) { + clearTimeout(entry.timer) + pendingCommands.delete(callId) + entry.reject(new Error(reason)) + } + for (const timer of pendingEvents.values()) { + clearTimeout(timer) + } + pendingEvents.clear() + } + + child.on('exit', (code) => { + exited = true + exitCode = code + rejectAllPending(`${tag} worker exited before responding`) + for (const callback of exitCallbacks) { + callback(code) + } + }) + child.on('disconnect', () => { + // Why: a worker can drop fork IPC while its event loop stays alive. Kill + // it so the ensuing exit enters the normal supervision/backoff path. + rejectAllPending(`${tag} worker disconnected before responding`) + if (!exited) { + child.kill('SIGKILL') + } + }) + + const commands = await new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + fail(new Error(`${tag} worker did not become ready within ${readyTimeoutMs}ms`)) + child.kill('SIGKILL') + }, readyTimeoutMs) + function fail(error: Error): void { + if (!settled) { + settled = true + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + reject(error) + } + } + const onAbort = (): void => { + fail(new Error(`${tag} worker startup was cancelled`)) + child.kill('SIGKILL') + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + child.on('error', (error) => { + const failure = new Error(`${tag} worker process error: ${error.message}`) + fail(failure) + child.kill('SIGKILL') + // Why: fail() no-ops once ready; a post-ready channel fault must still + // reject in-flight calls instead of letting each hit its own timeout. + rejectAllPending(failure.message) + }) + child.on('exit', (code) => fail(new Error(`${tag} worker exited before ready (code ${code})`))) + child.on('message', (raw) => { + const parsed = pluginWorkerChildMessageSchema.safeParse(raw) + if (!parsed.success) { + log('warn', 'ignoring malformed worker message') + return + } + const message = parsed.data + switch (message.type) { + case 'ready': { + if (!settled) { + settled = true + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + resolve(message.commands) + } + return + } + case 'commandResult': { + const entry = pendingCommands.get(message.callId) + if (!entry) { + return + } + clearTimeout(entry.timer) + pendingCommands.delete(message.callId) + lastActivityAt = Date.now() + if (message.ok) { + entry.resolve(message.value) + } else { + entry.reject(new Error(message.error ?? 'plugin command failed')) + } + return + } + case 'eventAck': { + const timer = pendingEvents.get(message.eventId) + if (timer) { + clearTimeout(timer) + pendingEvents.delete(message.eventId) + } + lastActivityAt = Date.now() + return + } + case 'hostCall': { + lastActivityAt = Date.now() + // Host API calls from the worker: gate + execute in main, then + // relay the outcome. Never throws — errors become outcomes. + void options.executeHostCall(message.method, message.params).then((outcome) => { + lastActivityAt = Date.now() + sendToChild( + outcome.ok + ? { type: 'hostResult', callId: message.callId, ok: true, value: outcome.value } + : { + type: 'hostResult', + callId: message.callId, + ok: false, + errorCode: outcome.code, + error: outcome.error + } + ) + }) + return + } + case 'log': { + log(message.level, message.message) + return + } + case 'fatal': { + fail(new Error(`${tag} worker crashed: ${message.error}`)) + rejectAllPending(`${tag} worker crashed: ${message.error}`) + child.kill('SIGKILL') + } + } + }) + sendToChild({ + type: 'init', + pluginId, + pluginRoot: rootDir, + mainEntry, + grantedCapabilities: [...options.grantedCapabilities] + }) + if (options.signal?.aborted) { + onAbort() + } + }) + + return { + commands, + invokeCommand(commandId, args) { + if (exited || disposed) { + return Promise.reject(new Error(`${tag} worker is not running`)) + } + const callId = nextCallId++ + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingCommands.delete(callId) + reject(new Error(`${tag} ${commandId} timed out after ${invokeTimeoutMs}ms`)) + }, invokeTimeoutMs) + pendingCommands.set(callId, { resolve, reject, timer }) + sendToChild({ type: 'invokeCommand', callId, commandId, args }) + }) + }, + deliverEvent(event, payload) { + if (exited || disposed) { + return + } + if (pendingEvents.size >= PLUGIN_WORKER_MAX_PENDING_EVENTS) { + log('error', `${tag} exceeded the pending event limit`) + child.kill('SIGKILL') + return + } + lastActivityAt = Date.now() + const eventId = nextEventId++ + const timer = setTimeout(() => { + pendingEvents.delete(eventId) + log('error', `${tag} ${event} did not finish within ${eventTimeoutMs}ms`) + child.kill('SIGKILL') + }, eventTimeoutMs) + pendingEvents.set(eventId, timer) + sendToChild({ type: 'deliverEvent', eventId, event, payload }) + }, + lastActivityAt: () => lastActivityAt, + inFlightCount: () => pendingCommands.size + pendingEvents.size, + async dispose() { + if (disposed) { + return + } + disposed = true + if (exited) { + return + } + sendToChild({ type: 'shutdown' }) + await new Promise((resolve) => { + const killTimer = setTimeout(() => { + child.kill('SIGKILL') + }, PLUGIN_WORKER_SHUTDOWN_GRACE_MS) + child.once('exit', () => { + clearTimeout(killTimer) + resolve() + }) + if (exited) { + clearTimeout(killTimer) + resolve() + } + }) + }, + kill() { + child.kill('SIGKILL') + }, + onExit(callback) { + if (exited) { + callback(exitCode) + } else { + exitCallbacks.push(callback) + } + } + } +} diff --git a/src/main/plugins/plugin-host-runtime.test.ts b/src/main/plugins/plugin-host-runtime.test.ts new file mode 100644 index 00000000000..48d680a0382 --- /dev/null +++ b/src/main/plugins/plugin-host-runtime.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createPluginWorkerRuntime } from './plugin-host-runtime' + +describe('plugin worker shutdown', () => { + it('normalizes either manifest separator before importing the worker', async () => { + const importModule = vi.fn(async () => ({ default: vi.fn() })) + const runtime = createPluginWorkerRuntime({ send: vi.fn(), importModule }) + + await runtime.handleMessage({ + type: 'init', + pluginId: 'orca-samples.demo', + pluginRoot: join('plugin-root'), + mainEntry: 'nested\\worker.js', + grantedCapabilities: [] + }) + + expect(importModule).toHaveBeenCalledWith( + pathToFileURL(join('plugin-root', 'nested', 'worker.js')).href + ) + }) + + it('awaits an optional deactivate export before exiting', async () => { + let finishDeactivate!: () => void + const deactivate = vi.fn( + () => + new Promise((resolve) => { + finishDeactivate = resolve + }) + ) + const send = vi.fn() + const exit = vi.fn() + const runtime = createPluginWorkerRuntime({ + send, + exit, + importModule: async () => ({ default: vi.fn(), deactivate }) + }) + await runtime.handleMessage({ + type: 'init', + pluginId: 'orca-samples.demo', + pluginRoot: '/plugin', + mainEntry: 'worker.js', + grantedCapabilities: [] + }) + + const shutdown = runtime.handleMessage({ type: 'shutdown' }) + await Promise.resolve() + expect(deactivate).toHaveBeenCalledOnce() + expect(exit).not.toHaveBeenCalled() + finishDeactivate() + await shutdown + + expect(exit).toHaveBeenCalledWith(0) + }) + + it('exits immediately when the plugin has no deactivate export', async () => { + const exit = vi.fn() + const runtime = createPluginWorkerRuntime({ + send: vi.fn(), + exit, + importModule: async () => ({ default: vi.fn() }) + }) + await runtime.handleMessage({ + type: 'init', + pluginId: 'orca-samples.demo', + pluginRoot: '/plugin', + mainEntry: 'worker.js', + grantedCapabilities: [] + }) + + await runtime.handleMessage({ type: 'shutdown' }) + + expect(exit).toHaveBeenCalledWith(0) + }) +}) diff --git a/src/main/plugins/plugin-host-runtime.ts b/src/main/plugins/plugin-host-runtime.ts new file mode 100644 index 00000000000..2a2f5f9fcab --- /dev/null +++ b/src/main/plugins/plugin-host-runtime.ts @@ -0,0 +1,210 @@ +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + pluginWorkerParentMessageSchema, + type PluginWorkerChildMessage +} from '../../shared/plugins/plugin-host-protocol' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' + +/** + * Message-loop core of the out-of-process plugin worker. Electron-free and + * side-effect-free (send/import/exit are injected) so it unit-tests without + * forking a real child process; `plugin-host-entry.ts` wires it to the fork + * IPC channel. + */ + +export type PluginHostCallError = Error & { code?: string } + +/** API surface handed to a plugin's `activate(orca)` export. Everything is + * EXPERIMENTAL until pluginApi v1 freezes. */ +export type PluginWorkerOrcaApi = { + /** Register the handler for a command declared in the manifest. */ + commands: { + register(commandId: string, handler: (args: unknown) => unknown | Promise): void + } + /** Handle an event the manifest subscribed to (`contributes.events`). */ + events: { + on(event: PluginEventName, handler: (payload: unknown) => void | Promise): void + } + /** Call a host API method (capability-gated host-side). */ + host: { + call(method: string, params?: unknown): Promise + } + /** Consented capability kinds (informational — the host re-gates). */ + grantedCapabilities: readonly string[] + log(message: string): void +} + +export type PluginWorkerRuntimeOptions = { + send: (message: PluginWorkerChildMessage) => void + importModule?: (specifier: string) => Promise + exit?: (code: number) => void +} + +export type PluginWorkerRuntime = { + handleMessage(raw: unknown): Promise +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error) +} + +export function createPluginWorkerRuntime( + options: PluginWorkerRuntimeOptions +): PluginWorkerRuntime { + const send = options.send + const importModule = options.importModule ?? ((specifier: string) => import(specifier)) + const exit = options.exit ?? ((code: number) => process.exit(code)) + const commandHandlers = new Map unknown | Promise>() + const eventHandlers = new Map void | Promise)[]>() + const pendingHostCalls = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: PluginHostCallError) => void } + >() + let nextHostCallId = 0 + let initialized = false + let shuttingDown = false + let deactivate: (() => unknown | Promise) | null = null + + async function handleInit(input: { + pluginRoot: string + mainEntry: string + grantedCapabilities: string[] + }): Promise { + if (initialized) { + send({ type: 'log', level: 'warn', message: 'ignoring duplicate init message' }) + return + } + initialized = true + // Why: file URL import keeps ESM plugin entries working on Windows paths. + // Why: manifest paths accept either portable separator; split explicitly + // so a Windows-authored plugin also imports on macOS/Linux and vice versa. + const entryUrl = pathToFileURL(join(input.pluginRoot, ...input.mainEntry.split(/[\\/]/))).href + const module = (await importModule(entryUrl)) as { default?: unknown; deactivate?: unknown } + const activate = module?.default + if (typeof activate !== 'function') { + throw new Error(`plugin entry ${input.mainEntry} has no default-exported activate function`) + } + if (module.deactivate !== undefined && typeof module.deactivate !== 'function') { + throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`) + } + deactivate = (module.deactivate as (() => unknown | Promise) | undefined) ?? null + const orca: PluginWorkerOrcaApi = { + commands: { + register(commandId, handler) { + commandHandlers.set(commandId, handler) + } + }, + events: { + on(event, handler) { + const handlers = eventHandlers.get(event) ?? [] + handlers.push(handler) + eventHandlers.set(event, handlers) + } + }, + host: { + call(method, params) { + const callId = nextHostCallId++ + return new Promise((resolve, reject) => { + pendingHostCalls.set(callId, { resolve, reject }) + send({ type: 'hostCall', callId, method, params }) + }) + } + }, + grantedCapabilities: input.grantedCapabilities, + log(message) { + send({ type: 'log', level: 'info', message: String(message).slice(0, 8192) }) + } + } + await activate(orca) + send({ type: 'ready', commands: [...commandHandlers.keys()] }) + } + + return { + async handleMessage(raw) { + const parsed = pluginWorkerParentMessageSchema.safeParse(raw) + if (!parsed.success) { + send({ type: 'log', level: 'warn', message: 'ignoring malformed parent message' }) + return + } + const message = parsed.data + try { + switch (message.type) { + case 'init': { + await handleInit(message) + return + } + case 'invokeCommand': { + const handler = commandHandlers.get(message.commandId) + if (!handler) { + send({ + type: 'commandResult', + callId: message.callId, + ok: false, + error: `no handler registered for command ${message.commandId}` + }) + return + } + try { + const value = await handler(message.args) + send({ type: 'commandResult', callId: message.callId, ok: true, value }) + } catch (error) { + send({ + type: 'commandResult', + callId: message.callId, + ok: false, + error: toErrorMessage(error) + }) + } + return + } + case 'deliverEvent': { + const handlers = eventHandlers.get(message.event) ?? [] + for (const handler of handlers) { + try { + await handler(message.payload) + } catch (error) { + send({ type: 'log', level: 'error', message: toErrorMessage(error) }) + } + } + send({ type: 'eventAck', eventId: message.eventId }) + return + } + case 'hostResult': { + const pending = pendingHostCalls.get(message.callId) + if (!pending) { + return + } + pendingHostCalls.delete(message.callId) + if (message.ok) { + pending.resolve(message.value) + } else { + const error: PluginHostCallError = new Error(message.error ?? 'host call failed') + error.code = message.errorCode + pending.reject(error) + } + return + } + case 'shutdown': { + if (shuttingDown) { + return + } + shuttingDown = true + try { + await deactivate?.() + } catch (error) { + send({ type: 'log', level: 'error', message: toErrorMessage(error).slice(0, 8192) }) + } + exit(0) + } + } + } catch (error) { + // Why: an init/activation failure leaves the worker useless; report + // and die so the parent surfaces the error instead of hanging on + // the ready timeout. + send({ type: 'fatal', error: toErrorMessage(error) }) + exit(1) + } + } + } +} diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts new file mode 100644 index 00000000000..a9aad46d387 --- /dev/null +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -0,0 +1,84 @@ +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' +import type { PluginHostServices } from './plugin-host-methods' +import { PluginSecretsStore } from './plugin-secrets-store' +import { PluginKvStore } from './plugin-storage-store' + +/** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ +export type PluginRuntimeDelegate = { + resolveActiveWorktreeContext(): Promise<{ + worktreeId: string + path: string + branch: string + displayName: string + } | null> + listTerminals( + worktreeSelector?: string, + limit?: number + ): Promise<{ terminals: { handle: string; title: string | null }[] }> + sendTerminal( + handle: string, + action: { text?: string; enter?: boolean } + ): Promise<{ accepted: boolean }> + dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> +} + +export function bindPluginHostServices(input: { + delegate: PluginRuntimeDelegate + pluginsDataDir: string + subscribeEvents: (pluginKey: string, events: PluginEventName[]) => PluginEventName[] +}): PluginHostServices { + const { delegate, pluginsDataDir, subscribeEvents } = input + return { + resolveActiveWorktreeContext: async () => { + const context = await delegate.resolveActiveWorktreeContext() + if (!context) { + return null + } + // Why: retain the internal id only for host-side terminal membership; + // the public handler projects it out because it embeds provider paths. + return { + worktreeId: context.worktreeId, + branch: context.branch, + displayName: context.displayName + } + }, + listWorktreeTerminals: async (worktreeId) => { + const result = await delegate.listTerminals( + `id:${worktreeId}`, + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + return result.terminals + .slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT) + .map((terminal) => ({ id: terminal.handle })) + }, + sendTerminalText: async (terminalId, action) => { + const result = await delegate.sendTerminal(terminalId, action) + return { accepted: result.accepted } + }, + dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), + storage: { + get: (key, itemKey) => new PluginKvStore(pluginsDataDir, key, 'storage.json').get(itemKey), + set: (key, itemKey, value) => + new PluginKvStore(pluginsDataDir, key, 'storage.json').set(itemKey, value), + delete: (key, itemKey) => + new PluginKvStore(pluginsDataDir, key, 'storage.json').delete(itemKey), + keys: (key) => new PluginKvStore(pluginsDataDir, key, 'storage.json').keys() + }, + secrets: { + get: (key, itemKey) => new PluginSecretsStore(pluginsDataDir, key).get(itemKey), + set: (key, itemKey, value) => new PluginSecretsStore(pluginsDataDir, key).set(itemKey, value), + delete: (key, itemKey) => new PluginSecretsStore(pluginsDataDir, key).delete(itemKey) + }, + settings: { + getAll: (key) => new PluginKvStore(pluginsDataDir, key, 'settings.json').getAll(), + set: (key, itemKey, value) => + new PluginKvStore(pluginsDataDir, key, 'settings.json').set(itemKey, value) + }, + subscribeEvents + } +} diff --git a/src/main/plugins/plugin-install-lockfile-store.ts b/src/main/plugins/plugin-install-lockfile-store.ts new file mode 100644 index 00000000000..b83079c1f0f --- /dev/null +++ b/src/main/plugins/plugin-install-lockfile-store.ts @@ -0,0 +1,83 @@ +import { createReadStream } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { + emptyPluginLockfile, + parsePluginLockfile, + serializePluginLockfile, + type PluginLockfile +} from '../../shared/plugins/plugin-install-lockfile' +import { writePluginFileAtomically } from './plugin-atomic-file-write' +import { recoverPluginLockfile } from './plugin-install-provenance' + +export const PLUGIN_LOCKFILE_MAX_BYTES = 5 * 1024 * 1024 +const lockfileAccessChains = new Map>() + +export function pluginLockfilePath(pluginsDir: string): string { + return join(pluginsDir, 'plugins.lock.json') +} + +async function serializeLockfileAccess( + pluginsDir: string, + operation: () => Promise +): Promise { + const previous = lockfileAccessChains.get(pluginsDir) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(operation) + const settled = run.then( + () => undefined, + () => undefined + ) + lockfileAccessChains.set(pluginsDir, settled) + try { + return await run + } finally { + if (lockfileAccessChains.get(pluginsDir) === settled) { + lockfileAccessChains.delete(pluginsDir) + } + } +} + +/** Reads the install index through a byte cap so a corrupt local file cannot + * turn every plugin-list refresh into an unbounded main-process allocation. */ +export async function readPluginLockfile(pluginsDir: string): Promise { + return serializeLockfileAccess(pluginsDir, () => readPluginLockfileUnserialized(pluginsDir)) +} + +async function readPluginLockfileUnserialized(pluginsDir: string): Promise { + let lock = emptyPluginLockfile() + try { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(pluginLockfilePath(pluginsDir))) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_LOCKFILE_MAX_BYTES) { + throw new Error('plugin lockfile exceeds its size limit') + } + chunks.push(bytes) + } + lock = parsePluginLockfile(JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8'))) + } catch { + // Missing/corrupt global indexes can be reconstructed from current-version provenance. + } + const recovered = await recoverPluginLockfile(pluginsDir, lock) + if (recovered.changed) { + await writePluginLockfileUnserialized(pluginsDir, recovered.lock).catch(() => undefined) + } + return recovered.lock +} + +export async function writePluginLockfile(pluginsDir: string, lock: PluginLockfile): Promise { + await serializeLockfileAccess(pluginsDir, () => writePluginLockfileUnserialized(pluginsDir, lock)) +} + +async function writePluginLockfileUnserialized( + pluginsDir: string, + lock: PluginLockfile +): Promise { + await mkdir(pluginsDir, { recursive: true }) + await writePluginFileAtomically( + pluginLockfilePath(pluginsDir), + JSON.stringify(serializePluginLockfile(lock), null, 2) + ) +} diff --git a/src/main/plugins/plugin-install-provenance.ts b/src/main/plugins/plugin-install-provenance.ts new file mode 100644 index 00000000000..76f06c318fe --- /dev/null +++ b/src/main/plugins/plugin-install-provenance.ts @@ -0,0 +1,118 @@ +import { createReadStream } from 'node:fs' +import { mkdir, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { + PLUGIN_CONTENT_HASH_PATTERN, + pluginLockEntrySchema, + type PluginLockEntry, + type PluginLockfile +} from '../../shared/plugins/plugin-install-lockfile' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { writePluginFileAtomically } from './plugin-atomic-file-write' +import { readPluginCurrentPointer } from './plugin-current-pointer' + +const PROVENANCE_DIRECTORY = '.install-provenance' +const PROVENANCE_MAX_BYTES = 64 * 1024 + +function provenancePath(pluginDir: string, contentHash: string): string { + if (!PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) { + throw new Error('invalid plugin content hash') + } + return join(pluginDir, PROVENANCE_DIRECTORY, `${contentHash}.json`) +} + +/** Prewrites immutable provenance before the executable current pointer moves. */ +export async function writePluginInstallProvenance( + pluginDir: string, + entry: PluginLockEntry +): Promise { + const parsedEntry = pluginLockEntrySchema.parse(entry) + const directory = join(pluginDir, PROVENANCE_DIRECTORY) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await writePluginFileAtomically( + provenancePath(pluginDir, parsedEntry.contentHash), + JSON.stringify({ version: 1, entry: parsedEntry }, null, 2), + { mode: 0o600 } + ) +} + +export async function readPluginInstallProvenance( + pluginDir: string, + contentHash: string +): Promise { + try { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(provenancePath(pluginDir, contentHash))) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PROVENANCE_MAX_BYTES) { + return null + } + chunks.push(bytes) + } + const raw = JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) as { + version?: unknown + entry?: unknown + } + if (raw.version !== 1) { + return null + } + const parsed = pluginLockEntrySchema.safeParse(raw.entry) + return parsed.success ? parsed.data : null + } catch { + return null + } +} + +/** Repairs a pointer-new/lock-old interrupted publication from immutable provenance. */ +export async function recoverPluginLockfile( + pluginsDir: string, + lock: PluginLockfile +): Promise<{ lock: PluginLockfile; changed: boolean }> { + const plugins = { ...lock.plugins } + let changed = false + const directories = await readdir(pluginsDir, { withFileTypes: true }).catch(() => []) + for (const directory of directories) { + if (!directory.isDirectory() || !isQualifiedPluginKey(directory.name)) { + continue + } + const pluginDir = join(pluginsDir, directory.name) + const contentHash = await readPluginCurrentPointer(pluginDir).catch(() => null) + if (!contentHash || !PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) { + continue + } + const provenance = await readPluginInstallProvenance(pluginDir, contentHash) + if ( + !provenance || + provenance.pluginKey !== directory.name || + provenance.contentHash !== contentHash + ) { + continue + } + if (JSON.stringify(plugins[directory.name]) !== JSON.stringify(provenance)) { + plugins[directory.name] = provenance + changed = true + } + } + return { lock: { version: 1, plugins }, changed } +} + +export async function prunePluginInstallProvenance( + pluginDir: string, + retained: ReadonlySet +): Promise { + const directory = join(pluginDir, PROVENANCE_DIRECTORY) + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []) + await Promise.all( + entries + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.json') && + PLUGIN_CONTENT_HASH_PATTERN.test(entry.name.slice(0, -'.json'.length)) && + !retained.has(entry.name.slice(0, -'.json'.length)) + ) + .map((entry) => rm(join(directory, entry.name), { force: true })) + ) +} diff --git a/src/main/plugins/plugin-install-publication.ts b/src/main/plugins/plugin-install-publication.ts new file mode 100644 index 00000000000..291afa76fc7 --- /dev/null +++ b/src/main/plugins/plugin-install-publication.ts @@ -0,0 +1,94 @@ +import { readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { + PLUGIN_CONTENT_HASH_PATTERN, + upsertPluginLock, + type PluginLockEntry +} from '../../shared/plugins/plugin-install-lockfile' +import { + readPluginCurrentPointer, + restorePluginCurrentPointer, + writePluginCurrentPointer +} from './plugin-current-pointer' +import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' +import { + prunePluginInstallProvenance, + readPluginInstallProvenance, + writePluginInstallProvenance +} from './plugin-install-provenance' + +/** Publishes executable identity and provenance as one recoverable mutation, + * then retains only current plus one rollback version. */ +export async function publishPluginInstall(input: { + pluginsDir: string + pluginDir: string + entry: PluginLockEntry +}): Promise { + const previousContentHash = await readPluginCurrentPointer(input.pluginDir) + const currentLock = await readPluginLockfile(input.pluginsDir) + const provenanceCandidate = + previousContentHash === input.entry.contentHash + ? await readPluginInstallProvenance(input.pluginDir, input.entry.contentHash) + : null + const matchesCurrentIdentity = (entry: PluginLockEntry | undefined | null): boolean => + entry?.pluginKey === input.entry.pluginKey && entry.contentHash === input.entry.contentHash + const existingProvenance = matchesCurrentIdentity(provenanceCandidate) + ? provenanceCandidate + : null + const legacyLockEntry = currentLock.plugins[input.entry.pluginKey] + const legacyCurrentEntry = + previousContentHash === input.entry.contentHash && matchesCurrentIdentity(legacyLockEntry) + ? legacyLockEntry + : null + // Provenance is immutable per executable identity. A same-byte reinstall + // is a no-op so a failed or interrupted source change cannot be recovered + // later as though it had successfully published. + const publishedEntry = existingProvenance ?? legacyCurrentEntry ?? input.entry + const nextLock = upsertPluginLock(currentLock, publishedEntry) + // Why: after a crash between pointer and global-index publication, startup + // can reconstruct exact source/commit identity from this immutable record. + if (!existingProvenance) { + await writePluginInstallProvenance(input.pluginDir, publishedEntry) + } + await writePluginCurrentPointer(input.pluginDir, input.entry.contentHash) + try { + await writePluginLockfile(input.pluginsDir, nextLock) + } catch (publicationError) { + try { + await restorePluginCurrentPointer(input.pluginDir, previousContentHash) + } catch (rollbackError) { + throw new AggregateError( + [publicationError, rollbackError], + 'plugin install publication and pointer rollback both failed' + ) + } + throw publicationError + } + // Reinstalling B must not collapse an existing A rollback into {B}. + if (previousContentHash !== input.entry.contentHash) { + await pruneHistoricalVersions( + input.pluginDir, + new Set( + [input.entry.contentHash, previousContentHash].filter( + (hash): hash is string => + typeof hash === 'string' && PLUGIN_CONTENT_HASH_PATTERN.test(hash) + ) + ) + ).catch(() => undefined) + } +} + +async function pruneHistoricalVersions(pluginDir: string, retained: ReadonlySet) { + const entries = await readdir(pluginDir, { withFileTypes: true }) + await Promise.all( + entries + .filter( + (entry) => + entry.isDirectory() && + PLUGIN_CONTENT_HASH_PATTERN.test(entry.name) && + !retained.has(entry.name) + ) + .map((entry) => rm(join(pluginDir, entry.name), { recursive: true, force: true })) + ) + await prunePluginInstallProvenance(pluginDir, retained) +} diff --git a/src/main/plugins/plugin-install-staging.ts b/src/main/plugins/plugin-install-staging.ts new file mode 100644 index 00000000000..2f0a71fe7a0 --- /dev/null +++ b/src/main/plugins/plugin-install-staging.ts @@ -0,0 +1,253 @@ +import { existsSync } from 'node:fs' +import { cp, mkdir, rm } from 'node:fs/promises' +import { join, relative, resolve, sep } from 'node:path' +import { + PLUGIN_MANIFEST_FILENAME, + parsePluginManifest, + qualifiedPluginKey, + satisfiesOrcaEngineRange, + type PluginManifest +} from '../../shared/plugins/plugin-manifest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import type { + PluginInstallSource, + PluginLockEntry +} from '../../shared/plugins/plugin-install-lockfile' +import { + validateDeclaredPluginArtifacts, + validatePluginInstallContent, + type PluginArtifactValidationResult +} from './plugin-artifact-validation' +import { renamePluginFileWithWindowsRetry } from './plugin-atomic-file-write' +import { hashPluginTree } from './plugin-content-hash' +import { publishPluginInstall } from './plugin-install-publication' +import { readPluginManifestText } from './plugin-manifest-file' +import { pluginInstallTrustError } from './plugin-install-trust' + +export type PluginInstallResult = + | { + ok: true + pluginKey: string + version: string + contentHash: string + consentFingerprint: string + resolvedCommit: string | null + } + | { ok: false; error: string } + +export type PluginInstallInspection = + | { + ok: true + manifest: PluginManifest + pluginKey: string + contentHash: string + consentFingerprint: string + } + | { ok: false; error: string } + +async function validatePluginInstallTree( + rootDir: string, + manifest: PluginManifest +): Promise { + const declared = await validateDeclaredPluginArtifacts(rootDir, manifest) + return declared.ok ? validatePluginInstallContent(rootDir, manifest) : declared +} + +async function readInstallManifest( + rootDir: string, + hostVersion: string +): Promise<{ ok: true; manifest: PluginManifest } | { ok: false; error: string }> { + let raw: unknown + try { + raw = JSON.parse(await readPluginManifestText(rootDir)) + } catch (error) { + return { + ok: false, + error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}` + } + } + const parsed = parsePluginManifest(raw) + if (!parsed.ok) { + return { ok: false, error: `invalid manifest: ${parsed.error}` } + } + if (!satisfiesOrcaEngineRange(hostVersion, parsed.manifest.engines.orca)) { + return { + ok: false, + error: `plugin requires Orca ${parsed.manifest.engines.orca} (this is ${hostVersion})` + } + } + return { ok: true, manifest: parsed.manifest } +} + +/** Validates and hashes a source tree without publishing it. Marketplace + * previews use this exact path so the reviewed bytes match install policy. */ +export async function inspectPluginInstallTree(input: { + rootDir: string + hostVersion: string + expectedPluginKey?: string +}): Promise { + const sourceManifest = await readInstallManifest(input.rootDir, input.hostVersion) + if (!sourceManifest.ok) { + return sourceManifest + } + const pluginKey = qualifiedPluginKey(sourceManifest.manifest) + if (input.expectedPluginKey && pluginKey !== input.expectedPluginKey) { + return { + ok: false, + error: `plugin manifest identity ${pluginKey} does not match marketplace listing ${input.expectedPluginKey}` + } + } + const declaredArtifacts = await validatePluginInstallTree(input.rootDir, sourceManifest.manifest) + if (!declaredArtifacts.ok) { + return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` } + } + const treeHash = await hashPluginTree(input.rootDir) + if (!treeHash.ok) { + return { ok: false, error: treeHash.error } + } + return { + ok: true, + manifest: sourceManifest.manifest, + pluginKey, + contentHash: treeHash.hash, + consentFingerprint: fingerprintPluginConsent(sourceManifest.manifest, treeHash.hash) + } +} + +/** Installs a validated staging tree into the hash-addressed layout. */ +export async function installStagedPluginTree(input: { + pluginsDir: string + stagingDir: string + hostVersion: string + source: PluginInstallSource + resolvedCommit: string | null + expectedPluginKey?: string + /** Trusted bundled bytes may restore an immutable directory damaged on disk. */ + repairCorruptedVersion?: boolean + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + const sourceInspection = await inspectPluginInstallTree({ + rootDir: input.stagingDir, + hostVersion: input.hostVersion, + ...(input.expectedPluginKey ? { expectedPluginKey: input.expectedPluginKey } : {}) + }) + if (!sourceInspection.ok) { + return sourceInspection + } + const trustError = pluginInstallTrustError(sourceInspection.pluginKey, input.source) + if (trustError) { + return { ok: false, error: trustError } + } + const blockedReason = input.blockedPluginReason?.(sourceInspection.pluginKey) + if (blockedReason) { + return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } + } + let manifest = sourceInspection.manifest + const pluginKey = sourceInspection.pluginKey + const pluginDir = join(input.pluginsDir, pluginKey) + const versionDir = join(pluginDir, sourceInspection.contentHash) + if (existsSync(versionDir) && input.repairCorruptedVersion) { + const existingHash = await hashPluginTree(versionDir) + if (!existingHash.ok || existingHash.hash !== sourceInspection.contentHash) { + // Why: bundled resources are release-index verified above, so they can + // safely restore a damaged immutable install instead of staying broken. + await rm(versionDir, { recursive: true, force: true }) + } + } + if (!existsSync(versionDir)) { + const stagedVersionDir = `${versionDir}.staging` + try { + await rm(stagedVersionDir, { recursive: true, force: true }) + await mkdir(pluginDir, { recursive: true }) + // Source trees can change while copying. Hashing the destination closes + // that race before the immutable directory becomes current. + const stagingRoot = resolve(input.stagingDir) + await cp(stagingRoot, stagedVersionDir, { + recursive: true, + verbatimSymlinks: true, + // Source-control metadata is not plugin content. Skip it at the copy + // boundary so a large local repository cannot bypass install limits. + filter: (source) => { + const fromRoot = relative(stagingRoot, resolve(source)) + return fromRoot !== '.git' && !fromRoot.startsWith(`.git${sep}`) + } + }) + const copiedHash = await hashPluginTree(stagedVersionDir) + if (!copiedHash.ok || copiedHash.hash !== sourceInspection.contentHash) { + return { + ok: false, + error: copiedHash.ok + ? 'plugin content changed while it was being copied' + : copiedHash.error + } + } + const copiedManifest = await readInstallManifest(stagedVersionDir, input.hostVersion) + if (!copiedManifest.ok) { + return { ok: false, error: `copied ${copiedManifest.error}` } + } + if (qualifiedPluginKey(copiedManifest.manifest) !== pluginKey) { + return { ok: false, error: 'plugin manifest identity changed while it was being staged' } + } + manifest = copiedManifest.manifest + const copiedArtifacts = await validatePluginInstallTree(stagedVersionDir, manifest) + if (!copiedArtifacts.ok) { + return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` } + } + await renamePluginFileWithWindowsRetry(stagedVersionDir, versionDir) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + await rm(stagedVersionDir, { recursive: true, force: true }) + } + } else { + // Never repoint at an existing hash directory without proving its bytes; + // a previous partial/tampered install must not be revived by reinstall. + const existingHash = await hashPluginTree(versionDir) + if (!existingHash.ok || existingHash.hash !== sourceInspection.contentHash) { + return { + ok: false, + error: existingHash.ok + ? 'existing plugin content failed integrity verification' + : existingHash.error + } + } + const existingManifest = await readInstallManifest(versionDir, input.hostVersion) + if (!existingManifest.ok) { + return { ok: false, error: `installed ${existingManifest.error}` } + } + if (qualifiedPluginKey(existingManifest.manifest) !== pluginKey) { + return { ok: false, error: 'installed plugin manifest identity does not match its directory' } + } + manifest = existingManifest.manifest + const existingArtifacts = await validatePluginInstallTree(versionDir, manifest) + if (!existingArtifacts.ok) { + return { + ok: false, + error: `installed artifact validation failed: ${existingArtifacts.error}` + } + } + } + const consentFingerprint = fingerprintPluginConsent(manifest, sourceInspection.contentHash) + const entry: PluginLockEntry = { + pluginKey, + version: manifest.version, + source: input.source, + resolvedCommit: input.resolvedCommit, + contentHash: sourceInspection.contentHash, + consentFingerprint, + installedAt: Date.now() + } + try { + await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + return { + ok: true, + pluginKey, + version: manifest.version, + contentHash: sourceInspection.contentHash, + consentFingerprint, + resolvedCommit: input.resolvedCommit + } +} diff --git a/src/main/plugins/plugin-install-trust.test.ts b/src/main/plugins/plugin-install-trust.test.ts new file mode 100644 index 00000000000..8f1c5e302d9 --- /dev/null +++ b/src/main/plugins/plugin-install-trust.test.ts @@ -0,0 +1,115 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { PluginInstallSource } from '../../shared/plugins/plugin-install-lockfile' +import { + installBundledPlugin, + installPluginFromLocalPath, + readPluginLockfile +} from './plugin-install' +import { pluginInstallTrustError } from './plugin-install-trust' + +const roots: string[] = [] + +async function tempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + return root +} + +async function writePlugin(root: string, publisher: string, id: string): Promise { + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id, + publisher, + name: 'Plugin', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [] + }) + ) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('plugin install trust', () => { + it.each<[PluginInstallSource, string | null]>([ + [ + { + kind: 'git', + url: 'https://github.com/attacker/orca-secrets.git', + ref: 'main' + }, + 'reserved plugin identity community.orca-secrets must resolve to the stablyai organization' + ], + [ + { + kind: 'git', + url: 'git@github.com:stablyai/orca-secrets.git', + ref: 'main' + }, + null + ] + ])('enforces reserved source organization', (source, expected) => { + expect(pluginInstallTrustError('community.orca-secrets', source)).toBe(expected) + }) + + it('rejects locally installed reserved identities before publication', async () => { + const sourcePath = await tempRoot('orca-reserved-plugin-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePlugin(sourcePath, 'stablyai', 'orca-skills') + + await expect( + installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ + ok: false, + error: 'reserved plugin identity stablyai.orca-skills cannot be installed from a local path' + }) + await expect(readPluginLockfile(pluginsDir)).resolves.toEqual({ version: 1, plugins: {} }) + }) + + it('allows the app-bundled path only for the complete official identity', async () => { + const sourcePath = await tempRoot('orca-bundled-plugin-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePlugin(sourcePath, 'stablyai', 'orca-skills') + + const result = await installBundledPlugin({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0', + expectedPluginKey: 'stablyai.orca-skills' + }) + + expect(result).toMatchObject({ ok: true, pluginKey: 'stablyai.orca-skills' }) + const lock = await readPluginLockfile(pluginsDir) + expect(lock.plugins['stablyai.orca-skills']?.source).toEqual({ + kind: 'bundled', + bundleId: 'stablyai.orca-skills' + }) + }) + + it('blocks a killed plugin even when the caller bypasses marketplace UI', async () => { + const sourcePath = await tempRoot('orca-killed-plugin-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePlugin(sourcePath, 'community', 'unsafe') + + await expect( + installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0', + blockedPluginReason: (pluginKey) => + pluginKey === 'community.unsafe' ? 'Security incident' : null + }) + ).resolves.toEqual({ + ok: false, + error: "plugin is blocked by Orca's safety list: Security incident" + }) + }) +}) diff --git a/src/main/plugins/plugin-install-trust.ts b/src/main/plugins/plugin-install-trust.ts new file mode 100644 index 00000000000..33b46614af6 --- /dev/null +++ b/src/main/plugins/plugin-install-trust.ts @@ -0,0 +1,27 @@ +import type { PluginInstallSource } from '../../shared/plugins/plugin-install-lockfile' +import { + isOfficialOrganizationGitSource, + isOfficialPluginIdentity, + isReservedPluginIdentity +} from '../../shared/plugins/plugin-marketplace' + +export function pluginInstallTrustError( + pluginKey: string, + source: PluginInstallSource +): string | null { + if (source.kind === 'bundled') { + return source.bundleId === pluginKey && isOfficialPluginIdentity(pluginKey) + ? null + : 'bundled plugins must use an official stablyai.orca-* identity' + } + if (!isReservedPluginIdentity(pluginKey)) { + return null + } + if (source.kind === 'local-path') { + return `reserved plugin identity ${pluginKey} cannot be installed from a local path` + } + const url = source.kind === 'git' ? source.url : source.plugin.url + return isOfficialOrganizationGitSource(url) + ? null + : `reserved plugin identity ${pluginKey} must resolve to the stablyai organization` +} diff --git a/src/main/plugins/plugin-install.test.ts b/src/main/plugins/plugin-install.test.ts new file mode 100644 index 00000000000..ab9591eba3d --- /dev/null +++ b/src/main/plugins/plugin-install.test.ts @@ -0,0 +1,531 @@ +import { execFile } from 'node:child_process' +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + symlink, + truncate, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + installPluginFromLocalPath, + installPluginFromGit, + PLUGIN_LOCKFILE_MAX_BYTES, + readPluginLockfile, + removeInstalledPlugin +} from './plugin-install' +import { PLUGIN_MANIFEST_MAX_BYTES } from './plugin-manifest-file' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { writePluginLockfile } from './plugin-install-lockfile-store' +import { installStagedPluginTree } from './plugin-install-staging' +import * as manifestFile from './plugin-manifest-file' + +const roots: string[] = [] +const execFileAsync = promisify(execFile) + +async function tempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + return root +} + +async function writePluginSource( + root: string, + options: { id?: string; panelEntry?: string; includePanel?: boolean } = {} +): Promise { + const panelEntry = options.panelEntry ?? 'panel.html' + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: options.id ?? 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: panelEntry }], + commands: [], + events: [] + }, + capabilities: [] + }) + ) + if (options.includePanel !== false) { + await writeFile(join(root, panelEntry), '

    Panel

    ') + } +} + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('installPluginFromLocalPath', () => { + it('refuses to allocate an oversized install lockfile', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const lockPath = join(pluginsDir, 'plugins.lock.json') + await writeFile(lockPath, '') + await truncate(lockPath, PLUGIN_LOCKFILE_MAX_BYTES + 1) + + await expect(readPluginLockfile(pluginsDir)).resolves.toEqual({ version: 1, plugins: {} }) + }) + + it('verifies copied content and writes a rollback-compatible consent field', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + await expect( + readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8') + ).resolves.toBe('

    Panel

    ') + const lock = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as { + plugins: Record> + } + expect(lock.plugins[result.pluginKey]).toMatchObject({ + capabilityHash: result.consentFingerprint + }) + expect(lock.plugins[result.pluginKey]).not.toHaveProperty('consentFingerprint') + }) + + it('publishes metadata from the copied immutable manifest', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const firstManifest = await readFile(join(sourcePath, 'orca-plugin.json'), 'utf8') + const changedManifest = { + ...(JSON.parse(firstManifest) as Record), + name: 'Changed During Staging', + version: '2.0.0' + } + await writeFile(join(sourcePath, 'orca-plugin.json'), JSON.stringify(changedManifest)) + const manifestRead = vi + .spyOn(manifestFile, 'readPluginManifestText') + .mockResolvedValueOnce(firstManifest) + + const result = await installStagedPluginTree({ + pluginsDir, + stagingDir: sourcePath, + hostVersion: '1.4.0', + source: { kind: 'local-path', path: sourcePath }, + resolvedCommit: null + }) + + expect(manifestRead).toHaveBeenCalledTimes(2) + expect(result).toMatchObject({ ok: true, version: '2.0.0' }) + if (result.ok) { + const lock = await readPluginLockfile(pluginsDir) + expect(lock.plugins[result.pluginKey]?.version).toBe('2.0.0') + } + }) + + it('skips root Git metadata before copying while still enforcing plugin limits', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const gitDir = join(sourcePath, '.git') + await mkdir(gitDir) + await writeFile(join(gitDir, 'large.pack'), '') + await truncate(join(gitDir, 'large.pack'), 50 * 1024 * 1024 + 1) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: true }) + if (result.ok) { + await expect( + readFile(join(pluginsDir, result.pluginKey, result.contentHash, '.git', 'large.pack')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + } + }) + + it('restores the previous current pointer when lockfile publication fails', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const first = await installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + await writeFile(join(sourcePath, 'panel.html'), '

    Updated

    ') + await rm(join(pluginsDir, 'plugins.lock.json')) + await mkdir(join(pluginsDir, 'plugins.lock.json')) + + const failed = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(failed).toMatchObject({ ok: false }) + await expect(readPluginCurrentPointer(join(pluginsDir, first.pluginKey))).resolves.toBe( + first.contentHash + ) + }) + + it('does not replace provenance when a same-content reinstall fails to publish', async () => { + const firstSource = await tempRoot('orca-plugin-source-') + const secondSource = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(firstSource) + await writePluginSource(secondSource) + const first = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: firstSource, + hostVersion: '1.4.0' + }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + const acceptedLock = await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8') + await rm(join(pluginsDir, 'plugins.lock.json')) + await mkdir(join(pluginsDir, 'plugins.lock.json')) + + const failed = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: secondSource, + hostVersion: '1.4.0' + }) + expect(failed).toMatchObject({ ok: false }) + + await rm(join(pluginsDir, 'plugins.lock.json'), { recursive: true }) + await writeFile(join(pluginsDir, 'plugins.lock.json'), acceptedLock) + const recovered = await readPluginLockfile(pluginsDir) + expect(recovered.plugins[first.pluginKey]?.source).toEqual({ + kind: 'local-path', + path: firstSource + }) + }) + + it('preserves legacy lock provenance during a same-content reinstall', async () => { + const firstSource = await tempRoot('orca-plugin-source-') + const secondSource = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(firstSource) + await writePluginSource(secondSource) + const first = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: firstSource, + hostVersion: '1.4.0' + }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + await rm(join(pluginsDir, first.pluginKey, '.install-provenance', `${first.contentHash}.json`)) + const reinstalled = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: secondSource, + hostVersion: '1.4.0' + }) + expect(reinstalled).toMatchObject({ ok: true }) + + // Recovery from the newly backfilled provenance must retain the accepted + // legacy source rather than the same-byte reinstall's alternate source. + await rm(join(pluginsDir, 'plugins.lock.json')) + const recovered = await readPluginLockfile(pluginsDir) + expect(recovered.plugins[first.pluginKey]?.source).toEqual({ + kind: 'local-path', + path: firstSource + }) + }) + + it('retains only the current and immediately previous content versions', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const hashes: string[] = [] + for (const content of ['one', 'two', 'three']) { + await writeFile(join(sourcePath, 'panel.html'), `

    ${content}

    `) + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(result.ok).toBe(true) + if (result.ok) { + hashes.push(result.contentHash) + } + } + + const versionDirs = ( + await readdir(join(pluginsDir, 'orca-samples.demo'), { + withFileTypes: true + }) + ) + .filter((entry) => entry.isDirectory() && /^[0-9a-f]{64}$/.test(entry.name)) + .map((entry) => entry.name) + .sort() + expect(versionDirs).toEqual(hashes.slice(-2).sort()) + }) + + it('keeps the rollback version when current content is reinstalled', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const hashes: string[] = [] + for (const content of ['one', 'two', 'two']) { + await writeFile(join(sourcePath, 'panel.html'), `

    ${content}

    `) + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(result.ok).toBe(true) + if (result.ok) { + hashes.push(result.contentHash) + } + } + + const versionDirs = ( + await readdir(join(pluginsDir, 'orca-samples.demo'), { withFileTypes: true }) + ) + .filter((entry) => entry.isDirectory() && /^[0-9a-f]{64}$/.test(entry.name)) + .map((entry) => entry.name) + .sort() + expect(versionDirs).toEqual([...new Set(hashes)].sort()) + }) + + it('repairs a pointer-new lock-old interrupted publication from provenance', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const first = await installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' }) + expect(first.ok).toBe(true) + const oldLock = await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8') + await writeFile(join(sourcePath, 'panel.html'), '

    new current

    ') + const second = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(second.ok).toBe(true) + if (!first.ok || !second.ok) { + return + } + + await writeFile(join(pluginsDir, 'plugins.lock.json'), oldLock) + const repaired = await readPluginLockfile(pluginsDir) + + expect(repaired.plugins[second.pluginKey]?.contentHash).toBe(second.contentHash) + const persisted = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as { + plugins: Record + } + expect(persisted.plugins[second.pluginKey]?.contentHash).toBe(second.contentHash) + }) + + it('rejects a manifest whose declared panel artifact is missing', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath, { includePanel: false }) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: false }) + }) + + it('rejects an oversized manifest without reading an unbounded JSON payload', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + const manifestPath = join(sourcePath, 'orca-plugin.json') + await writeFile(manifestPath, '') + await truncate(manifestPath, PLUGIN_MANIFEST_MAX_BYTES + 1) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: false, error: expect.stringContaining('exceeds') }) + }) + + it('serializes concurrent installs so lockfile entries are not lost', async () => { + const firstSource = await tempRoot('orca-plugin-source-') + const secondSource = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(firstSource, { id: 'first' }) + await writePluginSource(secondSource, { id: 'second' }) + + const results = await Promise.all([ + installPluginFromLocalPath({ pluginsDir, sourcePath: firstSource, hostVersion: '1.4.0' }), + installPluginFromLocalPath({ pluginsDir, sourcePath: secondSource, hostVersion: '1.4.0' }) + ]) + expect(results.every((result) => result.ok)).toBe(true) + const lock = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as { + plugins: Record + } + expect(Object.keys(lock.plugins).sort()).toEqual(['orca-samples.first', 'orca-samples.second']) + }) + + it('serializes concurrent lockfile publications without temporary-file collisions', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const lock = { version: 1 as const, plugins: {} } + + await expect( + Promise.all([ + writePluginLockfile(pluginsDir, lock), + writePluginLockfile(pluginsDir, lock), + writePluginLockfile(pluginsDir, lock) + ]) + ).resolves.toHaveLength(3) + await expect(readPluginLockfile(pluginsDir)).resolves.toEqual(lock) + }) + + it('refuses to repoint at a tampered existing content directory', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const first = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + await writeFile( + join(pluginsDir, first.pluginKey, first.contentHash, 'panel.html'), + '

    Tampered

    ' + ) + + const second = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(second).toMatchObject({ + ok: false, + error: expect.stringContaining('integrity verification') + }) + }) +}) + +describe('installPluginFromGit', () => { + it('uses system Git, resolves the requested ref, and installs its exact bytes', async () => { + const sourcePath = await tempRoot('orca-plugin-git-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + await execFileAsync('git', ['init', '--quiet'], { cwd: sourcePath }) + await execFileAsync('git', ['config', 'user.email', 'plugins@example.invalid'], { + cwd: sourcePath + }) + await execFileAsync('git', ['config', 'user.name', 'Plugin Test'], { cwd: sourcePath }) + await execFileAsync('git', ['add', '.'], { cwd: sourcePath }) + await execFileAsync('git', ['commit', '--quiet', '-m', 'fixture'], { cwd: sourcePath }) + await execFileAsync('git', ['tag', 'v1.0.0'], { cwd: sourcePath }) + const { stdout: commitStdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { + cwd: sourcePath + }) + + const configKeys = ['GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'] as const + const previous = Object.fromEntries(configKeys.map((key) => [key, process.env[key]])) + process.env.GIT_CONFIG_COUNT = '1' + process.env.GIT_CONFIG_KEY_0 = `url.${pathToFileURL(sourcePath).href}.insteadOf` + process.env.GIT_CONFIG_VALUE_0 = 'https://plugin.test/demo.git' + try { + const result = await installPluginFromGit({ + pluginsDir, + url: 'https://plugin.test/demo.git', + ref: 'v1.0.0', + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: true, resolvedCommit: commitStdout.trim() }) + if (result.ok) { + await expect( + readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8') + ).resolves.toBe('

    Panel

    ') + } + } finally { + for (const key of configKeys) { + const value = previous[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + }) +}) + +describe('removeInstalledPlugin', () => { + it('rejects an unqualified or traversing key before removing anything', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const pluginsDataDir = await tempRoot('orca-plugin-data-') + const outside = join(await tempRoot('orca-plugin-outside-'), 'keep.txt') + await writeFile(outside, 'keep') + + await expect( + removeInstalledPlugin({ pluginsDir, pluginsDataDir, pluginKey: '../outside' }) + ).rejects.toThrow('invalid qualified plugin key') + await expect(readFile(outside, 'utf8')).resolves.toBe('keep') + }) + + it('rejects a resolved uninstall target outside its root', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const pluginsDataDir = await tempRoot('orca-plugin-data-') + const outside = await tempRoot('orca-plugin-outside-') + const marker = join(outside, 'keep.txt') + await writeFile(marker, 'keep') + await symlink( + outside, + join(pluginsDir, 'orca-samples.demo'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + await expect( + removeInstalledPlugin({ + pluginsDir, + pluginsDataDir, + pluginKey: 'orca-samples.demo' + }) + ).rejects.toThrow('outside') + await expect(readFile(marker, 'utf8')).resolves.toBe('keep') + }) + + it('removes qualified install and data directories', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const pluginsDataDir = await tempRoot('orca-plugin-data-') + const key = 'orca-samples.demo' + await mkdir(join(pluginsDir, key)) + await mkdir(join(pluginsDataDir, key)) + await writeFile(join(pluginsDir, key, 'content'), 'installed') + await writeFile(join(pluginsDataDir, key, 'storage.json'), '{}') + + await removeInstalledPlugin({ pluginsDir, pluginsDataDir, pluginKey: key }) + + await expect(readFile(join(pluginsDir, key, 'content'))).rejects.toThrow() + await expect(readFile(join(pluginsDataDir, key, 'storage.json'))).rejects.toThrow() + }) +}) diff --git a/src/main/plugins/plugin-install.ts b/src/main/plugins/plugin-install.ts new file mode 100644 index 00000000000..df3b6b4ed74 --- /dev/null +++ b/src/main/plugins/plugin-install.ts @@ -0,0 +1,319 @@ +import { mkdtemp, readdir, realpath, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + PLUGIN_MANIFEST_FILENAME, + isQualifiedPluginKey +} from '../../shared/plugins/plugin-manifest' +import { + isAllowedPluginGitUrl, + PLUGIN_COMMIT_PATTERN, + PLUGIN_CONTENT_HASH_PATTERN, + pluginInstallSourceSchema, + removePluginLock +} from '../../shared/plugins/plugin-install-lockfile' +import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' +import { + inspectPluginInstallTree, + installStagedPluginTree, + type PluginInstallResult +} from './plugin-install-staging' +import { checkoutPluginGitSource } from './plugin-git-repository' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { readPluginInstallProvenance } from './plugin-install-provenance' +import { publishPluginInstall } from './plugin-install-publication' + +export type { PluginInstallResult } from './plugin-install-staging' + +export { + PLUGIN_LOCKFILE_MAX_BYTES, + pluginLockfilePath, + readPluginLockfile +} from './plugin-install-lockfile-store' + +/** + * Plugin installer, v0 sources: local path + git URL `#ref`. Git operations + * shell out to SYSTEM git (execFile, argv arrays — never a shell string, and + * never a vendored checkout: private repos must work with the user's + * existing credential helpers and SSH remotes). No script execution during + * install, ever — the installer copies files, nothing more. + * + * Installs land in immutable hash-addressed dirs behind an atomic pointer + * swap; the previous version dir is kept for one-step rollback. + */ + +const pluginMutationChains = new Map>() + +async function serializePluginMutation( + pluginsDir: string, + operation: () => Promise +): Promise { + const previous = pluginMutationChains.get(pluginsDir) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(operation) + const settled = run.then( + () => undefined, + () => undefined + ) + pluginMutationChains.set(pluginsDir, settled) + try { + return await run + } finally { + if (pluginMutationChains.get(pluginsDir) === settled) { + pluginMutationChains.delete(pluginsDir) + } + } +} + +export async function installPluginFromLocalPath(input: { + pluginsDir: string + sourcePath: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + return serializePluginMutation(input.pluginsDir, async () => { + if (!existsSync(join(input.sourcePath, PLUGIN_MANIFEST_FILENAME))) { + return { ok: false, error: `no ${PLUGIN_MANIFEST_FILENAME} found in ${input.sourcePath}` } + } + return installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir: input.sourcePath, + hostVersion: input.hostVersion, + source: { kind: 'local-path', path: input.sourcePath }, + resolvedCommit: null, + blockedPluginReason: input.blockedPluginReason + }) + }) +} + +export async function installBundledPlugin(input: { + pluginsDir: string + sourcePath: string + hostVersion: string + expectedPluginKey: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + return serializePluginMutation(input.pluginsDir, () => + installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir: input.sourcePath, + hostVersion: input.hostVersion, + source: { kind: 'bundled', bundleId: input.expectedPluginKey }, + resolvedCommit: null, + expectedPluginKey: input.expectedPluginKey, + repairCorruptedVersion: true, + blockedPluginReason: input.blockedPluginReason + }) + ) +} + +export async function installPluginFromGit(input: { + pluginsDir: string + url: string + /** `#ref` suffix: branch, tag, or full commit SHA. Empty = default branch. */ + ref: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + if (!isAllowedPluginGitUrl(input.url)) { + return { ok: false, error: 'plugin Git URL must use HTTPS or SSH' } + } + return serializePluginMutation(input.pluginsDir, async () => { + const stagingDir = await mkdtemp(join(tmpdir(), 'orca-plugin-install-')) + try { + const ref = input.ref.trim() + const resolvedCommit = await checkoutPluginGitSource({ + url: input.url, + ref, + destination: stagingDir, + workingDirectory: tmpdir() + }) + return await installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir, + hostVersion: input.hostVersion, + source: { kind: 'git', url: input.url, ref }, + resolvedCommit, + blockedPluginReason: input.blockedPluginReason + }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } + }) +} + +export async function installPluginFromMarketplace(input: { + pluginsDir: string + hostVersion: string + expectedPluginKey: string + expectedResolvedCommit: string + marketplace: { url: string; ref: string; resolvedCommit: string } + plugin: { url: string; ref: string } + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + const source = pluginInstallSourceSchema.parse({ + kind: 'marketplace', + marketplace: input.marketplace, + plugin: input.plugin + }) + if (!isQualifiedPluginKey(input.expectedPluginKey)) { + return { ok: false, error: 'invalid marketplace plugin identity' } + } + if (!PLUGIN_COMMIT_PATTERN.test(input.expectedResolvedCommit)) { + return { ok: false, error: 'invalid previewed plugin commit' } + } + return serializePluginMutation(input.pluginsDir, async () => { + const stagingDir = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-install-')) + try { + const resolvedCommit = await checkoutPluginGitSource({ + url: input.plugin.url, + ref: input.plugin.ref, + destination: stagingDir, + workingDirectory: tmpdir() + }) + if (resolvedCommit !== input.expectedResolvedCommit) { + return { ok: false, error: 'plugin source changed after preview; review the update again' } + } + return await installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir, + hostVersion: input.hostVersion, + source, + resolvedCommit, + expectedPluginKey: input.expectedPluginKey, + blockedPluginReason: input.blockedPluginReason + }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } + }) +} + +/** Restores the single retained immutable predecessor. The old consent + * fingerprint becomes current again, so enablement still fails closed until + * the user has approved those exact bytes. */ +export async function rollbackInstalledPlugin(input: { + pluginsDir: string + pluginKey: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + if (!isQualifiedPluginKey(input.pluginKey)) { + return { ok: false, error: 'invalid qualified plugin key' } + } + const blockedReason = input.blockedPluginReason?.(input.pluginKey) + if (blockedReason) { + return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } + } + return serializePluginMutation(input.pluginsDir, async () => { + const pluginDir = join(input.pluginsDir, input.pluginKey) + const currentContentHash = await readPluginCurrentPointer(pluginDir).catch(() => null) + if (!currentContentHash) { + return { ok: false, error: 'installed plugin has no current version' } + } + const candidates = (await readdir(pluginDir, { withFileTypes: true }).catch(() => [])) + .filter( + (entry) => + entry.isDirectory() && + PLUGIN_CONTENT_HASH_PATTERN.test(entry.name) && + entry.name !== currentContentHash + ) + .map((entry) => entry.name) + if (candidates.length !== 1) { + return { + ok: false, + error: + candidates.length === 0 + ? 'no rollback version is available' + : 'rollback state is ambiguous' + } + } + const contentHash = candidates[0]! + const provenance = await readPluginInstallProvenance(pluginDir, contentHash) + if ( + !provenance || + provenance.pluginKey !== input.pluginKey || + provenance.contentHash !== contentHash + ) { + return { ok: false, error: 'rollback version has no valid install provenance' } + } + const inspection = await inspectPluginInstallTree({ + rootDir: join(pluginDir, contentHash), + hostVersion: input.hostVersion, + expectedPluginKey: input.pluginKey + }) + if (!inspection.ok || inspection.contentHash !== contentHash) { + return { + ok: false, + error: inspection.ok ? 'rollback version failed integrity verification' : inspection.error + } + } + try { + await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry: provenance }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + return { + ok: true, + pluginKey: input.pluginKey, + version: inspection.manifest.version, + contentHash, + consentFingerprint: provenance.consentFingerprint, + resolvedCommit: provenance.resolvedCommit + } + }) +} + +/** Removes the install dir, the plugin's data dir, and the lock entry. */ +export async function removeInstalledPlugin(input: { + pluginsDir: string + pluginsDataDir: string + pluginKey: string +}): Promise { + await serializePluginMutation(input.pluginsDir, async () => { + if (!isQualifiedPluginKey(input.pluginKey)) { + throw new Error(`invalid qualified plugin key: ${input.pluginKey}`) + } + await removeResolvedPluginDirectory(input.pluginsDir, input.pluginKey) + await removeResolvedPluginDirectory(input.pluginsDataDir, input.pluginKey) + await writePluginLockfile( + input.pluginsDir, + removePluginLock(await readPluginLockfile(input.pluginsDir), input.pluginKey) + ) + }) +} + +async function removeResolvedPluginDirectory(rootDir: string, pluginKey: string): Promise { + let rootReal: string + let targetReal: string + try { + rootReal = await realpath(resolve(rootDir)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + try { + targetReal = await realpath(resolve(rootDir, pluginKey)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + const fromRoot = relative(rootReal, targetReal) + if ( + fromRoot.length === 0 || + isAbsolute(fromRoot) || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) + ) { + throw new Error(`refusing to remove plugin path outside ${rootReal}`) + } + await rm(resolve(rootDir, pluginKey), { recursive: true, force: true }) +} diff --git a/src/main/plugins/plugin-instructional-content-integrity.ts b/src/main/plugins/plugin-instructional-content-integrity.ts new file mode 100644 index 00000000000..fe2c9a3dc01 --- /dev/null +++ b/src/main/plugins/plugin-instructional-content-integrity.ts @@ -0,0 +1,30 @@ +import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint' +import { hashPluginTree } from './plugin-content-hash' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +/** Instructional bytes execute later, so every read must still match the tree + * identity the user reviewed rather than a cached discovery-time snapshot. */ +export async function verifyInstructionalPluginContent( + plugin: ValidDiscoveredPlugin +): Promise { + if (!hasInstructionalPluginContributions(plugin.manifest)) { + return + } + if (!plugin.consentContentHash) { + throw new Error(`plugin ${plugin.pluginKey} has no instructional consent content identity`) + } + const actual = await hashPluginTree(plugin.rootDir) + if (!actual.ok) { + throw new Error( + `plugin ${plugin.pluginKey} instructional content is unreadable: ${actual.error}` + ) + } + const matches = + actual.hash === plugin.consentContentHash || + (plugin.consentContentHash.length === 32 && actual.hash.startsWith(plugin.consentContentHash)) + if (!matches) { + throw new Error( + `plugin ${plugin.pluginKey} instructional content changed since it was reviewed` + ) + } +} diff --git a/src/main/plugins/plugin-kill-list-service.test.ts b/src/main/plugins/plugin-kill-list-service.test.ts new file mode 100644 index 00000000000..3da97e08576 --- /dev/null +++ b/src/main/plugins/plugin-kill-list-service.test.ts @@ -0,0 +1,111 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginKillList } from '../../shared/plugins/plugin-kill-list' +import { fetchPluginKillList, PluginKillListService } from './plugin-kill-list-service' +import type { PluginKillListStore } from './plugin-kill-list-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-kill-list-')) + roots.push(root) + return root +} + +function killList(date = '2026-07-12T20:00:00Z'): PluginKillList { + return { + version: 1, + generatedAt: date, + plugins: [{ pluginKey: 'community.unsafe', reason: 'Malware advisory' }] + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginKillListService', () => { + it('loads cached revocations before any network refresh', async () => { + const root = await tempRoot() + const first = new PluginKillListService({ + pluginsDataDir: root, + fetcher: async () => killList() + }) + await first.refresh() + const fetcher = vi.fn(async () => killList()) + const restarted = new PluginKillListService({ pluginsDataDir: root, fetcher }) + + await restarted.initialize() + + expect(restarted.reason('community.unsafe')).toBe('Malware advisory') + expect(fetcher).not.toHaveBeenCalled() + }) + + it('publishes valid refreshes and notifies runtime reconciliation', async () => { + const service = new PluginKillListService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => killList() + }) + const changed = vi.fn() + service.onChanged(changed) + + await service.refresh() + + expect(service.find('community.unsafe')).toMatchObject({ reason: 'Malware advisory' }) + expect(changed).toHaveBeenCalledTimes(1) + }) + + it('starts with no revocations after a corrupt cache and accepts a valid refresh', async () => { + const store = { + read: vi.fn().mockRejectedValue(new Error('invalid JSON')), + write: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginKillListStore + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const service = new PluginKillListService({ + pluginsDataDir: await tempRoot(), + store, + fetcher: async () => killList() + }) + + await expect(service.initialize()).resolves.toBeUndefined() + expect(service.snapshot()).toBeNull() + await expect(service.refresh()).resolves.toEqual(killList()) + expect(service.reason('community.unsafe')).toBe('Malware advisory') + expect(warning).toHaveBeenCalledWith( + '[plugins] ignoring invalid cached plugin safety list:', + expect.any(Error) + ) + }) + + it('rejects a replayed older snapshot without replacing cached revocations', async () => { + const fetcher = vi + .fn<() => Promise>() + .mockResolvedValueOnce(killList('2026-07-12T20:00:00Z')) + .mockResolvedValueOnce(killList('2026-07-11T20:00:00Z')) + const service = new PluginKillListService({ pluginsDataDir: await tempRoot(), fetcher }) + await service.refresh() + + await expect(service.refresh()).rejects.toThrow('older snapshot') + expect(service.snapshot()?.generatedAt).toBe('2026-07-12T20:00:00Z') + }) +}) + +describe('fetchPluginKillList', () => { + it('validates a bounded HTTPS response body', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response(JSON.stringify(killList()), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ) + + await expect(fetchPluginKillList(fetcher)).resolves.toEqual(killList()) + }) + + it('rejects non-success responses', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response('no', { status: 503 })) + await expect(fetchPluginKillList(fetcher)).rejects.toThrow('HTTP 503') + }) +}) diff --git a/src/main/plugins/plugin-kill-list-service.ts b/src/main/plugins/plugin-kill-list-service.ts new file mode 100644 index 00000000000..572e7074b66 --- /dev/null +++ b/src/main/plugins/plugin-kill-list-service.ts @@ -0,0 +1,140 @@ +import { + findKilledPlugin, + pluginKillListSchema, + type PluginKillList, + type PluginKillListEntry +} from '../../shared/plugins/plugin-kill-list' +import { PluginKillListStore } from './plugin-kill-list-store' + +export const PLUGIN_KILL_LIST_URL = 'https://onorca.dev/plugins/kill-list.json' +const PLUGIN_KILL_LIST_DOWNLOAD_LIMIT = 4 * 1024 * 1024 + +type PluginKillListFetcher = () => Promise + +export class PluginKillListService { + private readonly store: PluginKillListStore + private readonly fetcher: PluginKillListFetcher + private readonly listeners = new Set<() => void>() + private currentList: PluginKillList | null = null + private loadPromise: Promise | null = null + private refreshChain: Promise = Promise.resolve({ + version: 1, + generatedAt: '1970-01-01T00:00:00Z', + plugins: [] + }) + + constructor(options: { + pluginsDataDir: string + store?: PluginKillListStore + fetcher?: PluginKillListFetcher + }) { + this.store = options.store ?? new PluginKillListStore(options.pluginsDataDir) + this.fetcher = options.fetcher ?? (() => fetchPluginKillList()) + } + + async initialize(): Promise { + this.loadPromise ??= this.store + .read() + .then((killList) => { + this.currentList = killList + }) + .catch((error) => { + // Why: an unusable cache must not prevent Orca from starting; a valid + // network refresh can still restore runtime revocations this session. + console.warn('[plugins] ignoring invalid cached plugin safety list:', error) + this.currentList = null + }) + await this.loadPromise + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + find(pluginKey: string): PluginKillListEntry | null { + return this.currentList ? findKilledPlugin(this.currentList, pluginKey) : null + } + + reason(pluginKey: string): string | null { + return this.find(pluginKey)?.reason ?? null + } + + snapshot(): PluginKillList | null { + return this.currentList + } + + refresh(): Promise { + const refresh = this.refreshChain + .catch(() => this.currentList ?? emptyKillList()) + .then(() => this.performRefresh()) + this.refreshChain = refresh + return refresh + } + + private async performRefresh(): Promise { + await this.initialize() + const fetched = pluginKillListSchema.parse(await this.fetcher()) + if ( + this.currentList && + Date.parse(fetched.generatedAt) < Date.parse(this.currentList.generatedAt) + ) { + throw new Error('refusing to replace the plugin kill list with an older snapshot') + } + await this.store.write(fetched) + this.currentList = fetched + for (const listener of this.listeners) { + listener() + } + return fetched + } +} + +export async function fetchPluginKillList( + fetcher: typeof fetch = fetch, + url = PLUGIN_KILL_LIST_URL +): Promise { + const response = await fetcher(url, { cache: 'no-store' }) + if (!response.ok) { + throw new Error(`plugin kill-list request failed with HTTP ${response.status}`) + } + const declaredBytes = Number(response.headers.get('content-length') ?? '0') + if (Number.isFinite(declaredBytes) && declaredBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) { + throw new Error('plugin kill-list response exceeds its size limit') + } + if (!response.body) { + throw new Error('plugin kill-list response has no body') + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let totalBytes = 0 + while (true) { + const chunk = await reader.read() + if (chunk.done) { + break + } + totalBytes += chunk.value.byteLength + if (totalBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) { + await reader.cancel() + throw new Error('plugin kill-list response exceeds its size limit') + } + chunks.push(chunk.value) + } + const bytes = new Uint8Array(totalBytes) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + try { + return pluginKillListSchema.parse(JSON.parse(new TextDecoder().decode(bytes))) + } catch (error) { + throw new Error( + `invalid plugin kill-list response: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function emptyKillList(): PluginKillList { + return { version: 1, generatedAt: '1970-01-01T00:00:00Z', plugins: [] } +} diff --git a/src/main/plugins/plugin-kill-list-store.ts b/src/main/plugins/plugin-kill-list-store.ts new file mode 100644 index 00000000000..5dca14190fd --- /dev/null +++ b/src/main/plugins/plugin-kill-list-store.ts @@ -0,0 +1,46 @@ +import { createReadStream } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pluginKillListSchema, type PluginKillList } from '../../shared/plugins/plugin-kill-list' +import { writePluginFileAtomically } from './plugin-atomic-file-write' + +const PLUGIN_KILL_LIST_MAX_BYTES = 4 * 1024 * 1024 + +export class PluginKillListStore { + private readonly filePath: string + + constructor(pluginsDataDir: string) { + this.filePath = join(pluginsDataDir, 'plugin-kill-list.json') + } + + async read(): Promise { + try { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(this.filePath)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_KILL_LIST_MAX_BYTES) { + throw new Error('plugin kill list exceeds its size limit') + } + chunks.push(bytes) + } + return pluginKillListSchema.parse( + JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw new Error( + `cached plugin kill list is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + async write(killList: PluginKillList): Promise { + const parsed = pluginKillListSchema.parse(killList) + await mkdir(dirname(this.filePath), { recursive: true }) + await writePluginFileAtomically(this.filePath, `${JSON.stringify(parsed, null, 2)}\n`) + } +} diff --git a/src/main/plugins/plugin-language-pack-registry.test.ts b/src/main/plugins/plugin-language-pack-registry.test.ts new file mode 100644 index 00000000000..94fd93e45ff --- /dev/null +++ b/src/main/plugins/plugin-language-pack-registry.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' + +const roots: string[] = [] + +async function pluginWithCatalog(catalog: unknown): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-language-registry-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'locales')) + await writeFile(join(rootDir, 'locales', 'pt-BR.json'), JSON.stringify(catalog)) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'portuguese', + publisher: 'orca-samples', + name: 'Portuguese', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }] + }, + capabilities: [] + }) + return { + pluginKey: 'orca-samples.portuguese', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginLanguagePackRegistry', () => { + it('loads approved catalogs under an isolated plugin language id', async () => { + const plugin = await pluginWithCatalog({ common: { save: 'Salvar' } }) + const registry = new PluginLanguagePackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([ + { + id: 'plugin:orca-samples.portuguese/pt-BR', + resourceLanguage: + 'plugin0070006c007500670069006e003a006f007200630061002d00730061006d0070006c00650073002e0070006f00720074007500670075006500730065002f00700074002d00420052', + pluginKey: 'orca-samples.portuguese', + locale: 'pt-BR', + catalog: { common: { save: 'Salvar' } } + } + ]) + expect(registry.error(plugin.pluginKey)).toBeNull() + }) + + it('fails closed for protected security copy and clears state when disabled', async () => { + const plugin = await pluginWithCatalog({ + auto: { components: { settings: { PluginConsentDialog: { disclaimer: 'Safe' } } } } + }) + const registry = new PluginLanguagePackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toEqual([]) + expect(registry.error(plugin.pluginKey)).toContain('protected security copy') + + await registry.reconcile([plugin], () => false) + expect(registry.error(plugin.pluginKey)).toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-language-pack-registry.ts b/src/main/plugins/plugin-language-pack-registry.ts new file mode 100644 index 00000000000..df409264772 --- /dev/null +++ b/src/main/plugins/plugin-language-pack-registry.ts @@ -0,0 +1,96 @@ +import { + parsePluginLanguagePackArtifact, + pluginLanguageResourceId, + type PluginLanguagePackRegistration +} from '../../shared/plugins/plugin-language-pack-artifact' +import { + PLUGIN_LANGUAGE_PACK_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +const LANGUAGE_PACK_LOAD_CONCURRENCY = 4 + +type LanguageLoadResult = + | { pluginKey: string; packs: PluginLanguagePackRegistration[] } + | { pluginKey: string; error: string } + +export class PluginLanguagePackRegistry { + private packs: PluginLanguagePackRegistration[] = [] + private readonly errors = new Map() + + constructor(private readonly contentVerifier: PluginContentVerifier) {} + + list(): readonly PluginLanguagePackRegistration[] { + return this.packs + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates: ValidDiscoveredPlugin[] = [] + for (const plugin of discovered) { + if ( + !isInvalidDiscoveredPlugin(plugin) && + isApproved(plugin) && + plugin.manifest.contributes.languagePacks.length > 0 + ) { + candidates.push(plugin) + } + } + const results = await mapWithConcurrency( + candidates, + LANGUAGE_PACK_LOAD_CONCURRENCY, + async (plugin): Promise => { + try { + await this.contentVerifier.verify(plugin) + const packs = await Promise.all( + plugin.manifest.contributes.languagePacks.map(async (contribution) => { + const text = await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_LANGUAGE_PACK_MAX_BYTES + ) + const parsed = parsePluginLanguagePackArtifact(text) + if (!parsed.ok) { + throw new Error(`language pack "${contribution.locale}" ${parsed.error}`) + } + const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const + return { + id, + resourceLanguage: pluginLanguageResourceId(id), + pluginKey: plugin.pluginKey, + locale: contribution.locale, + catalog: parsed.catalog + } + }) + ) + return { pluginKey: plugin.pluginKey, packs } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + this.packs = results.flatMap((result) => ('packs' in result ? result.packs : [])) + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } + } + } +} diff --git a/src/main/plugins/plugin-launch-content.test.ts b/src/main/plugins/plugin-launch-content.test.ts new file mode 100644 index 00000000000..692a987555c --- /dev/null +++ b/src/main/plugins/plugin-launch-content.test.ts @@ -0,0 +1,113 @@ +import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + isOfficialOrganizationGitSource, + isOfficialPluginIdentity, + pluginMarketplaceSchema +} from '../../shared/plugins/plugin-marketplace' +import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap' +import { inspectPluginInstallTree } from './plugin-install-staging' + +const launchRoot = join(process.cwd(), 'resources', 'plugins', 'launch') +const temporaryRoots: string[] = [] + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) +} + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('Phase 1 launch plugin content', () => { + it('lists and validates the launch plugin packs', async () => { + const marketplace = pluginMarketplaceSchema.parse( + await readJson(join(launchRoot, 'orca-marketplace.json')) + ) + expect(marketplace.plugins.map((plugin) => plugin.id).sort()).toEqual([ + 'stablyai.orca-multipass-recipes', + 'stablyai.orca-navigation-shortcuts', + 'stablyai.orca-portuguese' + ]) + expect( + marketplace.plugins.filter( + (plugin) => + isOfficialPluginIdentity(plugin.id) && isOfficialOrganizationGitSource(plugin.source.url) + ).length + ).toBeGreaterThanOrEqual(2) + + const localPluginDirectories = (await readdir(launchRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + expect(marketplace.plugins.map((plugin) => plugin.id).sort()).toEqual(localPluginDirectories) + + const contributionKinds = new Set() + for (const listing of marketplace.plugins) { + const inspection = await inspectPluginInstallTree({ + rootDir: join(launchRoot, listing.id), + hostVersion: '1.4.0', + expectedPluginKey: listing.id + }) + expect(inspection, `${listing.id} must pass the production install inspection`).toMatchObject( + { + ok: true + } + ) + if (!inspection.ok) { + continue + } + const contributes = inspection.manifest.contributes + if (contributes.languagePacks.length > 0) { + contributionKinds.add('language') + } + if (contributes.vmRecipes.length > 0) { + contributionKinds.add('vm-recipe') + } + if (contributes.commands.length > 0 && contributes.keybindings.length > 0) { + contributionKinds.add('command-keybinding') + } + } + expect(contributionKinds).toEqual(new Set(['language', 'vm-recipe', 'command-keybinding'])) + }) + + it('publishes every bundled pack only when its release hash matches exact bytes', async () => { + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-launch-content-')) + temporaryRoots.push(userDataPath) + + const result = await bootstrapBundledPlugins({ + root: launchRoot, + userDataPath, + hostVersion: '1.4.0' + }) + + expect(result.errors).toEqual([]) + expect(result.installed.length).toBeGreaterThanOrEqual(1) + expect(result.installed.every(isOfficialPluginIdentity)).toBe(true) + }) + + it('boots release-indexed content from the packaged resources layout', async () => { + const resourcesPath = await mkdtemp(join(tmpdir(), 'orca-packaged-resources-')) + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-packaged-user-data-')) + temporaryRoots.push(resourcesPath, userDataPath) + const packagedRoot = join(resourcesPath, 'plugins', 'launch') + await cp(launchRoot, packagedRoot, { recursive: true }) + + const result = await bootstrapBundledPlugins({ + root: resolveBundledPluginRoot({ + isPackaged: true, + resourcesPath, + appPath: join(resourcesPath, 'app.asar') + }), + userDataPath, + hostVersion: '1.4.0' + }) + + expect(result.errors).toEqual([]) + expect(result.installed).toEqual(['stablyai.orca-navigation-shortcuts']) + }) +}) diff --git a/src/main/plugins/plugin-list-projection.test.ts b/src/main/plugins/plugin-list-projection.test.ts new file mode 100644 index 00000000000..36904cfa262 --- /dev/null +++ b/src/main/plugins/plugin-list-projection.test.ts @@ -0,0 +1,224 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { emptyPluginLockfile } from '../../shared/plugins/plugin-install-lockfile' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { InvalidDiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery' +import { buildPluginList } from './plugin-list-projection' +import type { PluginService } from './plugin-service' + +const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { panels: [], commands: [], events: [] }, + capabilities: [{ kind: 'workspace:read' }] +}) + +function serviceWith( + discovered: ValidDiscoveredPlugin, + options: { + activation?: ReturnType + worker?: ReturnType + vmRecipes?: ReturnType + commands?: ReturnType + } = {} +): PluginService { + return { + options: { + getPluginConsents: () => ({}), + getDisabledPlugins: () => [] + }, + getDiscovered: () => [discovered], + activationState: () => options.activation ?? 'pending', + workerState: () => options.worker ?? { state: 'inactive', restarts: 0 }, + activationError: () => null, + contentPacks: { + vmRecipes: { preview: () => options.vmRecipes ?? [] }, + commands: { preview: () => options.commands ?? [] } + } + } as unknown as PluginService +} + +describe('buildPluginList consent identity', () => { + it('projects the exact current fingerprint for an optimistic consent write', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest, + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } + + expect((await buildPluginList(serviceWith(plugin), emptyPluginLockfile()))[0]).toMatchObject({ + pluginKey: plugin.pluginKey, + consentFingerprint: 'sha256-current', + status: 'pending' + }) + }) + + it('projects supervised backoff as restarting instead of running', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest, + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } + + expect( + ( + await buildPluginList( + serviceWith(plugin, { + activation: 'approved', + worker: { state: 'restarting', restarts: 2 } + }), + emptyPluginLockfile() + ) + )[0] + ).toMatchObject({ status: 'restarting', restarts: 2 }) + }) + + it('does not attribute a shadowing dev plugin to the installed source', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'development', 'demo'), + manifest, + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } + const lock = { + version: 1 as const, + plugins: { + [plugin.pluginKey]: { + pluginKey: plugin.pluginKey, + version: '1.0.0', + source: { kind: 'git' as const, url: 'https://example.com/demo.git', ref: 'v1' }, + resolvedCommit: 'a'.repeat(40), + contentHash: 'b'.repeat(64), + consentFingerprint: 'sha256-installed', + installedAt: 1 + } + } + } + + expect((await buildPluginList(serviceWith(plugin), lock))[0]).not.toHaveProperty('source') + }) + + it('does not expose an invalid development plugin absolute path as identity', async () => { + const invalid: InvalidDiscoveredPlugin = { + rootDir: join(tmpdir(), 'private', 'secret-plugin-path'), + error: 'missing orca-plugin.json', + isDev: true + } + const service = { + options: { getPluginConsents: () => ({}), getDisabledPlugins: () => [] }, + getDiscovered: () => [invalid] + } as unknown as PluginService + + const projected = (await buildPluginList(service, emptyPluginLockfile()))[0]! + expect(projected.pluginKey).toBe('invalid-development-plugin-1') + expect(projected.name).toBe('invalid-development-plugin-1') + expect(JSON.stringify(projected)).not.toContain(invalid.rootDir) + }) + + it('projects exact VM lifecycle commands for instructional consent', async () => { + const recipeManifest = pluginManifestSchema.parse({ + ...manifest, + contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] } + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest: recipeManifest, + consentFingerprint: 'sha256-current', + consentContentHash: 'a'.repeat(64), + contentHash: null, + isDev: true + } + + expect( + ( + await buildPluginList( + serviceWith(plugin, { + vmRecipes: [ + { + pluginKey: plugin.pluginKey, + recipe: { + id: 'cloud', + name: 'Cloud', + create: './create.sh', + destroyDisabled: true + } + } + ] + }), + emptyPluginLockfile() + ) + )[0]?.vmRecipes + ).toEqual([ + { + id: 'cloud', + name: 'Cloud', + commands: [ + { phase: 'create', command: './create.sh' }, + { phase: 'destroy', command: 'none' } + ] + } + ]) + }) + + it('projects command handlers and normalized keybindings for consent and dispatch', async () => { + const commandManifest = pluginManifestSchema.parse({ + ...manifest, + contributes: { + commands: [{ id: 'tasks', title: 'Open Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'mod+alt+t' }] + } + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest: commandManifest, + consentFingerprint: 'sha256-current', + consentContentHash: 'a'.repeat(64), + contentHash: null, + isDev: true + } + + expect( + ( + await buildPluginList( + serviceWith(plugin, { + commands: [ + { + pluginKey: plugin.pluginKey, + id: 'tasks', + title: 'Open Tasks', + context: 'worktree', + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }] + } + ] + }), + emptyPluginLockfile() + ) + )[0]?.commands + ).toEqual([ + { + id: 'tasks', + title: 'Open Tasks', + context: 'worktree', + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }] + } + ]) + }) +}) diff --git a/src/main/plugins/plugin-list-projection.ts b/src/main/plugins/plugin-list-projection.ts new file mode 100644 index 00000000000..16d44935b18 --- /dev/null +++ b/src/main/plugins/plugin-list-projection.ts @@ -0,0 +1,231 @@ +import { + PLUGIN_CAPABILITY_DESCRIPTIONS, + type PluginCapabilityKind +} from '../../shared/plugins/plugin-capabilities' +import { needsReconsent } from '../../shared/plugins/plugin-consent-state' +import { pluginPanelTabKey } from '../../shared/plugins/plugin-manifest' +import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile' +import { isInvalidDiscoveredPlugin } from './plugin-discovery' +import type { PluginService } from './plugin-service' +import { listPluginVmRecipeCommands } from '../../shared/plugins/plugin-vm-recipe-artifact' +import type { PluginCommandAliasActionId } from '../../shared/plugins/plugin-command-actions' +import { + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + isOfficialPluginIdentity +} from '../../shared/plugins/plugin-marketplace' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' + +const PLUGIN_LIST_PROJECTION_CONCURRENCY = 4 + +/** + * Wire projection of installed plugins for the renderer and serve RPC. + * `invalid` = unreadable/failed manifest; `pending` = awaiting (re-)consent; + * `idle` = enabled with no worker running (lazy); `restarting` = waiting for + * supervised backoff; `errored` = crashed past the budget or failed to activate. + */ + +export type PluginListPanelEntry = { + id: string + title: string + icon?: string + tabKey: `plugin:${string}` +} + +export type PluginListStatus = + | 'running' + | 'restarting' + | 'idle' + | 'pending' + | 'disabled' + | 'errored' + | 'invalid' + +export type PluginListEntry = { + pluginKey: string + /** Opaque identity of the exact capabilities and worker tier shown for review. */ + consentFingerprint: string | null + name: string + version: string + publisher: string + description?: string + status: PluginListStatus + needsReconsent: boolean + error?: string + isDev: boolean + official: boolean + bundled: boolean + capabilities: { kind: PluginCapabilityKind; description: string }[] + panels: PluginListPanelEntry[] + commands: { + id: string + title: string + context: 'global' | 'worktree' + handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' } + keybindings: { key: string; when: 'global' | 'worktree' }[] + }[] + hasWorker: boolean + vmRecipes: { + id: string + name: string + description?: string + commands: { phase: 'create' | 'suspend' | 'resume' | 'destroy'; command: string }[] + }[] + restarts: number + blockedByKillList?: { reason: string; advisoryUrl?: string } + source?: { + kind: 'local-path' | 'git' | 'marketplace' | 'bundled' + reference: string + resolvedCommit: string | null + contentHash: string + marketplace?: { reference: string; resolvedCommit: string } + } +} + +export async function buildPluginList( + service: PluginService, + lock: PluginLockfile +): Promise { + const consents = { + pluginConsents: service.options.getPluginConsents(), + disabledPlugins: service.options.getDisabledPlugins() + } + return mapWithConcurrency( + service.getDiscovered(), + PLUGIN_LIST_PROJECTION_CONCURRENCY, + async (plugin, index): Promise => { + if (isInvalidDiscoveredPlugin(plugin)) { + // Why: invalid dev paths can contain private absolute desktop paths; + // never project those as identity over desktop/serve transports. + const fallbackKey = plugin.pluginKey ?? `invalid-development-plugin-${index + 1}` + return { + pluginKey: fallbackKey, + consentFingerprint: null, + name: fallbackKey, + version: '0.0.0', + publisher: '', + status: 'invalid' as const, + needsReconsent: false, + error: plugin.error, + isDev: plugin.isDev, + official: false, + bundled: false, + capabilities: [], + panels: [], + commands: [], + hasWorker: false, + vmRecipes: [], + restarts: 0 + } + } + const activation = service.activationState(plugin) + const worker = service.workerState(plugin.pluginKey) + const activationError = service.activationError(plugin.pluginKey) + const killListEntry = service.options.getPluginKillListEntry?.(plugin.pluginKey) ?? null + let status: PluginListStatus + if (activation === 'disabled') { + status = 'disabled' + } else if (activation === 'pending') { + status = 'pending' + } else if (worker.state === 'errored' || activationError) { + status = 'errored' + } else if (worker.state === 'restarting') { + status = 'restarting' + } else { + status = worker.state === 'running' ? 'running' : 'idle' + } + const candidateLockEntry = lock.plugins[plugin.pluginKey] + // Why: never show provenance for bytes other than the current executable + // identity. Dev overrides execute outside the immutable installed tree and + // must never inherit the shadowed install's pinned-source attribution. + const lockEntry = + candidateLockEntry && + !plugin.isDev && + plugin.contentHash !== null && + candidateLockEntry.contentHash === plugin.contentHash + ? candidateLockEntry + : undefined + const bundled = lockEntry?.source.kind === 'bundled' + const official = + bundled || + (lockEntry?.source.kind === 'marketplace' && + isOfficialPluginIdentity(plugin.pluginKey) && + isOfficialMarketplaceGitSource(lockEntry.source.marketplace.url) && + isOfficialOrganizationGitSource(lockEntry.source.plugin.url)) + return { + pluginKey: plugin.pluginKey, + consentFingerprint: plugin.consentFingerprint, + name: plugin.manifest.name, + version: plugin.manifest.version, + publisher: plugin.manifest.publisher, + ...(plugin.manifest.description ? { description: plugin.manifest.description } : {}), + status, + needsReconsent: needsReconsent(plugin.pluginKey, plugin.consentFingerprint, consents), + ...(status === 'errored' + ? { error: activationError ?? 'plugin worker crashed repeatedly' } + : {}), + isDev: plugin.isDev, + official, + bundled, + capabilities: plugin.manifest.capabilities.map((capability) => ({ + kind: capability.kind, + description: PLUGIN_CAPABILITY_DESCRIPTIONS[capability.kind] + })), + panels: plugin.manifest.contributes.panels.map((panel) => ({ + id: panel.id, + title: panel.title, + ...(panel.icon ? { icon: panel.icon } : {}), + tabKey: pluginPanelTabKey(plugin.pluginKey, panel.id) + })), + commands: service.contentPacks.commands.preview(plugin.pluginKey).map((command) => ({ + id: command.id, + title: command.title, + context: command.context, + handler: command.handler, + keybindings: command.keybindings + })), + hasWorker: Boolean(plugin.manifest.main), + vmRecipes: service.contentPacks.vmRecipes.preview(plugin.pluginKey).map(({ recipe }) => ({ + id: recipe.id, + name: recipe.name, + ...(recipe.description ? { description: recipe.description } : {}), + commands: listPluginVmRecipeCommands(recipe) + })), + restarts: worker.restarts, + ...(killListEntry + ? { + blockedByKillList: { + reason: killListEntry.reason, + ...(killListEntry.advisoryUrl ? { advisoryUrl: killListEntry.advisoryUrl } : {}) + } + } + : {}), + ...(lockEntry + ? { + source: { + kind: lockEntry.source.kind, + reference: + lockEntry.source.kind === 'local-path' + ? lockEntry.source.path + : lockEntry.source.kind === 'git' + ? lockEntry.source.url + : lockEntry.source.kind === 'marketplace' + ? lockEntry.source.plugin.url + : `bundled:${lockEntry.source.bundleId}`, + resolvedCommit: lockEntry.resolvedCommit, + contentHash: lockEntry.contentHash, + ...(lockEntry.source.kind === 'marketplace' + ? { + marketplace: { + reference: lockEntry.source.marketplace.url, + resolvedCommit: lockEntry.source.marketplace.resolvedCommit + } + } + : {}) + } + } + : {}) + } + } + ) +} diff --git a/src/main/plugins/plugin-log-buffer.ts b/src/main/plugins/plugin-log-buffer.ts new file mode 100644 index 00000000000..54fbdcd83b8 --- /dev/null +++ b/src/main/plugins/plugin-log-buffer.ts @@ -0,0 +1,20 @@ +export type PluginLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string } + +const LOG_RING_LIMIT = 200 + +export class PluginLogBuffer { + private readonly logs = new Map() + + get(pluginKey: string): PluginLogLine[] { + return this.logs.get(pluginKey) ?? [] + } + + append(pluginKey: string, level: PluginLogLine['level'], line: string): void { + const ring = this.logs.get(pluginKey) ?? [] + ring.push({ ts: Date.now(), level, line }) + if (ring.length > LOG_RING_LIMIT) { + ring.splice(0, ring.length - LOG_RING_LIMIT) + } + this.logs.set(pluginKey, ring) + } +} diff --git a/src/main/plugins/plugin-manifest-file.ts b/src/main/plugins/plugin-manifest-file.ts new file mode 100644 index 00000000000..68b01dfe1da --- /dev/null +++ b/src/main/plugins/plugin-manifest-file.ts @@ -0,0 +1,22 @@ +import { createReadStream } from 'node:fs' +import { join } from 'node:path' +import { PLUGIN_MANIFEST_FILENAME } from '../../shared/plugins/plugin-manifest' + +/** A manifest is startup metadata, not an artifact payload. Bounding it keeps + * discovery and install preview from allocating an attacker-sized JSON file. */ +export const PLUGIN_MANIFEST_MAX_BYTES = 1024 * 1024 + +export async function readPluginManifestText(rootDir: string): Promise { + const chunks: Buffer[] = [] + let totalBytes = 0 + const stream = createReadStream(join(rootDir, PLUGIN_MANIFEST_FILENAME)) + for await (const chunk of stream) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_MANIFEST_MAX_BYTES) { + throw new Error(`${PLUGIN_MANIFEST_FILENAME} exceeds ${PLUGIN_MANIFEST_MAX_BYTES} bytes`) + } + chunks.push(bytes) + } + return Buffer.concat(chunks, totalBytes).toString('utf8') +} diff --git a/src/main/plugins/plugin-marketplace-error-message.ts b/src/main/plugins/plugin-marketplace-error-message.ts new file mode 100644 index 00000000000..d829036926d --- /dev/null +++ b/src/main/plugins/plugin-marketplace-error-message.ts @@ -0,0 +1,3 @@ +export function pluginMarketplaceErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/plugins/plugin-marketplace-fetch.ts b/src/main/plugins/plugin-marketplace-fetch.ts new file mode 100644 index 00000000000..d035841eb84 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-fetch.ts @@ -0,0 +1,63 @@ +import { createReadStream } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + PLUGIN_MARKETPLACE_FILENAME, + pluginMarketplaceSchema, + type PluginMarketplace +} from '../../shared/plugins/plugin-marketplace' +import { checkoutPluginGitSource } from './plugin-git-repository' +import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' + +const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024 + +export type PluginMarketplaceFetchResult = { + marketplaceCommit: string + marketplace: PluginMarketplace +} + +/** Fetches a marketplace through system Git so private repositories use the + * same SSH agent and credential helpers as every other Orca Git operation. */ +export async function fetchPluginMarketplace( + source: PluginMarketplaceRegisteredSource +): Promise { + const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-')) + try { + const marketplaceCommit = await checkoutPluginGitSource({ + url: source.source.url, + ref: source.source.ref, + destination: stagingDirectory, + workingDirectory: tmpdir() + }) + const marketplace = await readPluginMarketplaceIndex(stagingDirectory) + return { marketplaceCommit, marketplace } + } finally { + await rm(stagingDirectory, { recursive: true, force: true }) + } +} + +export async function readPluginMarketplaceIndex( + rootDirectory: string +): Promise { + const path = join(rootDirectory, PLUGIN_MARKETPLACE_FILENAME) + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(path)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) { + throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} exceeds its size limit`) + } + chunks.push(bytes) + } + try { + return pluginMarketplaceSchema.parse( + JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) + ) + } catch (error) { + throw new Error( + `invalid ${PLUGIN_MARKETPLACE_FILENAME}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} diff --git a/src/main/plugins/plugin-marketplace-installer.test.ts b/src/main/plugins/plugin-marketplace-installer.test.ts new file mode 100644 index 00000000000..589a90699ef --- /dev/null +++ b/src/main/plugins/plugin-marketplace-installer.test.ts @@ -0,0 +1,198 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginMarketplace } from '../../shared/plugins/plugin-marketplace' +import { readPluginLockfile } from './plugin-install' +import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' +import { PluginMarketplaceService } from './plugin-marketplace-service' + +const git = vi.hoisted(() => ({ + checkout: vi.fn(), + version: '1.0.0', + publisher: 'community', + id: 'notes', + commit: 'a'.repeat(40), + payload: 'first' +})) + +vi.mock('./plugin-git-repository', () => ({ + checkoutPluginGitSource: git.checkout +})) + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-installer-')) + roots.push(root) + return root +} + +function marketplace(): PluginMarketplace { + return { + name: 'Community', + owner: 'community', + plugins: [ + { + id: 'community.notes', + source: { + kind: 'git', + url: 'https://github.com/community/notes.git', + ref: 'stable' + }, + categories: ['productivity'] + } + ] + } +} + +async function writeCurrentPlugin(destination: string): Promise { + await mkdir(destination, { recursive: true }) + await writeFile( + join(destination, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: git.id, + publisher: git.publisher, + name: 'Notes', + version: git.version, + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [] + }) + ) + await writeFile(join(destination, 'payload.txt'), git.payload) +} + +async function setup(): Promise<{ + root: string + marketplace: PluginMarketplaceService + installer: PluginMarketplaceInstaller + sourceId: string +}> { + const root = await tempRoot() + const service = new PluginMarketplaceService({ + pluginsDataDir: join(root, 'plugins-data'), + fetcher: async () => ({ marketplaceCommit: 'f'.repeat(40), marketplace: marketplace() }) + }) + const added = await service.addSource({ + kind: 'git', + url: 'https://github.com/community/plugins.git', + ref: 'main' + }) + return { + root, + marketplace: service, + installer: new PluginMarketplaceInstaller({ + marketplace: service, + userDataPath: root, + hostVersion: '1.4.0' + }), + sourceId: added.id + } +} + +beforeEach(() => { + git.version = '1.0.0' + git.publisher = 'community' + git.id = 'notes' + git.commit = 'a'.repeat(40) + git.payload = 'first' + git.checkout.mockReset() + git.checkout.mockImplementation(async ({ destination }: { destination: string }) => { + await writeCurrentPlugin(destination) + return git.commit + }) +}) + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginMarketplaceInstaller', () => { + it('previews exact validated bytes and records marketplace provenance on install', async () => { + const { root, installer, sourceId } = await setup() + + const preview = await installer.preview(sourceId, 'community.notes') + expect(preview).toMatchObject({ + pluginKey: 'community.notes', + resolvedCommit: 'a'.repeat(40), + marketplaceCommit: 'f'.repeat(40), + manifest: { version: '1.0.0' } + }) + const result = await installer.install(preview) + + if (!result.ok) { + throw new Error(result.error) + } + expect(result).toMatchObject({ ok: true, resolvedCommit: 'a'.repeat(40) }) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']?.source).toEqual({ + kind: 'marketplace', + marketplace: { + url: 'https://github.com/community/plugins.git', + ref: 'main', + resolvedCommit: 'f'.repeat(40) + }, + plugin: { + url: 'https://github.com/community/notes.git', + ref: 'stable' + } + }) + }) + + it('requires a fresh review when the plugin ref moves after preview', async () => { + const { root, installer, sourceId } = await setup() + const preview = await installer.preview(sourceId, 'community.notes') + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + + await expect(installer.install(preview)).resolves.toEqual({ + ok: false, + error: 'plugin source changed after preview; review the update again' + }) + await expect(readFile(join(root, 'plugins', 'plugins.lock.json'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('rejects a source whose manifest identity differs from its listing', async () => { + const { installer, sourceId } = await setup() + git.publisher = 'attacker' + + await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( + 'attacker.notes does not match marketplace listing community.notes' + ) + }) + + it('updates from recorded marketplace provenance and rolls back one immutable version', async () => { + const { root, installer, sourceId } = await setup() + const firstPreview = await installer.preview(sourceId, 'community.notes') + const firstInstall = await installer.install(firstPreview) + expect(firstInstall.ok).toBe(true) + if (!firstInstall.ok) { + return + } + + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + git.payload = 'second' + const updatePreview = await installer.previewInstalledUpdate('community.notes') + expect(updatePreview).toMatchObject({ resolvedCommit: 'b'.repeat(40) }) + await expect(installer.install(updatePreview)).resolves.toMatchObject({ + ok: true, + version: '2.0.0' + }) + + await expect(installer.rollback('community.notes')).resolves.toMatchObject({ + ok: true, + version: '1.0.0', + contentHash: firstInstall.contentHash + }) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']).toMatchObject({ + version: '1.0.0', + resolvedCommit: 'a'.repeat(40) + }) + }) +}) diff --git a/src/main/plugins/plugin-marketplace-installer.ts b/src/main/plugins/plugin-marketplace-installer.ts new file mode 100644 index 00000000000..179e687682c --- /dev/null +++ b/src/main/plugins/plugin-marketplace-installer.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +import { getUserPluginsDir } from './plugin-discovery' +import { checkoutPluginGitSource } from './plugin-git-repository' +import { + installPluginFromMarketplace, + readPluginLockfile, + rollbackInstalledPlugin, + type PluginInstallResult +} from './plugin-install' +import { inspectPluginInstallTree } from './plugin-install-staging' +import type { + PluginMarketplaceListing, + PluginMarketplaceService +} from './plugin-marketplace-service' +import { marketplaceSourceId } from './plugin-marketplace-store' + +export type PluginMarketplaceInstallPreview = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceListing['source'] + resolvedCommit: string + contentHash: string + consentFingerprint: string + manifest: PluginManifest + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + +export type PluginMarketplacePreviewIdentity = Pick< + PluginMarketplaceInstallPreview, + 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' +> + +export class PluginMarketplaceInstaller { + private readonly marketplace: PluginMarketplaceService + private readonly userDataPath: string + private readonly hostVersion: string + private readonly blockedPluginReason: (pluginKey: string) => string | null + + constructor(options: { + marketplace: PluginMarketplaceService + userDataPath: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null + }) { + this.marketplace = options.marketplace + this.userDataPath = options.userDataPath + this.hostVersion = options.hostVersion + this.blockedPluginReason = options.blockedPluginReason ?? (() => null) + } + + async preview( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const listing = await this.requireListing(marketplaceSourceId, pluginKey) + const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-preview-')) + try { + const resolvedCommit = await checkoutPluginGitSource({ + url: listing.source.url, + ref: listing.source.ref, + destination: stagingDirectory, + workingDirectory: tmpdir() + }) + const inspection = await inspectPluginInstallTree({ + rootDir: stagingDirectory, + hostVersion: this.hostVersion, + expectedPluginKey: pluginKey + }) + if (!inspection.ok) { + throw new Error(inspection.error) + } + return { + marketplaceSourceId, + marketplaceName: listing.marketplaceName, + marketplaceOwner: listing.marketplaceOwner, + marketplaceCommit: listing.marketplaceCommit, + pluginKey, + source: listing.source, + resolvedCommit, + contentHash: inspection.contentHash, + consentFingerprint: inspection.consentFingerprint, + manifest: inspection.manifest, + official: listing.official, + bundled: listing.bundled, + ...(listing.blockedByKillList ? { blockedByKillList: listing.blockedByKillList } : {}) + } + } finally { + await rm(stagingDirectory, { recursive: true, force: true }) + } + } + + async install(preview: PluginMarketplacePreviewIdentity): Promise { + const listing = await this.requireListing(preview.marketplaceSourceId, preview.pluginKey) + const blockedReason = + listing.blockedByKillList?.reason ?? this.blockedPluginReason(preview.pluginKey) + if (blockedReason) { + return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } + } + if (listing.marketplaceCommit !== preview.marketplaceCommit) { + return { ok: false, error: 'marketplace changed after preview; review the plugin again' } + } + const sourceState = (await this.marketplace.listSources()).find( + (source) => source.id === preview.marketplaceSourceId + ) + if (!sourceState) { + return { ok: false, error: 'marketplace source is no longer configured' } + } + return installPluginFromMarketplace({ + pluginsDir: getUserPluginsDir(this.userDataPath), + hostVersion: this.hostVersion, + expectedPluginKey: preview.pluginKey, + expectedResolvedCommit: preview.resolvedCommit, + marketplace: { + url: sourceState.source.url, + ref: sourceState.source.ref, + resolvedCommit: preview.marketplaceCommit + }, + plugin: { url: listing.source.url, ref: listing.source.ref }, + blockedPluginReason: this.blockedPluginReason + }) + } + + async previewInstalledUpdate(pluginKey: string): Promise { + const lock = await readPluginLockfile(getUserPluginsDir(this.userDataPath)) + const entry = lock.plugins[pluginKey] + if (!entry || entry.source.kind !== 'marketplace') { + throw new Error(`plugin ${pluginKey} was not installed from a marketplace`) + } + const sourceId = marketplaceSourceId({ + kind: 'git', + url: entry.source.marketplace.url, + ref: entry.source.marketplace.ref + }) + return this.preview(sourceId, pluginKey) + } + + async rollback(pluginKey: string): Promise { + return rollbackInstalledPlugin({ + pluginsDir: getUserPluginsDir(this.userDataPath), + pluginKey, + hostVersion: this.hostVersion, + blockedPluginReason: this.blockedPluginReason + }) + } + + private async requireListing( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const listing = await this.marketplace.findPlugin(marketplaceSourceId, pluginKey) + if (!listing) { + throw new Error(`plugin ${pluginKey} is not listed by marketplace ${marketplaceSourceId}`) + } + return listing + } +} diff --git a/src/main/plugins/plugin-marketplace-projection.ts b/src/main/plugins/plugin-marketplace-projection.ts new file mode 100644 index 00000000000..c411801d8d9 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-projection.ts @@ -0,0 +1,33 @@ +import type { + PluginMarketplaceEntry, + PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' + +export type PluginMarketplaceSourceState = { + id: string + source: PluginMarketplaceGitSource + addedAt: number + marketplace: { + name: string + owner: string + resolvedCommit: string + fetchedAt: number + } | null + stale: boolean + official: boolean + error?: string +} + +export type PluginMarketplaceListing = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceEntry['source'] + description?: string + categories: string[] + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} diff --git a/src/main/plugins/plugin-marketplace-provenance.ts b/src/main/plugins/plugin-marketplace-provenance.ts new file mode 100644 index 00000000000..fbef2ce69f8 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-provenance.ts @@ -0,0 +1,27 @@ +import { + OFFICIAL_MARKETPLACE_OWNER, + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + isReservedPluginIdentity +} from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch' +import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' + +export function validateMarketplaceProvenance( + source: PluginMarketplaceRegisteredSource, + fetched: PluginMarketplaceFetchResult +): void { + if ( + isOfficialMarketplaceGitSource(source.source.url) && + fetched.marketplace.owner.toLowerCase() !== OFFICIAL_MARKETPLACE_OWNER + ) { + throw new Error('official marketplace metadata has an unexpected owner') + } + for (const entry of fetched.marketplace.plugins) { + if (isReservedPluginIdentity(entry.id) && !isOfficialOrganizationGitSource(entry.source.url)) { + throw new Error( + `reserved plugin identity ${entry.id} must resolve to the stablyai organization` + ) + } + } +} diff --git a/src/main/plugins/plugin-marketplace-service.test.ts b/src/main/plugins/plugin-marketplace-service.test.ts new file mode 100644 index 00000000000..38c39a79ce0 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-service.test.ts @@ -0,0 +1,336 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + PluginMarketplace, + PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' +import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch' +import { PluginMarketplaceService } from './plugin-marketplace-service' +import { + marketplaceSourceId, + PLUGIN_MARKETPLACE_SOURCE_LIMIT, + PluginMarketplaceStore, + type PluginMarketplaceRegisteredSource +} from './plugin-marketplace-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-service-')) + roots.push(root) + return root +} + +function source(url = 'https://github.com/community/plugins.git'): PluginMarketplaceGitSource { + return { kind: 'git', url, ref: 'main' } +} + +function marketplace( + name = 'Community', + pluginKey = 'community.notes', + pluginUrl = 'https://github.com/community/notes.git' +): PluginMarketplace { + return { + name, + owner: name.toLowerCase(), + plugins: [ + { + id: pluginKey, + source: { kind: 'git', url: pluginUrl, ref: 'v1' }, + description: 'Notes for active worktrees.', + categories: ['productivity'] + } + ] + } +} + +function fetched(value = marketplace(), commit = 'a'.repeat(40)): PluginMarketplaceFetchResult { + return { marketplaceCommit: commit, marketplace: value } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginMarketplaceService', () => { + it('fetches before registration, then serves browse data from the local snapshot', async () => { + const fetcher = vi + .fn< + (registration: PluginMarketplaceRegisteredSource) => Promise + >() + .mockResolvedValue(fetched()) + const service = new PluginMarketplaceService({ pluginsDataDir: await tempRoot(), fetcher }) + + const added = await service.addSource(source()) + + expect(added).toMatchObject({ marketplace: { name: 'Community' }, stale: false }) + expect(fetcher).toHaveBeenCalledTimes(1) + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ + marketplaceSourceId: added.id, + pluginKey: 'community.notes', + official: false, + bundled: false + }) + ]) + await service.listSources() + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('keeps and labels the last valid snapshot when a refresh is offline', async () => { + const fetcher = vi + .fn< + (registration: PluginMarketplaceRegisteredSource) => Promise + >() + .mockResolvedValueOnce(fetched()) + .mockRejectedValueOnce(new Error('offline')) + const service = new PluginMarketplaceService({ pluginsDataDir: await tempRoot(), fetcher }) + const added = await service.addSource(source()) + + await expect(service.refreshSource(added.id)).resolves.toMatchObject({ + marketplace: { name: 'Community', resolvedCommit: 'a'.repeat(40) }, + stale: true, + error: 'offline' + }) + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ pluginKey: 'community.notes' }) + ]) + }) + + it('atomically replaces the cache only after a valid refreshed index', async () => { + const fetcher = vi + .fn< + (registration: PluginMarketplaceRegisteredSource) => Promise + >() + .mockResolvedValueOnce(fetched()) + .mockResolvedValueOnce(fetched(marketplace('Updated'), 'b'.repeat(40))) + const root = await tempRoot() + const service = new PluginMarketplaceService({ pluginsDataDir: root, fetcher }) + const added = await service.addSource(source()) + + await expect(service.refreshSource(added.id)).resolves.toMatchObject({ + marketplace: { name: 'Updated', resolvedCommit: 'b'.repeat(40) }, + stale: false + }) + await expect( + new PluginMarketplaceService({ pluginsDataDir: root, fetcher }).listPlugins() + ).resolves.toEqual([expect.objectContaining({ marketplaceName: 'Updated' })]) + }) + + it('does not persist a source whose first fetch fails', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => { + throw new Error('authentication failed') + } + }) + + await expect(service.addSource(source())).rejects.toThrow('authentication failed') + await expect(service.listSources()).resolves.toEqual([]) + }) + + it('rejects reserved identities outside the official organization', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => + fetched( + marketplace('Attack', 'community.orca-secrets', 'https://github.com/attacker/x.git') + ) + }) + + await expect(service.addSource(source())).rejects.toThrow( + 'reserved plugin identity community.orca-secrets' + ) + await expect(service.listSources()).resolves.toEqual([]) + }) + + it('derives the Official badge only from the canonical marketplace and source organization', async () => { + const officialMarketplace: PluginMarketplace = { + name: 'Orca Plugins', + owner: 'stablyai', + plugins: [ + { + id: 'stablyai.orca-shortcuts', + source: { + kind: 'git', + url: 'git@github.com:stablyai/orca-shortcuts.git', + ref: 'main' + }, + categories: ['keybindings'] + } + ] + } + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => fetched(officialMarketplace) + }) + + await service.addSource(source('https://github.com/stablyai/orca-plugins.git')) + + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ pluginKey: 'stablyai.orca-shortcuts', official: true }) + ]) + }) + + it('hides listings whose contribution kind this build no longer supports', async () => { + const mixed: PluginMarketplace = { + name: 'Community', + owner: 'community', + plugins: [ + { + id: 'community.midnight', + source: { kind: 'git', url: 'https://github.com/community/midnight.git', ref: 'v1' }, + categories: ['themes', 'official'] + }, + { + id: 'community.recipes', + source: { kind: 'git', url: 'https://github.com/community/recipes.git', ref: 'v1' }, + categories: ['vm-recipes'] + } + ] + } + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => fetched(mixed) + }) + + const added = await service.addSource(source()) + + // The theme pack is filtered out; only the supported recipe listing remains. + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ pluginKey: 'community.recipes' }) + ]) + + // Preview and install resolve by key, so an unsupported pack must be + // unreachable that way too — otherwise the dead install just moves later. + await expect(service.findPlugin(added.id, 'community.midnight')).resolves.toBeNull() + await expect(service.findPlugin(added.id, 'community.recipes')).resolves.toEqual( + expect.objectContaining({ pluginKey: 'community.recipes' }) + ) + }) + + it('seeds the official marketplace once and keeps it configured across restarts', async () => { + const root = await tempRoot() + const officialMarketplace = marketplace( + 'Orca Plugins', + 'stablyai.orca-notes', + 'https://github.com/stablyai/orca-notes.git' + ) + officialMarketplace.owner = 'stablyai' + const fetcher = vi.fn(async () => fetched(officialMarketplace)) + const first = new PluginMarketplaceService({ pluginsDataDir: root, fetcher }) + + await expect(first.seedOfficialSource()).resolves.toMatchObject({ + official: true, + marketplace: { name: 'Orca Plugins' } + }) + await expect(first.seedOfficialSource()).resolves.toMatchObject({ official: true }) + expect(fetcher).toHaveBeenCalledTimes(1) + + const restarted = new PluginMarketplaceService({ pluginsDataDir: root, fetcher }) + await expect(restarted.seedOfficialSource()).resolves.toMatchObject({ official: true }) + expect(fetcher).toHaveBeenCalledTimes(1) + await expect(restarted.listSources()).resolves.toHaveLength(1) + }) + + it('persists an offline official source for a later refresh and does not remove it', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => { + throw new Error('offline') + } + }) + + const seeded = await service.seedOfficialSource() + + expect(seeded).toMatchObject({ official: true, stale: true, marketplace: null }) + await expect(service.removeSource(seeded.id)).rejects.toThrow('cannot be removed') + await expect(service.listSources()).resolves.toEqual([seeded]) + }) + + it('keeps reads usable and allows retry after official seeding rejects', async () => { + const registered: PluginMarketplaceRegisteredSource = { + id: marketplaceSourceId(OFFICIAL_MARKETPLACE_GIT_SOURCE), + source: OFFICIAL_MARKETPLACE_GIT_SOURCE, + addedAt: 1 + } + const officialMarketplace = marketplace( + 'Orca Plugins', + 'stablyai.orca-notes', + 'https://github.com/stablyai/orca-notes.git' + ) + officialMarketplace.owner = 'stablyai' + const listSources = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('source store temporarily unavailable')) + .mockResolvedValue([registered]) + const store = { + listSources, + readSnapshot: vi.fn().mockResolvedValue(null), + writeSnapshot: vi.fn(async ({ source: snapshotSource, ...snapshot }) => ({ + schemaVersion: 1 as const, + sourceId: snapshotSource.id, + source: snapshotSource.source, + fetchedAt: 2, + ...snapshot + })) + } as unknown as PluginMarketplaceStore + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + store, + fetcher: async () => fetched(officialMarketplace) + }) + + await expect(service.seedOfficialSource()).rejects.toThrow('temporarily unavailable') + await expect(service.listSources()).resolves.toEqual([ + expect.objectContaining({ id: registered.id, official: true }) + ]) + await expect(service.seedOfficialSource()).resolves.toMatchObject({ + marketplace: { name: 'Orca Plugins' }, + official: true + }) + }) + + it('recovers the managed source after a full existing store frees a slot', async () => { + const root = await tempRoot() + const store = new PluginMarketplaceStore(root) + const registrations = await Promise.all( + Array.from({ length: PLUGIN_MARKETPLACE_SOURCE_LIMIT }, (_, index) => + store.addSource(source(`https://example.com/community-${index}.git`), index + 1) + ) + ) + const officialMarketplace = marketplace( + 'Orca Plugins', + 'stablyai.orca-notes', + 'https://github.com/stablyai/orca-notes.git' + ) + officialMarketplace.owner = 'stablyai' + const service = new PluginMarketplaceService({ + pluginsDataDir: root, + store, + fetcher: async () => fetched(officialMarketplace) + }) + + await expect(service.seedOfficialSource()).rejects.toThrow('source limit') + await expect(service.removeSource(registrations[0]!.id)).resolves.toBe(true) + + const sources = await service.listSources() + expect(sources).toHaveLength(PLUGIN_MARKETPLACE_SOURCE_LIMIT) + expect(sources).toContainEqual(expect.objectContaining({ official: true })) + }) + + it('removes source metadata and browse listings together', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => fetched() + }) + const added = await service.addSource(source()) + + await expect(service.removeSource(added.id)).resolves.toBe(true) + await expect(service.listSources()).resolves.toEqual([]) + await expect(service.listPlugins()).resolves.toEqual([]) + }) +}) diff --git a/src/main/plugins/plugin-marketplace-service.ts b/src/main/plugins/plugin-marketplace-service.ts new file mode 100644 index 00000000000..cdee5bf9721 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-service.ts @@ -0,0 +1,320 @@ +import { + OFFICIAL_MARKETPLACE_OWNER, + OFFICIAL_MARKETPLACE_GIT_SOURCE, + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + isOfficialPluginIdentity, + isMarketplaceListingSupported, + pluginMarketplaceGitSourceSchema, + type PluginMarketplaceEntry, + type PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' +import { + fetchPluginMarketplace, + type PluginMarketplaceFetchResult +} from './plugin-marketplace-fetch' +import { + marketplaceSourceId, + PluginMarketplaceStore, + type PluginMarketplaceCachedSnapshot, + type PluginMarketplaceRegisteredSource +} from './plugin-marketplace-store' +import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list' +import { validateMarketplaceProvenance } from './plugin-marketplace-provenance' +import { pluginMarketplaceErrorMessage } from './plugin-marketplace-error-message' +import type { + PluginMarketplaceListing, + PluginMarketplaceSourceState +} from './plugin-marketplace-projection' +export type { + PluginMarketplaceListing, + PluginMarketplaceSourceState +} from './plugin-marketplace-projection' + +type MarketplaceFetcher = ( + source: PluginMarketplaceRegisteredSource +) => Promise + +export class PluginMarketplaceService { + private readonly store: PluginMarketplaceStore + private readonly fetcher: MarketplaceFetcher + private readonly getKillListEntry: (pluginKey: string) => PluginKillListEntry | null + private readonly refreshChains = new Map>() + private readonly sourceErrors = new Map() + private officialSeedPromise: Promise | null = null + private officialSeedRequested = false + + constructor(options: { + pluginsDataDir: string + fetcher?: MarketplaceFetcher + store?: PluginMarketplaceStore + getKillListEntry?: (pluginKey: string) => PluginKillListEntry | null + }) { + this.store = options.store ?? new PluginMarketplaceStore(options.pluginsDataDir) + this.fetcher = options.fetcher ?? fetchPluginMarketplace + this.getKillListEntry = options.getKillListEntry ?? (() => null) + } + + async listSources(): Promise { + await this.waitForOfficialSeed() + const sources = await this.store.listSources() + return Promise.all( + sources.map(async (source) => { + try { + const error = this.sourceErrors.get(source.id) + return this.stateFromSnapshot( + source, + await this.store.readSnapshot(source.id), + Boolean(error), + error + ) + } catch (error) { + return this.stateFromSnapshot(source, null, true, pluginMarketplaceErrorMessage(error)) + } + }) + ) + } + + async addSource(source: PluginMarketplaceGitSource): Promise { + const parsedSource = pluginMarketplaceGitSourceSchema.parse(source) + const sourceId = marketplaceSourceId(parsedSource) + const existing = (await this.store.listSources()).find((candidate) => candidate.id === sourceId) + const candidate: PluginMarketplaceRegisteredSource = existing ?? { + id: sourceId, + source: parsedSource, + addedAt: Date.now() + } + const fetched = await this.fetchAndValidate(candidate) + const registered = existing ?? (await this.store.addSource(parsedSource, candidate.addedAt)) + try { + const snapshot = await this.store.writeSnapshot({ source: registered, ...fetched }) + this.sourceErrors.delete(registered.id) + return this.stateFromSnapshot(registered, snapshot, false) + } catch (error) { + if (!existing) { + await this.store.removeSource(registered.id).catch(() => undefined) + } + throw error + } + } + + async removeSource(sourceId: string): Promise { + const source = (await this.store.listSources()).find((candidate) => candidate.id === sourceId) + if (source && isOfficialMarketplaceGitSource(source.source.url)) { + throw new Error('the official marketplace is managed by Orca and cannot be removed') + } + const removed = await this.store.removeSource(sourceId) + if (removed) { + this.sourceErrors.delete(sourceId) + if (this.officialSeedRequested) { + // Why: an existing profile may already occupy every source slot. Once + // the user frees one, recover the managed source without a restart. + await this.seedOfficialSource().catch(() => undefined) + } + } + return removed + } + + seedOfficialSource(): Promise { + this.officialSeedRequested = true + if (!this.officialSeedPromise) { + const seed = this.performOfficialSeed() + this.officialSeedPromise = seed + void seed.catch(() => { + if (this.officialSeedPromise === seed) { + // Why: a transient store failure or full source list must not poison + // every marketplace read or prevent a later recovery attempt. + this.officialSeedPromise = null + } + }) + } + return this.officialSeedPromise + } + + async refreshSource(sourceId: string): Promise { + const previous = this.refreshChains.get(sourceId) ?? Promise.resolve(null) + const refresh = previous.catch(() => null).then(() => this.performRefresh(sourceId)) + this.refreshChains.set(sourceId, refresh) + try { + return await refresh + } finally { + if (this.refreshChains.get(sourceId) === refresh) { + this.refreshChains.delete(sourceId) + } + } + } + + async refreshAll(): Promise { + await this.waitForOfficialSeed() + const sources = await this.store.listSources() + return Promise.all(sources.map((source) => this.refreshSource(source.id))) + } + + async listPlugins(): Promise { + await this.waitForOfficialSeed() + const states = await this.listSnapshots() + return states + .flatMap(({ source, snapshot }) => + snapshot.marketplace.plugins + // Why: hide packs whose contribution kind this build no longer + // supports (themes/icons/skills) so users never reach a dead install. + .filter((entry) => isMarketplaceListingSupported(entry.categories)) + .map((entry) => this.listingFromEntry(source, snapshot, entry)) + ) + .sort((left, right) => + `${left.pluginKey}\0${left.marketplaceSourceId}`.localeCompare( + `${right.pluginKey}\0${right.marketplaceSourceId}` + ) + ) + } + + async findPlugin( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const source = (await this.store.listSources()).find( + (candidate) => candidate.id === marketplaceSourceId + ) + if (!source) { + return null + } + const snapshot = await this.store.readSnapshot(source.id) + // Why: preview and install resolve listings through here, so an unsupported + // pack must be unreachable by key too — hiding only the catalog card would + // move the dead install one click later instead of removing it. + const entry = snapshot?.marketplace.plugins.find( + (plugin) => plugin.id === pluginKey && isMarketplaceListingSupported(plugin.categories) + ) + return snapshot && entry ? this.listingFromEntry(source, snapshot, entry) : null + } + + private async performRefresh(sourceId: string): Promise { + const source = (await this.store.listSources()).find((candidate) => candidate.id === sourceId) + if (!source) { + throw new Error(`unknown marketplace source: ${sourceId}`) + } + try { + const fetched = await this.fetchAndValidate(source) + const snapshot = await this.store.writeSnapshot({ source, ...fetched }) + this.sourceErrors.delete(source.id) + return this.stateFromSnapshot(source, snapshot, false) + } catch (error) { + const cached = await this.store.readSnapshot(source.id).catch(() => null) + if (!cached) { + throw error + } + const message = pluginMarketplaceErrorMessage(error) + this.sourceErrors.set(source.id, message) + return this.stateFromSnapshot(source, cached, true, message) + } + } + + private async performOfficialSeed(): Promise { + const sources = await this.store.listSources() + const existing = sources.find((source) => isOfficialMarketplaceGitSource(source.source.url)) + const source = + existing ?? (await this.store.addSource(OFFICIAL_MARKETPLACE_GIT_SOURCE, Date.now())) + const snapshot = await this.store.readSnapshot(source.id).catch(() => null) + if (snapshot) { + return this.stateFromSnapshot(source, snapshot, false) + } + try { + return await this.performRefresh(source.id) + } catch (error) { + // Why: the official source remains configured offline so a later manual + // or startup refresh can recover without asking the user for its URL. + const message = pluginMarketplaceErrorMessage(error) + this.sourceErrors.set(source.id, message) + return this.stateFromSnapshot(source, null, true, message) + } + } + + private async waitForOfficialSeed(): Promise { + await this.officialSeedPromise?.catch(() => undefined) + } + + private async fetchAndValidate( + source: PluginMarketplaceRegisteredSource + ): Promise { + const fetched = await this.fetcher(source) + validateMarketplaceProvenance(source, fetched) + return fetched + } + + private async listSnapshots(): Promise< + { source: PluginMarketplaceRegisteredSource; snapshot: PluginMarketplaceCachedSnapshot }[] + > { + const sources = await this.store.listSources() + const snapshots = await Promise.all( + sources.map(async (source) => ({ + source, + snapshot: await this.store.readSnapshot(source.id) + })) + ) + return snapshots.filter( + ( + candidate + ): candidate is { + source: PluginMarketplaceRegisteredSource + snapshot: PluginMarketplaceCachedSnapshot + } => candidate.snapshot !== null + ) + } + + private listingFromEntry( + source: PluginMarketplaceRegisteredSource, + snapshot: PluginMarketplaceCachedSnapshot, + entry: PluginMarketplaceEntry + ): PluginMarketplaceListing { + const official = + isOfficialMarketplaceGitSource(source.source.url) && + snapshot.marketplace.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER && + isOfficialPluginIdentity(entry.id) && + isOfficialOrganizationGitSource(entry.source.url) + const blocked = this.getKillListEntry(entry.id) + return { + marketplaceSourceId: source.id, + marketplaceName: snapshot.marketplace.name, + marketplaceOwner: snapshot.marketplace.owner, + marketplaceCommit: snapshot.marketplaceCommit, + pluginKey: entry.id, + source: entry.source, + ...(entry.description ? { description: entry.description } : {}), + categories: entry.categories, + official, + bundled: false, + ...(blocked + ? { + blockedByKillList: { + reason: blocked.reason, + ...(blocked.advisoryUrl ? { advisoryUrl: blocked.advisoryUrl } : {}) + } + } + : {}) + } + } + + private stateFromSnapshot( + source: PluginMarketplaceRegisteredSource, + snapshot: PluginMarketplaceCachedSnapshot | null, + stale: boolean, + error?: string + ): PluginMarketplaceSourceState { + return { + id: source.id, + source: source.source, + addedAt: source.addedAt, + marketplace: snapshot + ? { + name: snapshot.marketplace.name, + owner: snapshot.marketplace.owner, + resolvedCommit: snapshot.marketplaceCommit, + fetchedAt: snapshot.fetchedAt + } + : null, + stale, + official: isOfficialMarketplaceGitSource(source.source.url), + ...(error ? { error } : {}) + } + } +} diff --git a/src/main/plugins/plugin-marketplace-store.test.ts b/src/main/plugins/plugin-marketplace-store.test.ts new file mode 100644 index 00000000000..4607037334b --- /dev/null +++ b/src/main/plugins/plugin-marketplace-store.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' +import { marketplaceSourceId, PluginMarketplaceStore } from './plugin-marketplace-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-store-')) + roots.push(root) + return root +} + +function source(ref = 'main'): PluginMarketplaceGitSource { + return { kind: 'git', url: 'https://github.com/community/plugins.git', ref } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginMarketplaceStore', () => { + it('persists bounded source registrations under a deterministic opaque id', async () => { + const root = await tempRoot() + const first = new PluginMarketplaceStore(root) + const registered = await first.addSource(source(), 123) + + expect(registered).toEqual({ + id: marketplaceSourceId(source()), + source: source(), + addedAt: 123 + }) + await expect(new PluginMarketplaceStore(root).listSources()).resolves.toEqual([registered]) + await expect(first.addSource(source(), 999)).resolves.toEqual(registered) + }) + + it('atomically publishes a strict cached snapshot and removes it with its source', async () => { + const root = await tempRoot() + const store = new PluginMarketplaceStore(root) + const registered = await store.addSource(source(), 123) + const snapshot = await store.writeSnapshot({ + source: registered, + marketplaceCommit: 'a'.repeat(40), + fetchedAt: 456, + marketplace: { + name: 'Community', + owner: 'community', + plugins: [ + { + id: 'community.theme', + source: { + kind: 'git', + url: 'https://github.com/community/theme.git', + ref: 'v1' + }, + categories: ['themes'] + } + ] + } + }) + + await expect(new PluginMarketplaceStore(root).readSnapshot(registered.id)).resolves.toEqual( + snapshot + ) + expect( + (await readdir(join(root, 'marketplaces', 'snapshots'))).filter((entry) => + entry.endsWith('.tmp') + ) + ).toEqual([]) + await expect(store.removeSource(registered.id)).resolves.toBe(true) + await expect(store.readSnapshot(registered.id)).resolves.toBeNull() + }) + + it('keeps distinct refs as distinct marketplace sources', () => { + expect(marketplaceSourceId(source('main'))).not.toBe(marketplaceSourceId(source('stable'))) + }) + + it('rejects path-like source ids before reading or deleting cache files', async () => { + const store = new PluginMarketplaceStore(await tempRoot()) + await expect(store.readSnapshot('../outside')).rejects.toThrow() + await expect(store.removeSource('../outside')).rejects.toThrow() + }) +}) diff --git a/src/main/plugins/plugin-marketplace-store.ts b/src/main/plugins/plugin-marketplace-store.ts new file mode 100644 index 00000000000..437f6148f46 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-store.ts @@ -0,0 +1,212 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { + pluginMarketplaceGitSourceSchema, + pluginMarketplaceSchema, + type PluginMarketplace, + type PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' +import { writePluginFileAtomically } from './plugin-atomic-file-write' + +export const PLUGIN_MARKETPLACE_SOURCE_LIMIT = 64 +export const PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN = /^[0-9a-f]{32}$/ + +const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN) +const registeredSourceSchema = z.strictObject({ + id: sourceIdSchema, + source: pluginMarketplaceGitSourceSchema, + addedAt: z.number().finite().nonnegative() +}) +const sourceFileSchema = z.strictObject({ + schemaVersion: z.literal(1), + sources: z.array(registeredSourceSchema).max(PLUGIN_MARKETPLACE_SOURCE_LIMIT) +}) +const cachedSnapshotSchema = z.strictObject({ + schemaVersion: z.literal(1), + sourceId: sourceIdSchema, + source: pluginMarketplaceGitSourceSchema, + marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN), + fetchedAt: z.number().finite().nonnegative(), + marketplace: pluginMarketplaceSchema +}) + +export type PluginMarketplaceRegisteredSource = z.infer +export type PluginMarketplaceCachedSnapshot = z.infer + +const SOURCE_FILE_MAX_BYTES = 2 * 1024 * 1024 +const SNAPSHOT_FILE_MAX_BYTES = 16 * 1024 * 1024 + +export function marketplaceSourceId(source: PluginMarketplaceGitSource): string { + const parsed = pluginMarketplaceGitSourceSchema.parse(source) + return createHash('sha256') + .update(`orca-plugin-marketplace-source-v1\0${parsed.url}\0${parsed.ref}`) + .digest('hex') + .slice(0, 32) +} + +export class PluginMarketplaceStore { + private readonly sourcesPath: string + private readonly snapshotDirectory: string + private sources: PluginMarketplaceRegisteredSource[] | null = null + private writeChain: Promise = Promise.resolve() + + constructor(pluginsDataDir: string) { + const root = join(pluginsDataDir, 'marketplaces') + this.sourcesPath = join(root, 'sources.json') + this.snapshotDirectory = join(root, 'snapshots') + } + + async listSources(): Promise { + await this.loadSources() + return this.sources! + } + + async addSource( + source: PluginMarketplaceGitSource, + addedAt = Date.now() + ): Promise { + const parsedSource = pluginMarketplaceGitSourceSchema.parse(source) + const registration = registeredSourceSchema.parse({ + id: marketplaceSourceId(parsedSource), + source: parsedSource, + addedAt + }) + await this.mutateSources((sources) => { + if (sources.some((candidate) => candidate.id === registration.id)) { + return [...sources] + } + if (sources.length >= PLUGIN_MARKETPLACE_SOURCE_LIMIT) { + throw new Error(`marketplace source limit (${PLUGIN_MARKETPLACE_SOURCE_LIMIT}) reached`) + } + return [...sources, registration] + }) + return this.sources!.find((candidate) => candidate.id === registration.id)! + } + + async removeSource(sourceId: string): Promise { + const parsedId = sourceIdSchema.parse(sourceId) + let removed = false + await this.mutateSources((sources) => { + const next = sources.filter((source) => source.id !== parsedId) + removed = next.length !== sources.length + return next + }) + if (removed) { + await rm(this.snapshotPath(parsedId), { force: true }) + } + return removed + } + + async writeSnapshot(input: { + source: PluginMarketplaceRegisteredSource + marketplaceCommit: string + fetchedAt?: number + marketplace: PluginMarketplace + }): Promise { + const snapshot = cachedSnapshotSchema.parse({ + schemaVersion: 1, + sourceId: input.source.id, + source: input.source.source, + marketplaceCommit: input.marketplaceCommit, + fetchedAt: input.fetchedAt ?? Date.now(), + marketplace: input.marketplace + }) + await writeAtomicJson(this.snapshotPath(input.source.id), snapshot) + return snapshot + } + + async readSnapshot(sourceId: string): Promise { + const parsedId = sourceIdSchema.parse(sourceId) + try { + const raw = JSON.parse( + await readBoundedText(this.snapshotPath(parsedId), SNAPSHOT_FILE_MAX_BYTES) + ) + const parsed = cachedSnapshotSchema.parse(raw) + if (parsed.sourceId !== parsedId) { + throw new Error('marketplace snapshot source identity does not match its cache path') + } + return parsed + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw new Error( + `marketplace snapshot is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async loadSources(): Promise { + if (this.sources !== null) { + return + } + try { + const parsed = sourceFileSchema.parse( + JSON.parse(await readBoundedText(this.sourcesPath, SOURCE_FILE_MAX_BYTES)) + ) + const ids = new Set() + for (const source of parsed.sources) { + if (source.id !== marketplaceSourceId(source.source) || ids.has(source.id)) { + throw new Error('marketplace source identity is inconsistent or duplicated') + } + ids.add(source.id) + } + this.sources = parsed.sources + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.sources = [] + return + } + throw new Error( + `marketplace sources are invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async mutateSources( + mutation: ( + sources: readonly PluginMarketplaceRegisteredSource[] + ) => PluginMarketplaceRegisteredSource[] + ): Promise { + const update = this.writeChain + .catch(() => undefined) + .then(async () => { + await this.loadSources() + const next = sourceFileSchema.parse({ + schemaVersion: 1, + sources: mutation(this.sources!) + }).sources + await writeAtomicJson(this.sourcesPath, { schemaVersion: 1, sources: next }) + this.sources = next + }) + this.writeChain = update + await update + } + + private snapshotPath(sourceId: string): string { + return join(this.snapshotDirectory, `${sourceId}.json`) + } +} + +async function readBoundedText(path: string, limit: number): Promise { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(path)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > limit) { + throw new Error(`file exceeds its ${limit}-byte limit`) + } + chunks.push(bytes) + } + return Buffer.concat(chunks, totalBytes).toString('utf8') +} + +async function writeAtomicJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }) + await writePluginFileAtomically(path, `${JSON.stringify(value, null, 2)}\n`) +} diff --git a/src/main/plugins/plugin-panel-controller.test.ts b/src/main/plugins/plugin-panel-controller.test.ts new file mode 100644 index 00000000000..1a5af816b6c --- /dev/null +++ b/src/main/plugins/plugin-panel-controller.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { createPluginPanelCallAdmission } from '../../shared/plugins/plugin-panel-call-admission' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginPanelController } from './plugin-panel-controller' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function createPlugin(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-panel-controller-')) + roots.push(rootDir) + await writeFile(join(rootDir, 'panel.html'), '

    Panel

    ') + return { + pluginKey: 'orca-samples.demo', + rootDir, + manifest: pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + panels: [{ id: 'dashboard', title: 'Dashboard', entry: 'panel.html' }], + commands: [], + events: [] + }, + capabilities: [{ kind: 'notifications:show' }] + }), + consentFingerprint: 'sha256-consented', + contentHash: null, + isDev: true + } +} + +describe('PluginPanelController identity binding', () => { + it('uses the session identity and rejects caller-supplied plugin claims', async () => { + const plugin = await createPlugin() + const executeHostCall = vi.fn().mockResolvedValue({ ok: true, value: { delivered: true } }) + const controller = new PluginPanelController({ + resolveApprovedPlugin: (pluginKey) => (pluginKey === plugin.pluginKey ? plugin : null), + contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) }, + executeHostCall, + log: vi.fn() + }) + const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard') + expect(entry).not.toBeNull() + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + pluginId: 'orca-samples.other', + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + expect(executeHostCall).not.toHaveBeenCalled() + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: true }) + expect(executeHostCall).toHaveBeenCalledWith(plugin.pluginKey, 'notifications.show', { + title: 'Hello' + }) + await expect( + controller.execute('runtime:other', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + }) + + it('charges raw malformed and oversized calls before strict parsing', async () => { + const plugin = await createPlugin() + const executeHostCall = vi.fn() + const controller = new PluginPanelController({ + resolveApprovedPlugin: () => plugin, + contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) }, + executeHostCall, + log: vi.fn(), + panelAdmission: createPluginPanelCallAdmission({ + limits: { maxBytes: 128, maxMessages: 2, perMs: 10_000 }, + now: () => 0 + }) + }) + const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard') + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + unexpected: true + }) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'x'.repeat(256) } + }) + ).resolves.toEqual({ + ok: false, + code: 'invalid_request', + error: 'panel message exceeds the size limit' + }) + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'third' } + }) + ).resolves.toEqual({ + ok: false, + code: 'rate_limited', + error: 'too many panel requests' + }) + expect(executeHostCall).not.toHaveBeenCalled() + }) + + it('does not publish stale panel code after approval changes during verification', async () => { + const plugin = await createPlugin() + let approved = true + let finishVerification!: () => void + const verification = new Promise((resolve) => { + finishVerification = resolve + }) + const controller = new PluginPanelController({ + resolveApprovedPlugin: () => (approved ? plugin : null), + contentVerifier: { verify: () => verification }, + executeHostCall: vi.fn(), + log: vi.fn() + }) + + const opening = controller.open('runtime:one', plugin.pluginKey, 'dashboard') + approved = false + finishVerification() + + await expect(opening).resolves.toBeNull() + }) + + it('invalidates an open dev-panel session when its manifest revision changes', async () => { + const plugin = await createPlugin() + let current = plugin + const executeHostCall = vi.fn().mockResolvedValue({ ok: true, value: { delivered: true } }) + const controller = new PluginPanelController({ + resolveApprovedPlugin: () => current, + contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) }, + executeHostCall, + log: vi.fn() + }) + const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard') + current = { + ...plugin, + manifest: pluginManifestSchema.parse({ ...plugin.manifest, version: '1.0.1' }) + } + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: false, code: 'unavailable' }) + expect(executeHostCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/plugins/plugin-panel-controller.ts b/src/main/plugins/plugin-panel-controller.ts new file mode 100644 index 00000000000..32c45831c7d --- /dev/null +++ b/src/main/plugins/plugin-panel-controller.ts @@ -0,0 +1,170 @@ +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import { panelActionCallSchema } from '../../shared/plugins/plugin-panel-bridge' +import { + admitPluginPanelCall, + createPluginPanelCallAdmission, + type PluginPanelCallAdmission +} from '../../shared/plugins/plugin-panel-call-admission' +import { buildPluginPanelShellHtml } from '../../shared/plugins/plugin-panel-shell' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { + PLUGIN_PANEL_ENTRY_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import { PluginPanelSessions, type PluginPanelSessionBinding } from './plugin-panel-sessions' + +type PluginPanelControllerOptions = { + resolveApprovedPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null + contentVerifier: Pick + executeHostCall: ( + pluginKey: string, + method: string, + params: unknown + ) => Promise + log: (pluginKey: string, line: string) => void + panelAdmission?: PluginPanelCallAdmission +} + +type LoadedPluginPanel = { + entry: { html: string } + binding: PluginPanelSessionBinding +} + +export class PluginPanelController { + private readonly sessions = new PluginPanelSessions() + private readonly boundOwnerSignals = new WeakSet() + private readonly panelAdmission: PluginPanelCallAdmission + + constructor(private readonly options: PluginPanelControllerOptions) { + this.panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission() + } + + async readEntry(pluginKey: string, panelId: string): Promise<{ html: string } | null> { + return (await this.load(pluginKey, panelId))?.entry ?? null + } + + async open( + ownerKey: string, + pluginKey: string, + panelId: string + ): Promise { + const loaded = await this.load(pluginKey, panelId) + if (!loaded) { + return null + } + return { + ...loaded.entry, + sessionToken: this.sessions.issue(ownerKey, loaded.binding) + } + } + + async execute(ownerKey: string, call: unknown): Promise { + const sessionToken = this.extractSessionToken(call) + if (!sessionToken) { + return { ok: false, code: 'invalid_request', error: 'invalid panel session' } + } + const binding = this.sessions.resolve(ownerKey, sessionToken) + if (!binding) { + return { ok: false, code: 'invalid_request', error: 'invalid panel session' } + } + const admissionRefusal = admitPluginPanelCall(this.panelAdmission, binding.pluginKey, call) + if (admissionRefusal) { + return admissionRefusal + } + const parsed = panelActionCallSchema.safeParse(call) + if (!parsed.success) { + return { ok: false, code: 'invalid_request', error: 'malformed panel action call' } + } + const plugin = this.options.resolveApprovedPlugin(binding.pluginKey) + const panelExists = plugin?.manifest.contributes.panels.some( + (panel) => panel.id === binding.panelId + ) + if ( + !plugin || + plugin.rootDir !== binding.rootDir || + JSON.stringify(plugin.manifest) !== binding.manifestRevision || + !panelExists + ) { + return { ok: false, code: 'unavailable', error: 'panel session is no longer available' } + } + return this.options.executeHostCall(binding.pluginKey, parsed.data.action, parsed.data.params) + } + + revokeOwner(ownerKey: string): void { + this.sessions.revokeOwner(ownerKey) + } + + bindOwnerSignal(ownerKey: string, signal: AbortSignal | undefined): void { + if (!signal || this.boundOwnerSignals.has(signal)) { + return + } + this.boundOwnerSignals.add(signal) + if (signal.aborted) { + this.revokeOwner(ownerKey) + return + } + signal.addEventListener('abort', () => this.revokeOwner(ownerKey), { once: true }) + } + + revokeAll(): void { + this.sessions.clear() + this.panelAdmission.clear() + } + + dispose(): void { + this.revokeAll() + } + + private extractSessionToken(call: unknown): string | null { + if (typeof call !== 'object' || call === null) { + return null + } + try { + const token = (call as { sessionToken?: unknown }).sessionToken + return typeof token === 'string' && token.length >= 32 && token.length <= 128 ? token : null + } catch { + return null + } + } + + private async load(pluginKey: string, panelId: string): Promise { + const plugin = this.options.resolveApprovedPlugin(pluginKey) + const panel = plugin?.manifest.contributes.panels.find((entry) => entry.id === panelId) + if (!plugin || !panel) { + return null + } + try { + await this.options.contentVerifier.verify(plugin) + const html = buildPluginPanelShellHtml( + await readContainedPluginArtifactText( + plugin.rootDir, + panel.entry, + PLUGIN_PANEL_ENTRY_MAX_BYTES + ) + ) + const current = this.options.resolveApprovedPlugin(pluginKey) + if (current !== plugin || current.rootDir !== plugin.rootDir) { + return null + } + return { + entry: { html }, + binding: { + pluginKey, + panelId, + rootDir: plugin.rootDir, + manifestRevision: JSON.stringify(plugin.manifest) + } + } + } catch (error) { + this.options.log( + pluginKey, + `panel entry ${panel.entry} rejected: ${error instanceof Error ? error.message : String(error)}` + ) + return null + } + } +} diff --git a/src/main/plugins/plugin-panel-navigation-guard.test.ts b/src/main/plugins/plugin-panel-navigation-guard.test.ts new file mode 100644 index 00000000000..2889e57cf70 --- /dev/null +++ b/src/main/plugins/plugin-panel-navigation-guard.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' +import { PluginPanelNavigationRegistry } from './plugin-panel-navigation-guard' + +function frame(input: { id: number; name?: string; url?: string }) { + let destroyed = false + return { + frameTreeNodeId: input.id, + name: input.name ?? '', + isDestroyed: () => destroyed, + destroy: () => { + destroyed = true + } + } +} + +describe('PluginPanelNavigationRegistry', () => { + it('blocks only host-marked plugin srcdoc frames', () => { + const registry = new PluginPanelNavigationRegistry() + const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) + const notebook = frame({ id: 2 }) + registry.register(plugin) + registry.register(notebook) + + expect(registry.shouldBlock(plugin, null, 'about:srcdoc')).toBe(false) + expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(true) + expect(registry.shouldBlock(notebook, notebook, 'https://example.com')).toBe(false) + }) + + it('keeps pre-parse identity after name mutation and prunes destroyed frames', () => { + const registry = new PluginPanelNavigationRegistry() + const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) + registry.register(plugin) + plugin.name = '' + expect(registry.shouldBlock(plugin, null, 'about:srcdoc')).toBe(false) + expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(true) + + plugin.destroy() + expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(false) + }) +}) diff --git a/src/main/plugins/plugin-panel-navigation-guard.ts b/src/main/plugins/plugin-panel-navigation-guard.ts new file mode 100644 index 00000000000..d183eae9888 --- /dev/null +++ b/src/main/plugins/plugin-panel-navigation-guard.ts @@ -0,0 +1,75 @@ +import type { WebContents, WebFrameMain } from 'electron' +import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' + +type NavigationFrame = Pick + +type RegisteredFrame = { + frame: NavigationFrame + initialSrcdocPending: boolean +} + +/** Records host-marked panel frame identities at browsing-context creation, + * before plugin parsing can mutate window.name. */ +export class PluginPanelNavigationRegistry { + private readonly frames = new Map() + + register(frame: NavigationFrame): void { + this.prune() + if (frame.name.startsWith(PLUGIN_PANEL_FRAME_NAME_PREFIX)) { + this.frames.set(frame.frameTreeNodeId, { frame, initialSrcdocPending: true }) + } + } + + shouldBlock( + frame: NavigationFrame | null, + initiator: NavigationFrame | null, + destinationUrl: string + ): boolean { + this.prune() + const registeredTarget = frame ? this.frames.get(frame.frameTreeNodeId) : undefined + if (registeredTarget) { + // Why: registration happens before the host-provided srcdoc commits; + // allow exactly that initial document, then contain every navigation. + if (registeredTarget.initialSrcdocPending && destinationUrl === 'about:srcdoc') { + registeredTarget.initialSrcdocPending = false + return false + } + return true + } + return Boolean(initiator && this.frames.has(initiator.frameTreeNodeId)) + } + + clear(): void { + this.frames.clear() + } + + private prune(): void { + for (const [id, registered] of this.frames) { + if (registered.frame.isDestroyed()) { + this.frames.delete(id) + } + } + } +} + +export function registerPluginPanelNavigationGuard(webContents: WebContents): void { + const registry = new PluginPanelNavigationRegistry() + webContents.on('frame-created', (_event, { frame }) => { + if (frame) { + registry.register(frame) + } + }) + webContents.on('did-start-navigation', (event) => { + if (!event.isMainFrame && event.url === 'about:srcdoc' && event.frame) { + // Some Chromium builds populate the frame name only when navigation + // starts; this event still precedes document parsing and plugin code. + registry.register(event.frame) + } + }) + webContents.on('will-frame-navigate', (event) => { + if (registry.shouldBlock(event.frame, event.initiator ?? null, event.url)) { + event.preventDefault() + } + }) + webContents.on('destroyed', () => registry.clear()) +} diff --git a/src/main/plugins/plugin-panel-owner-lifecycle.test.ts b/src/main/plugins/plugin-panel-owner-lifecycle.test.ts new file mode 100644 index 00000000000..cef035f158d --- /dev/null +++ b/src/main/plugins/plugin-panel-owner-lifecycle.test.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { + bindPluginPanelOwnerLifecycle, + type PluginPanelOwnerSender +} from './plugin-panel-owner-lifecycle' + +describe('bindPluginPanelOwnerLifecycle', () => { + it('deduplicates hooks, revokes on renderer loss, and invalidates in-flight loads', () => { + const sender = new EventEmitter() as PluginPanelOwnerSender & EventEmitter + const revoke = vi.fn() + const first = bindPluginPanelOwnerLifecycle(sender, revoke) + const duplicate = bindPluginPanelOwnerLifecycle(sender, revoke) + + expect(sender.listenerCount('destroyed')).toBe(1) + expect(sender.listenerCount('render-process-gone')).toBe(1) + expect(first.isCurrent()).toBe(true) + expect(duplicate.isCurrent()).toBe(true) + + sender.emit('render-process-gone') + + expect(revoke).toHaveBeenCalledTimes(1) + expect(first.isCurrent()).toBe(false) + expect(duplicate.isCurrent()).toBe(false) + expect(sender.listenerCount('destroyed')).toBe(0) + + const restarted = bindPluginPanelOwnerLifecycle(sender, revoke) + expect(restarted.isCurrent()).toBe(true) + sender.emit('destroyed') + expect(revoke).toHaveBeenCalledTimes(2) + expect(restarted.isCurrent()).toBe(false) + }) +}) diff --git a/src/main/plugins/plugin-panel-owner-lifecycle.ts b/src/main/plugins/plugin-panel-owner-lifecycle.ts new file mode 100644 index 00000000000..5f400c583e6 --- /dev/null +++ b/src/main/plugins/plugin-panel-owner-lifecycle.ts @@ -0,0 +1,42 @@ +import type { WebContents } from 'electron' + +export type PluginPanelOwnerSender = Pick + +type OwnerState = { + bound: boolean + generation: number +} + +const ownerStates = new WeakMap() + +/** Deduplicates WebContents lifecycle hooks and returns a generation lease so + * an async panel load cannot publish a session after its renderer died. */ +export function bindPluginPanelOwnerLifecycle( + sender: PluginPanelOwnerSender, + revoke: () => void +): { isCurrent: () => boolean } { + let state = ownerStates.get(sender) + if (!state) { + state = { bound: false, generation: 0 } + ownerStates.set(sender, state) + } + if (!state.bound) { + state.bound = true + let finished = false + const cleanup = (): void => { + if (finished) { + return + } + finished = true + sender.removeListener('destroyed', cleanup) + sender.removeListener('render-process-gone', cleanup) + state!.bound = false + state!.generation += 1 + revoke() + } + sender.once('destroyed', cleanup) + sender.once('render-process-gone', cleanup) + } + const generation = state.generation + return { isCurrent: () => state!.bound && state!.generation === generation } +} diff --git a/src/main/plugins/plugin-panel-sessions.test.ts b/src/main/plugins/plugin-panel-sessions.test.ts new file mode 100644 index 00000000000..df38e34c1da --- /dev/null +++ b/src/main/plugins/plugin-panel-sessions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { PluginPanelSessions } from './plugin-panel-sessions' + +const binding = { + pluginKey: 'orca-samples.demo', + panelId: 'dashboard', + rootDir: '/plugins/orca-samples.demo/hash-one', + manifestRevision: 'manifest-v1' +} + +describe('PluginPanelSessions', () => { + it('binds an opaque token to its transport owner and panel revision', () => { + const sessions = new PluginPanelSessions() + const token = sessions.issue('renderer:1', binding) + + expect(token).toHaveLength(43) + expect(sessions.resolve('renderer:1', token)).toEqual(binding) + expect(sessions.resolve('renderer:2', token)).toBeNull() + expect(sessions.issue('renderer:1', binding)).toBe(token) + expect(sessions.issue('renderer:1', { ...binding, rootDir: '/plugins/new' })).not.toBe(token) + expect(sessions.issue('renderer:1', { ...binding, manifestRevision: 'manifest-v2' })).not.toBe( + token + ) + }) + + it('revokes every session owned by a disconnected transport', () => { + const sessions = new PluginPanelSessions() + const first = sessions.issue('connection:one', binding) + const second = sessions.issue('connection:one', { ...binding, panelId: 'secondary' }) + const other = sessions.issue('connection:two', binding) + + sessions.revokeOwner('connection:one') + + expect(sessions.resolve('connection:one', first)).toBeNull() + expect(sessions.resolve('connection:one', second)).toBeNull() + expect(sessions.resolve('connection:two', other)).toEqual(binding) + }) +}) diff --git a/src/main/plugins/plugin-panel-sessions.ts b/src/main/plugins/plugin-panel-sessions.ts new file mode 100644 index 00000000000..0f1fcf4f533 --- /dev/null +++ b/src/main/plugins/plugin-panel-sessions.ts @@ -0,0 +1,84 @@ +import { randomBytes } from 'node:crypto' + +export type PluginPanelSessionBinding = { + pluginKey: string + panelId: string + rootDir: string + manifestRevision: string +} + +type PluginPanelSession = PluginPanelSessionBinding & { + ownerKey: string +} + +const MAX_PANEL_SESSIONS = 1_024 + +function bindingKey(ownerKey: string, binding: PluginPanelSessionBinding): string { + return JSON.stringify([ + ownerKey, + binding.pluginKey, + binding.panelId, + binding.rootDir, + binding.manifestRevision + ]) +} + +/** Opaque bearer sessions bind a loaded panel to its transport owner without + * accepting a plugin identity on later action calls. */ +export class PluginPanelSessions { + private readonly sessions = new Map() + private readonly tokensByBinding = new Map() + + issue(ownerKey: string, binding: PluginPanelSessionBinding): string { + const key = bindingKey(ownerKey, binding) + const existing = this.tokensByBinding.get(key) + if (existing) { + return existing + } + while (this.sessions.size >= MAX_PANEL_SESSIONS) { + const oldest = this.sessions.entries().next().value as + | [string, PluginPanelSession] + | undefined + if (!oldest) { + break + } + this.delete(oldest[0], oldest[1]) + } + const token = randomBytes(32).toString('base64url') + const session = { ownerKey, ...binding } + this.sessions.set(token, session) + this.tokensByBinding.set(key, token) + return token + } + + resolve(ownerKey: string, token: string): PluginPanelSessionBinding | null { + const session = this.sessions.get(token) + if (!session || session.ownerKey !== ownerKey) { + return null + } + return { + pluginKey: session.pluginKey, + panelId: session.panelId, + rootDir: session.rootDir, + manifestRevision: session.manifestRevision + } + } + + revokeOwner(ownerKey: string): void { + for (const [token, session] of this.sessions) { + if (session.ownerKey === ownerKey) { + this.delete(token, session) + } + } + } + + clear(): void { + this.sessions.clear() + this.tokensByBinding.clear() + } + + private delete(token: string, session: PluginPanelSession): void { + this.sessions.delete(token) + this.tokensByBinding.delete(bindingKey(session.ownerKey, session)) + } +} diff --git a/src/main/plugins/plugin-private-marketplace-ssh-shim.cjs b/src/main/plugins/plugin-private-marketplace-ssh-shim.cjs new file mode 100644 index 00000000000..c88809a37ad --- /dev/null +++ b/src/main/plugins/plugin-private-marketplace-ssh-shim.cjs @@ -0,0 +1,12 @@ +const { spawnSync } = require('node:child_process') + +const command = process.argv.at(-1) ?? '' +const match = /^git-upload-pack '([^']+)'$/.exec(command) +const repositories = JSON.parse(process.env.ORCA_TEST_SSH_REPOSITORIES ?? '{}') +const repository = match ? repositories[match[1]] : undefined +if (!repository) { + process.stderr.write(`unknown test SSH repository: ${command}\n`) + process.exit(1) +} +const result = spawnSync('git', ['upload-pack', repository], { stdio: 'inherit' }) +process.exit(result.status ?? 1) diff --git a/src/main/plugins/plugin-private-marketplace.integration.test.ts b/src/main/plugins/plugin-private-marketplace.integration.test.ts new file mode 100644 index 00000000000..7c37025487c --- /dev/null +++ b/src/main/plugins/plugin-private-marketplace.integration.test.ts @@ -0,0 +1,153 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' +import { getUserPluginsDir } from './plugin-discovery' +import { readPluginLockfile } from './plugin-install' +import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' +import { PluginMarketplaceService } from './plugin-marketplace-service' + +const execFileAsync = promisify(execFile) +const temporaryRoots: string[] = [] +const savedEnvironment = { + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND, + GIT_SSH_VARIANT: process.env.GIT_SSH_VARIANT, + ORCA_TEST_SSH_REPOSITORIES: process.env.ORCA_TEST_SSH_REPOSITORIES +} + +async function runGit(cwd: string, args: string[]): Promise { + await execFileAsync('git', args, { cwd }) +} + +async function createGitRepository( + root: string, + name: string, + files: Record +): Promise { + const repository = join(root, name) + await mkdir(repository, { recursive: true }) + for (const [relativePath, contents] of Object.entries(files)) { + const path = join(repository, relativePath) + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, contents, 'utf8') + } + await runGit(repository, ['init', '--quiet']) + await runGit(repository, ['checkout', '--quiet', '-b', 'main']) + await runGit(repository, ['add', '--all']) + await runGit(repository, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca-test@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture' + ]) + return repository +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +afterEach(async () => { + for (const [key, value] of Object.entries(savedEnvironment)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('private Git marketplace integration', () => { + it('uses the caller SSH environment for marketplace preview and install', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-private-marketplace-')) + temporaryRoots.push(root) + const pluginKey = 'private.private-locale' + const pluginUrl = 'ssh://git@example.invalid/private/locale.git' + const marketplaceUrl = 'ssh://git@example.invalid/private/marketplace.git' + const pluginRepository = await createGitRepository(root, 'locale-source', { + 'orca-plugin.json': JSON.stringify({ + manifestVersion: 1, + id: 'private-locale', + publisher: 'private', + name: 'Private Locale', + version: '1.0.0', + engines: { orca: '>=1.4.0' }, + pluginApi: 1, + contributes: { + languagePacks: [{ locale: 'pt-BR', path: 'locale.json' }] + }, + capabilities: [] + }), + 'locale.json': JSON.stringify({ + settings: { title: 'Ajustes' } + }) + }) + const marketplaceRepository = await createGitRepository(root, 'marketplace-source', { + 'orca-marketplace.json': JSON.stringify({ + name: 'Private Team Plugins', + owner: 'private-team', + plugins: [ + { + id: pluginKey, + source: { kind: 'git', url: pluginUrl, ref: 'main' }, + categories: ['languages'] + } + ] + }) + }) + const sshShim = join(root, 'git-ssh-shim.cjs') + await writeFile( + sshShim, + await readFile(join(import.meta.dirname, 'plugin-private-marketplace-ssh-shim.cjs'), 'utf8'), + 'utf8' + ) + process.env.GIT_SSH_COMMAND = `${shellQuote(process.execPath.replaceAll('\\', '/'))} ${shellQuote(sshShim.replaceAll('\\', '/'))}` + process.env.GIT_SSH_VARIANT = 'ssh' + process.env.ORCA_TEST_SSH_REPOSITORIES = JSON.stringify({ + '/private/locale.git': pluginRepository, + '/private/marketplace.git': marketplaceRepository + }) + + const userDataPath = join(root, 'user-data') + const marketplace = new PluginMarketplaceService({ + pluginsDataDir: join(userDataPath, 'plugins-data') + }) + const source: PluginMarketplaceGitSource = { + kind: 'git', + url: marketplaceUrl, + ref: 'main' + } + const registered = await marketplace.addSource(source) + const installer = new PluginMarketplaceInstaller({ + marketplace, + userDataPath, + hostVersion: '1.4.0' + }) + + const preview = await installer.preview(registered.id, pluginKey) + const installed = await installer.install(preview) + + expect(registered).toMatchObject({ + stale: false, + marketplace: { name: 'Private Team Plugins' } + }) + expect(preview).toMatchObject({ pluginKey, official: false, source: { url: pluginUrl } }) + expect(installed).toMatchObject({ ok: true, pluginKey }) + const lock = await readPluginLockfile(getUserPluginsDir(userDataPath)) + expect(lock.plugins[pluginKey]?.source).toMatchObject({ + kind: 'marketplace', + marketplace: { url: marketplaceUrl }, + plugin: { url: pluginUrl } + }) + }) +}) diff --git a/src/main/plugins/plugin-refresh-settlement.ts b/src/main/plugins/plugin-refresh-settlement.ts new file mode 100644 index 00000000000..47bc464d20f --- /dev/null +++ b/src/main/plugins/plugin-refresh-settlement.ts @@ -0,0 +1,11 @@ +export async function waitForPluginRefreshSettlement( + getCurrent: () => Promise +): Promise { + while (true) { + const pending = getCurrent() + await pending.catch(() => undefined) + if (pending === getCurrent()) { + return + } + } +} diff --git a/src/main/plugins/plugin-secrets-store.test.ts b/src/main/plugins/plugin-secrets-store.test.ts new file mode 100644 index 00000000000..b14bf9dd548 --- /dev/null +++ b/src/main/plugins/plugin-secrets-store.test.ts @@ -0,0 +1,117 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storageMocks = vi.hoisted(() => ({ + available: true, + encryptString: vi.fn((value: string) => Buffer.from(`encrypted:${value}`, 'utf8')), + decryptString: vi.fn((value: Buffer) => { + const text = value.toString('utf8') + if (!text.startsWith('encrypted:')) { + throw new Error('wrong key or corrupt ciphertext') + } + return text.slice('encrypted:'.length) + }) +})) + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => storageMocks.available, + encryptString: storageMocks.encryptString, + decryptString: storageMocks.decryptString + } +})) + +import { PluginSecretsStore } from './plugin-secrets-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-secrets-')) + roots.push(root) + return root +} + +beforeEach(() => { + storageMocks.available = true + storageMocks.encryptString.mockImplementation((value) => + Buffer.from(`encrypted:${value}`, 'utf8') + ) + storageMocks.decryptString.mockImplementation((value) => { + const text = value.toString('utf8') + if (!text.startsWith('encrypted:')) { + throw new Error('wrong key or corrupt ciphertext') + } + return text.slice('encrypted:'.length) + }) +}) + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginSecretsStore', () => { + it('encrypts, persists, decrypts, deletes, and isolates plugin namespaces', async () => { + const root = await tempRoot() + const first = new PluginSecretsStore(root, 'acme.first') + const second = new PluginSecretsStore(root, 'acme.second') + + expect(first.set('token', 'top-secret')).toEqual({ ok: true, value: true }) + expect(first.get('token')).toEqual({ ok: true, value: 'top-secret' }) + expect(second.get('token')).toEqual({ ok: true, value: null }) + const persisted = await readFile(join(root, 'acme.first', 'secrets.json.enc'), 'utf8') + expect(persisted).not.toContain('top-secret') + if (process.platform !== 'win32') { + expect((await stat(join(root, 'acme.first', 'secrets.json.enc'))).mode & 0o077).toBe(0) + } + + first.delete('token') + expect(first.get('token')).toEqual({ ok: true, value: null }) + }) + + it('fails closed without OS encryption and writes no plaintext file', async () => { + const root = await tempRoot() + storageMocks.available = false + const store = new PluginSecretsStore(root, 'acme.demo') + + expect(store.set('token', 'plaintext')).toMatchObject({ ok: false }) + expect(store.get('token')).toMatchObject({ ok: true, value: null }) + await expect(readFile(join(root, 'acme.demo', 'secrets.json.enc'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('reports corrupt or wrong-key ciphertext without returning bytes', async () => { + const root = await tempRoot() + const pluginDir = join(root, 'acme.demo') + await mkdir(pluginDir, { recursive: true }) + await writeFile( + join(pluginDir, 'secrets.json.enc'), + JSON.stringify({ + version: 1, + format: 'electron-safe-storage-v1', + ciphertexts: { token: Buffer.from('not-encrypted').toString('base64') } + }) + ) + const store = new PluginSecretsStore(root, 'acme.demo') + + expect(store.get('token')).toEqual({ ok: false, error: 'failed to decrypt stored secret' }) + }) + + it('refuses ciphertext that would exceed the bounded vault', async () => { + const root = await tempRoot() + storageMocks.encryptString.mockReturnValue(Buffer.alloc(6 * 1024 * 1024)) + const store = new PluginSecretsStore(root, 'acme.demo') + + expect(store.set('token', 'small-input')).toMatchObject({ ok: false }) + await expect(readFile(join(root, 'acme.demo', 'secrets.json.enc'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('rejects unsafe plugin namespaces', async () => { + const root = await tempRoot() + expect(() => new PluginSecretsStore(root, 'constructor.demo')).toThrow('unsafe plugin key') + }) +}) diff --git a/src/main/plugins/plugin-secrets-store.ts b/src/main/plugins/plugin-secrets-store.ts new file mode 100644 index 00000000000..7f16821cfb6 --- /dev/null +++ b/src/main/plugins/plugin-secrets-store.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { safeStorage } from 'electron' +import { writeSecureFile } from '../../shared/secure-file' +import { + PLUGIN_STORAGE_KEY_LIMIT, + PLUGIN_STORAGE_TOTAL_MAX_BYTES +} from '../../shared/plugins/plugin-host-api' +import { pluginDataDir } from './plugin-storage-store' + +/** + * Per-plugin secret vault, following the repo's safeStorage-backed + * credential-file pattern (versioned envelope + base64 ciphertext via the + * atomic secure-file writer). No plaintext fallback: when OS encryption is + * unavailable, writes fail loudly instead of silently downgrading — plugin + * secrets are API-token grade. + */ + +type PersistedSecretsFile = { + version: 1 + format: 'electron-safe-storage-v1' + /** key → base64 ciphertext of the secret value. */ + ciphertexts: Record +} + +export type PluginSecretsResult = { ok: true; value: T } | { ok: false; error: string } + +export class PluginSecretsStore { + private readonly filePath: string + + constructor(pluginsDataDir: string, qualifiedKey: string) { + this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), 'secrets.json.enc') + } + + private read(): PersistedSecretsFile { + const empty: PersistedSecretsFile = { + version: 1, + format: 'electron-safe-storage-v1', + ciphertexts: {} + } + try { + if (!existsSync(this.filePath)) { + return empty + } + if (statSync(this.filePath).size > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return empty + } + const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as PersistedSecretsFile + if ( + parsed && + parsed.version === 1 && + parsed.format === 'electron-safe-storage-v1' && + parsed.ciphertexts && + typeof parsed.ciphertexts === 'object' && + !Array.isArray(parsed.ciphertexts) + ) { + return parsed + } + } catch { + // Corrupt vaults read as empty; set() rewrites a valid file. + } + return empty + } + + get(key: string): PluginSecretsResult { + const file = this.read() + const ciphertext = file.ciphertexts[key] + if (typeof ciphertext !== 'string') { + return { ok: true, value: null } + } + if (!safeStorage.isEncryptionAvailable()) { + return { ok: false, error: 'OS-backed encryption is unavailable' } + } + try { + return { ok: true, value: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')) } + } catch { + return { ok: false, error: 'failed to decrypt stored secret' } + } + } + + set(key: string, value: string): PluginSecretsResult { + if (!safeStorage.isEncryptionAvailable()) { + return { ok: false, error: 'OS-backed encryption is unavailable; secret not stored' } + } + const file = this.read() + if ( + !Object.hasOwn(file.ciphertexts, key) && + Object.keys(file.ciphertexts).length >= PLUGIN_STORAGE_KEY_LIMIT + ) { + return { ok: false, error: `secret vault exceeds the ${PLUGIN_STORAGE_KEY_LIMIT}-key limit` } + } + file.ciphertexts[key] = safeStorage.encryptString(value).toString('base64') + const nextFile = JSON.stringify(file, null, 2) + if (Buffer.byteLength(nextFile, 'utf8') > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return { ok: false, error: `secret vault exceeds ${PLUGIN_STORAGE_TOTAL_MAX_BYTES} bytes` } + } + writeSecureFile(this.filePath, nextFile) + return { ok: true, value: true } + } + + delete(key: string): void { + const file = this.read() + if (Object.hasOwn(file.ciphertexts, key)) { + delete file.ciphertexts[key] + writeSecureFile(this.filePath, JSON.stringify(file, null, 2)) + } + } +} diff --git a/src/main/plugins/plugin-service-housekeeping.ts b/src/main/plugins/plugin-service-housekeeping.ts new file mode 100644 index 00000000000..fb1032bac4c --- /dev/null +++ b/src/main/plugins/plugin-service-housekeeping.ts @@ -0,0 +1,47 @@ +import { PluginDevWatcher } from './plugin-dev-watcher' + +/** Starts and stops lifecycle maintenance as the feature flag and dev paths change. */ +export class PluginServiceHousekeeping { + private readonly devWatcher = new PluginDevWatcher() + private reapTimer: ReturnType | null = null + private watchedPathsKey: string | null = null + + sync(options: { + enabled: boolean + devPaths: readonly string[] + reapIdle: () => void + refresh: () => void + }): void { + if (!options.enabled) { + this.stop() + return + } + if (!this.reapTimer) { + this.reapTimer = setInterval(options.reapIdle, 60_000) + this.reapTimer.unref?.() + } + const pathsKey = JSON.stringify(options.devPaths) + if (pathsKey !== this.watchedPathsKey) { + this.devWatcher.dispose() + this.devWatcher.start(options.devPaths, options.refresh, () => { + // The next refresh retries a failed watcher even when the configured + // path list itself did not change. + this.watchedPathsKey = null + }) + this.watchedPathsKey = pathsKey + } + } + + dispose(): void { + this.stop() + } + + private stop(): void { + if (this.reapTimer) { + clearInterval(this.reapTimer) + this.reapTimer = null + } + this.devWatcher.dispose() + this.watchedPathsKey = null + } +} diff --git a/src/main/plugins/plugin-service-integrity.test.ts b/src/main/plugins/plugin-service-integrity.test.ts new file mode 100644 index 00000000000..56a344eb3f8 --- /dev/null +++ b/src/main/plugins/plugin-service-integrity.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import { hashPluginTree } from './plugin-content-hash' +import { PluginContentVerifier } from './plugin-content-integrity' +import { PluginService } from './plugin-service' +import type { PluginWorkerFactory } from './plugin-worker-manager' + +const roots: string[] = [] + +async function createInstalledPlugin(options: { worker: boolean }): Promise<{ + userDataPath: string + pluginKey: string + rootDir: string + manifest: PluginManifest +}> { + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-service-integrity-')) + roots.push(userDataPath) + const pluginKey = 'orca-samples.demo' + const pluginDir = join(userDataPath, 'plugins', pluginKey) + const stagingDir = join(pluginDir, 'staging') + await mkdir(stagingDir, { recursive: true }) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + ...(options.worker ? { main: 'worker.js' } : {}), + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: options.worker ? [{ id: 'run', title: 'Run' }] : [], + events: [] + }, + capabilities: [] + }) + await writeFile(join(stagingDir, 'orca-plugin.json'), JSON.stringify(manifest)) + await writeFile(join(stagingDir, 'panel.html'), '

    Panel

    ') + await writeFile(join(stagingDir, 'payload.txt'), 'original') + if (options.worker) { + await writeFile(join(stagingDir, 'worker.js'), 'export default async function () {}') + } + const content = await hashPluginTree(stagingDir) + if (!content.ok) { + throw new Error(content.error) + } + const rootDir = join(pluginDir, content.hash) + await rename(stagingDir, rootDir) + await writeFile(join(pluginDir, 'current'), content.hash) + return { userDataPath, pluginKey, rootDir, manifest } +} + +function createService( + plugin: Awaited>, + workerFactory?: PluginWorkerFactory +): PluginService { + const consentFingerprint = fingerprintPluginConsent(plugin.manifest) + return new PluginService({ + userDataPath: plugin.userDataPath, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ [plugin.pluginKey]: consentFingerprint }), + getDevPluginPaths: () => [], + workerFactory + }) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginService lazy content verification', () => { + it('does not share an in-flight verification across same-key content revisions', async () => { + const oldPlugin = await createInstalledPlugin({ worker: false }) + const newPlugin = await createInstalledPlugin({ worker: false }) + await writeFile(join(oldPlugin.rootDir, 'payload.txt'), 'tampered old revision') + const verifier = new PluginContentVerifier() + + const oldVerification = verifier.verify({ + pluginKey: oldPlugin.pluginKey, + rootDir: oldPlugin.rootDir, + contentHash: basename(oldPlugin.rootDir) + }) + const newVerification = verifier.verify({ + pluginKey: newPlugin.pluginKey, + rootDir: newPlugin.rootDir, + contentHash: basename(newPlugin.rootDir) + }) + + await expect(oldVerification).rejects.toThrow('integrity verification') + await expect(newVerification).resolves.toBeUndefined() + }) + + it('detects tampering only when panel code is first consumed', async () => { + const plugin = await createInstalledPlugin({ worker: false }) + const service = createService(plugin) + await service.initialize() + expect(service.findValidPlugin(plugin.pluginKey)).not.toBeNull() + + await writeFile(join(plugin.rootDir, 'payload.txt'), 'tampered after discovery') + + await expect(service.panels.readEntry(plugin.pluginKey, 'panel')).resolves.toBeNull() + await service.dispose() + }) + + it('blocks a worker fork when installed content changed after discovery', async () => { + const plugin = await createInstalledPlugin({ worker: true }) + const workerFactory = vi.fn() + const service = createService(plugin, workerFactory) + await service.initialize() + await writeFile(join(plugin.rootDir, 'payload.txt'), 'tampered after discovery') + + await expect(service.invokeCommand(plugin.pluginKey, 'run')).rejects.toThrow( + 'integrity verification' + ) + expect(workerFactory).not.toHaveBeenCalled() + await service.dispose() + }) +}) diff --git a/src/main/plugins/plugin-service-options.ts b/src/main/plugins/plugin-service-options.ts new file mode 100644 index 00000000000..f6df4ba8edf --- /dev/null +++ b/src/main/plugins/plugin-service-options.ts @@ -0,0 +1,18 @@ +import type { PluginWorkerFactory } from './plugin-worker-manager' +import type { KeybindingOverrides } from '../../shared/keybindings' +import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list' + +export type PluginServiceOptions = { + userDataPath: string + hostVersion: string + isPluginSystemEnabled: () => boolean + getDisabledPlugins: () => string[] + getPluginConsents: () => Record + getDevPluginPaths: () => string[] + getKeybindings?: () => KeybindingOverrides + getPluginKillListEntry?: (pluginKey: string) => PluginKillListEntry | null + hostEntryPath?: string + workerFactory?: PluginWorkerFactory + maxActiveWorkers?: number + idleReapMs?: number +} diff --git a/src/main/plugins/plugin-service-reconciliation.test.ts b/src/main/plugins/plugin-service-reconciliation.test.ts new file mode 100644 index 00000000000..eb373d654da --- /dev/null +++ b/src/main/plugins/plugin-service-reconciliation.test.ts @@ -0,0 +1,462 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { KeybindingOverrides } from '../../shared/keybindings' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import type { PluginWorkerHandle } from './plugin-host-process' +import { PluginService } from './plugin-service' +import type { PluginWorkerFactory } from './plugin-worker-manager' +import { hashPluginTree } from './plugin-content-hash' + +const roots: string[] = [] +const services: PluginService[] = [] +const pluginKey = 'orca-samples.demo' + +function manifest(options: { main?: string; capabilities?: PluginManifest['capabilities'] } = {}) { + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + main: options.main ?? 'worker.js', + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [{ id: 'run', title: 'Run' }], + events: [] + }, + capabilities: options.capabilities ?? [] + }) +} + +async function pluginRoot(pluginManifest = manifest()): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-reconcile-')) + roots.push(root) + await writeFile(join(root, 'orca-plugin.json'), JSON.stringify(pluginManifest)) + await writeFile(join(root, 'worker.js'), 'export default async function () {}') + await writeFile(join(root, 'worker-v2.js'), 'export default async function () {}') + await writeFile(join(root, 'panel.html'), '

    Panel

    ') + return root +} + +function testWorker(): PluginWorkerHandle & { dispose: ReturnType } { + return { + commands: ['run'], + invokeCommand: vi.fn(async () => null), + deliverEvent: vi.fn(), + lastActivityAt: () => Date.now(), + inFlightCount: () => 0, + dispose: vi.fn(async () => undefined), + kill: vi.fn(), + onExit: vi.fn() + } +} + +function createHarness(root: string) { + let enabled = true + let disabled: string[] = [] + let devPaths = [root] + let killed = false + const consent = fingerprintPluginConsent(manifest()) + const workers: ReturnType[] = [] + const factory = vi.fn(async () => { + const handle = testWorker() + workers.push(handle) + return handle + }) + const service = new PluginService({ + userDataPath: root, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => enabled, + getDisabledPlugins: () => disabled, + getPluginConsents: () => ({ [pluginKey]: consent }), + getDevPluginPaths: () => devPaths, + getPluginKillListEntry: (key) => + killed && key === pluginKey + ? { pluginKey, reason: 'Security incident', advisoryUrl: 'https://orca.example/advisory' } + : null, + workerFactory: factory + }) + services.push(service) + return { + service, + factory, + workers, + setEnabled: (value: boolean) => { + enabled = value + }, + setDisabled: (value: string[]) => { + disabled = value + }, + setDevPaths: (value: string[]) => { + devPaths = value + }, + setKilled: (value: boolean) => { + killed = value + } + } +} + +async function activate(service: PluginService): Promise { + await service.initialize() + await service.invokeCommand(pluginKey, 'run') +} + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(services.splice(0).map((service) => service.dispose())) + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginService worker reconciliation', () => { + it('blocks every runtime surface until a saved override resolves a content conflict', async () => { + const conflictingManifest = (id: string): PluginManifest => + pluginManifestSchema.parse({ + manifestVersion: 1, + id, + publisher: 'orca-samples', + name: id, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + main: 'worker.js', + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [{ id: 'run', title: 'Run' }], + keybindings: [{ command: 'run', key: 'Mod+Alt+T' }], + events: [{ on: 'worktree.created' }] + }, + capabilities: [{ kind: 'events:subscribe' }] + }) + const firstManifest = conflictingManifest('first') + const secondManifest = conflictingManifest('second') + const firstRoot = await pluginRoot(firstManifest) + const secondRoot = await pluginRoot(secondManifest) + const firstHash = await hashPluginTree(firstRoot) + const secondHash = await hashPluginTree(secondRoot) + if (!firstHash.ok || !secondHash.ok) { + throw new Error('could not hash conflict fixtures') + } + let keybindings: KeybindingOverrides = {} + const factory = vi.fn(async () => testWorker()) + const service = new PluginService({ + userDataPath: firstRoot, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ + 'orca-samples.first': fingerprintPluginConsent(firstManifest, firstHash.hash), + 'orca-samples.second': fingerprintPluginConsent(secondManifest, secondHash.hash) + }), + getDevPluginPaths: () => [firstRoot, secondRoot], + getKeybindings: () => keybindings, + workerFactory: factory + }) + services.push(service) + + await service.initialize() + + expect(service.activationError('orca-samples.first')).toContain('conflicts') + expect(service.getGrantedCapabilities('orca-samples.first')).toBeNull() + await expect(service.invokeCommand('orca-samples.first', 'run')).rejects.toThrow('not enabled') + await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toBeNull() + service.emitEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(factory).not.toHaveBeenCalled() + + keybindings = { 'plugin:orca-samples.first/run': ['Mod+Shift+T'] } + await service.reconcileActivationState() + + expect(service.activationError('orca-samples.first')).toBeNull() + await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toMatchObject({ + html: expect.stringContaining('

    Panel

    ') + }) + await expect(service.invokeCommand('orca-samples.first', 'run')).resolves.toBeNull() + expect(factory).toHaveBeenCalledOnce() + }) + + it('rejects declarative aliases at the worker-command boundary without activating code', async () => { + const aliasManifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] + }, + capabilities: [] + }) + const root = await pluginRoot(aliasManifest) + const factory = vi.fn() + const service = new PluginService({ + userDataPath: root, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ [pluginKey]: fingerprintPluginConsent(aliasManifest) }), + getDevPluginPaths: () => [root], + workerFactory: factory + }) + services.push(service) + + await service.initialize() + + await expect(service.invokeCommand(pluginKey, 'tasks')).rejects.toThrow( + 'is a built-in action alias' + ) + expect(factory).not.toHaveBeenCalled() + }) + + it('denies every authority boundary immediately when the feature flag turns off', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + const plugin = harness.service.findValidPlugin(pluginKey)! + const opened = await harness.service.panels.open('renderer:1', pluginKey, 'panel') + expect(opened).not.toBeNull() + + harness.setEnabled(false) + + expect(harness.service.activationState(plugin)).toBe('disabled') + expect(harness.service.getGrantedCapabilities(pluginKey)).toBeNull() + await expect(harness.service.invokeCommand(pluginKey, 'run')).rejects.toThrow('not enabled') + await expect(harness.service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull() + + await harness.service.refresh() + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + harness.setEnabled(true) + await harness.service.refresh() + await expect( + harness.service.panels.execute('renderer:1', { + sessionToken: opened!.sessionToken, + action: 'notifications.show', + params: { title: 'stale' } + }) + ).resolves.toMatchObject({ ok: false, error: 'invalid panel session' }) + }) + + it('deactivates a worker when its plugin becomes disabled', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + + harness.setDisabled([pluginKey]) + await harness.service.refresh() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.workerState(pluginKey).state).toBe('inactive') + }) + + it('immediately revokes every authority surface and stops a killed plugin', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + expect(await harness.service.panels.open('renderer:1', pluginKey, 'panel')).not.toBeNull() + + harness.setKilled(true) + + expect(harness.service.getGrantedCapabilities(pluginKey)).toBeNull() + expect(harness.service.activationError(pluginKey)).toContain('Security incident') + await expect(harness.service.invokeCommand(pluginKey, 'run')).rejects.toThrow('not enabled') + await expect(harness.service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull() + harness.service.emitEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + await harness.service.reconcileActivationState() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.options.getPluginKillListEntry?.(pluginKey)).toMatchObject({ + reason: 'Security incident' + }) + }) + + it('deactivates a worker when changed capabilities make consent pending', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify(manifest({ capabilities: [{ kind: 'storage' }] })) + ) + + await harness.service.refresh() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.activationState(harness.service.findValidPlugin(pluginKey)!)).toBe( + 'pending' + ) + }) + + it('cancels the old generation when a worker spec changes without eager reactivation', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify(manifest({ main: 'worker-v2.js' })) + ) + + await harness.service.refresh() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.factory).toHaveBeenCalledTimes(1) + await harness.service.invokeCommand(pluginKey, 'run') + expect(harness.factory).toHaveBeenCalledTimes(2) + expect(harness.factory.mock.calls[1]?.[0].mainEntry).toBe('worker-v2.js') + }) + + it('cannot reactivate the old revision while refresh awaits worker shutdown', async () => { + const root = await pluginRoot() + let finishOldDispose!: () => void + const oldDispose = new Promise((resolve) => { + finishOldDispose = resolve + }) + const workers: ReturnType[] = [] + const factory = vi.fn(async () => { + const handle = testWorker() + if (workers.length === 0) { + handle.dispose.mockImplementation(() => oldDispose) + } + workers.push(handle) + return handle + }) + const consent = fingerprintPluginConsent(manifest()) + const service = new PluginService({ + userDataPath: root, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ [pluginKey]: consent }), + getDevPluginPaths: () => [root], + workerFactory: factory + }) + services.push(service) + await activate(service) + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify(manifest({ main: 'worker-v2.js' })) + ) + + const refreshing = service.refresh() + await vi.waitFor(() => expect(workers[0]!.dispose).toHaveBeenCalledOnce()) + const invoking = service.invokeCommand(pluginKey, 'run') + await vi.waitFor(() => expect(factory).toHaveBeenCalledTimes(2)) + expect(factory.mock.calls[1]?.[0].mainEntry).toBe('worker-v2.js') + finishOldDispose() + + await expect(invoking).resolves.toBeNull() + await refreshing + }) + + it('deactivates removed and replaced dev paths without eager activation', async () => { + const firstRoot = await pluginRoot() + const secondRoot = await pluginRoot() + const harness = createHarness(firstRoot) + await activate(harness.service) + + harness.setDevPaths([]) + await harness.service.refresh() + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.findValidPlugin(pluginKey)).toBeNull() + + harness.setDevPaths([secondRoot]) + await harness.service.refresh() + expect(harness.factory).toHaveBeenCalledTimes(1) + await harness.service.invokeCommand(pluginKey, 'run') + expect(harness.factory.mock.calls[1]?.[0].rootDir).toBe(secondRoot) + }) + + it('starts and stops housekeeping on feature-flag transitions', async () => { + vi.useFakeTimers() + const root = await pluginRoot() + const harness = createHarness(root) + await harness.service.initialize() + expect(vi.getTimerCount()).toBe(1) + + harness.setEnabled(false) + await harness.service.refresh() + expect(vi.getTimerCount()).toBe(0) + expect(harness.service.getDiscovered()).toEqual([]) + + harness.setEnabled(true) + await harness.service.refresh() + expect(vi.getTimerCount()).toBe(1) + expect(harness.factory).not.toHaveBeenCalled() + }) + + it('serializes activation reconciliation so the latest disabled state wins', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await harness.service.initialize() + + const originalReconcile = harness.service.contentPacks.reconcile.bind( + harness.service.contentPacks + ) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + let firstStarted!: () => void + const firstStartedPromise = new Promise((resolve) => { + firstStarted = resolve + }) + let activeReconciliations = 0 + let maximumConcurrentReconciliations = 0 + let callCount = 0 + const reconcile = vi + .spyOn(harness.service.contentPacks, 'reconcile') + .mockImplementation(async (...args) => { + callCount += 1 + activeReconciliations += 1 + maximumConcurrentReconciliations = Math.max( + maximumConcurrentReconciliations, + activeReconciliations + ) + try { + if (callCount === 1) { + firstStarted() + await firstGate + } + await originalReconcile(...args) + } finally { + activeReconciliations -= 1 + } + }) + + const first = harness.service.reconcileActivationState() + await firstStartedPromise + harness.setDisabled([pluginKey]) + const second = harness.service.reconcileActivationState() + let clientsReleased = false + const clientsReady = harness.service.whenReady().then(() => { + clientsReleased = true + }) + + await Promise.resolve() + expect(reconcile).toHaveBeenCalledTimes(1) + expect(clientsReleased).toBe(false) + releaseFirst() + await Promise.all([first, second, clientsReady]) + + expect(maximumConcurrentReconciliations).toBe(1) + expect(clientsReleased).toBe(true) + expect(reconcile).toHaveBeenCalledTimes(2) + expect(harness.service.activationState(harness.service.findValidPlugin(pluginKey)!)).toBe( + 'disabled' + ) + expect(harness.service.workerState(pluginKey).state).toBe('inactive') + }) +}) diff --git a/src/main/plugins/plugin-service.ts b/src/main/plugins/plugin-service.ts new file mode 100644 index 00000000000..9f214cc834b --- /dev/null +++ b/src/main/plugins/plugin-service.ts @@ -0,0 +1,336 @@ +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { + capabilityKinds, + type PluginCapabilityKind +} from '../../shared/plugins/plugin-capabilities' +import { + getPluginActivationState, + type PluginConsentLists +} from '../../shared/plugins/plugin-consent-state' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { + createPluginExtensionRegistry, + type PluginExtensionRegistry +} from '../../shared/plugins/plugin-extension-registry' +import { + discoverPlugins, + getPluginsDataDir, + getUserPluginsDir, + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import { PluginEventBus } from './plugin-event-bus' +import { PluginAuditLog } from './plugin-audit-log' +import { executePluginHostCallRequest } from './plugin-host-call-adapter' +import { PluginContentVerifier } from './plugin-content-integrity' +import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' +import { PluginLogBuffer, type PluginLogLine } from './plugin-log-buffer' +import { PluginPanelController } from './plugin-panel-controller' +import { PluginWorkerController } from './plugin-worker-controller' +import { PluginServiceHousekeeping } from './plugin-service-housekeeping' +import { collectApprovedWorkerSpecs } from './plugin-worker-reconciliation' +import type { PluginRunState } from './plugin-supervisor' +import { isPluginApproved, snapshotPluginConsentLists } from './plugin-activation-policy' +import { PluginContentPackRegistry } from './plugin-content-pack-registry' +import type { PluginServiceOptions } from './plugin-service-options' +import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement' +import { assertPluginWorkerCommand } from './plugin-command-invocation' +import { deliverPluginEvent } from './plugin-event-delivery' + +export type { PluginRuntimeDelegate } from './plugin-host-service-bindings' +export type { PluginLogLine } from './plugin-log-buffer' +export type { PluginServiceOptions } from './plugin-service-options' + +export class PluginService { + readonly options: PluginServiceOptions + private readonly registry: PluginExtensionRegistry = createPluginExtensionRegistry() + private readonly eventBus = new PluginEventBus() + private readonly audit: PluginAuditLog + private readonly workerController: PluginWorkerController + private readonly logBuffer = new PluginLogBuffer() + private readonly contentVerifier = new PluginContentVerifier() + readonly contentPacks: PluginContentPackRegistry + readonly panels: PluginPanelController + private readonly changeListeners = new Set<(event: PluginChangeEvent) => void>() + private readonly housekeeping = new PluginServiceHousekeeping() + private discovered: DiscoveredPlugin[] = [] + private runtimeDelegate: PluginRuntimeDelegate | null = null + private initPromise: Promise | null = null + private refreshChain: Promise = Promise.resolve() + private contentPacksReady = false + private disposed = false + + constructor(options: PluginServiceOptions) { + this.options = options + this.contentPacks = new PluginContentPackRegistry(this.contentVerifier) + this.audit = new PluginAuditLog(getPluginsDataDir(options.userDataPath)) + this.panels = new PluginPanelController({ + resolveApprovedPlugin: (pluginKey) => { + const plugin = this.findValidPlugin(pluginKey) + return plugin && this.isRuntimeApproved(plugin) ? plugin : null + }, + contentVerifier: this.contentVerifier, + executeHostCall: (pluginKey, method, params) => + this.executeHostCall(pluginKey, method, params, { viaPanel: true }), + log: (pluginKey, line) => this.logBuffer.append(pluginKey, 'error', line) + }) + this.workerController = new PluginWorkerController({ + entryPath: options.hostEntryPath ?? '', + maxActive: options.maxActiveWorkers, + idleReapMs: options.idleReapMs, + workerFactory: options.workerFactory, + registry: this.registry, + contentVerifier: this.contentVerifier, + capabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey), + isCurrentApproved: (plugin) => + this.findValidPlugin(plugin.pluginKey) === plugin && this.isRuntimeApproved(plugin), + invokeCommand: (pluginKey, commandId, args) => this.invokeCommand(pluginKey, commandId, args), + executeHostCall: (pluginKey, method, params) => + this.executeHostCall(pluginKey, method, params, { viaPanel: false }), + log: (pluginKey, level, line) => this.logBuffer.append(pluginKey, level, line), + onStateChanged: () => this.notifyChanged(false), + onWorkerGone: (pluginKey) => this.eventBus.clear(pluginKey) + }) + } + + setRuntimeDelegate(delegate: PluginRuntimeDelegate | null): void { + this.runtimeDelegate = delegate + } + + onChanged(listener: (event: PluginChangeEvent) => void): () => void { + this.changeListeners.add(listener) + return () => this.changeListeners.delete(listener) + } + + private notifyChanged(contentPacksChanged: boolean): void { + for (const listener of this.changeListeners) { + listener({ contentPacksChanged }) + } + } + + async initialize(): Promise { + this.initPromise ??= this.refresh() + return this.initPromise + } + + async whenReady(): Promise { + await (this.initPromise ?? Promise.resolve()).catch(() => undefined) + // Client reads wait for the complete transaction so rollback-based content + // validation cannot expose a partially activated plugin between passes. + await waitForPluginRefreshSettlement(() => this.refreshChain) + } + + refresh(): Promise { + // Snapshot settings at request time so a quick off→on sequence still + // processes the off transition and revokes old workers/panel sessions. + const enabled = this.options.isPluginSystemEnabled() + const devPaths = this.options.getDevPluginPaths() + const consentLists = snapshotPluginConsentLists(this.options) + const refresh = this.refreshChain.then(() => + this.performRefresh(enabled, devPaths, consentLists) + ) + this.refreshChain = refresh.catch(() => undefined) + return refresh + } + + private async performRefresh( + enabled: boolean, + devPaths: string[], + consentLists: PluginConsentLists + ): Promise { + if (this.disposed) { + return + } + this.contentPacksReady = false + this.contentVerifier.clear() + if (!enabled) { + this.panels.revokeAll() + } + const next = enabled + ? await discoverPlugins({ + pluginsDir: getUserPluginsDir(this.options.userDataPath), + devPluginPaths: devPaths, + hostVersion: this.options.hostVersion + }) + : [] + if (this.disposed) { + return + } + // Publish identity before shutdown so triggers cannot restart old code. + this.discovered = next + await this.contentPacks.reconcile( + next, + (plugin) => isPluginApproved(enabled, plugin, consentLists), + this.options.getKeybindings?.() + ) + this.contentPacksReady = true + const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) => this.isRuntimeApproved(plugin)) + // Notify before slow shutdown so feature-off unmounts panels immediately. + this.notifyChanged(true) + await this.workerController.reconcile(nextSpecs) + if (this.disposed) { + return + } + this.housekeeping.sync({ + enabled, + devPaths, + reapIdle: () => this.workerController.reapIdle(), + refresh: () => void this.refresh() + }) + this.notifyChanged(false) + } + + getDiscovered(): readonly DiscoveredPlugin[] { + return this.discovered + } + + getLogs(pluginKey: string): PluginLogLine[] { + return this.logBuffer.get(pluginKey) + } + + findValidPlugin(pluginKey: string): ValidDiscoveredPlugin | null { + for (const plugin of this.discovered) { + if (!isInvalidDiscoveredPlugin(plugin) && plugin.pluginKey === pluginKey) { + return plugin + } + } + return null + } + + activationState(plugin: ValidDiscoveredPlugin): ReturnType { + // The feature flag is an authority boundary, not only a discovery hint: + // callers fail closed immediately even before async reconciliation ends. + if (!this.options.isPluginSystemEnabled()) { + return 'disabled' + } + return getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, { + pluginConsents: this.options.getPluginConsents(), + disabledPlugins: this.options.getDisabledPlugins() + }) + } + + private isRuntimeApproved(plugin: ValidDiscoveredPlugin): boolean { + return ( + this.contentPacksReady && + this.activationState(plugin) === 'approved' && + !this.contentPacks.error(plugin.pluginKey) && + !this.options.getPluginKillListEntry?.(plugin.pluginKey) + ) + } + + workerState(pluginKey: string): { state: PluginRunState; restarts: number } { + return this.workerController.state(pluginKey) + } + + activationError(pluginKey: string): string | null { + const blocked = this.options.getPluginKillListEntry?.(pluginKey) + return ( + (blocked ? `Blocked by Orca's plugin safety list: ${blocked.reason}` : null) ?? + this.contentPacks.error(pluginKey) ?? + this.workerController.activationError(pluginKey) + ) + } + + /** Consented capability kinds for an approved plugin; null otherwise so + * callers deny uniformly (no probe-able distinction). */ + getGrantedCapabilities(pluginKey: string): PluginCapabilityKind[] | null { + const plugin = this.findValidPlugin(pluginKey) + if (!plugin || !this.isRuntimeApproved(plugin)) { + return null + } + return capabilityKinds(plugin.manifest.capabilities) + } + + /** Host API chokepoint for both transports (worker fork IPC + panel + * bridge); serve RPC reuses it through the same entry points. */ + async executeHostCall( + pluginKey: string, + method: string, + params: unknown, + options: { viaPanel: boolean } + ): Promise { + return executePluginHostCallRequest({ + pluginKey, + request: { method, params }, + viaPanel: options.viaPanel, + resolvePolicy: (boundPluginKey) => ({ + grantedCapabilities: this.getGrantedCapabilities(boundPluginKey), + services: this.runtimeDelegate + ? bindPluginHostServices({ + delegate: this.runtimeDelegate, + pluginsDataDir: getPluginsDataDir(this.options.userDataPath), + subscribeEvents: (key, events) => this.eventBus.subscribe(key, events) + }) + : null, + audit: this.audit + }) + }) + } + + async invokeCommand(pluginKey: string, commandId: string, args?: unknown): Promise { + const plugin = this.findValidPlugin(pluginKey) + if (!plugin || !this.isRuntimeApproved(plugin)) { + throw new Error(`plugin ${pluginKey} is not enabled`) + } + assertPluginWorkerCommand(plugin, commandId) + const handle = await this.workerController.ensure(plugin) + if (!handle.commands.includes(commandId)) { + throw new Error(`plugin ${pluginKey} registered no handler for ${commandId}`) + } + return handle.invokeCommand(commandId, args) + } + + emitEvent(event: PluginEventName, payload: unknown): void { + if (!this.options.isPluginSystemEnabled() || this.disposed) { + return + } + deliverPluginEvent({ + event, + payload, + plugins: this.discovered, + eventBus: this.eventBus, + workerController: this.workerController, + isRuntimeApproved: (plugin) => this.isRuntimeApproved(plugin), + logWarning: (pluginKey, line) => this.logBuffer.append(pluginKey, 'warn', line) + }) + } + + async deactivatePlugin(pluginKey: string): Promise { + await this.workerController.deactivate(pluginKey) + this.notifyChanged(false) + } + + /** Reconciles live workers and client projections after consent or + * enablement changes without re-reading plugin files or starting workers. */ + async reconcileActivationState(): Promise { + const reconcile = this.refreshChain.then(() => this.performActivationStateReconciliation()) + this.refreshChain = reconcile.catch(() => undefined) + return reconcile + } + + private async performActivationStateReconciliation(): Promise { + this.contentPacksReady = false + await this.contentPacks.reconcile( + this.discovered, + (plugin) => this.activationState(plugin) === 'approved', + this.options.getKeybindings?.() + ) + this.contentPacksReady = true + const nextSpecs = collectApprovedWorkerSpecs(this.discovered, (plugin) => + this.isRuntimeApproved(plugin) + ) + await this.workerController.reconcile(nextSpecs) + this.notifyChanged(true) + } + + async dispose(): Promise { + this.disposed = true + this.housekeeping.dispose() + this.panels.dispose() + await this.refreshChain.catch(() => undefined) + await this.workerController.dispose() + await this.audit.flush() + } +} diff --git a/src/main/plugins/plugin-startup-budget.test.ts b/src/main/plugins/plugin-startup-budget.test.ts new file mode 100644 index 00000000000..456aa3822f8 --- /dev/null +++ b/src/main/plugins/plugin-startup-budget.test.ts @@ -0,0 +1,137 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { + PLUGIN_MANIFEST_FILENAME, + pluginManifestSchema, + qualifiedPluginKey, + type PluginManifest +} from '../../shared/plugins/plugin-manifest' +import { PluginService, type PluginServiceOptions } from './plugin-service' +import type { PluginWorkerFactory } from './plugin-worker-manager' + +const PLUGIN_COUNT = 20 +const SAMPLE_COUNT = 20 +// Real disk I/O, so the number moves with machine load: ~16-34ms idle, higher +// when the suite saturates the box. Sized to catch an order-of-magnitude +// regression rather than scheduling noise — the behavioral assertions below +// (no worker spawned, no plugin code run) are what this test really guards. +const STARTUP_P95_BUDGET_MS = 400 + +let userDataPath = '' +let markerPaths: string[] = [] +let consents: Record = {} + +function dummyManifest(index: number): PluginManifest { + const key = String.fromCharCode('A'.charCodeAt(0) + index) + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: `dummy-${index}`, + publisher: 'startup-budget', + name: `Startup Dummy ${index}`, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + panels: [], + commands: [ + { + id: 'open', + title: `Open Startup Dummy ${index}`, + action: 'view.tasks' + } + ], + events: [], + keybindings: [{ command: 'open', key: `Mod+Alt+${key}` }], + languagePacks: [{ locale: 'pt-BR', path: 'locale.json' }] + }, + capabilities: [] + }) +} + +async function installDummy(index: number): Promise<{ pluginKey: string; markerPath: string }> { + const manifest = dummyManifest(index) + const pluginKey = qualifiedPluginKey(manifest) + const contentHash = (index + 1).toString(16).padStart(64, '0') + const pluginDir = join(userDataPath, 'plugins', pluginKey) + const versionDir = join(pluginDir, contentHash) + const markerPath = join(userDataPath, `activation-${index}.marker`) + await mkdir(versionDir, { recursive: true }) + await Promise.all([ + writeFile(join(pluginDir, 'current'), contentHash), + writeFile(join(versionDir, PLUGIN_MANIFEST_FILENAME), JSON.stringify(manifest)), + writeFile( + join(versionDir, 'locale.json'), + JSON.stringify({ startup: { label: `Startup Dummy ${index}` } }) + ) + ]) + consents[pluginKey] = fingerprintPluginConsent(manifest) + return { pluginKey, markerPath } +} + +function nearestRankP95(samples: readonly number[]): number { + const sorted = [...samples].sort((left, right) => left - right) + return sorted[Math.ceil(sorted.length * 0.95) - 1]! +} + +describe('plugin startup budget', () => { + beforeAll(async () => { + userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-startup-budget-')) + const installed = await Promise.all( + Array.from({ length: PLUGIN_COUNT }, (_, index) => installDummy(index)) + ) + markerPaths = installed.map(({ markerPath }) => markerPath) + }) + + afterAll(async () => { + await rm(userDataPath, { recursive: true, force: true }) + }) + + it('stays below 50ms P95 with 20 approved content packs and executes no plugin code', async () => { + const workerFactory = vi.fn(async () => { + throw new Error('startup must not create a plugin worker') + }) + const options: PluginServiceOptions = { + userDataPath, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => consents, + getDevPluginPaths: () => [], + workerFactory + } + const measure = async (): Promise => { + const startedAt = performance.now() + const service = new PluginService(options) + await service.initialize() + const elapsedMs = performance.now() - startedAt + expect(service.getDiscovered()).toHaveLength(PLUGIN_COUNT) + await service.dispose() + return elapsedMs + } + + await measure() + const samples: number[] = [] + for (let index = 0; index < SAMPLE_COUNT; index += 1) { + samples.push(await measure()) + } + + const p95 = nearestRankP95(samples) + if (process.env.ORCA_PLUGIN_STARTUP_BUDGET_REPORT === '1') { + process.stdout.write(`plugin startup P95 ${p95.toFixed(2)}ms (${SAMPLE_COUNT} samples)\n`) + } + expect(workerFactory).not.toHaveBeenCalled() + await Promise.all( + markerPaths.map((markerPath) => + expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + ) + ) + expect( + p95, + `plugin startup P95 ${p95.toFixed(2)}ms; samples: ${samples.map((sample) => sample.toFixed(2)).join(', ')}` + ).toBeLessThan(STARTUP_P95_BUDGET_MS) + }) +}) diff --git a/src/main/plugins/plugin-storage-store.test.ts b/src/main/plugins/plugin-storage-store.test.ts new file mode 100644 index 00000000000..579fd3f079c --- /dev/null +++ b/src/main/plugins/plugin-storage-store.test.ts @@ -0,0 +1,27 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PLUGIN_STORAGE_VALUE_MAX_BYTES } from '../../shared/plugins/plugin-host-api' +import { PluginKvStore } from './plugin-storage-store' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginKvStore limits', () => { + it('enforces the value cap in UTF-8 bytes rather than JavaScript code units', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-storage-')) + roots.push(root) + const store = new PluginKvStore(root, 'orca-samples.demo', 'storage.json') + const value = '😀'.repeat(Math.ceil(PLUGIN_STORAGE_VALUE_MAX_BYTES / 4) + 1) + + expect(store.set('large', value)).toMatchObject({ + ok: false, + error: expect.stringContaining('exceeds') + }) + expect(store.get('large')).toBeUndefined() + }) +}) diff --git a/src/main/plugins/plugin-storage-store.ts b/src/main/plugins/plugin-storage-store.ts new file mode 100644 index 00000000000..b3949b8f6d4 --- /dev/null +++ b/src/main/plugins/plugin-storage-store.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { writeSecureFile } from '../../shared/secure-file' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { + PLUGIN_STORAGE_KEY_LIMIT, + PLUGIN_STORAGE_TOTAL_MAX_BYTES, + PLUGIN_STORAGE_VALUE_MAX_BYTES +} from '../../shared/plugins/plugin-host-api' + +/** + * Per-plugin JSON key-value persistence backing both `storage.*` (plugin + * data) and `settings.*` (settings:own). Each plugin's data lives in its OWN + * file under `/plugins-data/./` — never a shared + * namespaced blob, so one plugin's path can never resolve into another's. + * Adapted from community PR #5801's per-plugin settings store. + */ + +export function pluginDataDir(pluginsDataDir: string, qualifiedKey: string): string { + if (!isQualifiedPluginKey(qualifiedKey)) { + throw new Error(`unsafe plugin key: ${qualifiedKey}`) + } + return join(pluginsDataDir, qualifiedKey) +} + +export type PluginKvWriteResult = { ok: true } | { ok: false; error: string } + +export class PluginKvStore { + private readonly filePath: string + + constructor( + pluginsDataDir: string, + qualifiedKey: string, + fileName: 'storage.json' | 'settings.json' + ) { + this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), fileName) + } + + private read(): Record { + try { + if (!existsSync(this.filePath)) { + return {} + } + if (statSync(this.filePath).size > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return {} + } + const parsed: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // Corrupt files reset to empty rather than wedging the plugin. + } + return {} + } + + get(key: string): unknown { + return this.read()[key] + } + + getAll(): Record { + return this.read() + } + + keys(): string[] { + return Object.keys(this.read()) + } + + set(key: string, value: unknown): PluginKvWriteResult { + let serialized: string + try { + serialized = JSON.stringify(value) + } catch { + return { ok: false, error: 'value is not JSON-serializable' } + } + if (serialized === undefined) { + return { ok: false, error: 'value is not JSON-serializable' } + } + if (Buffer.byteLength(serialized, 'utf8') > PLUGIN_STORAGE_VALUE_MAX_BYTES) { + return { ok: false, error: `value exceeds ${PLUGIN_STORAGE_VALUE_MAX_BYTES} bytes` } + } + const settings = this.read() + if (!Object.hasOwn(settings, key) && Object.keys(settings).length >= PLUGIN_STORAGE_KEY_LIMIT) { + return { ok: false, error: `storage exceeds the ${PLUGIN_STORAGE_KEY_LIMIT}-key limit` } + } + settings[key] = value + const nextFile = JSON.stringify(settings, null, 2) + if (Buffer.byteLength(nextFile, 'utf8') > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return { ok: false, error: `storage exceeds ${PLUGIN_STORAGE_TOTAL_MAX_BYTES} bytes` } + } + writeSecureFile(this.filePath, nextFile) + return { ok: true } + } + + delete(key: string): void { + const settings = this.read() + if (Object.hasOwn(settings, key)) { + delete settings[key] + writeSecureFile(this.filePath, JSON.stringify(settings, null, 2)) + } + } +} diff --git a/src/main/plugins/plugin-supervisor.ts b/src/main/plugins/plugin-supervisor.ts new file mode 100644 index 00000000000..f2aea28cc2a --- /dev/null +++ b/src/main/plugins/plugin-supervisor.ts @@ -0,0 +1,98 @@ +/** + * Crash/restart supervision policy for plugin workers — the pure decision + * half of the runtime. Decides whether a worker that exited should be + * restarted (with backoff) or marked errored after too many crashes. + * + * Pure + deterministic: callers own the actual timers and forking; this just + * tracks per-plugin state and returns decisions. Ported from community PR + * #5801 (gsxdsm). + */ + +export type PluginRunState = 'inactive' | 'running' | 'restarting' | 'errored' + +export type PluginExitInfo = { + /** Clean exit from a host-initiated deactivate vs. an unexpected crash. */ + crashed: boolean +} + +export type PluginRestartDecision = + | { restart: true; delayMs: number; attempt: number } + | { restart: false; state: PluginRunState } + +export type PluginSupervisionConfig = { + /** Max crash-restarts before a plugin is marked errored. `0` means no + * restart attempts — the first crash goes straight to errored. */ + maxRestarts: number + /** Backoff schedule indexed by attempt; the last entry is reused past its + * end. Must be non-empty (enforced in the constructor). */ + backoffMs: number[] +} + +const DEFAULT_CONFIG: PluginSupervisionConfig = { + maxRestarts: 3, + backoffMs: [500, 2000, 5000] +} + +type Entry = { state: PluginRunState; restarts: number } + +export class PluginSupervisor { + private readonly entries = new Map() + private readonly config: PluginSupervisionConfig + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config } + // Guard misconfiguration: an empty backoff schedule would index [-1] → + // undefined delay (immediate restart loop); a negative cap is meaningless. + if (this.config.maxRestarts < 0) { + throw new Error('PluginSupervisionConfig.maxRestarts must be >= 0') + } + if (this.config.backoffMs.length === 0) { + throw new Error('PluginSupervisionConfig.backoffMs must be non-empty') + } + } + + getState(id: string): PluginRunState { + return this.entries.get(id)?.state ?? 'inactive' + } + + restartCount(id: string): number { + return this.entries.get(id)?.restarts ?? 0 + } + + /** Mark a plugin as running. A fresh activation (not a restart) resets the + * crash counter so a previously-flaky plugin gets a clean slate. */ + markRunning(id: string, options: { resetRestarts?: boolean } = {}): void { + const prior = this.entries.get(id) + this.entries.set(id, { + state: 'running', + restarts: options.resetRestarts ? 0 : (prior?.restarts ?? 0) + }) + } + + /** Record that the worker exited and decide what to do next. */ + markExited(id: string, info: PluginExitInfo): PluginRestartDecision { + if (!info.crashed) { + // Host-initiated stop (or idle reap): go inactive, clear history. + this.entries.set(id, { state: 'inactive', restarts: 0 }) + return { restart: false, state: 'inactive' } + } + const entry = this.entries.get(id) + // An exit for an untracked plugin is not a running crash to restart. + if (!entry) { + return { restart: false, state: 'inactive' } + } + if (entry.restarts >= this.config.maxRestarts) { + this.entries.set(id, { state: 'errored', restarts: entry.restarts }) + return { restart: false, state: 'errored' } + } + const attempt = entry.restarts + 1 + const idx = Math.max(0, Math.min(entry.restarts, this.config.backoffMs.length - 1)) + this.entries.set(id, { state: 'restarting', restarts: attempt }) + return { restart: true, delayMs: this.config.backoffMs[idx]!, attempt } + } + + /** Clear all state (deactivate/remove or a manual re-enable). */ + reset(id: string): void { + this.entries.delete(id) + } +} diff --git a/src/main/plugins/plugin-vm-recipe-registry.test.ts b/src/main/plugins/plugin-vm-recipe-registry.test.ts new file mode 100644 index 00000000000..fd931d79f02 --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-registry.test.ts @@ -0,0 +1,169 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { hashPluginTree } from './plugin-content-hash' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function recipePlugin( + id: string, + artifacts: { path: string; recipe: unknown }[] +): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-vm-recipe-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'recipes')) + await Promise.all( + artifacts.map((artifact) => + writeFile(join(rootDir, artifact.path), JSON.stringify(artifact.recipe), 'utf8') + ) + ) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id, + publisher: 'orca-samples', + name: id, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { vmRecipes: artifacts.map((artifact) => ({ path: artifact.path })) }, + capabilities: [] + }) + const content = await hashPluginTree(rootDir) + if (!content.ok) { + throw new Error(content.error) + } + return { + pluginKey: `orca-samples.${id}`, + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, content.hash), + consentContentHash: content.hash, + contentHash: null, + isDev: true + } +} + +function artifact(id: string): { + schemaVersion: 1 + id: string + name: string + create: string + suspend: string + resume: string + destroy: string +} { + return { + schemaVersion: 1, + id, + name: `Recipe ${id}`, + create: `create-${id}`, + suspend: `suspend-${id}`, + resume: `resume-${id}`, + destroy: `destroy-${id}` + } +} + +describe('PluginVmRecipeRegistry', () => { + it('retains pending previews and exposes only approved recipes', async () => { + const plugin = await recipePlugin('recipes', [ + { path: 'recipes/cloud.json', recipe: artifact('cloud') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([plugin], () => false) + + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toMatchObject([ + { pluginKey: plugin.pluginKey, recipe: { id: 'cloud', create: 'create-cloud' } } + ]) + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toMatchObject([{ recipe: { id: 'cloud' } }]) + }) + + it('rejects malformed artifacts and duplicate ids within one plugin', async () => { + const malformed = await recipePlugin('malformed', [ + { + path: 'recipes/bad.json', + recipe: { schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', suspend: 'stop' } + } + ]) + const duplicate = await recipePlugin('duplicate', [ + { path: 'recipes/one.json', recipe: artifact('same') }, + { path: 'recipes/two.json', recipe: artifact('same') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([malformed, duplicate], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.error(malformed.pluginKey)).toContain('suspend and resume') + expect(registry.error(duplicate.pluginKey)).toContain('duplicate VM recipe id') + }) + + it('errors every approved plugin that contributes the same global id', async () => { + const first = await recipePlugin('first', [ + { path: 'recipes/shared.json', recipe: artifact('shared') } + ]) + const second = await recipePlugin('second', [ + { path: 'recipes/shared.json', recipe: artifact('shared') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([first, second], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.error(first.pluginKey)).toContain('multiple plugins') + expect(registry.error(second.pluginKey)).toContain('multiple plugins') + }) + + it('deactivates disabled recipes and removes previews after uninstall', async () => { + const plugin = await recipePlugin('lifecycle', [ + { path: 'recipes/cloud.json', recipe: artifact('cloud') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toHaveLength(1) + + await registry.reconcile([plugin], () => false) + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toHaveLength(1) + + await registry.reconcile([], () => false) + expect(registry.preview(plugin.pluginKey)).toEqual([]) + expect(registry.error(plugin.pluginKey)).toBeNull() + }) + + it('refuses recipe bytes changed after the reviewed content identity', async () => { + const plugin = await recipePlugin('mutable', [ + { path: 'recipes/cloud.json', recipe: artifact('cloud') } + ]) + // Exercise the installed-tree identity as well as mutable dev previews. + plugin.contentHash = plugin.consentContentHash ?? null + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toHaveLength(1) + + await writeFile( + join(plugin.rootDir, 'recipes', 'cloud.json'), + JSON.stringify({ ...artifact('cloud'), create: 'changed-after-review' }), + 'utf8' + ) + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toEqual([]) + expect(registry.error(plugin.pluginKey)).toContain('changed since it was reviewed') + }) +}) diff --git a/src/main/plugins/plugin-vm-recipe-registry.ts b/src/main/plugins/plugin-vm-recipe-registry.ts new file mode 100644 index 00000000000..ac2b90d9b64 --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-registry.ts @@ -0,0 +1,127 @@ +import type { OrcaVmRecipe } from '../../shared/types' +import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact' +import { + PLUGIN_VM_RECIPE_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' + +const VM_RECIPE_LOAD_CONCURRENCY = 4 + +export type PluginVmRecipeRegistration = { + pluginKey: string + recipe: OrcaVmRecipe +} + +type VmRecipeLoadResult = + | { + pluginKey: string + approved: boolean + registrations: PluginVmRecipeRegistration[] + } + | { pluginKey: string; error: string } + +export class PluginVmRecipeRegistry { + private active: PluginVmRecipeRegistration[] = [] + private readonly previews = new Map() + private readonly errors = new Map() + + list(): readonly PluginVmRecipeRegistration[] { + return this.active + } + + preview(pluginKey: string): readonly PluginVmRecipeRegistration[] { + return this.previews.get(pluginKey) ?? [] + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates = discovered.filter( + (plugin): plugin is ValidDiscoveredPlugin => + !isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.vmRecipes.length > 0 + ) + const results = await mapWithConcurrency( + candidates, + VM_RECIPE_LOAD_CONCURRENCY, + async (plugin): Promise => { + try { + const approved = isApproved(plugin) + const registrations: PluginVmRecipeRegistration[] = [] + const seen = new Set() + for (const contribution of plugin.manifest.contributes.vmRecipes) { + const recipe = parsePluginVmRecipeArtifact( + await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_VM_RECIPE_MAX_BYTES + ) + ) + if (seen.has(recipe.id)) { + throw new Error(`duplicate VM recipe id "${recipe.id}"`) + } + seen.add(recipe.id) + registrations.push({ pluginKey: plugin.pluginKey, recipe }) + } + // Verify after reading so the in-memory commands shown/activated are + // bound to the exact tree identity the user reviewed. + await verifyInstructionalPluginContent(plugin) + return { pluginKey: plugin.pluginKey, approved, registrations } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + + this.previews.clear() + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } else { + this.previews.set(result.pluginKey, result.registrations) + } + } + const approved = results.filter( + (result): result is Extract => + 'approved' in result && result.approved + ) + const owners = new Map>() + for (const result of approved) { + for (const registration of result.registrations) { + const recipeOwners = owners.get(registration.recipe.id) ?? new Set() + recipeOwners.add(result.pluginKey) + owners.set(registration.recipe.id, recipeOwners) + } + } + const conflicted = new Set() + for (const [recipeId, recipeOwners] of owners) { + if (recipeOwners.size > 1) { + for (const pluginKey of recipeOwners) { + conflicted.add(pluginKey) + this.errors.set( + pluginKey, + `VM recipe id "${recipeId}" is contributed by multiple plugins` + ) + } + } + } + this.active = approved + .filter((result) => !conflicted.has(result.pluginKey)) + .flatMap((result) => result.registrations) + } +} diff --git a/src/main/plugins/plugin-worker-controller.test.ts b/src/main/plugins/plugin-worker-controller.test.ts new file mode 100644 index 00000000000..63e05cd6a7b --- /dev/null +++ b/src/main/plugins/plugin-worker-controller.test.ts @@ -0,0 +1,166 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPluginExtensionRegistry } from '../../shared/plugins/plugin-extension-registry' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginWorkerHandle } from './plugin-host-process' +import { PluginWorkerController } from './plugin-worker-controller' +import type { PluginWorkerFactory } from './plugin-worker-manager' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function plugin(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-worker-controller-')) + roots.push(rootDir) + await writeFile(join(rootDir, 'main.mjs'), 'export default function activate() {}') + return { + pluginKey: 'orca-samples.demo', + rootDir, + manifest: pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + main: 'main.mjs', + contributes: { + panels: [], + commands: [{ id: 'run', title: 'Run' }], + events: [] + }, + capabilities: [] + }), + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } +} + +function worker(commands: string[]): PluginWorkerHandle & { dispose: ReturnType } { + return { + commands, + invokeCommand: vi.fn(async () => null), + deliverEvent: vi.fn(), + lastActivityAt: () => Date.now(), + inFlightCount: () => 0, + dispose: vi.fn(async () => undefined), + kill: vi.fn(), + onExit: vi.fn() + } +} + +function controller(options: { + factory: PluginWorkerFactory + verify: () => Promise + isApproved: () => boolean +}): PluginWorkerController { + return new PluginWorkerController({ + entryPath: '/host-entry.js', + workerFactory: options.factory, + registry: createPluginExtensionRegistry(), + contentVerifier: { verify: options.verify } as unknown as PluginContentVerifier, + capabilities: () => (options.isApproved() ? [] : null), + isCurrentApproved: () => options.isApproved(), + invokeCommand: vi.fn(async () => null), + executeHostCall: vi.fn(async () => ({ ok: true as const, value: null })), + log: vi.fn(), + onStateChanged: vi.fn(), + onWorkerGone: vi.fn() + }) +} + +describe('PluginWorkerController activation authority', () => { + it('does not start code after approval is revoked during integrity verification', async () => { + const subjectPlugin = await plugin() + let approved = true + let finishVerification!: () => void + const verification = new Promise((resolve) => { + finishVerification = resolve + }) + const factory = vi.fn() + const subject = controller({ factory, verify: () => verification, isApproved: () => approved }) + + const activation = subject.ensure(subjectPlugin) + approved = false + finishVerification() + + await expect(activation).rejects.toThrow('no longer approved') + expect(factory).not.toHaveBeenCalled() + await subject.dispose() + }) + + it('disposes a worker whose approval changes while the process starts', async () => { + const subjectPlugin = await plugin() + let approved = true + let finishStart!: (handle: PluginWorkerHandle) => void + const factory = vi.fn( + () => new Promise((resolve) => (finishStart = resolve)) + ) + const subject = controller({ + factory, + verify: async () => undefined, + isApproved: () => approved + }) + const startedWorker = worker(['run']) + + const activation = subject.ensure(subjectPlugin) + await vi.waitFor(() => expect(factory).toHaveBeenCalledOnce()) + approved = false + finishStart(startedWorker) + + await expect(activation).rejects.toThrow('disabled during activation') + expect(startedWorker.dispose).toHaveBeenCalledOnce() + await subject.dispose() + }) + + it('rejects and stops workers that register undeclared commands', async () => { + const subjectPlugin = await plugin() + const startedWorker = worker(['run', 'undeclared']) + const subject = controller({ + factory: vi.fn().mockResolvedValue(startedWorker), + verify: async () => undefined, + isApproved: () => true + }) + + await expect(subject.ensure(subjectPlugin)).rejects.toThrow( + 'registered undeclared command undeclared' + ) + expect(startedWorker.dispose).toHaveBeenCalledOnce() + await subject.dispose() + }) + + it('rejects workers that register declarative action aliases', async () => { + const base = await plugin() + const subjectPlugin: ValidDiscoveredPlugin = { + ...base, + manifest: pluginManifestSchema.parse({ + ...base.manifest, + contributes: { + ...base.manifest.contributes, + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] + } + }) + } + const startedWorker = worker(['tasks']) + const subject = controller({ + factory: vi.fn().mockResolvedValue(startedWorker), + verify: async () => undefined, + isApproved: () => true + }) + + await expect(subject.ensure(subjectPlugin)).rejects.toThrow( + 'registered undeclared command tasks' + ) + expect(startedWorker.dispose).toHaveBeenCalledOnce() + await subject.dispose() + }) +}) diff --git a/src/main/plugins/plugin-worker-controller.ts b/src/main/plugins/plugin-worker-controller.ts new file mode 100644 index 00000000000..de166175b71 --- /dev/null +++ b/src/main/plugins/plugin-worker-controller.ts @@ -0,0 +1,171 @@ +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { + PLUGIN_COMMAND_EXTENSION_POINT, + type PluginExtensionRegistry +} from '../../shared/plugins/plugin-extension-registry' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { resolveContainedPluginArtifact } from './plugin-artifact-validation' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { + PluginWorkerManager, + type PluginWorkerFactory, + type PluginWorkerSpawnSpec +} from './plugin-worker-manager' +import { buildPluginWorkerSpawnSpec, pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec' +import type { PluginWorkerHandle } from './plugin-host-process' +import type { PluginRunState } from './plugin-supervisor' + +export type PluginWorkerControllerOptions = { + entryPath: string + maxActive?: number + idleReapMs?: number + workerFactory?: PluginWorkerFactory + registry: PluginExtensionRegistry + contentVerifier: PluginContentVerifier + capabilities: (pluginKey: string) => readonly PluginCapabilityKind[] | null + isCurrentApproved: (plugin: ValidDiscoveredPlugin) => boolean + invokeCommand: (pluginKey: string, commandId: string, args: unknown) => Promise + executeHostCall: ( + pluginKey: string, + method: string, + params: unknown + ) => Promise + log: (pluginKey: string, level: 'info' | 'warn' | 'error', line: string) => void + onStateChanged: (pluginKey: string) => void + onWorkerGone: (pluginKey: string) => void +} + +export class PluginWorkerController { + private readonly manager: PluginWorkerManager + private readonly activationErrors = new Map() + private readonly registeredSpecs = new Map() + + constructor(private readonly options: PluginWorkerControllerOptions) { + this.manager = new PluginWorkerManager({ + entryPath: options.entryPath, + maxActive: options.maxActive, + idleReapMs: options.idleReapMs, + workerFactory: options.workerFactory, + executeHostCall: options.executeHostCall, + log: options.log, + onWorkerStateChange: options.onStateChanged, + onWorkerGone: options.onWorkerGone + }) + } + + state(pluginKey: string): { state: PluginRunState; restarts: number } { + return { + state: this.manager.runState(pluginKey), + restarts: this.manager.restartCount(pluginKey) + } + } + + activationError(pluginKey: string): string | null { + return this.activationErrors.get(pluginKey) ?? null + } + + async ensure(plugin: ValidDiscoveredPlugin): Promise { + if (!plugin.manifest.main) { + throw new Error(`plugin ${plugin.pluginKey} has no worker entry`) + } + try { + this.assertCurrentApproved(plugin) + await this.options.contentVerifier.verify(plugin) + await resolveContainedPluginArtifact(plugin.rootDir, plugin.manifest.main) + this.assertCurrentApproved(plugin) + const capabilities = this.options.capabilities(plugin.pluginKey) + if (!capabilities) { + throw new Error(`plugin ${plugin.pluginKey} is no longer approved`) + } + const spec = buildPluginWorkerSpawnSpec(plugin, capabilities) + const handle = await this.manager.ensureActive(spec) + if (!this.options.isCurrentApproved(plugin)) { + await this.manager.deactivate(plugin.pluginKey) + throw new Error(`plugin ${plugin.pluginKey} changed or was disabled during activation`) + } + const declaredCommands = new Set( + plugin.manifest.contributes.commands + .filter((command) => command.action === undefined) + .map((command) => command.id) + ) + const undeclaredCommand = handle.commands.find((command) => !declaredCommands.has(command)) + if (undeclaredCommand) { + await this.manager.deactivate(plugin.pluginKey) + throw new Error( + `plugin ${plugin.pluginKey} registered undeclared command ${undeclaredCommand}` + ) + } + this.activationErrors.delete(plugin.pluginKey) + this.registerCommands(plugin, spec, handle.commands) + return handle + } catch (error) { + this.activationErrors.set( + plugin.pluginKey, + error instanceof Error ? error.message : String(error) + ) + this.options.onStateChanged(plugin.pluginKey) + throw error + } + } + + private assertCurrentApproved(plugin: ValidDiscoveredPlugin): void { + if (!this.options.isCurrentApproved(plugin)) { + throw new Error(`plugin ${plugin.pluginKey} changed or is no longer approved`) + } + } + + async reconcile(nextSpecs: ReadonlyMap): Promise { + const current = new Map([...this.registeredSpecs, ...this.manager.trackedSpecs()]) + for (const [pluginKey, spec] of current) { + const next = nextSpecs.get(pluginKey) + if (next && pluginWorkerSpawnSpecsEqual(spec, next)) { + continue + } + this.options.registry.clearPlugin(pluginKey) + this.registeredSpecs.delete(pluginKey) + this.activationErrors.delete(pluginKey) + await this.manager.deactivate(pluginKey) + } + } + + async deactivate(pluginKey: string): Promise { + this.options.registry.clearPlugin(pluginKey) + this.registeredSpecs.delete(pluginKey) + this.activationErrors.delete(pluginKey) + await this.manager.deactivate(pluginKey) + } + + reapIdle(): void { + this.manager.reapIdle() + } + + deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void { + this.manager.deliverEventIfRunning(pluginKey, event, payload) + } + + dispose(): Promise { + return this.manager.disposeAll() + } + + private registerCommands( + plugin: ValidDiscoveredPlugin, + spec: PluginWorkerSpawnSpec, + commands: readonly string[] + ): void { + this.options.registry.clearPlugin(plugin.pluginKey) + for (const commandId of commands) { + this.options.registry.register( + PLUGIN_COMMAND_EXTENSION_POINT, + plugin.pluginKey, + { + commandId, + invoke: (args) => this.options.invokeCommand(plugin.pluginKey, commandId, args) + }, + commandId + ) + } + this.registeredSpecs.set(plugin.pluginKey, spec) + } +} diff --git a/src/main/plugins/plugin-worker-env.test.ts b/src/main/plugins/plugin-worker-env.test.ts new file mode 100644 index 00000000000..1e90a0bd180 --- /dev/null +++ b/src/main/plugins/plugin-worker-env.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { buildPluginWorkerEnv } from './plugin-worker-env' + +describe('buildPluginWorkerEnv', () => { + it('matches allowlisted keys case-sensitively on POSIX', () => { + expect( + buildPluginWorkerEnv( + { PATH: '/safe', path: '/wrong', HOME: '/home', NODE_OPTIONS: '--inspect' }, + 'linux' + ) + ).toEqual({ PATH: '/safe', HOME: '/home', ELECTRON_RUN_AS_NODE: '1' }) + }) + + it('matches Windows environment keys case-insensitively', () => { + expect(buildPluginWorkerEnv({ Path: 'C:\\safe', systemroot: 'C:\\Windows' }, 'win32')).toEqual({ + PATH: 'C:\\safe', + SystemRoot: 'C:\\Windows', + ELECTRON_RUN_AS_NODE: '1' + }) + }) +}) diff --git a/src/main/plugins/plugin-worker-env.ts b/src/main/plugins/plugin-worker-env.ts new file mode 100644 index 00000000000..3dbe09b49a2 --- /dev/null +++ b/src/main/plugins/plugin-worker-env.ts @@ -0,0 +1,52 @@ +/** + * Scrubbed environment for plugin workers. Deliberately an allowlist — the + * app's own environment can carry secrets (tokens exported in the user's + * shell, CI credentials); plugins must not inherit it. This intentionally + * diverges from the sidecar precedent, which spreads the full process.env. + */ + +const WORKER_ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'USERPROFILE', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ', + 'TMPDIR', + 'TEMP', + 'TMP', + // Why: Windows Node/libuv need these to resolve DLLs and the machine root. + 'SYSTEMROOT', + 'SYSTEMDRIVE', + 'WINDIR', + 'COMSPEC', + 'PATHEXT', + 'PROCESSOR_ARCHITECTURE', + 'NUMBER_OF_PROCESSORS' +] as const + +export function buildPluginWorkerEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): Record { + const env: Record = {} + const windowsLookup = new Map() + if (platform === 'win32') { + // Why: Windows environment keys are case-insensitive, while POSIX keys + // are not; folding on every platform could promote an attacker-set `path`. + for (const [key, value] of Object.entries(baseEnv)) { + if (typeof value === 'string') { + windowsLookup.set(key.toUpperCase(), value) + } + } + } + for (const key of WORKER_ENV_ALLOWLIST) { + const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key] + if (value !== undefined) { + env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value + } + } + env.ELECTRON_RUN_AS_NODE = '1' + return env +} diff --git a/src/main/plugins/plugin-worker-manager.test.ts b/src/main/plugins/plugin-worker-manager.test.ts new file mode 100644 index 00000000000..932199a7eb5 --- /dev/null +++ b/src/main/plugins/plugin-worker-manager.test.ts @@ -0,0 +1,375 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginWorkerHandle } from './plugin-host-process' +import { + PluginWorkerManager, + type PluginWorkerFactory, + type PluginWorkerSpawnSpec +} from './plugin-worker-manager' + +type TestWorker = PluginWorkerHandle & { + exit(code?: number | null): void + dispose: ReturnType Promise>> +} + +function worker(lastActivity = Date.now()): TestWorker { + const exitCallbacks: ((code: number | null) => void)[] = [] + return { + commands: ['run'], + invokeCommand: vi.fn(async () => null), + deliverEvent: vi.fn(), + lastActivityAt: () => lastActivity, + inFlightCount: () => 0, + dispose: vi.fn(async () => undefined), + kill: vi.fn(), + onExit: (callback) => exitCallbacks.push(callback), + exit: (code = 1) => { + for (const callback of exitCallbacks) { + callback(code) + } + } + } +} + +function spec(pluginKey: string): PluginWorkerSpawnSpec { + return { + pluginKey, + rootDir: `/plugins/${pluginKey}`, + mainEntry: 'worker.js', + grantedCapabilities: [] + } +} + +function manager( + factory: PluginWorkerFactory, + options: { maxActive?: number; idleReapMs?: number } = {} +): PluginWorkerManager { + return new PluginWorkerManager({ + entryPath: '/host.js', + workerFactory: factory, + maxActive: options.maxActive, + idleReapMs: options.idleReapMs, + executeHostCall: async () => ({ ok: true, value: null }), + log: vi.fn(), + onWorkerStateChange: vi.fn(), + onWorkerGone: vi.fn() + }) +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('PluginWorkerManager capacity', () => { + it('atomically counts in-flight starts against maxActive', async () => { + const starts: { key: string; resolve: (handle: TestWorker) => void }[] = [] + const factory = vi.fn( + ({ pluginId }) => new Promise((resolve) => starts.push({ key: pluginId, resolve })) + ) + const subject = manager(factory, { maxActive: 1 }) + + const first = subject.ensureActive(spec('one')) + const second = subject.ensureActive(spec('two')) + const third = subject.ensureActive(spec('three')) + await flush() + + expect(starts.map((start) => start.key)).toEqual(['one']) + starts[0]!.resolve(worker()) + await first + await subject.deactivate('one') + await flush() + expect(starts.map((start) => start.key)).toEqual(['one', 'two']) + + starts[1]!.resolve(worker()) + await second + await subject.deactivate('two') + await flush() + expect(starts.map((start) => start.key)).toEqual(['one', 'two', 'three']) + starts[2]!.resolve(worker()) + await third + await subject.disposeAll() + }) + + it('removes a cancelled waiter without disturbing FIFO order', async () => { + const starts: { key: string; resolve: (handle: TestWorker) => void }[] = [] + const factory = vi.fn( + ({ pluginId }) => new Promise((resolve) => starts.push({ key: pluginId, resolve })) + ) + const subject = manager(factory, { maxActive: 1 }) + const first = subject.ensureActive(spec('one')) + const cancelled = subject.ensureActive(spec('two')) + const third = subject.ensureActive(spec('three')) + await flush() + starts[0]!.resolve(worker()) + await first + + await subject.deactivate('two') + await expect(cancelled).rejects.toThrow('cancelled') + await subject.deactivate('one') + await flush() + + expect(starts.map((start) => start.key)).toEqual(['one', 'three']) + starts[1]!.resolve(worker()) + await third + await subject.disposeAll() + }) + + it('releases a failed start so the next FIFO waiter can run', async () => { + vi.useFakeTimers() + const secondWorker = worker() + const factory = vi.fn(async ({ pluginId }) => { + if (pluginId === 'one') { + throw new Error('ready failed') + } + return secondWorker + }) + const subject = manager(factory, { maxActive: 1 }) + const first = subject.ensureActive(spec('one')) + const firstSettled = first.catch(() => undefined) + const second = subject.ensureActive(spec('two')) + + await flush() + await expect(second).resolves.toBe(secondWorker) + expect(factory.mock.calls.map(([options]) => options.pluginId)).toEqual(['one', 'two']) + await subject.deactivate('one') + await firstSettled + await subject.disposeAll() + }) + + it('cancels an in-flight start without allowing its generation to land', async () => { + const factory = vi.fn( + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('factory cancelled')), { + once: true + }) + }) + ) + const subject = manager(factory) + const activation = subject.ensureActive(spec('starting')) + await flush() + + await subject.deactivate('starting') + + await expect(activation).rejects.toThrow('cancelled') + expect(subject.runState('starting')).toBe('inactive') + expect(subject.trackedSpecs().has('starting')).toBe(false) + await subject.disposeAll() + }) + + it('disposes running workers and rejects queued waiters', async () => { + const first = worker() + const factory = vi.fn(async ({ pluginId }) => { + if (pluginId === 'one') { + return first + } + return new Promise(() => undefined) + }) + const subject = manager(factory, { maxActive: 1 }) + await subject.ensureActive(spec('one')) + const queued = subject.ensureActive(spec('two')) + const queuedSettled = queued.catch((error) => error) + await flush() + + await subject.disposeAll() + + expect(first.dispose).toHaveBeenCalledOnce() + await expect(queuedSettled).resolves.toBeInstanceOf(Error) + expect(factory).toHaveBeenCalledTimes(1) + }) +}) + +describe('PluginWorkerManager restart policy', () => { + it('cancels a stale in-flight revision instead of joining it by plugin key', async () => { + const currentWorker = worker() + const factory = vi.fn(({ rootDir, signal }) => { + if (rootDir === '/plugins/old') { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('old revision cancelled')), { + once: true + }) + }) + } + return Promise.resolve(currentWorker) + }) + const subject = manager(factory) + const oldSpec = { ...spec('demo'), rootDir: '/plugins/old', manifestRevision: 'old' } + const newSpec = { ...spec('demo'), rootDir: '/plugins/new', manifestRevision: 'new' } + + const oldActivation = subject.ensureActive(oldSpec) + await flush() + const currentActivation = subject.ensureActive(newSpec) + + await expect(oldActivation).rejects.toThrow('cancelled') + await expect(currentActivation).resolves.toBe(currentWorker) + expect(factory.mock.calls.map(([options]) => options.rootDir)).toEqual([ + '/plugins/old', + '/plugins/new' + ]) + await subject.disposeAll() + }) + + it('retries startup failures at 500/2000/5000ms before errored', async () => { + vi.useFakeTimers() + const factory = vi.fn(async () => { + throw new Error('not ready') + }) + const subject = manager(factory) + const activation = subject.ensureActive(spec('demo')) + let failure: unknown + const settled = activation.catch((error) => { + failure = error + }) + + await flush() + expect(factory).toHaveBeenCalledTimes(1) + expect(subject.restartCount('demo')).toBe(1) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(499) + expect(factory).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(factory).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(2_000) + expect(factory).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(5_000) + await settled + + expect(factory).toHaveBeenCalledTimes(4) + expect(subject.runState('demo')).toBe('errored') + expect(failure).toBeInstanceOf(Error) + await subject.disposeAll() + }) + + it('joins triggers during backoff without resetting restart history', async () => { + vi.useFakeTimers() + const ready = worker() + const factory = vi.fn(async () => { + if (factory.mock.calls.length === 1) { + throw new Error('first start failed') + } + return ready + }) + const subject = manager(factory) + const first = subject.ensureActive(spec('demo')) + await flush() + const joined = subject.ensureActive(spec('demo')) + + expect(subject.restartCount('demo')).toBe(1) + expect(factory).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(500) + + await expect(Promise.all([first, joined])).resolves.toEqual([ready, ready]) + expect(factory).toHaveBeenCalledTimes(2) + expect(subject.restartCount('demo')).toBe(1) + await subject.disposeAll() + }) + + it('applies the same backoff history to unexpected post-ready exits', async () => { + vi.useFakeTimers() + const workers = [worker(), worker(), worker(), worker()] + const factory = vi.fn(async () => workers[factory.mock.calls.length - 1]!) + const subject = manager(factory) + + await subject.ensureActive(spec('demo')) + workers[0]!.exit(11) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(500) + expect(factory).toHaveBeenCalledTimes(2) + workers[1]!.exit(12) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(2_000) + expect(factory).toHaveBeenCalledTimes(3) + workers[2]!.exit(13) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(5_000) + expect(factory).toHaveBeenCalledTimes(4) + workers[3]!.exit(14) + + expect(subject.runState('demo')).toBe('errored') + expect(subject.restartCount('demo')).toBe(3) + await subject.disposeAll() + }) + + it('cancels a pending restart and never resurrects after deactivate', async () => { + vi.useFakeTimers() + const first = worker() + const factory = vi.fn(async () => first) + const subject = manager(factory) + await subject.ensureActive(spec('demo')) + first.exit() + expect(subject.restartCount('demo')).toBe(1) + + await subject.deactivate('demo') + await vi.advanceTimersByTimeAsync(10_000) + + expect(factory).toHaveBeenCalledTimes(1) + expect(subject.runState('demo')).toBe('inactive') + await subject.disposeAll() + }) +}) + +describe('PluginWorkerManager idle reap', () => { + it('does not reap a worker while an event handler is still in flight', async () => { + const busy = worker(100) + busy.inFlightCount = () => 1 + const subject = manager(vi.fn().mockResolvedValue(busy), { + idleReapMs: 100 + }) + await subject.ensureActive(spec('demo')) + + subject.reapIdle(10_000) + + expect(busy.dispose).not.toHaveBeenCalled() + expect(subject.runState('demo')).toBe('running') + await subject.disposeAll() + }) + + it('disposes an idle worker and activates a fresh generation on demand', async () => { + const first = worker(100) + const second = worker(1_000) + const factory = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + const subject = manager(factory, { idleReapMs: 100 }) + await subject.ensureActive(spec('demo')) + + subject.reapIdle(201) + await flush() + expect(first.dispose).toHaveBeenCalledOnce() + expect(subject.runState('demo')).toBe('inactive') + await expect(subject.ensureActive(spec('demo'))).resolves.toBe(second) + await subject.disposeAll() + }) + + it('waits for an in-progress idle shutdown during manager disposal', async () => { + let finishShutdown!: () => void + const idle = worker(100) + idle.dispose.mockImplementation( + () => + new Promise((resolve) => { + finishShutdown = resolve + }) + ) + const subject = manager(vi.fn().mockResolvedValue(idle), { + idleReapMs: 100 + }) + await subject.ensureActive(spec('demo')) + subject.reapIdle(201) + let disposed = false + const disposal = subject.disposeAll().then(() => { + disposed = true + }) + await flush() + expect(disposed).toBe(false) + + finishShutdown() + await disposal + expect(disposed).toBe(true) + }) +}) diff --git a/src/main/plugins/plugin-worker-manager.ts b/src/main/plugins/plugin-worker-manager.ts new file mode 100644 index 00000000000..30e3d0c31dd --- /dev/null +++ b/src/main/plugins/plugin-worker-manager.ts @@ -0,0 +1,309 @@ +import { + PLUGIN_WORKER_IDLE_REAP_MS, + PLUGIN_WORKER_MAX_ACTIVE_DEFAULT +} from '../../shared/plugins/plugin-host-protocol' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { + PluginSupervisor, + type PluginRestartDecision, + type PluginRunState +} from './plugin-supervisor' +import type { PluginWorkerHandle, PluginWorkerHostCallExecutor } from './plugin-host-process' +import { PluginWorkerSlotPool } from './plugin-worker-slot-pool' +import { + startPluginWorkerAttempt, + type PluginWorkerFactory, + type PluginWorkerSpawnSpec, + type StartedPluginWorker +} from './plugin-worker-startup' +import { runPluginWorkerRestartLoop } from './plugin-worker-restart-loop' +import { pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec' + +export type { PluginWorkerFactory, PluginWorkerSpawnSpec } from './plugin-worker-startup' + +export type PluginWorkerManagerOptions = { + entryPath: string + maxActive?: number + idleReapMs?: number + workerFactory?: PluginWorkerFactory + executeHostCall: ( + pluginKey: string, + method: string, + params: unknown + ) => ReturnType + log: (pluginKey: string, level: 'info' | 'warn' | 'error', line: string) => void + onWorkerStateChange: (pluginKey: string) => void + onWorkerGone: (pluginKey: string) => void +} + +type ActivationRecord = { + spec: PluginWorkerSpawnSpec + generation: number + controller: AbortController + task: Promise +} + +/** Owns lazy activation, bounded capacity, restart policy, cancellation, and idle reap. */ +export class PluginWorkerManager { + private readonly supervisor = new PluginSupervisor() + private readonly workers = new Map() + private readonly activations = new Map() + private readonly knownSpecs = new Map() + private readonly generations = new Map() + private readonly stoppingWorkers = new Set>() + private readonly slots: PluginWorkerSlotPool + private readonly idleReapMs: number + private disposed = false + + constructor(private readonly options: PluginWorkerManagerOptions) { + this.slots = new PluginWorkerSlotPool(options.maxActive ?? PLUGIN_WORKER_MAX_ACTIVE_DEFAULT) + this.idleReapMs = options.idleReapMs ?? PLUGIN_WORKER_IDLE_REAP_MS + } + + runState(pluginKey: string): PluginRunState { + return this.supervisor.getState(pluginKey) + } + + restartCount(pluginKey: string): number { + return this.supervisor.restartCount(pluginKey) + } + + trackedSpecs(): ReadonlyMap { + return new Map(this.knownSpecs) + } + + async ensureActive(spec: PluginWorkerSpawnSpec): Promise { + if (this.disposed) { + throw new Error('plugin workers are shut down') + } + if (this.supervisor.getState(spec.pluginKey) === 'errored') { + throw new Error(`plugin ${spec.pluginKey} is errored after repeated failures`) + } + for (;;) { + const existing = this.workers.get(spec.pluginKey) + const pending = this.activations.get(spec.pluginKey) + const activeSpec = existing?.spec ?? pending?.spec + if (!activeSpec) { + break + } + if (pluginWorkerSpawnSpecsEqual(activeSpec, spec)) { + return existing?.handle ?? pending!.task + } + // Why: refresh/trigger races can present a new dev manifest while the + // old revision is still starting. Cancel and re-check atomically enough + // that callers never join a stale activation by key alone. + await this.deactivate(spec.pluginKey) + if (this.disposed) { + throw new Error('plugin workers are shut down') + } + } + const generation = this.nextGeneration(spec.pluginKey) + this.knownSpecs.set(spec.pluginKey, spec) + this.supervisor.markRunning(spec.pluginKey, { resetRestarts: true }) + return this.beginActivation(spec, generation) + } + + private beginActivation( + spec: PluginWorkerSpawnSpec, + generation: number, + firstRestart?: Extract + ): Promise { + const controller = new AbortController() + const task = this.activate(spec, generation, controller.signal, firstRestart) + const record: ActivationRecord = { spec, generation, controller, task } + this.activations.set(spec.pluginKey, record) + void task.then( + () => this.finishActivation(spec.pluginKey, record), + () => this.finishActivation(spec.pluginKey, record) + ) + return task + } + + private finishActivation(pluginKey: string, record: ActivationRecord): void { + if (this.activations.get(pluginKey) === record) { + this.activations.delete(pluginKey) + } + } + + private async activate( + spec: PluginWorkerSpawnSpec, + generation: number, + signal: AbortSignal, + firstRestart?: Extract + ): Promise { + return runPluginWorkerRestartLoop({ + signal, + firstRestart, + assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal), + start: async () => { + const worker = await startPluginWorkerAttempt({ + spec, + generation, + signal, + slots: this.slots, + entryPath: this.options.entryPath, + factory: this.options.workerFactory, + executeHostCall: (method, params) => + this.options.executeHostCall(spec.pluginKey, method, params), + log: (level, line) => this.options.log(spec.pluginKey, level, line), + assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal), + onExit: (record, code) => this.handleUnexpectedExit(spec.pluginKey, record, code) + }) + this.workers.set(spec.pluginKey, worker) + const earlyExit = worker.completeStart() + if (earlyExit.exited) { + this.detachWorker(spec.pluginKey, worker) + throw new Error(`worker exited immediately after ready (code ${earlyExit.code})`) + } + this.supervisor.markRunning(spec.pluginKey) + this.options.onWorkerStateChange(spec.pluginKey) + return worker.handle + }, + recordFailure: (error) => this.recordFailure(spec.pluginKey, 'worker failed to start', error), + erroredError: (error) => + new Error( + `plugin ${spec.pluginKey} is errored after repeated failures: ${this.errorText(error)}` + ) + }) + } + + private handleUnexpectedExit( + pluginKey: string, + record: StartedPluginWorker, + code: number | null + ): void { + if (!this.detachWorker(pluginKey, record)) { + return + } + if (this.isCancelled(pluginKey, record.generation)) { + return + } + const decision = this.recordFailure(pluginKey, `worker exited unexpectedly (code ${code})`) + if (decision.restart) { + this.beginActivation(record.spec, record.generation, decision) + } + } + + private recordFailure( + pluginKey: string, + context: string, + error?: unknown + ): PluginRestartDecision { + this.options.onWorkerGone(pluginKey) + const decision = this.supervisor.markExited(pluginKey, { crashed: true }) + this.options.onWorkerStateChange(pluginKey) + if (decision.restart) { + this.options.log( + pluginKey, + 'warn', + `${context}${error ? `: ${this.errorText(error)}` : ''}; restart ${decision.attempt} in ${decision.delayMs}ms` + ) + } else if (decision.state === 'errored') { + this.options.log(pluginKey, 'error', `${context}; marked errored after repeated failures`) + } + return decision + } + + private detachWorker(pluginKey: string, record: StartedPluginWorker): boolean { + if (this.workers.get(pluginKey) !== record) { + return false + } + this.workers.delete(pluginKey) + record.lease.release() + return true + } + + deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void { + this.workers.get(pluginKey)?.handle.deliverEvent(event, payload) + } + + async deactivate(pluginKey: string): Promise { + this.nextGeneration(pluginKey) + const activation = this.activations.get(pluginKey) + activation?.controller.abort() + const record = this.workers.get(pluginKey) + if (record) { + this.workers.delete(pluginKey) + } + this.options.onWorkerGone(pluginKey) + this.supervisor.reset(pluginKey) + this.knownSpecs.delete(pluginKey) + await Promise.all([ + activation?.task.catch(() => undefined), + record?.handle.dispose().catch(() => undefined) + ]) + record?.lease.release() + } + + reapIdle(now = Date.now()): void { + for (const [pluginKey, record] of this.workers) { + if ( + record.handle.inFlightCount() !== 0 || + now - record.handle.lastActivityAt() <= this.idleReapMs + ) { + continue + } + this.nextGeneration(pluginKey) + this.knownSpecs.delete(pluginKey) + this.workers.delete(pluginKey) + this.options.onWorkerGone(pluginKey) + this.supervisor.markExited(pluginKey, { crashed: false }) + this.options.log(pluginKey, 'info', 'worker reaped after idle period') + this.options.onWorkerStateChange(pluginKey) + const stopping = record.handle + .dispose() + .catch(() => undefined) + .finally(() => record.lease.release()) + this.stoppingWorkers.add(stopping) + void stopping.then(() => this.stoppingWorkers.delete(stopping)) + } + } + + async disposeAll(): Promise { + this.disposed = true + const pluginKeys = new Set([...this.activations.keys(), ...this.workers.keys()]) + for (const key of pluginKeys) { + this.nextGeneration(key) + this.options.onWorkerGone(key) + } + const activations = [...this.activations.values()] + for (const activation of activations) { + activation.controller.abort() + } + this.slots.dispose() + const workers = [...this.workers.values()] + this.workers.clear() + this.knownSpecs.clear() + const stoppingWorkers = [...this.stoppingWorkers] + await Promise.all([ + ...stoppingWorkers, + ...activations.map((activation) => activation.task.catch(() => undefined)), + ...workers.map(async (record) => { + await record.handle.dispose().catch(() => undefined) + record.lease.release() + }) + ]) + } + + private nextGeneration(pluginKey: string): number { + const generation = (this.generations.get(pluginKey) ?? 0) + 1 + this.generations.set(pluginKey, generation) + return generation + } + + private isCancelled(pluginKey: string, generation: number, signal?: AbortSignal): boolean { + return ( + this.disposed || signal?.aborted === true || this.generations.get(pluginKey) !== generation + ) + } + + private throwIfCancelled(pluginKey: string, generation: number, signal: AbortSignal): void { + if (this.isCancelled(pluginKey, generation, signal)) { + throw new Error('plugin worker activation was cancelled') + } + } + + private errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) + } +} diff --git a/src/main/plugins/plugin-worker-output-buffer.test.ts b/src/main/plugins/plugin-worker-output-buffer.test.ts new file mode 100644 index 00000000000..074184fdab5 --- /dev/null +++ b/src/main/plugins/plugin-worker-output-buffer.test.ts @@ -0,0 +1,22 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { + PLUGIN_WORKER_OUTPUT_LINE_LIMIT, + pipePluginWorkerOutput +} from './plugin-worker-output-buffer' + +describe('pipePluginWorkerOutput', () => { + it('bounds an unterminated line and resumes after its newline', () => { + const stream = new PassThrough() + const log = vi.fn() + pipePluginWorkerOutput(stream, 'info', log) + + stream.write('x'.repeat(PLUGIN_WORKER_OUTPUT_LINE_LIMIT + 1_000)) + stream.write('discarded') + expect(log).toHaveBeenCalledOnce() + expect(log.mock.calls[0]?.[1]).toHaveLength(PLUGIN_WORKER_OUTPUT_LINE_LIMIT) + + stream.write('\nok\n') + expect(log).toHaveBeenLastCalledWith('info', 'ok') + }) +}) diff --git a/src/main/plugins/plugin-worker-output-buffer.ts b/src/main/plugins/plugin-worker-output-buffer.ts new file mode 100644 index 00000000000..dfbe0478c28 --- /dev/null +++ b/src/main/plugins/plugin-worker-output-buffer.ts @@ -0,0 +1,70 @@ +import type { Readable } from 'node:stream' + +type PluginWorkerOutputSink = (level: 'info' | 'warn' | 'error', line: string) => void + +export const PLUGIN_WORKER_OUTPUT_LINE_LIMIT = 8192 +const TRUNCATION_SUFFIX = '… [truncated]' + +/** Keeps a worker's unterminated output bounded even if it never writes a newline. */ +export function pipePluginWorkerOutput( + stream: Readable | null, + level: 'info' | 'error', + log: PluginWorkerOutputSink +): void { + if (!stream) { + return + } + let buffered = '' + let discarding = false + + function emit(line: string, truncated = false): void { + if (line.trim().length > 0) { + log( + level, + truncated + ? `${line.slice(0, PLUGIN_WORKER_OUTPUT_LINE_LIMIT - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}` + : line + ) + } + } + + stream.setEncoding('utf8') + stream.on('data', (chunk: string) => { + let remaining = chunk + while (remaining.length > 0) { + if (discarding) { + const newline = remaining.indexOf('\n') + if (newline < 0) { + return + } + discarding = false + remaining = remaining.slice(newline + 1) + continue + } + const newline = remaining.indexOf('\n') + const segment = newline < 0 ? remaining : remaining.slice(0, newline) + const available = PLUGIN_WORKER_OUTPUT_LINE_LIMIT - buffered.length + if (segment.length > available) { + emit(buffered + segment.slice(0, available), true) + buffered = '' + discarding = newline < 0 + } else { + buffered += segment + if (newline >= 0) { + emit(buffered) + buffered = '' + } + } + if (newline < 0) { + return + } + remaining = remaining.slice(newline + 1) + } + }) + stream.on('end', () => { + if (!discarding) { + emit(buffered) + } + buffered = '' + }) +} diff --git a/src/main/plugins/plugin-worker-reconciliation.ts b/src/main/plugins/plugin-worker-reconciliation.ts new file mode 100644 index 00000000000..21b95e06940 --- /dev/null +++ b/src/main/plugins/plugin-worker-reconciliation.ts @@ -0,0 +1,22 @@ +import { capabilityKinds } from '../../shared/plugins/plugin-capabilities' +import type { DiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery' +import { isInvalidDiscoveredPlugin } from './plugin-discovery' +import type { PluginWorkerSpawnSpec } from './plugin-worker-manager' +import { buildPluginWorkerSpawnSpec } from './plugin-worker-spawn-spec' + +export function collectApprovedWorkerSpecs( + plugins: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean +): ReadonlyMap { + const specs = new Map() + for (const plugin of plugins) { + if (isInvalidDiscoveredPlugin(plugin) || !plugin.manifest.main || !isApproved(plugin)) { + continue + } + specs.set( + plugin.pluginKey, + buildPluginWorkerSpawnSpec(plugin, capabilityKinds(plugin.manifest.capabilities)) + ) + } + return specs +} diff --git a/src/main/plugins/plugin-worker-restart-loop.ts b/src/main/plugins/plugin-worker-restart-loop.ts new file mode 100644 index 00000000000..2c06f6ad724 --- /dev/null +++ b/src/main/plugins/plugin-worker-restart-loop.ts @@ -0,0 +1,50 @@ +import type { PluginRestartDecision } from './plugin-supervisor' + +function cancellationError(): Error { + return new Error('plugin worker activation was cancelled') +} + +function waitForBackoff(delayMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + timer.unref?.() + function onAbort(): void { + clearTimeout(timer) + reject(cancellationError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + }) +} + +export async function runPluginWorkerRestartLoop(options: { + signal: AbortSignal + firstRestart?: Extract + assertActive: () => void + start: () => Promise + recordFailure: (error: unknown) => PluginRestartDecision + erroredError: (error: unknown) => Error +}): Promise { + let restart = options.firstRestart + for (;;) { + if (restart) { + await waitForBackoff(restart.delayMs, options.signal) + } + options.assertActive() + try { + return await options.start() + } catch (error) { + options.assertActive() + const decision = options.recordFailure(error) + if (!decision.restart) { + throw options.erroredError(error) + } + restart = decision + } + } +} diff --git a/src/main/plugins/plugin-worker-slot-pool.ts b/src/main/plugins/plugin-worker-slot-pool.ts new file mode 100644 index 00000000000..5b86acf38d7 --- /dev/null +++ b/src/main/plugins/plugin-worker-slot-pool.ts @@ -0,0 +1,102 @@ +export type PluginWorkerSlotLease = { + release(): void +} + +type SlotWaiter = { + signal: AbortSignal + resolve: (lease: PluginWorkerSlotLease) => void + reject: (error: Error) => void + onAbort: () => void +} + +function cancellationError(): Error { + return new Error('plugin worker activation was cancelled') +} + +/** Atomically leases the bounded worker slots and hands releases to queued + * activations in FIFO order. */ +export class PluginWorkerSlotPool { + private readonly waiters: SlotWaiter[] = [] + private leased = 0 + private disposed = false + + constructor(private readonly capacity: number) { + if (!Number.isInteger(capacity) || capacity <= 0) { + throw new Error('plugin worker capacity must be a positive integer') + } + } + + acquire(signal: AbortSignal): Promise { + if (this.disposed) { + return Promise.reject(new Error('plugin worker slots are shut down')) + } + if (signal.aborted) { + return Promise.reject(cancellationError()) + } + if (this.leased < this.capacity && this.waiters.length === 0) { + this.leased += 1 + return Promise.resolve(this.createLease()) + } + return new Promise((resolve, reject) => { + let cancelled = false + const waiter: SlotWaiter = { + signal, + resolve, + reject, + onAbort: () => { + if (cancelled) { + return + } + cancelled = true + const index = this.waiters.indexOf(waiter) + if (index >= 0) { + this.waiters.splice(index, 1) + } + signal.removeEventListener('abort', waiter.onAbort) + reject(cancellationError()) + this.drain() + } + } + this.waiters.push(waiter) + signal.addEventListener('abort', waiter.onAbort, { once: true }) + }) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const waiter of this.waiters.splice(0)) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + waiter.reject(new Error('plugin worker slots are shut down')) + } + } + + private createLease(): PluginWorkerSlotLease { + let released = false + return { + release: () => { + if (released) { + return + } + released = true + this.leased -= 1 + this.drain() + } + } + } + + private drain(): void { + while (!this.disposed && this.leased < this.capacity && this.waiters.length > 0) { + const waiter = this.waiters.shift()! + waiter.signal.removeEventListener('abort', waiter.onAbort) + if (waiter.signal.aborted) { + waiter.reject(cancellationError()) + continue + } + this.leased += 1 + waiter.resolve(this.createLease()) + } + } +} diff --git a/src/main/plugins/plugin-worker-spawn-spec.ts b/src/main/plugins/plugin-worker-spawn-spec.ts new file mode 100644 index 00000000000..dd12eff372e --- /dev/null +++ b/src/main/plugins/plugin-worker-spawn-spec.ts @@ -0,0 +1,41 @@ +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginWorkerSpawnSpec } from './plugin-worker-startup' + +export function buildPluginWorkerSpawnSpec( + plugin: ValidDiscoveredPlugin, + grantedCapabilities: readonly PluginCapabilityKind[] +): PluginWorkerSpawnSpec { + if (!plugin.manifest.main) { + throw new Error(`plugin ${plugin.pluginKey} has no worker entry`) + } + return { + pluginKey: plugin.pluginKey, + rootDir: plugin.rootDir, + mainEntry: plugin.manifest.main, + // Dev plugins keep one root across manifest edits; include the parsed + // manifest so hot reload cannot reuse a worker with stale contributions. + manifestRevision: JSON.stringify(plugin.manifest), + grantedCapabilities + } +} + +export function pluginWorkerSpawnSpecsEqual( + left: PluginWorkerSpawnSpec, + right: PluginWorkerSpawnSpec +): boolean { + if ( + left.pluginKey !== right.pluginKey || + left.rootDir !== right.rootDir || + left.mainEntry !== right.mainEntry || + left.manifestRevision !== right.manifestRevision + ) { + return false + } + const leftCapabilities = [...left.grantedCapabilities].sort() + const rightCapabilities = [...right.grantedCapabilities].sort() + return ( + leftCapabilities.length === rightCapabilities.length && + leftCapabilities.every((capability, index) => capability === rightCapabilities[index]) + ) +} diff --git a/src/main/plugins/plugin-worker-startup.ts b/src/main/plugins/plugin-worker-startup.ts new file mode 100644 index 00000000000..efc527361f6 --- /dev/null +++ b/src/main/plugins/plugin-worker-startup.ts @@ -0,0 +1,99 @@ +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import { + startPluginWorker, + type PluginWorkerHandle, + type PluginWorkerHostCallExecutor, + type PluginWorkerLogSink +} from './plugin-host-process' +import type { PluginWorkerSlotLease, PluginWorkerSlotPool } from './plugin-worker-slot-pool' + +export type PluginWorkerSpawnSpec = { + pluginKey: string + rootDir: string + mainEntry: string + manifestRevision?: string + grantedCapabilities: readonly PluginCapabilityKind[] +} + +export type PluginWorkerFactory = (options: { + pluginId: string + rootDir: string + mainEntry: string + entryPath: string + grantedCapabilities: readonly PluginCapabilityKind[] + executeHostCall: PluginWorkerHostCallExecutor + log: PluginWorkerLogSink + signal: AbortSignal +}) => Promise + +export type StartedPluginWorker = { + spec: PluginWorkerSpawnSpec + generation: number + handle: PluginWorkerHandle + lease: PluginWorkerSlotLease + completeStart(): { exited: boolean; code: number | null } +} + +export async function startPluginWorkerAttempt(options: { + spec: PluginWorkerSpawnSpec + generation: number + signal: AbortSignal + slots: PluginWorkerSlotPool + entryPath: string + factory?: PluginWorkerFactory + executeHostCall: PluginWorkerHostCallExecutor + log: PluginWorkerLogSink + assertActive: () => void + onExit: (worker: StartedPluginWorker, code: number | null) => void +}): Promise { + const lease = await options.slots.acquire(options.signal) + let handle: PluginWorkerHandle | null = null + let retained = false + try { + options.assertActive() + const factory = options.factory ?? startPluginWorker + handle = await factory({ + pluginId: options.spec.pluginKey, + rootDir: options.spec.rootDir, + mainEntry: options.spec.mainEntry, + entryPath: options.entryPath, + grantedCapabilities: options.spec.grantedCapabilities, + executeHostCall: options.executeHostCall, + log: options.log, + signal: options.signal + }) + options.assertActive() + let startCompleted = false + let earlyExit = false + let earlyExitCode: number | null = null + const worker: StartedPluginWorker = { + spec: options.spec, + generation: options.generation, + handle, + lease, + completeStart: () => { + startCompleted = true + return { exited: earlyExit, code: earlyExitCode } + } + } + handle.onExit((code) => { + if (!startCompleted) { + earlyExit = true + earlyExitCode = code + return + } + options.onExit(worker, code) + }) + retained = true + return worker + } catch (error) { + if (handle) { + await handle.dispose().catch(() => undefined) + } + throw error + } finally { + if (!retained) { + lease.release() + } + } +} diff --git a/src/main/plugins/plugin-worker-supervision.integration.test.ts b/src/main/plugins/plugin-worker-supervision.integration.test.ts new file mode 100644 index 00000000000..06e41321a91 --- /dev/null +++ b/src/main/plugins/plugin-worker-supervision.integration.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { PluginWorkerHandle } from './plugin-host-process' +import { PluginWorkerManager, type PluginWorkerSpawnSpec } from './plugin-worker-manager' + +type LogEntry = { + at: number + level: 'info' | 'warn' | 'error' + line: string +} + +const pluginRoots: string[] = [] +const managers: PluginWorkerManager[] = [] +let bundleRoot = '' +let hostEntryPath = '' + +function createStateNotifications(): { + notify: () => void + waitFor: (predicate: () => boolean, description: string, timeoutMs?: number) => Promise +} { + const listeners = new Set<() => void>() + return { + notify: () => { + for (const listener of listeners) { + listener() + } + }, + waitFor: (predicate, description, timeoutMs = 10_000) => { + if (predicate()) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + listeners.delete(check) + reject(new Error(`timed out waiting for ${description}`)) + }, timeoutMs) + const check = (): void => { + if (!predicate()) { + return + } + clearTimeout(timeout) + listeners.delete(check) + resolve() + } + listeners.add(check) + }) + } + } +} + +beforeAll(async () => { + bundleRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-host-bundle-')) + hostEntryPath = join(bundleRoot, 'plugin-host-entry.cjs') + await build({ + entryPoints: [join(process.cwd(), 'src', 'main', 'plugins', 'plugin-host-entry.ts')], + outfile: hostEntryPath, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + sourcemap: false, + logLevel: 'silent' + }) +}, 30_000) + +afterEach(async () => { + await Promise.all(managers.splice(0).map((manager) => manager.disposeAll())) + await Promise.all(pluginRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +afterAll(async () => { + if (bundleRoot) { + await rm(bundleRoot, { recursive: true, force: true }) + } +}) + +async function createPluginSpec( + source = `export default function activate(orca) { orca.commands.register('run', async () => ({ ok: true })); }` +): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-supervision-')) + pluginRoots.push(rootDir) + await writeFile(join(rootDir, 'main.mjs'), source) + return { + pluginKey: 'orca-samples.supervision', + rootDir, + mainEntry: 'main.mjs', + grantedCapabilities: [] + } +} + +describe('real plugin worker supervision', () => { + it('terminates and supervises a live worker that disconnects IPC', async () => { + const spec = await createPluginSpec(` + export default function activate(orca) { + orca.commands.register('disconnect', async () => { + process.disconnect?.() + setInterval(() => {}, 1_000) + await new Promise(() => {}) + }) + } + `) + const notifications = createStateNotifications() + const manager = new PluginWorkerManager({ + entryPath: hostEntryPath, + executeHostCall: async () => ({ ok: true, value: null }), + log: vi.fn(), + onWorkerStateChange: notifications.notify, + onWorkerGone: vi.fn() + }) + managers.push(manager) + + const worker = await manager.ensureActive(spec) + const command = worker.invokeCommand('disconnect') + + await expect(command).rejects.toThrow('disconnected') + await notifications.waitFor( + () => manager.runState(spec.pluginKey) === 'restarting', + 'disconnected worker to enter supervised backoff' + ) + expect(manager.restartCount(spec.pluginKey)).toBe(1) + }) + + it('restarts forced exits with 500/2000/5000ms backoff, then stays errored', async () => { + const spec = await createPluginSpec() + const notifications = createStateNotifications() + const logs: LogEntry[] = [] + const manager = new PluginWorkerManager({ + entryPath: hostEntryPath, + executeHostCall: async () => ({ ok: true, value: null }), + log: (_pluginKey, level, line) => logs.push({ at: performance.now(), level, line }), + onWorkerStateChange: notifications.notify, + onWorkerGone: vi.fn() + }) + managers.push(manager) + + let current: PluginWorkerHandle = await manager.ensureActive(spec) + expect(current.commands).toContain('run') + expect(manager.runState(spec.pluginKey)).toBe('running') + + for (const [index, delayMs] of [500, 2_000, 5_000].entries()) { + const exited = current + exited.kill() + await notifications.waitFor( + () => + manager.runState(spec.pluginKey) === 'restarting' && + manager.restartCount(spec.pluginKey) === index + 1, + `restart ${index + 1} to enter backoff` + ) + const restartLog = logs.find((entry) => + entry.line.includes(`restart ${index + 1} in ${delayMs}ms`) + ) + expect(restartLog?.level).toBe('warn') + + current = await manager.ensureActive(spec) + + expect(current).not.toBe(exited) + expect(manager.runState(spec.pluginKey)).toBe('running') + expect(performance.now() - restartLog!.at).toBeGreaterThanOrEqual(delayMs - 25) + } + + current.kill() + await notifications.waitFor( + () => manager.runState(spec.pluginKey) === 'errored', + 'fourth forced exit to become terminally errored' + ) + + expect(manager.restartCount(spec.pluginKey)).toBe(3) + expect( + logs.some((entry) => entry.level === 'error' && entry.line.includes('marked errored')) + ).toBe(true) + await expect(manager.ensureActive(spec)).rejects.toThrow('errored after repeated failures') + }, 45_000) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index ede2767ea4f..6097f28105a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -473,7 +473,7 @@ import { } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' import { collectMemorySnapshot } from '../memory/collector' -import { BrowserWindow, ipcMain } from 'electron' +import { BrowserWindow, ipcMain, Notification } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import { BrowserError } from '../browser/cdp-bridge' @@ -2347,7 +2347,7 @@ type ResolvedWorktreeInFlight = { // events after it — idempotent, no duplicate local pushes. export type MobileNotificationDispatchEvent = { type: 'notification' - source: 'agent-task-complete' | 'terminal-bell' | 'test' + source: 'agent-task-complete' | 'terminal-bell' | 'test' | 'plugin' title: string body: string worktreeId?: string @@ -2355,6 +2355,10 @@ export type MobileNotificationDispatchEvent = { notificationSeq?: number } +export type RuntimeWorktreeLifecycleEvent = + | { kind: 'created'; worktreeId: string; path: string; branch: string } + | { kind: 'removed'; worktreeId: string; path: string } + export type MobileNotificationDismissEvent = { type: 'dismiss' notificationId: string @@ -2527,6 +2531,7 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null private offscreenBrowserBackend: BrowserBackend | null = null @@ -3616,6 +3621,28 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'worktreesChanged', repoId }) } + /** Detail-level worktree lifecycle tap (plugin event bus). The coarse + * worktreesChanged client event carries only repoId, which is not enough + * for subscribers that need the affected worktree's identity. + * Removal payloads carry no branch: the removal target resolves before + * the git worktree is torn down and only pins id + path. */ + onWorktreeLifecycle(listener: (event: RuntimeWorktreeLifecycleEvent) => void): () => void { + this.worktreeLifecycleListeners.add(listener) + return () => { + this.worktreeLifecycleListeners.delete(listener) + } + } + + private emitWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + for (const listener of this.worktreeLifecycleListeners) { + try { + listener(event) + } catch (err) { + console.error('[runtime] worktree lifecycle listener threw', err) + } + } + } + private notifyReposChanged(): void { this.notifier?.reposChanged() this.emitClientEvent({ type: 'reposChanged' }) @@ -10021,6 +10048,31 @@ export class OrcaRuntimeService { this.dispatchMobileNotification({ type: 'dismiss', notificationId }) } + /** Plugin panel action notifications.show. Native on desktop, relayed to + * paired mobile clients either way (mirrors notifications:dispatch). */ + async dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> { + // Why: prefix with the plugin id so a plugin cannot spoof an Orca system + // notification or impersonate another plugin. + const title = `${input.pluginId}: ${input.title}` + const body = input.body ?? '' + let delivered = false + try { + if (Notification.isSupported()) { + new Notification({ title, body }).show() + delivered = true + } + } catch { + // Headless serve has no notification display; the mobile relay below + // still runs. + } + this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body }) + return { delivered } + } + // ─── Account Services (mobile RPC bridge) ───────────────────── setAccountServices(services: RuntimeAccountServices): void { @@ -13481,6 +13533,41 @@ export class OrcaRuntimeService { return leaf?.worktreeId ?? this.getPtyRecordForPaneKey(paneKey)?.worktreeId ?? null } + /** Read-only context of the worktree the user is focused on, for plugin + * panels (workspace.readContext). Prefers the persisted session focus and + * falls back to the last-focused pane's worktree; null when neither + * resolves so panels degrade instead of erroring. */ + async resolveActiveWorktreeContext(): Promise<{ + worktreeId: string + path: string + branch: string + displayName: string + } | null> { + let worktreeId = this.store?.getWorkspaceSession?.()?.activeWorktreeId ?? null + if (!worktreeId && this.graphStatus === 'ready') { + for (const tab of this.tabs.values()) { + if (tab.activeLeafId && tab.worktreeId) { + worktreeId = tab.worktreeId + break + } + } + } + if (!worktreeId) { + return null + } + try { + const resolved = await this.resolveWorktreeSelector(`id:${worktreeId}`) + return { + worktreeId: resolved.id, + path: resolved.git.path, + branch: resolved.git.branch, + displayName: resolved.displayName + } + } catch { + return null + } + } + resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { // Why: the renderer context menu only knows the stable pane key; main owns // the runtime terminal handle that agents and CLI commands can address. @@ -18370,6 +18457,12 @@ export class OrcaRuntimeService { const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: worktree.id, + path: worktree.path, + branch: worktree.branch + }) const shouldActivate = args.activate === true || args.runHooks === true let warning: string | undefined let didSpawnStartup = false @@ -18459,6 +18552,12 @@ export class OrcaRuntimeService { ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) return { ...result, worktree: { @@ -19232,6 +19331,12 @@ export class OrcaRuntimeService { : {}) } : undefined + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: worktree.id, + path: worktree.path, + branch: worktree.branch + }) return { worktree: { ...worktree, @@ -21166,7 +21271,13 @@ export class OrcaRuntimeService { })() this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal }) try { - return await removal + const result = await removal + this.emitWorktreeLifecycle({ + kind: 'removed', + worktreeId: removalTarget.id, + path: removalTarget.path + }) + return result } finally { if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) { this.removeManagedWorktreeInFlight.delete(removalTarget.id) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 537e7ce97f5..df9f808006c 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -31,6 +31,7 @@ import { SPEECH_METHODS } from './speech' import { CLIENT_UI_METHODS } from './client-ui' import { CLIENT_EVENT_METHODS } from './client-events' import { WORKSPACE_PORT_METHODS } from './workspace-ports' +import { PLUGIN_METHODS } from './plugins' import { SKILL_METHODS } from './skills' import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' @@ -74,6 +75,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...SSH_METHODS, ...SPEECH_METHODS, ...WORKSPACE_PORT_METHODS, + ...PLUGIN_METHODS, ...SKILL_METHODS, ...CLIPBOARD_METHODS, ...HOST_CAPABILITY_METHODS, diff --git a/src/main/runtime/rpc/methods/plugins.test.ts b/src/main/runtime/rpc/methods/plugins.test.ts new file mode 100644 index 00000000000..bf67d29f19a --- /dev/null +++ b/src/main/runtime/rpc/methods/plugins.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext, RpcMethod } from '../core' +import type { PluginService } from '../../../plugins/plugin-service' +import { PLUGIN_METHODS, setPluginServiceForRpc } from './plugins' + +const SESSION_TOKEN = 's'.repeat(43) + +function method(name: string): RpcMethod { + const found = PLUGIN_METHODS.find((entry) => entry.name === name) + if (!found) { + throw new Error(`missing ${name}`) + } + if ('stream' in found) { + throw new Error(`${name} is streaming`) + } + return found +} + +function context(connectionId?: string): RpcContext { + return { runtime: {} as RpcContext['runtime'], connectionId, clientId: 'paired-device' } +} + +afterEach(() => setPluginServiceForRpc(null)) + +describe('plugin panel serve RPC identity', () => { + it('leaves the raw panel envelope for session resolution and admission', () => { + const schema = method('plugins.panelAction').params! + + expect( + schema.safeParse({ + pluginId: 'orca-samples.other', + unexpected: 'x'.repeat(100_000) + }).success + ).toBe(true) + }) + + it('binds panel loading and actions to the same runtime connection owner', async () => { + const service = { + whenReady: vi.fn().mockResolvedValue(undefined), + panels: { + open: vi.fn().mockResolvedValue({ html: '

    panel

    ', sessionToken: SESSION_TOKEN }), + execute: vi.fn().mockResolvedValue({ ok: true, value: { branch: 'main' } }), + bindOwnerSignal: vi.fn(), + revokeOwner: vi.fn() + } + } as unknown as PluginService + setPluginServiceForRpc(service) + const rpcContext = context('connection-one') + + await expect( + method('plugins.readPanelEntry').handler( + { pluginKey: 'orca-samples.demo', panelId: 'dashboard' }, + rpcContext + ) + ).resolves.toEqual({ html: '

    panel

    ', sessionToken: SESSION_TOKEN }) + expect(service.panels.open).toHaveBeenCalledWith( + 'runtime:connection-one', + 'orca-samples.demo', + 'dashboard' + ) + + await expect( + method('plugins.panelAction').handler( + { sessionToken: SESSION_TOKEN, action: 'workspace.readContext', params: {} }, + rpcContext + ) + ).resolves.toEqual({ outcome: { ok: true, value: { branch: 'main' } } }) + expect(service.panels.execute).toHaveBeenCalledWith('runtime:connection-one', { + sessionToken: SESSION_TOKEN, + action: 'workspace.readContext', + params: {} + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts new file mode 100644 index 00000000000..f463feff1cf --- /dev/null +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' +import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' +import { listPluginsForClients } from '../../../ipc/plugins' +import type { PluginListEntry } from '../../../plugins/plugin-list-projection' +import type { PluginService } from '../../../plugins/plugin-service' +import { + pluginConsentRequestSchema, + type PluginConsentRequest +} from '../../../../shared/plugins/plugin-consent-request' +import { isQualifiedPluginKey } from '../../../../shared/plugins/plugin-manifest' + +/** + * Serve/headless parity surface: the same consent, enablement, panel-action, + * and command paths the desktop IPC handlers expose, over runtime RPC. Both + * routes execute through PluginService's single chokepoint, so a permission + * decision can never differ between a local window and a paired client. + */ + +// Why: RpcContext only carries the OrcaRuntimeService, and plugins are a +// separate composition-root service — inject via module setter the way the +// desktop entry wires it, instead of widening the shared RPC context type. +let pluginServiceForRpc: PluginService | null = null +// Consent/enablement need the settings Store too, so the entry injects bound +// closures instead of the store itself. +let pluginConsentForRpc: ((request: PluginConsentRequest) => Promise) | null = null +let pluginEnablementForRpc: ((pluginKey: string, enabled: boolean) => Promise) | null = null + +export function setPluginServiceForRpc( + service: PluginService | null, + writes?: { + applyConsent: (request: PluginConsentRequest) => Promise + applyEnablement: (pluginKey: string, enabled: boolean) => Promise + } +): void { + pluginServiceForRpc = service + pluginConsentForRpc = writes?.applyConsent ?? null + pluginEnablementForRpc = writes?.applyEnablement ?? null +} + +function requirePluginService(): PluginService { + if (!pluginServiceForRpc) { + throw new Error('Plugin service is not available on this runtime') + } + return pluginServiceForRpc +} + +const PluginSetEnabledParams = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + enabled: z.boolean() +}) + +const PluginReadPanelEntryParams = z.object({ + pluginKey: z.string().min(1), + panelId: z.string().min(1) +}) + +const PluginInvokeCommandParams = z.object({ + pluginKey: z.string().min(1), + commandId: z.string().min(1), + args: z.unknown().optional() +}) + +async function listForRpc(): Promise { + return listPluginsForClients(requirePluginService()) +} + +function rpcPanelOwner(context: RpcContext): string { + // Why: the bearer session must not cross paired-client connections even + // when two sockets authenticate as the same device. + return `runtime:${context.connectionId ?? context.clientId ?? 'local'}` +} + +function bindRpcPanelOwner(service: PluginService, context: RpcContext): string { + const ownerKey = rpcPanelOwner(context) + service.panels.bindOwnerSignal(ownerKey, context.signal) + return ownerKey +} + +export const PLUGIN_METHODS: readonly RpcMethod[] = [ + defineMethod({ + name: 'plugins.list', + params: null, + handler: async () => listForRpc() + }), + defineMethod({ + // Why: headless serve has no consent dialog — an explicit consent call is + // the only way a pending plugin becomes active on a server. + name: 'plugins.consent', + params: pluginConsentRequestSchema, + handler: async (params) => { + const service = requirePluginService() + await service.whenReady() + if (!pluginConsentForRpc) { + throw new Error('Plugin consent is not available on this runtime') + } + await pluginConsentForRpc(params) + return listForRpc() + } + }), + defineMethod({ + name: 'plugins.setEnabled', + params: PluginSetEnabledParams, + handler: async (params) => { + const service = requirePluginService() + await service.whenReady() + if (!pluginEnablementForRpc) { + throw new Error('Plugin enablement is not available on this runtime') + } + await pluginEnablementForRpc(params.pluginKey, params.enabled) + return listForRpc() + } + }), + defineMethod({ + // Why: headless serve clients relay panel bridge requests over RPC, so + // capability enforcement must live behind this method too, not only in + // the desktop IPC handler. + name: 'plugins.panelAction', + // Why: raw admission must run before strict schema parsing so malformed + // and oversized traffic cannot bypass the panel budget. + params: z.unknown(), + handler: async (params, context) => { + const service = requirePluginService() + await service.whenReady() + return { + outcome: await service.panels.execute(bindRpcPanelOwner(service, context), params) + } + } + }), + defineMethod({ + name: 'plugins.readPanelEntry', + params: PluginReadPanelEntryParams, + handler: async (params, context): Promise => { + const service = requirePluginService() + await service.whenReady() + const ownerKey = bindRpcPanelOwner(service, context) + const entry = await service.panels.open(ownerKey, params.pluginKey, params.panelId) + if (context.signal?.aborted) { + service.panels.revokeOwner(ownerKey) + return null + } + return entry + } + }), + defineMethod({ + name: 'plugins.invokeCommand', + params: PluginInvokeCommandParams, + handler: async (params) => { + const service = requirePluginService() + await service.whenReady() + return service.invokeCommand(params.pluginKey, params.commandId, params.args) + } + }) +] diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0a481b209bc..5384d11c16a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -23,7 +23,7 @@ import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import { checkForUpdatesFromMenu, downloadUpdate, @@ -84,11 +84,14 @@ export function attachMainWindowServices( isRecoveryReloadInFlight?: (webContentsId: number) => boolean onBeforeUpdateQuit?: () => void | Promise updateInstallMode?: UpdateInstallMode + onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) registerRepoHandlers(mainWindow, store) - registerWorktreeHandlers(mainWindow, store, runtime) + registerWorktreeHandlers(mainWindow, store, runtime, { + onWorktreeLifecycle: options?.onWorktreeLifecycle + }) // Why: repo/settings mutations resync watchers through this attached main-window context. setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow) scheduleWorktreeBaseDirectoryWatcherSync(store, mainWindow) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 387f0d2b5f4..909ed473e86 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -50,6 +50,7 @@ import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' import { isMacosTahoeOrNewer } from './macos-tahoe-release' import { reflowRendererViewport } from './renderer-viewport-reflow' +import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -441,6 +442,9 @@ export function createMainWindow( }) installPrivilegedWindowNavigationPolicy(mainWindow.webContents) + // Why: containment must be listening before any plugin panel frame is created, + // so register it with the window's other navigation policy. + registerPluginPanelNavigationGuard(mainWindow.webContents) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2592e08e9e6..4be8b2bc51f 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -68,6 +68,15 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' +import type { PluginLanguagePackRegistration } from '../shared/plugins/plugin-language-pack-artifact' +import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' +import type { PluginManifest } from '../shared/plugins/plugin-manifest' +import type { PluginMarketplaceGitSource } from '../shared/plugins/plugin-marketplace' import type { LocalhostWorktreeLabelResult, LocalhostWorktreeLabelRoute @@ -969,6 +978,134 @@ export type AppApi = { ) => Promise } +/** Panel contribution as surfaced by the main-process plugin service. */ +export type PluginHostPanel = { + id: string + title: string + /** Lucide icon name declared in the plugin manifest. */ + icon?: string + tabKey: `plugin:${string}` +} + +/** `pending` = awaiting (re-)consent; `idle` = enabled, worker not running + * (lazy); `restarting` = waiting for supervised backoff; `errored` = crashed past the restart budget or failed to start; + * `invalid` = unreadable manifest. */ +export type PluginHostStatus = + | 'running' + | 'restarting' + | 'idle' + | 'pending' + | 'disabled' + | 'errored' + | 'invalid' + +/** Wire shape of plugins:list — must stay assignable from the main-process + * projection in src/main/plugins/plugin-list-projection.ts. */ +export type PluginHostListEntry = { + pluginKey: string + consentFingerprint: string | null + name: string + version: string + publisher: string + description?: string + status: PluginHostStatus + needsReconsent: boolean + error?: string + isDev: boolean + official: boolean + bundled: boolean + capabilities: { kind: string; description: string }[] + panels: PluginHostPanel[] + commands: { + id: string + title: string + context: 'global' | 'worktree' + handler: { type: 'built-in'; action: string } | { type: 'worker' } + keybindings: { key: string; when: 'global' | 'worktree' }[] + }[] + hasWorker: boolean + vmRecipes?: { + id: string + name: string + description?: string + commands: { + phase: 'create' | 'suspend' | 'resume' | 'destroy' + command: string + }[] + }[] + restarts: number + blockedByKillList?: { reason: string; advisoryUrl?: string } + source?: { + kind: 'local-path' | 'git' | 'marketplace' | 'bundled' + reference: string + resolvedCommit: string | null + contentHash: string + marketplace?: { reference: string; resolvedCommit: string } + } +} + +export type PluginHostLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string } + +export type PluginHostInstallSource = + | { kind: 'local-path'; path: string } + | { kind: 'git'; url: string; ref: string } + +export type PluginHostInstallResult = + | { + ok: true + pluginKey: string + version: string + contentHash: string + consentFingerprint: string + resolvedCommit: string | null + } + | { ok: false; error: string } + +export type PluginMarketplaceHostSourceState = { + id: string + source: PluginMarketplaceGitSource + addedAt: number + marketplace: { + name: string + owner: string + resolvedCommit: string + fetchedAt: number + } | null + stale: boolean + official: boolean + error?: string +} + +export type PluginMarketplaceHostListing = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + description?: string + categories: string[] + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + +export type PluginMarketplaceHostInstallPreview = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + resolvedCommit: string + contentHash: string + consentFingerprint: string + manifest: PluginManifest + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + export type PreloadApi = { app: AppApi orcaProfiles: { @@ -3254,6 +3391,62 @@ export type PreloadApi = { gitBash: { isAvailable: () => Promise } + plugins: { + list: () => Promise + listLanguagePacks: () => Promise + /** Records the consent-dialog answer; approval is keyed to the plugin's + * current capability and trusted-worker fingerprint. */ + consent: (args: PluginConsentRequest) => Promise + setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise + /** Returns the panel's CSP-wrapped HTML, or null when the plugin or + * panel is missing/disabled. Rendered only inside a sandboxed iframe. */ + readPanelEntry: (args: { + pluginKey: string + panelId: string + }) => Promise + invokeCommand: (args: { + pluginKey: string + commandId: string + args?: unknown + }) => Promise + /** Relays a sandboxed panel's bridge request to main, which enforces the + * plugin's consented capabilities before executing. */ + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }) => Promise + install: (source: PluginHostInstallSource) => Promise + listMarketplaces: () => Promise + addMarketplace: ( + source: PluginMarketplaceGitSource + ) => Promise + removeMarketplace: (args: { sourceId: string }) => Promise + refreshMarketplaces: (args?: { + sourceId?: string + }) => Promise + listMarketplacePlugins: () => Promise + previewMarketplacePlugin: (args: { + marketplaceSourceId: string + pluginKey: string + }) => Promise + installMarketplacePlugin: ( + preview: Pick< + PluginMarketplaceHostInstallPreview, + 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' + > + ) => Promise + previewMarketplaceUpdate: (args: { + pluginKey: string + }) => Promise + rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise + remove: (args: { pluginKey: string }) => Promise + getLogs: (args: { pluginKey: string }) => Promise + /** Re-discovers after settings edits (feature flag, dev paths). */ + refresh: () => Promise + /** Fires whenever installed plugins, worker states, panels, or content packs change. */ + onChanged: (callback: (event: PluginChangeEvent) => void) => () => void + } agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 21014fc1a35..342a0e1bd71 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,12 @@ import type { import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { SshMutationExpectation } from '../shared/ssh-types' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' +import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -241,7 +247,13 @@ import type { } from '../shared/crash-reporting' import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' -import type { PreloadApi } from './api-types' +import type { + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine, + PreloadApi +} from './api-types' import { createUpdaterQuitAbortRelay, prepareRendererForAppRestart @@ -521,6 +533,57 @@ const api = { isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') }, + plugins: { + list: (): Promise => ipcRenderer.invoke('plugins:list'), + listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + consent: (args: PluginConsentRequest): Promise => + ipcRenderer.invoke('plugins:consent', args), + setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => + ipcRenderer.invoke('plugins:setEnabled', args), + readPanelEntry: (args: { + pluginKey: string + panelId: string + }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + invokeCommand: (args: { + pluginKey: string + commandId: string + args?: unknown + }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }): Promise => ipcRenderer.invoke('plugins:panelAction', args), + install: (source: PluginHostInstallSource): Promise => + ipcRenderer.invoke('plugins:install', source), + listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), + addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), + removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), + refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), + listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), + previewMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), + installMarketplacePlugin: (preview) => + ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), + previewMarketplaceUpdate: (args) => + ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), + rollbackMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), + remove: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:remove', args), + getLogs: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:getLogs', args), + refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), + onChanged: (callback): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => + callback(change) + ipcRenderer.on('plugins:changed', listener) + return () => { + ipcRenderer.removeListener('plugins:changed', listener) + } + } + } satisfies PreloadApi['plugins'], + repos: { list: () => ipcRenderer.invoke('repos:list'), diff --git a/src/relay/plugin-host-call-handler.ts b/src/relay/plugin-host-call-handler.ts new file mode 100644 index 00000000000..c3f73c53c7b --- /dev/null +++ b/src/relay/plugin-host-call-handler.ts @@ -0,0 +1,69 @@ +import type { MethodHandler, RequestContext } from './dispatcher' +import { + admitPluginPanelCall, + createPluginPanelCallAdmission, + type PluginPanelCallAdmission +} from '../shared/plugins/plugin-panel-call-admission' +import { + executePluginHostCallRequest, + isPluginHostCallRequest, + type ResolvePluginHostCallPolicy +} from '../main/plugins/plugin-host-call-adapter' + +export const RELAY_PLUGIN_PANEL_HOST_CALL_METHOD = 'plugins.hostCall.panel' +export const RELAY_PLUGIN_WORKER_HOST_CALL_METHOD = 'plugins.hostCall.worker' + +export type RelayPluginHostCallDispatcher = { + onRequest(method: string, handler: MethodHandler): void +} + +export type ResolveRelayPluginHostCallIdentity = ( + context: RequestContext +) => string | null | Promise + +/** Relay provisioning is deliberately out of scope here. Its connection- + * keyed resolver owns plugin identity, consent, services, and audit authority. */ +export function registerRelayPluginHostCallHandlers( + dispatcher: RelayPluginHostCallDispatcher, + resolveIdentity: ResolveRelayPluginHostCallIdentity, + resolvePolicy: ResolvePluginHostCallPolicy, + options: { panelAdmission?: PluginPanelCallAdmission } = {} +): void { + const panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission() + const register = (registeredMethod: string, viaPanel: boolean): void => { + dispatcher.onRequest(registeredMethod, async (params, context) => { + let pluginKey: string | null + try { + pluginKey = await resolveIdentity(context) + } catch { + pluginKey = null + } + if (!pluginKey) { + return { + ok: false, + code: 'unavailable', + error: 'plugin host authority is not available' + } + } + if (viaPanel) { + const admissionRefusal = admitPluginPanelCall(panelAdmission, pluginKey, params) + if (admissionRefusal) { + return admissionRefusal + } + } + if (!isPluginHostCallRequest(params)) { + return { ok: false, code: 'invalid_request', error: 'malformed plugin host call request' } + } + return executePluginHostCallRequest({ + pluginKey, + request: params, + viaPanel, + resolvePolicy + }) + }) + } + // Why: transport authority is fixed by the registered RPC method; callers + // cannot promote a panel call to the wider worker method set in params. + register(RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, true) + register(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, false) +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 71e41d605c3..267b9ee8b36 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -52,6 +52,7 @@ import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' import { registerManagedHookInstaller } from './managed-hook-installer' +import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 const SOCK_NAME = 'relay.sock' @@ -428,6 +429,14 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler + // Why: relay-hosted plugin provisioning is a later phase. Register the + // enforcement boundary now with no consented identities or runtime services. + registerRelayPluginHostCallHandlers( + dispatcher, + () => null, + () => ({ grantedCapabilities: null, services: null }) + ) + dispatcher.onRequest('orca.cli', async (params, context) => { return await dispatcher.requestAnyClient('orca.cli', params, { excludeClientId: context.clientId, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a58f1c5c474..b5ef2b9cc77 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -181,6 +181,11 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' +import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../shared/plugins/plugin-command-actions' +import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch' +import { executePluginCommand } from '@/lib/plugin-command-execution' +import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings' +import { usePluginCommands } from '@/store/plugin-panels' import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, @@ -506,6 +511,7 @@ function App(): React.JSX.Element { hasRequestedBackgroundTerminalWorktreeMount ) const keybindings = useAppStore((s) => s.keybindings) + const pluginCommands = usePluginCommands() const updateStatus = useAppStore((s) => s.updateStatus) const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle') @@ -668,6 +674,7 @@ function App(): React.JSX.Element { settings?.primarySelectionMiddleClickPaste ) usePrimarySelectionPaste(primarySelectionMiddleClickPaste) + useAppMenuPaste() useLargeTextControlPaste() const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) @@ -1494,6 +1501,7 @@ function App(): React.JSX.Element { floatingTerminalOpen, floatingVisibleTabCount, keybindings, + pluginCommands, terminalShortcutPolicy: settings?.terminalShortcutPolicy, setFloatingTerminalOpenWithFocus, workspaceChromeActive, @@ -1508,6 +1516,7 @@ function App(): React.JSX.Element { floatingTerminalOpen, floatingVisibleTabCount, keybindings, + pluginCommands, terminalShortcutPolicy: settings?.terminalShortcutPolicy, setFloatingTerminalOpenWithFocus, workspaceChromeActive, @@ -1517,6 +1526,196 @@ function App(): React.JSX.Element { useEffect(() => { const doubleTapDetector = new ModifierDoubleTapDetector() + const createRegisteredCommandHandlers = ( + input?: ShortcutDispatchInput, + keybindingContext: KeybindingContext = 'app' + ): Map boolean> => { + const { + activeView, + activeWorktreeId, + actions, + floatingTerminalEnabled, + floatingTerminalOpen, + terminalShortcutPolicy, + keybindings, + setFloatingTerminalOpenWithFocus, + workspaceChromeActive, + creationLayoutActive + } = globalShortcutStateRef.current + const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() + const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView) + const claim = (actionId: KeybindingActionId, run: () => void): boolean => { + input?.preventDefault() + if ( + input && + keybindingContext === 'terminal' && + (terminalShortcutPolicy ?? 'orca-first') === 'orca-first' + ) { + showTerminalShortcutCaptureNotification({ + actionId, + platform: shortcutPlatform, + keybindings + }) + } + run() + return true + } + + return new Map boolean>([ + [ + 'worktree.history.back', + () => { + if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { + return false + } + return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree()) + } + ], + [ + 'worktree.history.forward', + () => { + if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { + return false + } + return claim('worktree.history.forward', () => + useAppStore.getState().goForwardWorktree() + ) + } + ], + ['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())], + [ + 'sidebar.sleepingWorkspaces.toggle', + () => + claim('sidebar.sleepingWorkspaces.toggle', () => { + const store = useAppStore.getState() + const nextShowSleeping = !store.showSleepingWorkspaces + store.setShowSleepingWorkspaces(nextShowSleeping) + if (nextShowSleeping) { + store.setSidebarOpen(true) + } + }) + ], + [ + 'floatingWorkspace.maximize', + () => { + if (floatingTerminalOpen || !floatingTerminalEnabled) { + return false + } + return claim('floatingWorkspace.maximize', () => { + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + }) + } + ], + [ + 'tab.rename', + () => { + const store = useAppStore.getState() + if ( + !workspaceChromeActive || + floatingWorkspaceFocused || + store.activeTabType !== 'terminal' || + !store.activeTabId + ) { + return false + } + return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!)) + } + ], + [ + 'workspace.rename', + () => { + if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) { + return false + } + return claim('workspace.rename', () => { + useAppStore.getState().setSidebarOpen(true) + requestScrollToCurrentWorkspaceRevealAndRename() + }) + } + ], + [ + 'workspace.openBoard', + () => { + if (activeView === 'settings') { + return false + } + return claim('workspace.openBoard', () => { + useAppStore.getState().setSidebarOpen(true) + window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT)) + }) + } + ], + [ + 'view.tasks', + () => { + const store = useAppStore.getState() + if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { + return false + } + return claim('view.tasks', () => store.openTaskPage()) + } + ], + [ + 'sidebar.right.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.right.toggle', () => actions.toggleRightSidebar()) + : false + ], + [ + 'sidebar.explorer.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles()) + : false + ], + [ + 'sidebar.search.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch()) + : false + ], + [ + 'sidebar.sourceControl.toggle', + () => { + if (!canRevealRightSidebar || document.querySelector('[data-terminal-search-root]')) { + return false + } + return claim('sidebar.sourceControl.toggle', () => { + actions.setRightSidebarTab('source-control') + actions.setRightSidebarOpen(true) + }) + } + ], + [ + 'sidebar.checks.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.checks.toggle', () => { + actions.setRightSidebarTab('checks') + actions.setRightSidebarOpen(true) + }) + : false + ], + [ + 'sidebar.ports.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.ports.toggle', () => { + actions.setRightSidebarTab('ports') + actions.setRightSidebarOpen(true) + }) + : false + ] + ]) + } + + const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) => + (createRegisteredCommandHandlers().get(actionId) ?? (() => false))() + ) + const dispatchShortcutInput = (input: ShortcutDispatchInput): void => { const { activeView, @@ -1526,9 +1725,9 @@ function App(): React.JSX.Element { floatingTerminalOpen, floatingVisibleTabCount, keybindings, + pluginCommands, terminalShortcutPolicy, setFloatingTerminalOpenWithFocus, - workspaceChromeActive, creationLayoutActive } = globalShortcutStateRef.current @@ -1630,22 +1829,6 @@ function App(): React.JSX.Element { return } - // Cmd/Ctrl+Alt+Arrow worktree history — kept before right-sidebar shortcuts because it's navigation, not sidebar reveal. - if (matchShortcut('worktree.history.back') || matchShortcut('worktree.history.forward')) { - // Back/Forward is live wherever the titlebar cluster shows (worktree + page visits), but suppressed in Settings. - if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { - return - } - input.preventDefault() - const store = useAppStore.getState() - if (matchShortcut('worktree.history.back')) { - store.goBackWorktree() - } else { - store.goForwardWorktree() - } - return - } - // Only short-circuit chords the floating panel itself claims; suppressing others here would silently no-op them when focus is in the panel. const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() if (floatingWorkspaceFocused) { @@ -1659,139 +1842,42 @@ function App(): React.JSX.Element { } } - // Cmd/Ctrl+B — toggle left sidebar - if (matchShortcut('sidebar.left.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.left.toggle') - actions.toggleSidebar() - return - } - - // Toggle the sleeping-workspaces filter without the filters menu (issue #5209); open the sidebar when revealing so they're reachable. - if (matchShortcut('sidebar.sleepingWorkspaces.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.sleepingWorkspaces.toggle') - const store = useAppStore.getState() - const nextShowSleeping = !store.showSleepingWorkspaces - store.setShowSleepingWorkspaces(nextShowSleeping) - if (nextShowSleeping) { - store.setSidebarOpen(true) - } - return - } - - // Cmd+R renames the active terminal tab — free here because the browser pane owns its own reload; non-terminal tabs fall through (no inline title editor). - if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) { - const store = useAppStore.getState() - if (store.activeTabType === 'terminal' && store.activeTabId) { + // Plugin chords are user-reviewed instructional content. They win over + // built-in defaults only in app focus; terminal/editor/browser handlers + // retain their own shortcut authority. + if (context === 'app') { + const pluginCommand = findPluginCommandForKeybinding( + pluginCommands, + input, + shortcutPlatform, + keybindings, + Boolean(activeWorktreeId) + ) + if (pluginCommand) { input.preventDefault() - notifyTerminalCapture('tab.rename') - store.setRenamingTabId(store.activeTabId) + void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => { + toast.error( + translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.') + ) + }) return } } - // Open/reveal the worktree card first so its inline title editor is mounted even when filters or collapse state would hide it. - if ( - workspaceChromeActive && - !floatingWorkspaceFocused && - matchShortcut('workspace.rename') && - activeWorktreeId - ) { - input.preventDefault() - notifyTerminalCapture('workspace.rename') - const store = useAppStore.getState() - store.setSidebarOpen(true) - requestScrollToCurrentWorkspaceRevealAndRename() - return - } - - if (matchShortcut('workspace.openBoard') && activeView !== 'settings') { - input.preventDefault() - notifyTerminalCapture('workspace.openBoard') - const store = useAppStore.getState() - store.setSidebarOpen(true) - window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT)) - return - } - - // Cmd/Ctrl+N is handled in the main-process before-input-event allowlist (window-shortcut-policy.ts), not here, so it fires even inside editors/browser guests. - - // Full-page navigation surfaces own the whole content area, so don't reveal the right sidebar. - if (matchShortcut('view.tasks') && activeView !== 'settings') { - const store = useAppStore.getState() - if (store.repos.some((repo) => isGitRepoKind(repo))) { - input.preventDefault() - notifyTerminalCapture('view.tasks') - store.openTaskPage() - } - return - } - - if (!canRevealRightSidebar) { - return - } - - // Cmd/Ctrl+L — toggle right sidebar - if (matchShortcut('sidebar.right.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.right.toggle') - actions.toggleRightSidebar() - return - } - - // Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab - if (matchShortcut('sidebar.explorer.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.explorer.toggle') - actions.showRightSidebarFiles() - return - } - - // Cmd/Ctrl+Shift+F — toggle right sidebar / search tab - if (matchShortcut('sidebar.search.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.search.toggle') - openSearchSidebar(null) - return - } - - // Cmd/Ctrl+Shift+G — source control tab; skip when terminal search is open (there it means "find previous"). DOM check because capture-phase order varies. - if (matchShortcut('sidebar.sourceControl.toggle')) { - if (document.querySelector('[data-terminal-search-root]')) { + const handlers = createRegisteredCommandHandlers(input, context) + for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { + if (matchShortcut(actionId) && handlers.get(actionId)?.()) { return } - input.preventDefault() - notifyTerminalCapture('sidebar.sourceControl.toggle') - actions.setRightSidebarTab('source-control') - actions.setRightSidebarOpen(true) - return } - // Unbound by default; opens the active worktree's Source Control notes send picker. Only consumes the chord when there are unsent notes. - if (matchShortcut('sourceControl.sendReviewNotes')) { + // Unbound by default, so it runs after the built-in alias handlers above; only consumes the chord when the active worktree has unsent notes. + if (canRevealRightSidebar && matchShortcut('sourceControl.sendReviewNotes')) { if (actions.openDiffNotesSendMenuForActiveWorktree()) { input.preventDefault() notifyTerminalCapture('sourceControl.sendReviewNotes') - return } } - - if (matchShortcut('sidebar.checks.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.checks.toggle') - actions.setRightSidebarTab('checks') - actions.setRightSidebarOpen(true) - return - } - - // Cmd+Shift+I — ports tab (macOS only); Ctrl+Shift+I is the DevTools accelerator on Windows/Linux. - if (matchShortcut('sidebar.ports.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.ports.toggle') - actions.setRightSidebarTab('ports') - actions.setRightSidebarOpen(true) - } } const onKeyDown = (e: KeyboardEvent): void => { @@ -1856,6 +1942,7 @@ function App(): React.JSX.Element { window.addEventListener('keyup', onKeyUp, { capture: true }) window.addEventListener('blur', onBlur) return () => { + unregisterAppCommandDispatcher() window.removeEventListener('keydown', onKeyDown, { capture: true }) window.removeEventListener('keyup', onKeyUp, { capture: true }) window.removeEventListener('blur', onBlur) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b119d30d515..89ed0743337 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -130,6 +130,23 @@ 'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono', monospace; --radius: 0.625rem; + --orca-security-background: #fff; + --orca-security-foreground: #0a0a0a; + --orca-security-card: #fff; + --orca-security-card-foreground: #0a0a0a; + --orca-security-popover: #fff; + --orca-security-popover-foreground: #0a0a0a; + --orca-security-primary: #171717; + --orca-security-primary-foreground: #fafafa; + --orca-security-secondary: #f5f5f5; + --orca-security-secondary-foreground: #171717; + --orca-security-muted: #f5f5f5; + --orca-security-muted-foreground: #737373; + --orca-security-accent: #f5f5f5; + --orca-security-accent-foreground: #171717; + --orca-security-border: #e5e5e5; + --orca-security-input: #e5e5e5; + --orca-security-ring: #a1a1a1; --background: #fff; --editor-surface: #ffffff; --foreground: #0a0a0a; @@ -216,6 +233,23 @@ /* ── Dark Mode ───────────────────────────────────────── */ .dark { + --orca-security-background: #0a0a0a; + --orca-security-foreground: #fafafa; + --orca-security-card: #171717; + --orca-security-card-foreground: #fafafa; + --orca-security-popover: #171717; + --orca-security-popover-foreground: #fafafa; + --orca-security-primary: #e5e5e5; + --orca-security-primary-foreground: #171717; + --orca-security-secondary: #262626; + --orca-security-secondary-foreground: #fafafa; + --orca-security-muted: #262626; + --orca-security-muted-foreground: #a1a1a1; + --orca-security-accent: #404040; + --orca-security-accent-foreground: #fafafa; + --orca-security-border: rgb(255 255 255 / 0.07); + --orca-security-input: rgb(255 255 255 / 0.15); + --orca-security-ring: #737373; --background: #0a0a0a; --editor-surface: #1e1e1e; --foreground: #fafafa; @@ -301,6 +335,28 @@ --tab-group-split-divider-strong: #a1a1aa; } +.plugin-security-chrome { + /* Why: plugin themes may style the app, but provenance and consent must + retain host-owned contrast so a pack cannot disguise a trust decision. */ + --background: var(--orca-security-background); + --foreground: var(--orca-security-foreground); + --card: var(--orca-security-card); + --card-foreground: var(--orca-security-card-foreground); + --popover: var(--orca-security-popover); + --popover-foreground: var(--orca-security-popover-foreground); + --primary: var(--orca-security-primary); + --primary-foreground: var(--orca-security-primary-foreground); + --secondary: var(--orca-security-secondary); + --secondary-foreground: var(--orca-security-secondary-foreground); + --muted: var(--orca-security-muted); + --muted-foreground: var(--orca-security-muted-foreground); + --accent: var(--orca-security-accent); + --accent-foreground: var(--orca-security-accent-foreground); + --border: var(--orca-security-border); + --input: var(--orca-security-input); + --ring: var(--orca-security-ring); +} + .linear-priority-bars { --linear-priority-bar-inactive-fill: color-mix(in srgb, lch(39.576 1.25 282) 34%, transparent); } diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 242ce097d7b..c034994f191 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -110,6 +110,8 @@ import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-chec import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-focus-restore-target' import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions' +import { usePluginCommands } from '@/store/plugin-panels' import { getComposerEligibleRepos, resolveComposerGitRepoId @@ -342,6 +344,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const projectHostSetups = useAppStore((s) => s.projectHostSetups) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations) + const pluginCommands = usePluginCommands() // Why: keep status maps subscribed through the close animation — dropping them while CommandDialog fades out would flash rows empty mid-animation. const [statusInputsLingering, setStatusInputsLingering] = useState(false) useEffect(() => { @@ -824,7 +827,14 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { () => buildCmdJSettingsResults(settingsSections), [settingsSections] ) - const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), []) + const actionResults = useMemo( + () => + buildCmdJActionResults([ + ...getCmdJQuickActions(), + ...buildPluginQuickActions(pluginCommands) + ]), + [pluginCommands] + ) // Why: only offer project jumps the sidebar can reveal — archived-only repos are excluded from navigation. const renderableProjectRepoIds = useMemo(() => { const ids = new Set() @@ -1391,17 +1401,30 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { closeModal() setSelectedItemId('') const ctx = buildQuickActionContext() - void action.run(ctx).then((result) => { - if (result.status === 'unavailable') { - toast.error(getUnavailableQuickActionMessage(action.title, result.reason)) - return - } - if (action.id === 'create-workspace') { - recordFeatureInteraction('cmd-j-create-workspace') - return - } - recordFeatureInteraction('cmd-j-quick-action') - }) + void action + .run(ctx) + .then((result) => { + if (result.status === 'unavailable') { + toast.error(getUnavailableQuickActionMessage(action.title, result.reason)) + return + } + if (action.id === 'create-workspace') { + recordFeatureInteraction('cmd-j-create-workspace') + return + } + recordFeatureInteraction('cmd-j-quick-action') + }) + .catch((error: unknown) => { + if (!action.id.startsWith('plugin:')) { + throw error + } + toast.error( + translate( + 'auto.components.WorktreeJumpPalette.pluginCommandFailed', + 'Could not run the plugin command.' + ) + ) + }) }, [buildQuickActionContext, closeModal, recordFeatureInteraction] ) diff --git a/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts b/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts new file mode 100644 index 00000000000..0caadb75569 --- /dev/null +++ b/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ActivePluginCommand } from '@/store/plugin-panels' +import { buildPluginQuickActions } from './plugin-quick-actions' + +vi.mock('@/lib/plugin-command-execution', () => ({ executePluginCommand: vi.fn() })) + +function command(context: 'global' | 'worktree'): ActivePluginCommand { + return { + pluginKey: 'orca-samples.tasks', + pluginName: 'Tasks Pack', + id: 'open', + title: 'Open Tasks', + context, + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [] + } +} + +describe('plugin Cmd+J actions', () => { + it('adds enabled plugin commands with plugin attribution', () => { + const action = buildPluginQuickActions([command('global')])[0]! + + expect(action).toMatchObject({ + id: 'plugin:orca-samples.tasks/open', + title: 'Open Tasks', + description: 'Tasks Pack plugin command' + }) + expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({ available: true }) + }) + + it('requires an active workspace for worktree commands', () => { + const action = buildPluginQuickActions([command('worktree')])[0]! + + expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({ + available: false, + reason: 'no-active-workspace' + }) + expect(action.isAvailable({ activeWorktreeId: 'worktree-1' } as never)).toEqual({ + available: true + }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/plugin-quick-actions.ts b/src/renderer/src/components/cmd-j/plugin-quick-actions.ts new file mode 100644 index 00000000000..73b1ffbcb65 --- /dev/null +++ b/src/renderer/src/components/cmd-j/plugin-quick-actions.ts @@ -0,0 +1,34 @@ +import { Blocks } from 'lucide-react' +import type { ActivePluginCommand } from '@/store/plugin-panels' +import { executePluginCommand } from '@/lib/plugin-command-execution' +import { translate } from '@/i18n/i18n' +import type { CmdJQuickAction } from './quick-actions' + +export function buildPluginQuickActions( + commands: readonly ActivePluginCommand[] +): CmdJQuickAction[] { + return commands.map((command) => ({ + id: `plugin:${command.pluginKey}/${command.id}`, + kind: 'action', + title: command.title, + description: translate( + 'auto.components.cmd.j.pluginQuickActions.description', + '{{value0}} plugin command', + { value0: command.pluginName } + ), + icon: Blocks, + verbKeywords: [ + command.title, + command.pluginName, + translate('auto.components.cmd.j.pluginQuickActions.keyword', 'plugin command') + ], + isAvailable: (context) => + command.context === 'worktree' && !context.activeWorktreeId + ? { available: false, reason: 'no-active-workspace' } + : { available: true }, + run: async () => { + await executePluginCommand(command, 'plugin-palette') + return { status: 'ok' } + } + })) +} diff --git a/src/renderer/src/components/plugin-catalog/PluginCatalogAvatar.tsx b/src/renderer/src/components/plugin-catalog/PluginCatalogAvatar.tsx new file mode 100644 index 00000000000..a6de3b41c69 --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/PluginCatalogAvatar.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/lib/utils' +import { pluginMonogram } from './plugin-display-name' + +type PluginCatalogAvatarProps = { + name: string + className?: string +} + +/** Quiet monogram tile — same footprint as the old icon well, unique per plugin. */ +export function PluginCatalogAvatar({ + name, + className +}: PluginCatalogAvatarProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/plugin-catalog/PluginCatalogEmptyState.tsx b/src/renderer/src/components/plugin-catalog/PluginCatalogEmptyState.tsx new file mode 100644 index 00000000000..5164047eb47 --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/PluginCatalogEmptyState.tsx @@ -0,0 +1,62 @@ +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type PluginCatalogEmptyStateProps = { + icon: LucideIcon + title: string + description: string + action?: React.ReactNode + className?: string + tone?: 'default' | 'destructive' +} + +/** Centered empty / error surface used across the plugin catalog. */ +export function PluginCatalogEmptyState({ + icon: Icon, + title, + description, + action, + className, + tone = 'default' +}: PluginCatalogEmptyStateProps): React.JSX.Element { + const destructive = tone === 'destructive' + return ( +
    +
    +
    +

    + {title} +

    +

    + {description} +

    + {action ? ( +
    {action}
    + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/plugin-catalog/PluginCatalogLayout.tsx b/src/renderer/src/components/plugin-catalog/PluginCatalogLayout.tsx new file mode 100644 index 00000000000..a1789d2d12b --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/PluginCatalogLayout.tsx @@ -0,0 +1,85 @@ +import { Search } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Input } from '../ui/input' +import { Tabs, TabsList, TabsTrigger } from '../ui/tabs' + +export type PluginCatalogFilter = 'all' | 'installed' + +type PluginCatalogLayoutProps = { + filter: PluginCatalogFilter + onFilterChange: (filter: PluginCatalogFilter) => void + search: string + onSearchChange: (search: string) => void + allCount: number + installedCount: number + toolbar?: React.ReactNode + children: React.ReactNode +} + +/** Shared catalog chrome for both the in-app manager and a future hosted directory. */ +export function PluginCatalogLayout({ + filter, + onFilterChange, + search, + onSearchChange, + allCount, + installedCount, + toolbar, + children +}: PluginCatalogLayoutProps): React.JSX.Element { + return ( +
    +
    + onFilterChange(value as PluginCatalogFilter)} + > + + + {translate('auto.components.pluginCatalog.PluginCatalogLayout.all', 'All')} + + {allCount} + + + + {translate( + 'auto.components.pluginCatalog.PluginCatalogLayout.installed', + 'Installed' + )} + + {installedCount} + + + + +
    + + onSearchChange(event.target.value)} + /> +
    + {toolbar ?
    {toolbar}
    : null} +
    + + {children} +
    + ) +} diff --git a/src/renderer/src/components/plugin-catalog/plugin-display-name.ts b/src/renderer/src/components/plugin-catalog/plugin-display-name.ts new file mode 100644 index 00000000000..c2661a99234 --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/plugin-display-name.ts @@ -0,0 +1,26 @@ +/** Human title from a dotted plugin key (`example.worktree-notes` → `Worktree Notes`). */ +export function pluginDisplayNameFromKey(pluginKey: string): string { + return pluginKey + .split('.') + .at(-1)! + .split(/[-_]+/) + .map((word) => + word.toLowerCase() === 'orca' ? 'Orca' : `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}` + ) + .join(' ') +} + +/** One- or two-letter monogram for plugin avatar tiles. */ +export function pluginMonogram(name: string): string { + const parts = name + .trim() + .split(/[\s._-]+/) + .filter(Boolean) + if (parts.length === 0) { + return '?' + } + if (parts.length === 1) { + return parts[0]!.slice(0, 2).toUpperCase() + } + return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase() +} diff --git a/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx b/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx new file mode 100644 index 00000000000..1b4d0d768e4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx @@ -0,0 +1,357 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ActivePluginPanel } from '@/store/plugin-panels' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const { usePluginPanelsMock, setPanelHealthMock } = vi.hoisted(() => ({ + usePluginPanelsMock: vi.fn<() => ActivePluginPanel[]>(() => []), + setPanelHealthMock: vi.fn() +})) + +const { watchdogStartMock, watchdogStopMock, watchdogCallbacks } = vi.hoisted(() => ({ + watchdogStartMock: vi.fn(), + watchdogStopMock: vi.fn(), + watchdogCallbacks: { onUnresponsive: null as (() => void) | null } +})) + +vi.mock('@/store/plugin-panels', () => ({ + usePluginPanels: usePluginPanelsMock, + usePluginPanelsStore: ( + selector: (state: { setPanelHealth: typeof setPanelHealthMock }) => unknown + ) => selector({ setPanelHealth: setPanelHealthMock }) +})) + +vi.mock('./plugin-panel-watchdog', () => ({ + createPanelWatchdog: (options: { onUnresponsive: () => void }) => { + watchdogCallbacks.onUnresponsive = options.onUnresponsive + return { + start: watchdogStartMock, + stop: watchdogStopMock, + handlePong: vi.fn() + } + } +})) + +import PluginPanel from './PluginPanel' + +;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true + +const dashboardPanel: ActivePluginPanel = { + id: 'dashboard', + title: 'Dashboard', + icon: 'gauge', + tabKey: 'plugin:orca-samples.my-plugin/dashboard', + pluginKey: 'orca-samples.my-plugin', + pluginName: 'My Plugin' +} + +let container: HTMLDivElement +let root: Root +const readPanelEntryMock = vi.fn() +const panelActionMock = vi.fn() +const SESSION_TOKEN = 's'.repeat(43) +const REFRESHED_SESSION_TOKEN = 'r'.repeat(43) +let pluginChangedListener: (() => void) | null + +function waitForHappyDomTasks(): Promise { + return ( + window as unknown as { happyDOM: { waitUntilComplete: () => Promise } } + ).happyDOM.waitUntilComplete() +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + readPanelEntryMock.mockReset() + panelActionMock.mockReset() + panelActionMock.mockResolvedValue({ ok: true, value: { delivered: true } }) + watchdogStartMock.mockReset() + watchdogStopMock.mockReset() + watchdogCallbacks.onUnresponsive = null + setPanelHealthMock.mockReset() + document.documentElement.classList.remove('dark') + pluginChangedListener = null + usePluginPanelsMock.mockReturnValue([dashboardPanel]) + globalThis.window.api = { + plugins: { + readPanelEntry: readPanelEntryMock, + panelAction: panelActionMock, + onChanged: (listener: () => void) => { + pluginChangedListener = listener + return vi.fn() + } + } + } as unknown as Window['api'] +}) + +afterEach(async () => { + await act(async () => { + root.unmount() + }) + container.remove() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +async function renderPanel(tabKey: string): Promise { + await act(async () => { + root.render() + }) +} + +describe('PluginPanel', () => { + it('renders the panel HTML in a scripts-only sandboxed iframe', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + const initialIframe = container.querySelector('iframe') + expect(initialIframe).not.toBeNull() + expect(readPanelEntryMock).toHaveBeenCalledWith({ + pluginKey: 'orca-samples.my-plugin', + panelId: 'dashboard' + }) + expect(initialIframe?.getAttribute('srcdoc')).toContain('

    Hello plugin

    ') + expect(initialIframe?.getAttribute('title')).toBe('Dashboard') + expect(initialIframe?.getAttribute('name')).toBe( + 'orca-plugin-panel:plugin:orca-samples.my-plugin/dashboard' + ) + // Why: allow-same-origin would let plugin HTML reach the app DOM/storage; + // the sandbox must stay scripts-only. + expect(initialIframe?.getAttribute('sandbox')).toBe('allow-scripts') + }) + + it('restarts the watchdog after a dev reload replaces the panel document', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + const iframe = container.querySelector('iframe') + expect(iframe).not.toBeNull() + expect(watchdogStartMock).toHaveBeenCalledTimes(1) + + readPanelEntryMock.mockResolvedValue({ + html: '

    Reloaded plugin

    ', + sessionToken: SESSION_TOKEN + }) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + + const reloadedIframe = container.querySelector('iframe') + expect(reloadedIframe).not.toBe(iframe) + expect(reloadedIframe?.getAttribute('srcdoc')).toContain('Reloaded plugin') + expect(watchdogStopMock).toHaveBeenCalledTimes(1) + expect(watchdogStartMock).toHaveBeenCalledTimes(2) + }) + + it('remounts with fresh host theme tokens when the app theme changes', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + const lightFrame = container.querySelector('iframe') + expect(lightFrame?.getAttribute('srcdoc')).toContain('') + + await act(async () => { + document.documentElement.classList.add('dark') + await waitForHappyDomTasks() + }) + + const darkFrame = container.querySelector('iframe') + expect(darkFrame).not.toBe(lightFrame) + expect(darkFrame?.getAttribute('srcdoc')).toContain('') + }) + + it('rebinds a refreshed session without remounting unchanged panel HTML', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + const iframe = container.querySelector('iframe') + expect(iframe).not.toBeNull() + expect(watchdogStartMock).toHaveBeenCalledTimes(1) + + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: REFRESHED_SESSION_TOKEN + }) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + + expect(container.querySelector('iframe')).toBe(iframe) + expect(watchdogStopMock).not.toHaveBeenCalled() + expect(watchdogStartMock).toHaveBeenCalledTimes(1) + + const event = new MessageEvent('message', { + data: { + type: 'orca-panel-action', + requestId: 'request-one', + action: 'notifications.show', + params: { title: 'Hello' } + } + }) + Object.defineProperty(event, 'source', { value: iframe?.contentWindow }) + await act(async () => { + window.dispatchEvent(event) + await waitForHappyDomTasks() + }) + expect(panelActionMock).toHaveBeenCalledWith({ + sessionToken: REFRESHED_SESSION_TOKEN, + action: 'notifications.show', + params: { title: 'Hello' } + }) + }) + + it('ignores an obsolete panel reload that finishes after a newer one', async () => { + readPanelEntryMock.mockResolvedValueOnce({ + html: '

    Initial plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + let resolveObsolete!: (entry: null) => void + let resolveCurrent!: (entry: { html: string; sessionToken: string }) => void + readPanelEntryMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveObsolete = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCurrent = resolve + }) + ) + + await act(async () => { + pluginChangedListener?.() + pluginChangedListener?.() + resolveCurrent({ + html: '

    Current plugin

    ', + sessionToken: REFRESHED_SESSION_TOKEN + }) + await waitForHappyDomTasks() + }) + await act(async () => { + resolveObsolete(null) + await waitForHappyDomTasks() + }) + + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('Current plugin') + expect(container.textContent).not.toContain('could not be loaded') + }) + + it('shows an error state when the panel entry cannot be read', async () => { + readPanelEntryMock.mockResolvedValue(null) + + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + expect(container.querySelector('iframe')).toBeNull() + expect(container.textContent).toContain('The plugin panel could not be loaded.') + }) + + it('recovers from a transient read failure with byte-identical HTML', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + const initialFrame = container.querySelector('iframe') + + readPanelEntryMock.mockResolvedValueOnce(null) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + expect(container.textContent).toContain('could not be loaded') + + readPanelEntryMock.mockResolvedValueOnce({ + html: '

    Hello plugin

    ', + sessionToken: REFRESHED_SESSION_TOKEN + }) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + + expect(container.querySelector('iframe')).not.toBe(initialFrame) + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('Hello plugin') + expect(setPanelHealthMock).toHaveBeenLastCalledWith( + 'plugin:orca-samples.my-plugin/dashboard', + 'healthy' + ) + }) + + it('publishes watchdog suspension to host-owned panel health state', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + await act(async () => watchdogCallbacks.onUnresponsive?.()) + + expect(setPanelHealthMock).toHaveBeenCalledWith( + 'plugin:orca-samples.my-plugin/dashboard', + 'error' + ) + expect(container.textContent).toContain('stopped responding and was suspended') + }) + + it('keeps a watchdog error published when navigation unmounts the failed panel', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + setPanelHealthMock.mockClear() + await act(async () => watchdogCallbacks.onUnresponsive?.()) + + await act(async () => root.render(
    Explorer
    )) + + expect(setPanelHealthMock).toHaveBeenCalledTimes(1) + expect(setPanelHealthMock).toHaveBeenCalledWith( + 'plugin:orca-samples.my-plugin/dashboard', + 'error' + ) + }) + + it('shows an unavailable state for a tab whose plugin is gone', async () => { + usePluginPanelsMock.mockReturnValue([]) + + await renderPanel('plugin:orca-samples.removed-plugin/dashboard') + + expect(readPanelEntryMock).not.toHaveBeenCalled() + expect(container.textContent).toContain('This plugin panel is no longer available.') + }) + + it('treats a malformed plugin tab key as unavailable', async () => { + await renderPanel('plugin:not-a-valid-key') + + expect(readPanelEntryMock).not.toHaveBeenCalled() + expect(container.textContent).toContain('This plugin panel is no longer available.') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/PluginPanel.tsx b/src/renderer/src/components/right-sidebar/PluginPanel.tsx new file mode 100644 index 00000000000..b8c8ee87dba --- /dev/null +++ b/src/renderer/src/components/right-sidebar/PluginPanel.tsx @@ -0,0 +1,236 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' +import { + PANEL_PING_TYPE, + PLUGIN_PANEL_FRAME_NAME_PREFIX +} from '../../../../shared/plugins/plugin-panel-bridge' +import { + PANEL_SHELL_COLOR_SCHEME_PLACEHOLDER, + PANEL_SHELL_TOKENS_PLACEHOLDER +} from '../../../../shared/plugins/plugin-panel-shell' +import { + callPanelActionViaPreload, + createPanelBridgeMessageHandler +} from './plugin-panel-bridge-host' +import { createPanelWatchdog } from './plugin-panel-watchdog' +import { buildPanelDesignTokenCss, currentPanelColorScheme } from './plugin-panel-design-token-css' +import { usePluginPanelThemeRevision } from './use-plugin-panel-theme-revision' +import { usePluginPanels, usePluginPanelsStore } from '@/store/plugin-panels' +import { translate } from '@/i18n/i18n' + +type PluginPanelProps = { + tabKey: string +} + +type PluginPanelEntryState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'unresponsive' } + | { status: 'ready'; shellHtml: string; documentRevision: number } + +function PluginPanelMessage({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +
    + {children} +
    + ) +} + +/** Fills the shell placeholders main cannot know (theme class + token + * values). First-occurrence replace: the prelude parses before any plugin + * content, so a plugin echoing the placeholder string is inert. */ +function fillPanelShell(html: string): string { + return html + .replace(PANEL_SHELL_COLOR_SCHEME_PLACEHOLDER, currentPanelColorScheme()) + .replace(PANEL_SHELL_TOKENS_PLACEHOLDER, buildPanelDesignTokenCss()) +} + +function PluginPanel({ tabKey }: PluginPanelProps): React.JSX.Element { + const panels = usePluginPanels() + const setPanelHealth = usePluginPanelsStore((state) => state.setPanelHealth) + const panel = isPluginPanelTabKey(tabKey) + ? (panels.find((entry) => entry.tabKey === tabKey) ?? null) + : null + const [entryState, setEntryState] = useState({ status: 'loading' }) + const [sessionToken, setSessionToken] = useState(null) + const [loadedFrameKey, setLoadedFrameKey] = useState(null) + const iframeRef = useRef(null) + const themeRevision = usePluginPanelThemeRevision() + + const pluginKey = panel?.pluginKey ?? null + const panelId = panel?.id ?? null + const panelShell = entryState.status === 'ready' ? entryState.shellHtml : null + const panelDocument = panelShell ? fillPanelShell(panelShell) : null + // Why: the shell bakes Orca's color scheme + design tokens into srcdoc, so the + // frame must be rebuilt when the app theme changes, not only when the document does. + const panelFrameKey = + entryState.status === 'ready' + ? `${tabKey}:${entryState.documentRevision}:${themeRevision}` + : null + const watchdog = useMemo( + () => + createPanelWatchdog({ + sendPing: (pingId) => + iframeRef.current?.contentWindow?.postMessage({ type: PANEL_PING_TYPE, pingId }, '*'), + onUnresponsive: () => { + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'unresponsive' }) + } + }), + [setPanelHealth, tabKey] + ) + + useEffect(() => { + if (!sessionToken || !panelDocument) { + return + } + let active = true + const handler = createPanelBridgeMessageHandler({ + sessionToken, + getPanelWindow: () => iframeRef.current?.contentWindow ?? null, + callPanelAction: callPanelActionViaPreload, + isActive: () => active, + onPong: (pingId) => watchdog.handlePong(pingId) + }) + window.addEventListener('message', handler) + return () => { + active = false + window.removeEventListener('message', handler) + } + }, [panelDocument, sessionToken, watchdog]) + + useEffect(() => { + if (!panelFrameKey || loadedFrameKey !== panelFrameKey) { + return + } + // The srcdoc prelude must install its pong listener before the first ping; + // otherwise a healthy panel can lose the startup ping and be suspended. + watchdog.start() + return () => watchdog.stop() + }, [loadedFrameKey, panelFrameKey, watchdog]) + + useEffect(() => { + if (!pluginKey || !panelId) { + return + } + let cancelled = false + let currentHtml: string | null = null + let documentRevision = 0 + setEntryState({ status: 'loading' }) + setSessionToken(null) + const pluginsApi = window.api?.plugins + if (!pluginsApi) { + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'error' }) + return + } + let loadGeneration = 0 + const load = (): void => { + const generation = ++loadGeneration + pluginsApi + .readPanelEntry({ pluginKey, panelId }) + .then((entry) => { + if (cancelled || generation !== loadGeneration) { + return + } + if (!entry) { + currentHtml = null + setSessionToken(null) + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'error' }) + return + } + // Session rotation rebinds authority without replacing an unchanged + // document or restarting its watchdog. + setSessionToken(entry.sessionToken) + setPanelHealth(tabKey, 'healthy') + if (entry.html !== currentHtml) { + currentHtml = entry.html + documentRevision += 1 + setPanelHealth(tabKey, 'healthy') + setEntryState({ + status: 'ready', + shellHtml: entry.html, + documentRevision + }) + } + }) + .catch(() => { + if (!cancelled && generation === loadGeneration) { + currentHtml = null + setSessionToken(null) + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'error' }) + } + }) + } + load() + const unsubscribe = pluginsApi.onChanged ? pluginsApi.onChanged(load) : null + return () => { + cancelled = true + loadGeneration += 1 + unsubscribe?.() + } + }, [panelId, pluginKey, setPanelHealth, tabKey]) + + // Persisted plugin tabs can outlive their plugin (uninstalled/disabled); + // render a graceful empty state instead of a broken frame. + if (!panel) { + return ( + + {translate( + 'auto.components.right.sidebar.PluginPanel.unavailable', + 'This plugin panel is no longer available.' + )} + + ) + } + + if (entryState.status === 'loading') { + return ( + + {translate('auto.components.right.sidebar.PluginPanel.loading', 'Loading plugin panel...')} + + ) + } + + if (entryState.status === 'unresponsive') { + return ( + + {translate( + 'auto.components.right.sidebar.PluginPanel.unresponsive', + 'This plugin panel stopped responding and was suspended.' + )} + + ) + } + + if (entryState.status === 'error') { + return ( + + {translate( + 'auto.components.right.sidebar.PluginPanel.loadFailed', + 'The plugin panel could not be loaded.' + )} + + ) + } + + return ( +