mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Stop hidden terminal panes from exhausting renderer memory (hidden-view parking, byte mode, default off)
Extracts the self-contained "hidden-view parking" slice from PR #7214 (orca-performance) as a standalone fix for the Windows renderer OOM/hang under multi-agent load (#7175 / STA-476): long-hidden terminal panes unmount after a 30s hysteresis while a pane-less byte watcher keeps bell/title/agent-completion side effects alive; reveal rides the existing tab-move reattach path. Byte mode only: no main-process side-effect emitter ships here, so constants.ts pins terminalMainSideEffectAuthority / terminalHiddenDeliveryGate / terminalModelQueryAuthority to false (the single intentional divergence from #7214, reconciled by taking #7214's values when its emitter lands). The parking kill switch terminalHiddenViewParking also ships default-OFF (opt-in) until the memory win is confirmed in a soak. Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
co-authored by
Jinwoo-H
Orca
parent
ac8d365296
commit
ff5833dd4f
@@ -0,0 +1,118 @@
|
||||
# Terminal Hidden View Parking
|
||||
|
||||
Status: Shipped standalone (byte mode only, extracted from PR #7214), kill
|
||||
switch `terminalHiddenViewParking` **default off** — opt-in until the memory
|
||||
win is confirmed in a soak, then a follow-up flips it on. The companion doc
|
||||
`terminal-model-view-contract.md` (invariants this design extends and the full
|
||||
phase list) ships with PR #7214 together with the fact-mode main emitter; in
|
||||
this build `terminalMainSideEffectAuthority` must stay `false` (see
|
||||
`src/shared/constants.ts`).
|
||||
|
||||
## Problem
|
||||
|
||||
Hidden terminal panes keep a full renderer xterm instance alive (buffer,
|
||||
scrollback, DOM, addons). At many-worktree scale this is the dominant renderer
|
||||
memory cost, and it forces every hidden byte through renderer-side write/skip
|
||||
decisions. The main-process model (daemon + runtime headless emulators) already
|
||||
ingests every byte and can serve restorable snapshots, so the renderer view for
|
||||
a long-hidden pane is redundant state.
|
||||
|
||||
A previous attempt shipped and was reverted the same day. The post-mortem
|
||||
finding: parking unmounted the pane component, which also tore down the
|
||||
renderer's PTY byte parsers — and those parsers are the only source of bell
|
||||
notifications, title-transition agent-complete notifications, and tab titles.
|
||||
A parked worktree whose agent finished would never notify. This design keeps
|
||||
those side effects alive while parked.
|
||||
|
||||
## Design
|
||||
|
||||
### Park policy (renderer)
|
||||
|
||||
A pure policy module decides which hidden terminal tabs may park:
|
||||
|
||||
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
|
||||
- Hot-retain working set: recently visible worktrees/tabs are retained
|
||||
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
|
||||
- Eligibility excludes: visible panes, hidden-measuring startup probes,
|
||||
activity-portal panes, tabs with pending startup commands or pending
|
||||
activation spawns, floating-panel tabs, and any tab whose PTY is not
|
||||
snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded).
|
||||
- Kill switch: `settings.terminalHiddenViewParking === false` disables parking
|
||||
entirely.
|
||||
|
||||
### Park mechanics
|
||||
|
||||
Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer
|
||||
renders null for parked tabs). This is the same teardown that tab-group moves
|
||||
already exercise: transports detach but the PTY session, daemon model, and tab
|
||||
state all survive. The xterm instance, its buffers, DOM, and WebGL/addon
|
||||
resources are released.
|
||||
|
||||
### Parked watcher (the piece the reverted attempt lacked)
|
||||
|
||||
While a tab is parked, a pane-less watcher
|
||||
(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its
|
||||
consumption mode is decided once at watcher start:
|
||||
|
||||
- **Main side-effect authority on (default):** the watcher is purely
|
||||
fact-driven — it registers exactly one `pty:sideEffect` fact consumer and
|
||||
parses no bytes. Titles, agent working/idle/exited transitions, BEL
|
||||
attention, and PR links arrive as main-tracker facts and drive the same
|
||||
policy callbacks a mounted pane uses. With the hidden-delivery gate also on,
|
||||
the watcher marks the PTY hidden so main stops renderer byte delivery
|
||||
entirely; the DECSET 2031 color-scheme subscribe arrives as main's
|
||||
`2031-subscribe` fact and the watcher replies out-of-band via
|
||||
`transport.sendInput`.
|
||||
- **Kill switch off:** the watcher subscribes to raw bytes through the
|
||||
dispatcher sidecar mechanism (the same mechanism background agent launches
|
||||
use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2
|
||||
titles (all-titles ordering, live-path normalization), the title-transition
|
||||
agent tracker (completion notification, prompt-cache timer), the OSC-aware
|
||||
stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031
|
||||
byte responder (`parked-terminal-mode2031-responder.ts`, whose
|
||||
`subscribeToPtyData` registration doubles as the delivery-interest signal).
|
||||
|
||||
The two modes drive one shared policy-callback block, so flipping the kill
|
||||
switch never changes notification semantics. Main's synthetic
|
||||
agent-title/permission frames feed the main tracker directly and arrive as
|
||||
facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off
|
||||
mode.
|
||||
|
||||
Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside
|
||||
hidden-dropped chunks are answered by main's model responder
|
||||
(`terminal-query-authority.md`, ships with PR #7214); in kill-switch-off byte
|
||||
mode only the 2031 reply is answered and Command Code output is not scraped,
|
||||
matching the pre-gate status quo.
|
||||
|
||||
### Reveal
|
||||
|
||||
Revealing a parked tab remounts the pane subtree and rides the existing
|
||||
reattach path: fresh xterm via `openTerminal` (unicode provider activation
|
||||
before any write), daemon model snapshot > relay replay > cold restore
|
||||
precedence, replay-guarded so snapshot-embedded queries never answer, then
|
||||
`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is
|
||||
disposed before the pane handlers re-register.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. PTY reads never stop; parking only changes renderer-side view lifetime.
|
||||
2. Bell, agent-completion, title, and PR-link side effects keep working while
|
||||
parked (watcher parity tests).
|
||||
3. Reveal shows model-correct output (visual gates: hidden TUI restore, long
|
||||
table, rendering golden) and accepts input immediately.
|
||||
4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers
|
||||
or strand parked state.
|
||||
5. Memory: parked tabs hold no xterm buffers; renderer memory scales with
|
||||
visible panes.
|
||||
|
||||
## Relation to later phases (all shipped)
|
||||
|
||||
Side-effect authority in main (Phase 3) replaced the watcher's byte parsing
|
||||
with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4)
|
||||
stops hidden byte delivery in main, moving the parked 2031 reply from the
|
||||
byte sidecar to the `2031-subscribe` fact; the model query responder
|
||||
(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser
|
||||
mode survives only behind the kill switches. Parking still excludes
|
||||
remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher
|
||||
would return as a byte parser only if remote-runtime tabs — whose bytes never
|
||||
transit local main — ever became parkable.
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
applyElectronProxySettingsMock,
|
||||
browserWindowGetAllWindowsMock,
|
||||
handleMock,
|
||||
onMock,
|
||||
previewGhosttyImportMock,
|
||||
previewWarpThemeImportMock,
|
||||
prepareLocalWorktreeRootsForReposMock,
|
||||
@@ -14,6 +15,7 @@ const {
|
||||
applyElectronProxySettingsMock: vi.fn(),
|
||||
browserWindowGetAllWindowsMock: vi.fn(),
|
||||
handleMock: vi.fn(),
|
||||
onMock: vi.fn(),
|
||||
previewGhosttyImportMock: vi.fn(),
|
||||
previewWarpThemeImportMock: vi.fn(),
|
||||
prepareLocalWorktreeRootsForReposMock: vi.fn(),
|
||||
@@ -22,7 +24,7 @@ const {
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock },
|
||||
ipcMain: { handle: handleMock },
|
||||
ipcMain: { handle: handleMock, on: onMock },
|
||||
nativeTheme: { themeSource: 'system' }
|
||||
}))
|
||||
|
||||
@@ -70,6 +72,7 @@ const store = {
|
||||
describe('registerSettingsHandlers', () => {
|
||||
beforeEach(() => {
|
||||
handleMock.mockClear()
|
||||
onMock.mockClear()
|
||||
applyAppIconMock.mockClear()
|
||||
applyElectronProxySettingsMock.mockClear()
|
||||
applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' })
|
||||
@@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => {
|
||||
expect(channels).toContain('settings:previewGhosttyImport')
|
||||
})
|
||||
|
||||
it('answers the synchronous settings read with the persisted settings', () => {
|
||||
// Why: panes can bind PTYs before async hydration; the side-effect
|
||||
// authority kill switch needs the persisted value synchronously.
|
||||
store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false })
|
||||
registerSettingsHandlers(store as never)
|
||||
|
||||
const listener = onMock.mock.calls.find(
|
||||
(call) => call[0] === 'settings:get-sync'
|
||||
)?.[1] as (event: { returnValue: unknown }) => void
|
||||
expect(listener).toBeTypeOf('function')
|
||||
|
||||
const event = { returnValue: undefined as unknown }
|
||||
listener(event)
|
||||
expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false })
|
||||
})
|
||||
|
||||
it('registers settings:previewWarpThemeImport handler', () => {
|
||||
registerSettingsHandlers(store as never)
|
||||
const channels = handleMock.mock.calls.map((call) => call[0])
|
||||
|
||||
@@ -66,6 +66,15 @@ export function registerSettingsHandlers(
|
||||
return store.getSettings()
|
||||
})
|
||||
|
||||
// Why: terminal panes can bind PTYs before async settings hydration
|
||||
// completes. The side-effect authority kill switch is consulted once at
|
||||
// transport creation, so the renderer needs the persisted value
|
||||
// synchronously or pre-hydration bindings would always pick main authority
|
||||
// (terminal-side-effect-authority.md, migration switch).
|
||||
ipcMain.on('settings:get-sync', (event) => {
|
||||
event.returnValue = store.getSettings()
|
||||
})
|
||||
|
||||
ipcMain.handle('settings:set', async (event, args: Partial<GlobalSettings>) => {
|
||||
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
|
||||
// Why: Floating Workspace grants are trusted only when written by the
|
||||
|
||||
@@ -262,6 +262,7 @@ import type {
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeMobileSessionTabMove,
|
||||
@@ -1155,6 +1156,11 @@ export type PreloadApi = {
|
||||
ackData: (id: string, charCount: number) => void
|
||||
setActiveRendererPty: (id: string, active: boolean) => void
|
||||
setRendererPtyVisible: (id: string, visible: boolean) => void
|
||||
/** Hidden-delivery gate (PR #7214): absent in this build — no main-process
|
||||
* gate exists yet. Declared optional so gate-aware renderer code
|
||||
* (parked-terminal byte watcher) typechecks; it only fires when the
|
||||
* terminalHiddenDeliveryGate setting is on, which requires the emitter. */
|
||||
setHiddenRendererPty?: (id: string, hidden: boolean) => void
|
||||
hasChildProcesses: (id: string) => Promise<boolean>
|
||||
getForegroundProcess: (id: string) => Promise<string | null>
|
||||
getCwd: (id: string) => Promise<string>
|
||||
@@ -1199,6 +1205,14 @@ export type PreloadApi = {
|
||||
}) => void
|
||||
) => () => void
|
||||
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
|
||||
/** Batched derived side-effect facts channel (PR #7214): absent in this
|
||||
* build — main has no side-effect emitter yet. Declared optional so the
|
||||
* renderer facts-handler typechecks and degrades to "no channel";
|
||||
* terminalMainSideEffectAuthority defaults false until the emitter lands. */
|
||||
onSideEffect?: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
|
||||
/** Title-only replay snapshot companion to onSideEffect (PR #7214):
|
||||
* absent in this build for the same reason. */
|
||||
getSideEffectSnapshot?: (id: string) => Promise<TerminalSideEffectBatch | null>
|
||||
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
|
||||
onSerializeBufferRequest: (
|
||||
callback: (data: {
|
||||
@@ -1894,6 +1908,10 @@ export type PreloadApi = {
|
||||
telemetryAcknowledgeBanner: () => Promise<void>
|
||||
settings: {
|
||||
get: () => Promise<GlobalSettings>
|
||||
/** Synchronous persisted-settings read for startup decisions that cannot
|
||||
* wait for async hydration (terminal side-effect authority). Blocking
|
||||
* IPC — call sparingly. */
|
||||
getSync: () => GlobalSettings | null
|
||||
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
|
||||
listFonts: () => Promise<string[]>
|
||||
previewGhosttyImport: () => Promise<GhosttyImportPreview>
|
||||
|
||||
@@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V
|
||||
export const preloadE2EConfig = createE2EConfig({
|
||||
headless: process.env.ORCA_E2E_HEADLESS === '1',
|
||||
exposeStore,
|
||||
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null
|
||||
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null,
|
||||
// Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so
|
||||
// only a real positive override reaches the renderer parking policy.
|
||||
terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null
|
||||
})
|
||||
|
||||
@@ -1688,6 +1688,10 @@ const api = {
|
||||
settings: {
|
||||
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
|
||||
|
||||
// Why: blocking read for the few startup decisions (terminal side-effect
|
||||
// authority) that cannot wait for async hydration. Call sparingly.
|
||||
getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'),
|
||||
|
||||
set: (args: Record<string, unknown>): Promise<unknown> =>
|
||||
ipcRenderer.invoke('settings:set', args),
|
||||
|
||||
|
||||
@@ -66,6 +66,18 @@ import {
|
||||
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
|
||||
import {
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalWorktrees,
|
||||
type TerminalWorktreeColdParkCandidate
|
||||
} from './terminal-pane/terminal-hidden-view-parking'
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
pruneParkedTerminalWatchers,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-pane/terminal-parked-tab-watchers'
|
||||
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
|
||||
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
@@ -125,6 +137,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
|
||||
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function haveSameWorktreeIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of left) {
|
||||
if (!right.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function findUnifiedTabByVisibleId(
|
||||
state: TerminalStoreSnapshot,
|
||||
worktreeId: string,
|
||||
@@ -207,6 +231,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
|
||||
function Terminal(): React.JSX.Element | null {
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
|
||||
const terminalWorktreeHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
|
||||
const workspaceSurfaces = useMemo(
|
||||
@@ -223,6 +249,8 @@ function Terminal(): React.JSX.Element | null {
|
||||
const renderedActiveWorktreeId = activeWorktreeId
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
const closeTab = useAppStore((s) => s.closeTab)
|
||||
@@ -710,7 +738,11 @@ function Terminal(): React.JSX.Element | null {
|
||||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
// spawning when restoring a session with many saved worktree tabs.
|
||||
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
|
||||
const [, setBackgroundMountRevision] = useState(0)
|
||||
const [backgroundMountRevision, setBackgroundMountRevision] = useState(0)
|
||||
const [terminalParkingRevision, setTerminalParkingRevision] = useState(0)
|
||||
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
useEffect(() => {
|
||||
const timers = measurableBackgroundWorktreeTimersRef.current
|
||||
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
|
||||
@@ -748,6 +780,122 @@ function Terminal(): React.JSX.Element | null {
|
||||
closeDialogDebounceTimers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalWorktreeParkingTimersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set
|
||||
// selection, and one recheck timer per still-pending deadline so React
|
||||
// re-renders exactly when the hysteresis elapses instead of polling.
|
||||
useEffect(() => {
|
||||
const parkingTimers = terminalWorktreeParkingTimersRef.current
|
||||
for (const timer of parkingTimers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
parkingTimers.clear()
|
||||
|
||||
const nowMs = Date.now()
|
||||
const overrides = getTerminalParkingPolicyOverrides()
|
||||
const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId))
|
||||
const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
|
||||
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
|
||||
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
const retentionCandidates: TerminalWorktreeColdParkCandidate[] = []
|
||||
for (const workspace of workspaceSurfaces) {
|
||||
const worktreeId = workspace.id
|
||||
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
continue
|
||||
}
|
||||
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
|
||||
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
|
||||
if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
} else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
|
||||
}
|
||||
|
||||
retentionCandidates.push({
|
||||
worktreeId,
|
||||
terminalTabs: tabsByWorktree[worktreeId] ?? [],
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
hasActivityTerminalPortal,
|
||||
hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
|
||||
worktrees: retentionCandidates,
|
||||
pendingStartupByTabId,
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
// Why: a worktree with any tab the byte watchers cannot cover (no
|
||||
// capture, no layout snapshot, legacy leaf ids) must never park — it
|
||||
// would go silent for bells/titles/completions, the failure that sank
|
||||
// the first parking attempt.
|
||||
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
|
||||
const tabs = tabsByWorktree[worktreeId] ?? []
|
||||
if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) {
|
||||
nextParkedTerminalWorktreeIds.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
setParkedTerminalWorktreeIds((current) =>
|
||||
haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds)
|
||||
? current
|
||||
: nextParkedTerminalWorktreeIds
|
||||
)
|
||||
|
||||
for (const candidate of retentionCandidates) {
|
||||
if (
|
||||
candidate.isVisible ||
|
||||
candidate.shouldMeasureHiddenWorktree ||
|
||||
candidate.hasActivityTerminalPortal ||
|
||||
nextParkedTerminalWorktreeIds.has(candidate.worktreeId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
hiddenSinceMs: candidate.hiddenSinceMs,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
if (delayMs !== null && delayMs > 0) {
|
||||
const worktreeId = candidate.worktreeId
|
||||
const timer = window.setTimeout(() => {
|
||||
parkingTimers.delete(worktreeId)
|
||||
setTerminalParkingRevision((revision) => revision + 1)
|
||||
}, delayMs)
|
||||
parkingTimers.set(worktreeId, timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
backgroundMountRevision,
|
||||
pendingStartupByTabId,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalParkingRevision,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
|
||||
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
|
||||
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
|
||||
@@ -770,6 +918,59 @@ function Terminal(): React.JSX.Element | null {
|
||||
groupsByWorktree,
|
||||
activeGroupIdByWorktree
|
||||
)
|
||||
// Why: parked byte-watcher reconciliation for the legacy (non-split)
|
||||
// terminal host, which renders TerminalPanes directly. In split mode each
|
||||
// TerminalPaneOverlayLayer owns its worktree's watchers, so here we only
|
||||
// dispose worktrees that render no overlay layer (no layout / unmounted)
|
||||
// and prune watchers for deleted worktrees.
|
||||
useEffect(() => {
|
||||
pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id)))
|
||||
for (const workspace of workspaceSurfaces) {
|
||||
if (
|
||||
anyMountedWorktreeHasLayout &&
|
||||
mountedWorktreeIdsRef.current.has(workspace.id) &&
|
||||
getEffectiveLayoutForWorktree(workspace.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const tabs = tabsByWorktree[workspace.id] ?? []
|
||||
const parkedTabIds = new Set<string>()
|
||||
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) {
|
||||
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const parked =
|
||||
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
|
||||
if (parked) {
|
||||
for (const tab of tabs) {
|
||||
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
|
||||
worktreeId: workspace.id,
|
||||
tabId: tab.id
|
||||
})
|
||||
if (!activityTerminalPortal) {
|
||||
parkedTabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds })
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
anyMountedWorktreeHasLayout,
|
||||
backgroundMountRevision,
|
||||
getEffectiveLayoutForWorktree,
|
||||
parkedTerminalWorktreeIds,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: symmetric with useTerminalTabColdParking's unmount cleanup — when
|
||||
// the terminal host unmounts, no reconciliation effect will run again, so
|
||||
// dispose every remaining parked watcher here (overlay-layer children have
|
||||
// already disposed theirs by the time this parent cleanup runs).
|
||||
useEffect(() => () => pruneParkedTerminalWatchers(new Set()), [])
|
||||
// Auto-create first tab when worktree activates
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
@@ -1068,6 +1269,12 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
handleCloseTab(tabId)
|
||||
},
|
||||
[consumeSuppressedPtyExit, handleCloseTab]
|
||||
@@ -1800,6 +2007,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${workspace.id}`}
|
||||
@@ -1809,6 +2020,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
|
||||
isVisible={isVisible}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
)
|
||||
@@ -1860,6 +2072,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
@@ -1881,6 +2097,12 @@ function Terminal(): React.JSX.Element | null {
|
||||
const isActivityPortalTab = activityTerminalPortal !== null
|
||||
const isActiveTerminalTab =
|
||||
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
|
||||
// Why: parking is exactly the unmount path tab-group
|
||||
// moves use — transports detach, the PTY survives, and
|
||||
// the parked byte watcher owns side effects until reveal.
|
||||
if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
|
||||
return null
|
||||
}
|
||||
const terminalPane = (
|
||||
<TerminalPane
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
@@ -2087,6 +2309,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
focusedGroupId,
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
shouldColdParkTerminalPanes,
|
||||
activityTerminalPortals
|
||||
}: {
|
||||
worktreeId: string
|
||||
@@ -2095,6 +2318,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
focusedGroupId?: string
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
shouldColdParkTerminalPanes: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
}): React.JSX.Element {
|
||||
const browserPageIds = useAppStore(
|
||||
@@ -2134,6 +2358,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
worktreeId={worktreeId}
|
||||
worktreePath={worktreePath}
|
||||
isWorktreeActive={isVisible}
|
||||
coldParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
/>
|
||||
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import TerminalPane from './TerminalPane'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
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
|
||||
@@ -237,6 +239,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTab(terminalTabId)
|
||||
leaveWorktreeIfEmpty()
|
||||
}}
|
||||
@@ -278,11 +286,15 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes = false,
|
||||
shouldMeasureHiddenWorktree = false,
|
||||
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS
|
||||
}: {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
isWorktreeActive: boolean
|
||||
coldParkTerminalPanes?: boolean
|
||||
shouldMeasureHiddenWorktree?: boolean
|
||||
activityTerminalPortals?: ActivityTerminalPortalTarget[]
|
||||
}): React.JSX.Element | null {
|
||||
const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore(
|
||||
@@ -346,6 +358,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
return entries
|
||||
}, [groupActiveTabById, unifiedTabs])
|
||||
|
||||
const parkedTerminalTabIds = useTerminalTabColdParking({
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes,
|
||||
shouldMeasureHiddenWorktree,
|
||||
activityTerminalPortals
|
||||
})
|
||||
|
||||
if (!worktreePath) {
|
||||
return null
|
||||
}
|
||||
@@ -360,6 +382,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
worktreeId,
|
||||
tabId: terminalTab.id
|
||||
})
|
||||
// Why: parking is exactly the unmount path tab-group moves use —
|
||||
// transports detach, the PTY and tab model survive, and the parked
|
||||
// byte watcher takes over side effects until reveal remounts here.
|
||||
if (parkedTerminalTabIds.has(terminalTab.id)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<TerminalOverlaySlot
|
||||
key={terminalTab.id}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Agent-task-complete notification policy predicates and timing constants.
|
||||
*
|
||||
* Why extracted from pty-connection.ts: the parked byte watcher and the
|
||||
* pty:sideEffect facts handler apply the exact live-path semantics without a
|
||||
* pane, and policy must not drift between the three consumers
|
||||
* (docs/reference/terminal-side-effect-authority.md). This module is
|
||||
* deliberately dependency-light — no pane/xterm imports — so pane-less
|
||||
* consumers can use it.
|
||||
*/
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
/** Delay before BEL/completion OS notifications so the richer
|
||||
* agent-task-complete notification can win a same-burst BEL race. */
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
|
||||
/** Hard cap on waiting for hook detail before dispatching a completion. */
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
|
||||
|
||||
type NotificationSettingsState = {
|
||||
settings: Pick<GlobalSettings, 'notifications' | 'experimentalTerminalAttention'> | null
|
||||
}
|
||||
|
||||
export function isAgentTaskCompleteOsNotificationEnabledFromState(
|
||||
state: NotificationSettingsState
|
||||
): boolean {
|
||||
const notifications = state.settings?.notifications
|
||||
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
|
||||
}
|
||||
|
||||
export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean {
|
||||
return state.settings?.experimentalTerminalAttention === true
|
||||
}
|
||||
|
||||
/** Completion tracking runs when either consumer (OS notification or the
|
||||
* experimental terminal-attention marker) is enabled. */
|
||||
export function isAgentTaskCompleteTrackingEnabledFromState(
|
||||
state: NotificationSettingsState
|
||||
): boolean {
|
||||
return (
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState(state) ||
|
||||
isTerminalAttentionEnabledFromState(state)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
|
||||
return Boolean(
|
||||
entry &&
|
||||
Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS &&
|
||||
(entry.lastAssistantMessage || entry.toolName || entry.toolInput)
|
||||
)
|
||||
}
|
||||
|
||||
export function canDispatchAgentNotificationAfterGrace(
|
||||
entry: AgentStatusEntry | undefined,
|
||||
options: { allowDoneDetailAfterGrace?: boolean } = {}
|
||||
): boolean {
|
||||
// Why: hook-backed goal/mission loops can report `done` between milestones.
|
||||
// User-input states may notify as soon as detail arrives, but `done` waits
|
||||
// for the max quiet window so resumed work can cancel the pending banner.
|
||||
return (
|
||||
hasAgentNotificationDetail(entry) &&
|
||||
(entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts'
|
||||
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
|
||||
|
||||
const PTY_ID = 'pty-parked-1'
|
||||
const TAB_ID = 'tab-1'
|
||||
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
|
||||
const PANE_ID = 1
|
||||
// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS.
|
||||
const NOTIFICATION_GRACE_MS = 250
|
||||
|
||||
// Real agent-detection titles: braille spinner classifies as working,
|
||||
// the "✳ " Claude prefix as idle, and both as Claude agents.
|
||||
const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07'
|
||||
const IDLE_TITLE = '✳ Build feature'
|
||||
const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07`
|
||||
|
||||
type MockStoreState = {
|
||||
settings: {
|
||||
theme?: 'system' | 'dark' | 'light'
|
||||
promptCacheTimerEnabled?: boolean
|
||||
experimentalTerminalAttention?: boolean
|
||||
terminalMainSideEffectAuthority?: boolean
|
||||
terminalHiddenDeliveryGate?: boolean
|
||||
notifications?: { enabled?: boolean; agentTaskComplete?: boolean }
|
||||
} | null
|
||||
setRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
updateTabTitle: ReturnType<typeof vi.fn>
|
||||
markWorktreeUnread: ReturnType<typeof vi.fn>
|
||||
markTerminalTabUnread: ReturnType<typeof vi.fn>
|
||||
markTerminalPaneUnread: ReturnType<typeof vi.fn>
|
||||
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
|
||||
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const dispatchTerminalNotification = vi.fn()
|
||||
let mockStoreState: MockStoreState
|
||||
|
||||
vi.mock('./use-notification-dispatch', () => ({
|
||||
dispatchTerminalNotification
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-theme', () => ({
|
||||
getSystemPrefersDark: () => true
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockStoreState(): MockStoreState {
|
||||
return {
|
||||
// Why: terminalMainSideEffectAuthority false pins the legacy byte-parser
|
||||
// mode this suite was written for; the authority-on fact-consumer mode is
|
||||
// covered by the dedicated describe block below.
|
||||
settings: {
|
||||
theme: 'system',
|
||||
promptCacheTimerEnabled: true,
|
||||
experimentalTerminalAttention: false,
|
||||
terminalMainSideEffectAuthority: false,
|
||||
notifications: { enabled: true, agentTaskComplete: true }
|
||||
},
|
||||
setRuntimePaneTitle: vi.fn(),
|
||||
clearRuntimePaneTitle: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
markWorktreeUnread: vi.fn(),
|
||||
markTerminalTabUnread: vi.fn(),
|
||||
markTerminalPaneUnread: vi.fn(),
|
||||
setCacheTimerStartedAt: vi.fn(),
|
||||
observeTerminalGitHubPullRequestLink: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('startParkedTerminalByteWatcher', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let onData: ((payload: { id: string; data: string }) => void) | null = null
|
||||
|
||||
function emit(data: string): void {
|
||||
onData?.({ id: PTY_ID, data })
|
||||
}
|
||||
|
||||
// The output processor defers title/bell side effects onto a 0ms drain timer.
|
||||
function flushSideEffects(): void {
|
||||
vi.advanceTimersByTime(0)
|
||||
}
|
||||
|
||||
async function startWatcher(
|
||||
overrides: Partial<ParkedTerminalByteWatcherOptions> = {}
|
||||
): Promise<{ dispose: () => void; sendInput: ReturnType<typeof vi.fn> }> {
|
||||
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
|
||||
const sendInput = vi.fn()
|
||||
const dispose = startParkedTerminalByteWatcher({
|
||||
ptyId: PTY_ID,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneId: PANE_ID,
|
||||
sendInput,
|
||||
...overrides
|
||||
})
|
||||
return { dispose, sendInput }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
dispatchTerminalNotification.mockClear()
|
||||
onData = null
|
||||
mockStoreState = createMockStoreState()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
pty: {
|
||||
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
|
||||
onData = callback
|
||||
return () => {}
|
||||
}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
ackData: vi.fn()
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards every OSC title in order to the pane and tab title store actions', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([
|
||||
[TAB_ID, PANE_ID, '⠋ Build feature'],
|
||||
[TAB_ID, PANE_ID, IDLE_TITLE]
|
||||
])
|
||||
expect(mockStoreState.updateTabTitle.mock.calls).toEqual([
|
||||
[TAB_ID, '⠋ Build feature'],
|
||||
[TAB_ID, IDLE_TITLE]
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('drops the bare cursor-agent native title before it reaches the store', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]0;Cursor Agent\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not drive the tab title when drivesTabTitle is false', async () => {
|
||||
const { dispose } = await startWatcher({ drivesTabTitle: false })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('build finished\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'terminal-bell',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks the exact pane unread when experimental terminal attention is enabled', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: true
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]0;par')
|
||||
emit('tial title\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('suppresses the completion OS notification when only terminal attention is on', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: true,
|
||||
notifications: { enabled: true, agentTaskComplete: false }
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY,
|
||||
suppressOsNotification: true
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: false,
|
||||
notifications: { enabled: false }
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('lets a same-burst completion supersede the pending bell OS notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
emit(`${IDLE_TITLE_OSC}\x07`)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8)
|
||||
|
||||
// The bell still marks unread immediately; only the OS notification yields.
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
expect.objectContaining({ source: 'agent-task-complete' })
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('cancels the pending completion and clears the cache timer when working resumes', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => {
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
emit('\x1b[?20')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
emit('31h')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
// theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol.
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
emit('\x1b[?2031l')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('stops answering DECSET 2031 after dispose', async () => {
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
dispose()
|
||||
emit('\x1b[?2031h')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('observes GitHub PR links across chunk boundaries', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('PR: https://github.com/orca-dev/orca/pull/42')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
|
||||
|
||||
emit('1\r\ndone')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
expect.objectContaining({
|
||||
url: 'https://github.com/orca-dev/orca/pull/421',
|
||||
number: 421,
|
||||
slug: { owner: 'orca-dev', repo: 'orca' }
|
||||
})
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
|
||||
// Why: the pane was working at park time; the watcher's fresh tracker
|
||||
// must be seeded or this working→idle transition can never fire.
|
||||
const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not fire completion for an idle title without a seed or observed transition', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('clears the watcher-written runtime title slot on dispose', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
|
||||
})
|
||||
|
||||
it('leaves the runtime title slot alone on dispose when it never wrote one', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('plain output with no titles\r\n')
|
||||
flushSideEffects()
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
// Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds.
|
||||
dispose()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
// The teardown flush that main emits after pty.kill must be a no-op.
|
||||
emit('final teardown flush\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispose unregisters the sidecar and cancels the pending bell notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
|
||||
|
||||
dispose()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Idempotent: a second dispose must not throw or clobber another watcher.
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('disposes the previous watcher when a new one starts for the same PTY', async () => {
|
||||
await startWatcher({ paneId: 1 })
|
||||
const second = await startWatcher({ paneId: 2 })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE)
|
||||
second.dispose()
|
||||
})
|
||||
|
||||
// ─── Main side-effect authority (terminal-side-effect-authority.md) ────
|
||||
//
|
||||
// With the kill switch on, the watcher must not register byte parsers —
|
||||
// main is the single byte parser and the watcher's policy block consumes
|
||||
// pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031
|
||||
// reply (query authority never moves to main); PR links arrive as facts.
|
||||
describe('with main side-effect authority on', () => {
|
||||
function enableMainAuthority(): void {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalMainSideEffectAuthority: true
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchFacts(
|
||||
facts: TerminalSideEffectFact[],
|
||||
options: { seq?: number; replay?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const handler = await import('./terminal-side-effect-facts-handler')
|
||||
handler._dispatchTerminalSideEffectBatchForTest({
|
||||
ptyId: PTY_ID,
|
||||
seq: options.seq ?? 0,
|
||||
...(options.replay ? { replay: true } : {}),
|
||||
facts
|
||||
})
|
||||
}
|
||||
|
||||
// NOTE (standalone parking extraction): #7214's copy of this suite also
|
||||
// has an emitViaMainTrackerFacts helper + a byte-vs-fact parity case that
|
||||
// replay the same bytes through shared/terminal-output-side-effects (the
|
||||
// main-process tracker). That module is fact-mode machinery outside this
|
||||
// byte-mode extraction, so the parity case returns with PR #7214.
|
||||
|
||||
it('does not consume bytes: a byte BEL produces no unread or notification', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('build finished\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([{ kind: 'bell' }])
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'terminal-bell',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires the cache timer and completion from working→idle facts', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
|
||||
{ kind: 'agent-idle', title: IDLE_TITLE }
|
||||
])
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('clears state without completion attention for stale-derived idle facts', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
// Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a
|
||||
// merely-paused agent must not earn a task-complete notification.
|
||||
await dispatchFacts([
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Build feature',
|
||||
rawTitle: 'Build feature',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true }
|
||||
])
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith(
|
||||
TAB_ID,
|
||||
PANE_ID,
|
||||
'Build feature'
|
||||
)
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('replay batches restore the title only — attention facts never replay', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
|
||||
{ kind: 'bell' },
|
||||
{ kind: 'agent-idle', title: IDLE_TITLE }
|
||||
],
|
||||
{ replay: true }
|
||||
)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => {
|
||||
// Why: with the hidden-delivery gate on (default), parked PTY bytes are
|
||||
// dropped in main — the fact is the only 2031 signal, and the byte
|
||||
// sidecar must NOT exist (its registration would re-enable delivery).
|
||||
enableMainAuthority()
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
emit('\x1b[?2031h')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
await dispatchFacts([{ kind: '2031-subscribe' }])
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
// Why: pr-link facts arrive on the channel; byte-scanning here too
|
||||
// would observe every link twice.
|
||||
emit('PR: https://github.com/orca-dev/orca/pull/42\r\n')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks the PTY hidden for delivery on start and clears it on dispose', async () => {
|
||||
enableMainAuthority()
|
||||
const setHiddenRendererPty = vi.fn()
|
||||
;(
|
||||
window as unknown as { api: { pty: Record<string, unknown> } }
|
||||
).api.pty.setHiddenRendererPty = setHiddenRendererPty
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true)
|
||||
|
||||
dispose()
|
||||
// Why: the unhide must land before reveal re-registers pane handlers —
|
||||
// the watcher registry disposes watchers before the remount effect runs.
|
||||
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false)
|
||||
})
|
||||
|
||||
it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => {
|
||||
enableMainAuthority()
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalHiddenDeliveryGate: false
|
||||
} as MockStoreState['settings']
|
||||
const setHiddenRendererPty = vi.fn()
|
||||
;(
|
||||
window as unknown as { api: { pty: Record<string, unknown> } }
|
||||
).api.pty.setHiddenRendererPty = setHiddenRendererPty
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
// Gate off — bytes keep flowing, so the split-chunk byte scan answers.
|
||||
emit('\x1b[?20')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
emit('31h')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
// Why: a 2031-subscribe fact must not double-fire the reply in byte
|
||||
// mode — exactly one responder owns the answer at any time.
|
||||
await dispatchFacts([{ kind: '2031-subscribe' }])
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(setHiddenRendererPty).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('observes PR links from pr-link facts with worktree attribution', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
const link = {
|
||||
url: 'https://github.com/orca-dev/orca/pull/421',
|
||||
slug: { owner: 'orca-dev', repo: 'orca' },
|
||||
number: 421
|
||||
}
|
||||
await dispatchFacts([{ kind: 'pr-link', link }])
|
||||
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
link
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('dispose unregisters the fact consumer and clears a written title slot', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }])
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
|
||||
|
||||
await dispatchFacts([{ kind: 'bell' }])
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Parked terminal side-effect watcher.
|
||||
*
|
||||
* Why: parking unmounts the TerminalPane subtree, which tears down the pane's
|
||||
* side-effect consumers — the parked tab's only source of bell, title,
|
||||
* agent-completion, and PR-link policy. (Losing them is the gap that sank the
|
||||
* first parking attempt.) Under main side-effect authority the watcher is
|
||||
* purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the
|
||||
* kill switch off it registers the legacy byte parsers on the dispatcher
|
||||
* sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate:
|
||||
* gate ON answers from main's '2031-subscribe' fact (no parked bytes exist),
|
||||
* gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts).
|
||||
* Either way the reply is sent from the renderer — query authority never
|
||||
* moves to main. See docs/reference/terminal-hidden-view-parking.md and
|
||||
* docs/reference/terminal-side-effect-authority.md.
|
||||
*/
|
||||
import { isClaudeAgent } from '../../../../shared/agent-detection'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import {
|
||||
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState,
|
||||
isAgentTaskCompleteTrackingEnabledFromState
|
||||
} from './agent-task-complete-policy'
|
||||
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
|
||||
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
|
||||
import { createPtyOutputProcessor } from './pty-transport'
|
||||
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
|
||||
import {
|
||||
isMainTerminalSideEffectAuthorityForPty,
|
||||
registerTerminalSideEffectFactConsumer
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
import { dispatchTerminalNotification } from './use-notification-dispatch'
|
||||
|
||||
// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts.
|
||||
// The parked path must keep the live path's BEL-vs-completion race window so
|
||||
// notification behavior is identical whether a tab is parked or mounted.
|
||||
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
|
||||
|
||||
type StoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean {
|
||||
return isAgentTaskCompleteOsNotificationEnabledFromState(state)
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean {
|
||||
return isAgentTaskCompleteTrackingEnabledFromState(state)
|
||||
}
|
||||
|
||||
export type ParkedTerminalByteWatcherOptions = {
|
||||
ptyId: string
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
/** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey
|
||||
* used for cache-timer, unread, and notification attribution. */
|
||||
leafId: string
|
||||
/** PaneManager pane id the unmounted pane used. Runtime pane titles are
|
||||
* keyed by it, so the watcher must write the slot the live path wrote —
|
||||
* a different id would leave a stale (possibly "working") title behind. */
|
||||
paneId: number
|
||||
/** Whether this PTY's pane was the tab's active split pane. Mirrors the
|
||||
* live path, where only the focused split drives the tab title. */
|
||||
drivesTabTitle?: boolean
|
||||
/** The pane's last known runtime title at park time. Seeds the agent
|
||||
* tracker so an agent that was working when the pane unmounted still
|
||||
* fires its completion when it goes idle while parked. */
|
||||
initialTitle?: string
|
||||
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
|
||||
sendInput: (data: string) => void
|
||||
}
|
||||
|
||||
const parkedWatcherDisposersByPtyId = new Map<string, () => void>()
|
||||
|
||||
export function startParkedTerminalByteWatcher(
|
||||
options: ParkedTerminalByteWatcherOptions
|
||||
): () => void {
|
||||
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
|
||||
const drivesTabTitle = options.drivesTabTitle ?? true
|
||||
const paneKey = makePaneKey(tabId, options.leafId)
|
||||
|
||||
// Why: one watcher per PTY. A stale watcher from a previous park cycle would
|
||||
// double-fire bell/completion side effects for the same bytes.
|
||||
parkedWatcherDisposersByPtyId.get(ptyId)?.()
|
||||
|
||||
let disposed = false
|
||||
let pendingBellNotification = false
|
||||
// Why: a watcher-written runtime title (especially into a negative fallback
|
||||
// slot) has no live pane to overwrite it after reveal; a stale 'working'
|
||||
// entry would pin worktree status forever. Track writes so dispose can
|
||||
// clear exactly the slot this watcher touched.
|
||||
let wroteRuntimeTitleSlot = false
|
||||
let bellNotificationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearBellNotificationTimer = (): void => {
|
||||
if (bellNotificationTimer !== null) {
|
||||
clearTimeout(bellNotificationTimer)
|
||||
bellNotificationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearAgentTaskCompleteTimer = (): void => {
|
||||
if (agentTaskCompleteTimer !== null) {
|
||||
clearTimeout(agentTaskCompleteTimer)
|
||||
agentTaskCompleteTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: like the live path, a BEL OS notification only yields when the
|
||||
// pending completion would itself produce an OS notification.
|
||||
const hasPendingAgentTaskCompleteNotification = (): boolean =>
|
||||
agentTaskCompleteTimer !== null &&
|
||||
isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
|
||||
|
||||
const scheduleTerminalBellNotification = (): void => {
|
||||
if (bellNotificationTimer !== null) {
|
||||
return
|
||||
}
|
||||
bellNotificationTimer = setTimeout(() => {
|
||||
bellNotificationTimer = null
|
||||
if (disposed) {
|
||||
pendingBellNotification = false
|
||||
return
|
||||
}
|
||||
if (hasPendingAgentTaskCompleteNotification()) {
|
||||
return
|
||||
}
|
||||
pendingBellNotification = false
|
||||
dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey })
|
||||
}, PARKED_NOTIFICATION_GRACE_MS)
|
||||
}
|
||||
|
||||
// Why: one policy block for both consumption modes — byte parsing (kill
|
||||
// switch off) and pty:sideEffect facts (main authority on). The semantics
|
||||
// must be identical or flipping the switch changes notification behavior.
|
||||
const sideEffectCallbacks = {
|
||||
onTitleChange: (title: string): void => {
|
||||
const state = useAppStore.getState()
|
||||
wroteRuntimeTitleSlot = true
|
||||
state.setRuntimePaneTitle(tabId, paneId, title)
|
||||
if (drivesTabTitle) {
|
||||
state.updateTabTitle(tabId, title)
|
||||
}
|
||||
},
|
||||
onBell: (): void => {
|
||||
const state = useAppStore.getState()
|
||||
state.markWorktreeUnread(worktreeId)
|
||||
state.markTerminalTabUnread(tabId)
|
||||
if (state.settings?.experimentalTerminalAttention === true) {
|
||||
state.markTerminalPaneUnread(paneKey)
|
||||
}
|
||||
// Why: agent CLIs often emit BEL in the same completion burst as their
|
||||
// working→idle title change. Delay only the OS notification so the
|
||||
// richer agent-task-complete notification can win (live-path parity).
|
||||
pendingBellNotification = true
|
||||
if (!hasPendingAgentTaskCompleteNotification()) {
|
||||
scheduleTerminalBellNotification()
|
||||
}
|
||||
},
|
||||
onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => {
|
||||
// Why: stale-derived idles come from main's unthrottled 3s timer, not
|
||||
// observed bytes — clear session state, never schedule the completion
|
||||
// notification a merely-paused agent did not earn (live-path parity).
|
||||
if (meta?.staleWorkingTitleClear) {
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
// Why: mirrors pty-connection — null settings means "not hydrated yet";
|
||||
// a spurious timestamp is harmless while a dropped one loses the timer.
|
||||
if (
|
||||
isClaudeAgent(title) &&
|
||||
(state.settings === null || state.settings.promptCacheTimerEnabled)
|
||||
) {
|
||||
state.setCacheTimerStartedAt(paneKey, Date.now())
|
||||
}
|
||||
if (!isAgentTaskCompleteTrackingEnabled(state)) {
|
||||
return
|
||||
}
|
||||
clearAgentTaskCompleteTimer()
|
||||
agentTaskCompleteTimer = setTimeout(() => {
|
||||
agentTaskCompleteTimer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Why: the completion supersedes a concurrent BEL so each completion
|
||||
// burst yields exactly one OS notification, same as the live path.
|
||||
pendingBellNotification = false
|
||||
clearBellNotificationTimer()
|
||||
dispatchTerminalNotification(worktreeId, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: title,
|
||||
paneKey,
|
||||
...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
|
||||
? {}
|
||||
: { suppressOsNotification: true })
|
||||
})
|
||||
}, PARKED_NOTIFICATION_GRACE_MS)
|
||||
},
|
||||
onAgentBecameWorking: (): void => {
|
||||
// Why: a new API call refreshes the prompt-cache TTL, so clear any
|
||||
// running countdown; it restarts when the agent next becomes idle.
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
clearAgentTaskCompleteTimer()
|
||||
if (pendingBellNotification) {
|
||||
scheduleTerminalBellNotification()
|
||||
}
|
||||
},
|
||||
onAgentExited: (): void => {
|
||||
// Why: title reverting to a plain shell means the agent session ended;
|
||||
// a stale countdown must not survive in the sidebar while parked.
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: parking eligibility excludes remote-runtime and SSH PTYs, so every
|
||||
// watched PTY's bytes transit local main — when the authority switch is on,
|
||||
// the watcher must NOT register byte parsers (the fact consumer below is
|
||||
// the single policy consumer; double registration would double-fire bells).
|
||||
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: useAppStore.getState().settings,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
// Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all —
|
||||
// facts carry side effects and the reveal remount restores from the model
|
||||
// snapshot. Decided once at watcher start: it picks which 2031 responder
|
||||
// (byte sidecar vs fact reply) exists, so it must never flip per chunk.
|
||||
const hiddenDeliveryGateActive =
|
||||
mainSideEffectAuthority &&
|
||||
isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings)
|
||||
|
||||
const sendMode2031Reply = (): void => {
|
||||
const settings = useAppStore.getState().settings
|
||||
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
|
||||
}
|
||||
|
||||
// Why (byte-parser mode only): reuse the transport's output processor so
|
||||
// the parked path keeps the exact live-path parsing semantics — all-titles
|
||||
// ordering, normalization, the cursor-agent native-title drop, the
|
||||
// OSC-aware stateful bell detector, and the working/idle agent tracker.
|
||||
// initialAgentTitle: an agent already working at park time must still
|
||||
// produce a working→idle transition; main's continuous tracker covers this
|
||||
// in fact-consumer mode.
|
||||
const processor = mainSideEffectAuthority
|
||||
? null
|
||||
: createPtyOutputProcessor({
|
||||
...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}),
|
||||
...sideEffectCallbacks
|
||||
})
|
||||
// Why (byte-parser mode only): with main authority, pr-link facts arrive on
|
||||
// the channel below; byte-scanning too would observe every link twice.
|
||||
const observeTerminalGitHubPRLink = mainSideEffectAuthority
|
||||
? null
|
||||
: createTerminalGitHubPRLinkDetector()
|
||||
const unregisterFactConsumer = mainSideEffectAuthority
|
||||
? registerTerminalSideEffectFactConsumer({
|
||||
ptyId,
|
||||
// Why: no title snapshot on park — the pane's runtime title slot is
|
||||
// already current at park time, exactly like the byte-parser mode.
|
||||
callbacks: {
|
||||
...sideEffectCallbacks,
|
||||
onPrLink: (link) =>
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
|
||||
// Why (gate mode only): bytes never arrive while gated, so the 2031
|
||||
// subscribe arrives as a main-tracker fact instead of a byte scan.
|
||||
// The reply is still sent from here — query authority stays with
|
||||
// the view/watcher (model/view contract invariant 6).
|
||||
...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {})
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
// Why: no xterm exists while parked, so nothing answers a DECSET 2031
|
||||
// subscription. With the hidden-delivery gate OFF the byte responder is the
|
||||
// parked path's only byte consumer under main authority. With the gate ON it
|
||||
// must NOT register: its subscribeToPtyData sidecar doubles as a
|
||||
// delivery-interest signal that would force-feed bytes to the gated PTY —
|
||||
// the fact callback above replaces the byte scan.
|
||||
const stopMode2031Responder = hiddenDeliveryGateActive
|
||||
? null
|
||||
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
|
||||
|
||||
// Why: parked tabs are the canonical hidden view — mark the PTY gated so
|
||||
// main stops renderer byte delivery; dispose clears the bit before the
|
||||
// reveal remount re-registers pane handlers (existing dispose ordering).
|
||||
if (hiddenDeliveryGateActive) {
|
||||
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, true)
|
||||
}
|
||||
|
||||
// Why (byte-parser mode only): with main authority the watcher consumes
|
||||
// pty:sideEffect facts exclusively and registers NO byte parsers here —
|
||||
// title/bell/agent parsing and the PR-link scan would double-fire policy.
|
||||
const unsubscribeByteParsers =
|
||||
processor === null
|
||||
? null
|
||||
: subscribeToPtyData(ptyId, (data) => {
|
||||
// Why: empty pane callbacks — the watcher wants only the parser
|
||||
// side effects; there is no xterm to deliver bytes to.
|
||||
processor.processData(data, {})
|
||||
if (observeTerminalGitHubPRLink) {
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
// Why: unhide BEFORE the reveal remount registers pane handlers — main
|
||||
// resumes delivery and (if bytes were dropped) emits the restore marker
|
||||
// the remounted pane's restore machinery consumes.
|
||||
if (hiddenDeliveryGateActive) {
|
||||
;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, false)
|
||||
}
|
||||
stopMode2031Responder?.()
|
||||
unsubscribeByteParsers?.()
|
||||
unregisterFactConsumer?.()
|
||||
// Why: cancels the deferred side-effect drain, stale-title timer, and
|
||||
// tracker/bell-detector state so the watcher cannot fire after the
|
||||
// revealed pane's live parsers take over.
|
||||
processor?.clearAccumulatedState()
|
||||
clearBellNotificationTimer()
|
||||
clearAgentTaskCompleteTimer()
|
||||
pendingBellNotification = false
|
||||
// Why: the store merge never deletes title slots, so a watcher-written
|
||||
// entry would strand after reveal (the revealing pane re-registers under
|
||||
// its own pane id) and could pin worktree status 'working'. The revealed
|
||||
// pane repopulates its slot via its own title flow.
|
||||
if (wroteRuntimeTitleSlot) {
|
||||
wroteRuntimeTitleSlot = false
|
||||
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
|
||||
}
|
||||
if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) {
|
||||
parkedWatcherDisposersByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
parkedWatcherDisposersByPtyId.set(ptyId, dispose)
|
||||
return dispose
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DECSET 2031 color-scheme responder for parked terminals (byte-scan mode).
|
||||
*
|
||||
* Why a dedicated byte sidecar: no xterm exists while a tab is parked, so
|
||||
* nothing answers a TUI's mode-2031 theme subscription. Query authority stays
|
||||
* with the view/watcher (model/view contract invariant 6), so this reply can
|
||||
* never move to main. Phase 4: this subscribeToPtyData registration doubles
|
||||
* as a delivery-interest signal, so it is only used while the hidden-delivery
|
||||
* gate is OFF — gated parked PTYs answer from the main tracker's
|
||||
* '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts).
|
||||
*
|
||||
* Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes
|
||||
* answer 2031 from xterm once the background queue drains, but a PARKED tab
|
||||
* has no xterm in any switch-off mode, and the '2031-subscribe' fact is only
|
||||
* consumed while the gate is ON — this sidecar stays the only answerer here.
|
||||
*/
|
||||
import {
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode,
|
||||
scanMode2031Sequences
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
|
||||
|
||||
export type ParkedTerminalMode2031ResponderOptions = {
|
||||
ptyId: string
|
||||
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
|
||||
sendInput: (data: string) => void
|
||||
}
|
||||
|
||||
export function startParkedTerminalMode2031Responder(
|
||||
options: ParkedTerminalMode2031ResponderOptions
|
||||
): () => void {
|
||||
const { ptyId, sendInput } = options
|
||||
// Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
|
||||
// carries a bounded tail between chunks so split sequences still match.
|
||||
let scanTail = ''
|
||||
return subscribeToPtyData(ptyId, (data) => {
|
||||
const scan = scanMode2031Sequences(scanTail, data)
|
||||
scanTail = scan.tail
|
||||
if (!scan.subscribe) {
|
||||
return
|
||||
}
|
||||
// Why: reply with the resolved theme so TUIs that subscribe while parked
|
||||
// still learn it before the pane is ever revealed.
|
||||
const settings = useAppStore.getState().settings
|
||||
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
|
||||
})
|
||||
}
|
||||
@@ -130,7 +130,7 @@ import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-comman
|
||||
import { createCommandCodeOutputStatusDetector } from './command-code-output-status'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import { getEagerPtyBufferHandle } from './pty-dispatcher'
|
||||
import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
|
||||
import {
|
||||
CONPTY_DA1_RESPONSE,
|
||||
|
||||
@@ -73,7 +73,12 @@ type PtyOutputProcessorOptions = Pick<
|
||||
| 'onAgentBecameWorking'
|
||||
| 'onAgentExited'
|
||||
| 'onAgentStatus'
|
||||
>
|
||||
> & {
|
||||
/** Seed for processors that start mid-session (parked-tab byte watchers):
|
||||
* the pane's last known title, so a working agent that finishes while the
|
||||
* processor owns the stream still yields a working→idle transition. */
|
||||
initialAgentTitle?: string
|
||||
}
|
||||
|
||||
type ProcessPtyOutputOptions = {
|
||||
replayingBufferedData?: boolean
|
||||
@@ -95,7 +100,8 @@ export function createPtyOutputProcessor({
|
||||
onAgentBecameIdle,
|
||||
onAgentBecameWorking,
|
||||
onAgentExited,
|
||||
onAgentStatus
|
||||
onAgentStatus,
|
||||
initialAgentTitle
|
||||
}: PtyOutputProcessorOptions): {
|
||||
processData: (
|
||||
data: string,
|
||||
@@ -110,7 +116,11 @@ export function createPtyOutputProcessor({
|
||||
} {
|
||||
const bellDetector = createBellDetector()
|
||||
const processAgentStatusChunk = createAgentStatusOscProcessor()
|
||||
let lastEmittedTitle: string | null = null
|
||||
// Why: seed both the emitted-title memory (stale-title probe) and the agent
|
||||
// tracker so a mid-session processor behaves as if it had observed the
|
||||
// pane's last live title — full parity with the live path it replaces.
|
||||
let lastEmittedTitle: string | null =
|
||||
initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null
|
||||
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let sideEffectDrainTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingSideEffects: PendingPtySideEffect[] = []
|
||||
@@ -123,7 +133,8 @@ export function createPtyOutputProcessor({
|
||||
onAgentBecameIdle?.(title)
|
||||
},
|
||||
onAgentBecameWorking,
|
||||
onAgentExited
|
||||
onAgentExited,
|
||||
initialAgentTitle
|
||||
)
|
||||
: null
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Renderer-side predicate for main's Phase-4 hidden PTY delivery gate.
|
||||
*
|
||||
* The gate only operates when main holds side-effect authority for the PTY
|
||||
* (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill
|
||||
* switch is on. Callers decide once at pane/watcher creation — the decision
|
||||
* picks which mode-2031 responder is registered (byte sidecar vs fact reply),
|
||||
* so it must never flip per chunk.
|
||||
*/
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
// Why: cached once per session — the blocking read should only ever run on
|
||||
// the pre-hydration startup path, never per pane bind.
|
||||
let persistedGateFlagCache: boolean | null | undefined
|
||||
|
||||
function readPersistedHiddenDeliveryGateFlagSync(): boolean | null {
|
||||
if (persistedGateFlagCache === undefined) {
|
||||
try {
|
||||
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
|
||||
persistedGateFlagCache =
|
||||
typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null
|
||||
} catch {
|
||||
persistedGateFlagCache = null
|
||||
}
|
||||
}
|
||||
return persistedGateFlagCache
|
||||
}
|
||||
|
||||
export function isRendererHiddenPtyDeliveryGateEnabled(
|
||||
settings: Pick<GlobalSettings, 'terminalHiddenDeliveryGate'> | null
|
||||
): boolean {
|
||||
if (settings !== null) {
|
||||
return settings.terminalHiddenDeliveryGate !== false
|
||||
}
|
||||
// Why: settings hydrate asynchronously; a pane/watcher bound before
|
||||
// hydration must honor the persisted kill switch — the responder-mode
|
||||
// decision made here is never revisited (same rationale as the
|
||||
// side-effect-authority sync read).
|
||||
return readPersistedHiddenDeliveryGateFlagSync() !== false
|
||||
}
|
||||
|
||||
/** Test seam: reset the persisted-flag cache between tests. */
|
||||
export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void {
|
||||
persistedGateFlagCache = undefined
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_TAB_HOT_RETAIN_MS,
|
||||
TERMINAL_WORKTREE_HOT_RETAIN_MS,
|
||||
TERMINAL_WORKTREE_PARK_DELAY_MS,
|
||||
canParkTerminalTabRenderer,
|
||||
canParkTerminalWorktreeRenderers,
|
||||
getTerminalTabColdParkRecheckDelayMs,
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
isSnapshotBackedTerminalPty,
|
||||
selectColdParkedTerminalTabs,
|
||||
selectColdParkedTerminalWorktrees
|
||||
} from './terminal-hidden-view-parking'
|
||||
|
||||
describe('isSnapshotBackedTerminalPty', () => {
|
||||
it('allows local daemon sessions owned by the worktree', () => {
|
||||
expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true)
|
||||
expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true)
|
||||
})
|
||||
|
||||
// Why: separator-less ids ('1', '2', 'pty-local-detached') come from the
|
||||
// daemon-fail-open LocalPtyProvider and have no daemon session model —
|
||||
// revealing a parked pane would silently respawn a fresh shell, so they
|
||||
// must not count as snapshot-backed (changed from the ported prior art).
|
||||
it('rejects separator-less local PTY ids that lack a daemon session model', () => {
|
||||
expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects tabs that do not have a PTY yet', () => {
|
||||
expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects daemon sessions owned by another worktree', () => {
|
||||
expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects SSH and remote runtime PTY handles', () => {
|
||||
expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false)
|
||||
expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canParkTerminalWorktreeRenderers', () => {
|
||||
const hiddenSinceMs = 1_000
|
||||
const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
|
||||
const base = {
|
||||
worktreeId: 'repo::/worktree',
|
||||
terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs,
|
||||
nowMs
|
||||
}
|
||||
|
||||
it('parks hidden local terminal renderers after the idle delay', () => {
|
||||
expect(canParkTerminalWorktreeRenderers(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('never parks when the settings kill switch disables parking', () => {
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
parkingEnabled: false,
|
||||
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => {
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false)
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors a per-call cold-park delay override', () => {
|
||||
const shortDelayArgs = { ...base, coldParkDelayMs: 100 }
|
||||
expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => {
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' },
|
||||
{ id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps renderers mounted while a tab has startup or activation work pending', () => {
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalWorktreeRenderers({
|
||||
...base,
|
||||
terminalTabs: [
|
||||
{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 }
|
||||
]
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canParkTerminalTabRenderer', () => {
|
||||
const hiddenSinceMs = 1_000
|
||||
const base = {
|
||||
worktreeId: 'wt-1',
|
||||
terminalTab: {
|
||||
id: 'tab-1',
|
||||
ptyId: 'wt-1@@session-1',
|
||||
isVisible: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
},
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS
|
||||
}
|
||||
|
||||
it('parks an idle hidden local tab and honors the kill switch', () => {
|
||||
expect(canParkTerminalTabRenderer(base)).toBe(true)
|
||||
expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('honors a per-call cold-park delay override', () => {
|
||||
expect(
|
||||
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 })
|
||||
).toBe(false)
|
||||
expect(
|
||||
canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 })
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectColdParkedTerminalWorktrees', () => {
|
||||
const nowMs = 500_000
|
||||
|
||||
function localCandidate(worktreeId: string, hiddenSinceMs: number) {
|
||||
return {
|
||||
worktreeId,
|
||||
terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }],
|
||||
isVisible: false,
|
||||
shouldMeasureHiddenWorktree: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps recent hidden local worktrees hot up to the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
|
||||
localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-3']))
|
||||
})
|
||||
|
||||
it('cold-parks aged local worktrees even when under the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 4
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-1']))
|
||||
})
|
||||
|
||||
it('selects nothing when the settings kill switch disables parking', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: false,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('does not cold-park terminals without local snapshot recovery', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
{
|
||||
...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }]
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }]
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['wt-local']))
|
||||
})
|
||||
|
||||
it('keeps visible, measuring, portaled, and pending terminals mounted', () => {
|
||||
const selected = selectColdParkedTerminalWorktrees({
|
||||
worktrees: [
|
||||
{
|
||||
...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
shouldMeasureHiddenWorktree: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
hasActivityTerminalPortal: true
|
||||
},
|
||||
{
|
||||
...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS),
|
||||
terminalTabs: [
|
||||
{
|
||||
id: 'tab-activation',
|
||||
ptyId: 'wt-activation@@session-1',
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
]
|
||||
},
|
||||
localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)
|
||||
],
|
||||
pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } },
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectColdParkedTerminalTabs', () => {
|
||||
const nowMs = 500_000
|
||||
|
||||
function localTab(id: string, hiddenSinceMs: number) {
|
||||
return {
|
||||
id,
|
||||
ptyId: `wt-1@@session-${id}`,
|
||||
pendingActivationSpawn: false,
|
||||
isVisible: false,
|
||||
hasActivityTerminalPortal: false,
|
||||
hiddenSinceMs
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps visible and recent inactive terminal tabs mounted', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
{ ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true },
|
||||
localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('cold-parks the oldest inactive local tabs beyond the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS),
|
||||
localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1),
|
||||
localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 2
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-3']))
|
||||
})
|
||||
|
||||
it('cold-parks aged inactive local tabs even when under the retain limit', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 12
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-1']))
|
||||
})
|
||||
|
||||
it('selects nothing when the settings kill switch disables parking', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2)
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: false,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('does not cold-park inactive terminal tabs without local snapshot recovery', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
{
|
||||
...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
ptyId: 'ssh:ssh-1@@pty-1'
|
||||
},
|
||||
{
|
||||
...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
ptyId: 'remote:env-1@@terminal-1'
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: {},
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set(['tab-local']))
|
||||
})
|
||||
|
||||
it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => {
|
||||
const selected = selectColdParkedTerminalTabs({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabs: [
|
||||
{
|
||||
...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
hasActivityTerminalPortal: true
|
||||
},
|
||||
localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
{
|
||||
...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS),
|
||||
pendingActivationSpawn: true
|
||||
}
|
||||
],
|
||||
pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } },
|
||||
parkingEnabled: true,
|
||||
nowMs,
|
||||
hotRetainLimit: 0
|
||||
})
|
||||
|
||||
expect(selected).toEqual(new Set())
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTerminalWorktreeColdParkRecheckDelayMs', () => {
|
||||
it('returns the next cold-park policy deadline', () => {
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: null,
|
||||
nowMs: 1_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(50)
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_100,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(900)
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 2_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('schedules no recheck when the settings kill switch disables parking', () => {
|
||||
expect(
|
||||
getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: false,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTerminalTabColdParkRecheckDelayMs', () => {
|
||||
it('returns the next terminal-tab cold-park policy deadline', () => {
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: null,
|
||||
nowMs: 1_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(50)
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_100,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBe(900)
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: true,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 2_000,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('schedules no recheck when the settings kill switch disables parking', () => {
|
||||
expect(
|
||||
getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: false,
|
||||
hiddenSinceMs: 1_000,
|
||||
nowMs: 1_050,
|
||||
coldParkDelayMs: 100,
|
||||
hotRetainMs: 1_000
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,285 @@
|
||||
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
|
||||
import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format'
|
||||
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
|
||||
// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab
|
||||
// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible
|
||||
// working set warm for 5 minutes beyond that.
|
||||
export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000
|
||||
export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4
|
||||
export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
|
||||
export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000
|
||||
export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12
|
||||
|
||||
// Why: tests override these per call (instead of process.env reads inside the
|
||||
// module) to shrink the 30s hysteresis to test-friendly durations.
|
||||
export type TerminalColdParkPolicyOverrides = {
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
hotRetainLimit?: number
|
||||
}
|
||||
|
||||
export type ColdParkableTerminalTab = Pick<TerminalTab, 'id' | 'ptyId' | 'pendingActivationSpawn'>
|
||||
|
||||
export type TerminalWorktreeColdParkCandidate = {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
}
|
||||
|
||||
export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & {
|
||||
isVisible: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
}
|
||||
|
||||
function getPendingActivationSpawnCount(value: boolean | number | undefined): number {
|
||||
if (value === true) {
|
||||
return 1
|
||||
}
|
||||
return typeof value === 'number' && value > 0 ? value : 0
|
||||
}
|
||||
|
||||
// Why: parking relies on the daemon model snapshot to re-hydrate. Remote
|
||||
// runtime and SSH PTYs have no local snapshot in this phase, and a session id
|
||||
// minted for another worktree reattaches through a path parking cannot replay.
|
||||
export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean {
|
||||
if (!ptyId) {
|
||||
return false
|
||||
}
|
||||
if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) {
|
||||
return false
|
||||
}
|
||||
// Why: separator-less ids come from the daemon-fail-open LocalPtyProvider;
|
||||
// they have no daemon session model, so revealing a parked pane would
|
||||
// silently respawn a fresh shell instead of restoring the snapshot.
|
||||
const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR)
|
||||
return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId
|
||||
}
|
||||
|
||||
export function canParkTerminalWorktreeRenderers(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly ColdParkableTerminalTab[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
// Why: callers pass settings.terminalHiddenViewParking !== false — the
|
||||
// design-doc kill switch that disables parking entirely.
|
||||
parkingEnabled: boolean
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
hasActivityTerminalPortal: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
}): boolean {
|
||||
if (
|
||||
!args.parkingEnabled ||
|
||||
args.isVisible ||
|
||||
args.shouldMeasureHiddenWorktree ||
|
||||
args.hasActivityTerminalPortal ||
|
||||
args.hiddenSinceMs === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
args.nowMs - args.hiddenSinceMs <
|
||||
(args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return args.terminalTabs.every((tab) => {
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
export function canParkTerminalTabRenderer(args: {
|
||||
worktreeId: string
|
||||
terminalTab: TerminalTabColdParkCandidate
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
}): boolean {
|
||||
const tab = args.terminalTab
|
||||
if (
|
||||
!args.parkingEnabled ||
|
||||
tab.isVisible ||
|
||||
tab.hasActivityTerminalPortal ||
|
||||
tab.hiddenSinceMs === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) {
|
||||
return false
|
||||
}
|
||||
if (args.pendingStartupByTabId[tab.id] !== undefined) {
|
||||
return false
|
||||
}
|
||||
if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) {
|
||||
return false
|
||||
}
|
||||
return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId)
|
||||
}
|
||||
|
||||
type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number }
|
||||
|
||||
// Why: hot-retain keeps the most recently hidden ids warm up to the limit;
|
||||
// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id
|
||||
// so the selection is deterministic.
|
||||
function selectIdsBeyondHotRetain(
|
||||
candidates: ColdParkRetainCandidate[],
|
||||
args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number }
|
||||
): Set<string> {
|
||||
const coldParkedIds = new Set<string>()
|
||||
const retainedCandidates: ColdParkRetainCandidate[] = []
|
||||
for (const candidate of candidates) {
|
||||
if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) {
|
||||
coldParkedIds.add(candidate.id)
|
||||
} else {
|
||||
retainedCandidates.push(candidate)
|
||||
}
|
||||
}
|
||||
retainedCandidates.sort((a, b) => {
|
||||
const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs
|
||||
return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta
|
||||
})
|
||||
for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) {
|
||||
coldParkedIds.add(candidate.id)
|
||||
}
|
||||
return coldParkedIds
|
||||
}
|
||||
|
||||
export function selectColdParkedTerminalWorktrees(
|
||||
args: {
|
||||
worktrees: readonly TerminalWorktreeColdParkCandidate[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
} & TerminalColdParkPolicyOverrides
|
||||
): Set<string> {
|
||||
if (!args.parkingEnabled) {
|
||||
return new Set()
|
||||
}
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS
|
||||
const candidates: ColdParkRetainCandidate[] = []
|
||||
for (const worktree of args.worktrees) {
|
||||
if (
|
||||
worktree.hiddenSinceMs === null ||
|
||||
!canParkTerminalWorktreeRenderers({
|
||||
...worktree,
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs })
|
||||
}
|
||||
return selectIdsBeyondHotRetain(candidates, {
|
||||
nowMs: args.nowMs,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS,
|
||||
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT
|
||||
})
|
||||
}
|
||||
|
||||
export function selectColdParkedTerminalTabs(
|
||||
args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly TerminalTabColdParkCandidate[]
|
||||
pendingStartupByTabId: Readonly<Record<string, unknown>>
|
||||
parkingEnabled: boolean
|
||||
nowMs: number
|
||||
} & TerminalColdParkPolicyOverrides
|
||||
): Set<string> {
|
||||
if (!args.parkingEnabled) {
|
||||
return new Set()
|
||||
}
|
||||
const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS
|
||||
const candidates: ColdParkRetainCandidate[] = []
|
||||
for (const tab of args.terminalTabs) {
|
||||
if (
|
||||
tab.hiddenSinceMs === null ||
|
||||
!canParkTerminalTabRenderer({
|
||||
worktreeId: args.worktreeId,
|
||||
terminalTab: tab,
|
||||
pendingStartupByTabId: args.pendingStartupByTabId,
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs
|
||||
})
|
||||
) {
|
||||
continue
|
||||
}
|
||||
candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs })
|
||||
}
|
||||
return selectIdsBeyondHotRetain(candidates, {
|
||||
nowMs: args.nowMs,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS,
|
||||
hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT
|
||||
})
|
||||
}
|
||||
|
||||
// Why: parking decisions change only at the cold-park and hot-retain
|
||||
// deadlines, so callers schedule one recheck at the next deadline instead of
|
||||
// polling.
|
||||
function nextColdParkDeadlineDelayMs(args: {
|
||||
parkingEnabled: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs: number
|
||||
hotRetainMs: number
|
||||
}): number | null {
|
||||
if (!args.parkingEnabled || args.hiddenSinceMs === null) {
|
||||
return null
|
||||
}
|
||||
const pendingDeadlines = [
|
||||
args.hiddenSinceMs + args.coldParkDelayMs,
|
||||
args.hiddenSinceMs + args.hotRetainMs
|
||||
].filter((deadlineMs) => deadlineMs > args.nowMs)
|
||||
return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs
|
||||
}
|
||||
|
||||
export function getTerminalWorktreeColdParkRecheckDelayMs(args: {
|
||||
parkingEnabled: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
}): number | null {
|
||||
return nextColdParkDeadlineDelayMs({
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
hiddenSinceMs: args.hiddenSinceMs,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS
|
||||
})
|
||||
}
|
||||
|
||||
export function getTerminalTabColdParkRecheckDelayMs(args: {
|
||||
parkingEnabled: boolean
|
||||
hiddenSinceMs: number | null
|
||||
nowMs: number
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
}): number | null {
|
||||
return nextColdParkDeadlineDelayMs({
|
||||
parkingEnabled: args.parkingEnabled,
|
||||
hiddenSinceMs: args.hiddenSinceMs,
|
||||
nowMs: args.nowMs,
|
||||
coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const OTHER_WORKTREE_ID = 'repo::/other-worktree'
|
||||
const TAB_ID = 'tab-1'
|
||||
const PTY_ID = `${WORKTREE_ID}@@session-1`
|
||||
const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2`
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
type StartedWatcher = {
|
||||
options: ParkedTerminalByteWatcherOptions
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const startedWatchers: StartedWatcher[] = []
|
||||
const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => {
|
||||
const dispose = vi.fn()
|
||||
startedWatchers.push({ options, dispose })
|
||||
return dispose
|
||||
})
|
||||
|
||||
vi.mock('./parked-terminal-byte-watcher', () => ({
|
||||
startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) =>
|
||||
startParkedTerminalByteWatcher(options)
|
||||
}))
|
||||
|
||||
type ExitSubscription = {
|
||||
ptyId: string
|
||||
callback: (code: number) => void
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const exitSubscriptions: ExitSubscription[] = []
|
||||
const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => {
|
||||
const unsubscribe = vi.fn()
|
||||
exitSubscriptions.push({ ptyId, callback, unsubscribe })
|
||||
return unsubscribe
|
||||
})
|
||||
|
||||
vi.mock('./pty-dispatcher', () => ({
|
||||
subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) =>
|
||||
subscribeToPtyExit(ptyId, callback)
|
||||
}))
|
||||
|
||||
type MockStoreState = {
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{
|
||||
root: unknown
|
||||
activeLeafId: string | null
|
||||
expandedLeafId: string | null
|
||||
ptyIdsByLeafId?: Record<string, string>
|
||||
}
|
||||
>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockStoreState: MockStoreState
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mockStoreState }
|
||||
}))
|
||||
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
captureParkedTerminalPaneCandidates,
|
||||
disposeParkedTerminalWatchersForPtyIds,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
fallbackParkedPaneCandidates,
|
||||
getParkedTerminalWatcherTabIds,
|
||||
pruneParkedTerminalWatchers,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-parked-tab-watchers'
|
||||
|
||||
const ptyWrite = vi.fn()
|
||||
const originalWindow = (globalThis as { window?: unknown }).window
|
||||
|
||||
function capturePanes(
|
||||
panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[],
|
||||
args?: { tabId?: string; worktreeId?: string }
|
||||
): void {
|
||||
captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes)
|
||||
}
|
||||
|
||||
function syncParked(args?: {
|
||||
worktreeId?: string
|
||||
tabs?: { id: string; ptyId: string | null }[]
|
||||
parkedTabIds?: Iterable<string>
|
||||
}): void {
|
||||
syncParkedTerminalTabWatchers({
|
||||
worktreeId: args?.worktreeId ?? WORKTREE_ID,
|
||||
tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }],
|
||||
parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID])
|
||||
})
|
||||
}
|
||||
|
||||
describe('terminal-parked-tab-watchers', () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState = {
|
||||
terminalLayoutsByTabId: {},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
clearRuntimePaneTitle: vi.fn()
|
||||
}
|
||||
;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Module-level registries persist across tests; clear them through the
|
||||
// public prune path so each test starts from an empty parked state.
|
||||
pruneParkedTerminalWatchers(new Set())
|
||||
startedWatchers.length = 0
|
||||
exitSubscriptions.length = 0
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as { window?: unknown }).window = originalWindow
|
||||
})
|
||||
|
||||
it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
|
||||
expect(startedWatchers[0].options).toMatchObject({
|
||||
ptyId: PTY_ID,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneId: 1,
|
||||
drivesTabTitle: true
|
||||
})
|
||||
expect(startedWatchers[1].options).toMatchObject({
|
||||
ptyId: SECOND_PTY_ID,
|
||||
paneId: 2,
|
||||
drivesTabTitle: false
|
||||
})
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
startedWatchers[0].options.sendInput('\x1b[?2031;1$y')
|
||||
expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y')
|
||||
})
|
||||
|
||||
it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID })
|
||||
})
|
||||
|
||||
it('never starts watchers for remote-runtime or SSH PTYs', () => {
|
||||
capturePanes([
|
||||
{ ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] })
|
||||
|
||||
expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled()
|
||||
// Why: the tab is still tracked as parked so debug introspection
|
||||
// (window.__terminalParkingDebug) reflects every parked tab.
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('keeps existing watchers across repeated syncs of the same parked state', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
syncParked()
|
||||
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes the watcher and exit subscription when the tab unparks', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
syncParked({ parkedTabIds: [] })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('disposes the watcher when the tab closes while parked', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
syncParked({ tabs: [], parkedTabIds: [TAB_ID] })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('disposes a PTY watcher when that PTY exits while parked', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)
|
||||
exited?.callback(0)
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
|
||||
// The tab itself is still parked, only the exited PTY's watcher is gone.
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
})
|
||||
|
||||
it('seeds each watcher with the pane slot last known runtime title', () => {
|
||||
mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } }
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature')
|
||||
expect(startedWatchers[1].options.initialTitle).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
disposeParkedTerminalWatchersForPtyIds([PTY_ID])
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(startedWatchers[1].dispose).not.toHaveBeenCalled()
|
||||
// Why: the entry survives so a sleeping parked tab cannot restart a
|
||||
// watcher against its stale PTY ids before wake re-mints them.
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
syncParked()
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('restarts watchers from store layout when the tab PTY was re-minted', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
const remintedPtyId = `${WORKTREE_ID}@@session-after-wake`
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId }
|
||||
}
|
||||
syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] })
|
||||
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2)
|
||||
expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID })
|
||||
})
|
||||
|
||||
it('scopes sync disposal to the given worktree', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9`
|
||||
capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], {
|
||||
tabId: 'tab-other',
|
||||
worktreeId: OTHER_WORKTREE_ID
|
||||
})
|
||||
syncParked({
|
||||
worktreeId: OTHER_WORKTREE_ID,
|
||||
tabs: [{ id: 'tab-other', ptyId: otherPtyId }],
|
||||
parkedTabIds: ['tab-other']
|
||||
})
|
||||
|
||||
// Unparking everything in the other worktree must not touch this one.
|
||||
syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] })
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID])
|
||||
expect(startedWatchers[0].dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
disposeParkedTerminalWatchersForWorktree(WORKTREE_ID)
|
||||
expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1)
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID]))
|
||||
expect(getParkedTerminalWatcherTabIds()).toEqual([])
|
||||
})
|
||||
|
||||
describe('shouldDeferParkedPtyExitTabClose', () => {
|
||||
const closeTab = vi.fn()
|
||||
|
||||
// Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab.
|
||||
function hostOnPtyExit(tabId: string, ptyId: string): void {
|
||||
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTab(tabId)
|
||||
}
|
||||
|
||||
it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
// The dead leaf's runtime-title slot cannot pin worktree status.
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1)
|
||||
})
|
||||
|
||||
it('keeps exit→closeTab parity for a parked single-leaf tab', () => {
|
||||
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
syncParked()
|
||||
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
|
||||
it('keeps exit→closeTab parity when the tab is not parked', () => {
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
|
||||
it('closes the tab when the last surviving leaf of a parked split exits', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
syncParked()
|
||||
|
||||
// First leaf dies: deferred, then its exit sidecar drops the watcher.
|
||||
hostOnPtyExit(TAB_ID, PTY_ID)
|
||||
exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0)
|
||||
expect(closeTab).not.toHaveBeenCalled()
|
||||
|
||||
hostOnPtyExit(TAB_ID, SECOND_PTY_ID)
|
||||
expect(closeTab).toHaveBeenCalledWith(TAB_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canWatcherCoverParkedTerminalTab', () => {
|
||||
it('rejects a tab with no unmount capture and no layout snapshot', () => {
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a current capture whose panes are all snapshot-backed', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a capture containing a legacy non-UUID leaf id', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a capture containing a PTY without snapshot backing', () => {
|
||||
capturePanes([
|
||||
{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true },
|
||||
{ ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false }
|
||||
])
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts layout-derived candidates when the capture is stale', () => {
|
||||
capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects layout-derived candidates missing a leaf PTY binding', () => {
|
||||
mockStoreState.terminalLayoutsByTabId[TAB_ID] = {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'row',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('fallbackParkedPaneCandidates', () => {
|
||||
it('returns nothing without a layout snapshot', () => {
|
||||
expect(
|
||||
fallbackParkedPaneCandidates(
|
||||
{ id: TAB_ID, ptyId: PTY_ID },
|
||||
{ terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} }
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('reuses the single runtime-title slot for a single-pane tab', () => {
|
||||
expect(
|
||||
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null }
|
||||
},
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } }
|
||||
} as never)
|
||||
).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }])
|
||||
})
|
||||
|
||||
it('maps split leaves to layout PTYs with collision-free negative pane ids', () => {
|
||||
expect(
|
||||
fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, {
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: {
|
||||
type: 'split',
|
||||
direction: 'row',
|
||||
first: { type: 'leaf', leafId: LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SECOND_LEAF_ID }
|
||||
},
|
||||
activeLeafId: SECOND_LEAF_ID,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID }
|
||||
}
|
||||
},
|
||||
runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } }
|
||||
} as never)
|
||||
).toEqual([
|
||||
{ ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false },
|
||||
{ ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Parked terminal tab watcher lifecycle.
|
||||
*
|
||||
* Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer
|
||||
* byte parsers. This module owns the pane-less replacement: it remembers the
|
||||
* unmounted panes' identities (pane id / leaf id), starts one
|
||||
* parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on
|
||||
* reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps
|
||||
* live in terminal-parked-watcher-registry so the terminals store slice can
|
||||
* dispose watchers without importing this store-coupled module.
|
||||
* See docs/reference/terminal-hidden-view-parking.md.
|
||||
*/
|
||||
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids'
|
||||
import { subscribeToPtyExit } from './pty-dispatcher'
|
||||
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
|
||||
import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking'
|
||||
import {
|
||||
capturedPanesByTabId,
|
||||
disposeParkedTabWatchers,
|
||||
parkedWatchersByTabId,
|
||||
type ParkedTerminalPaneCapture
|
||||
} from './terminal-parked-watcher-registry'
|
||||
|
||||
// Why: re-exported so park wiring keeps one import surface; the registry
|
||||
// split exists only to break the store-slice import cycle.
|
||||
export {
|
||||
captureParkedTerminalPaneCandidates,
|
||||
disposeParkedTerminalWatchersForPtyIds,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
getParkedTerminalWatcherTabIds,
|
||||
pruneParkedTerminalWatchers
|
||||
} from './terminal-parked-watcher-registry'
|
||||
export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry'
|
||||
|
||||
export type ParkableTerminalTabModel = Pick<TerminalTab, 'id' | 'ptyId'>
|
||||
|
||||
type ParkedPaneFallbackState = {
|
||||
terminalLayoutsByTabId: ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
|
||||
runtimePaneTitlesByTabId: ReturnType<typeof useAppStore.getState>['runtimePaneTitlesByTabId']
|
||||
}
|
||||
|
||||
// Why: if no unmount capture exists (or it predates a PTY respawn), derive
|
||||
// pane identities from the persisted layout snapshot. Numeric pane ids are
|
||||
// unknown here: reuse the single existing runtime-title slot when unambiguous
|
||||
// so a stale "working" title still gets overwritten, otherwise use negative
|
||||
// slots that can never collide with real PaneManager ids.
|
||||
export function fallbackParkedPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const layout = state.terminalLayoutsByTabId[tab.id]
|
||||
const leafIds = collectLeafIdsInOrder(layout?.root)
|
||||
if (leafIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {}
|
||||
const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {})
|
||||
const reusableSlot =
|
||||
leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null
|
||||
return leafIds.map((leafId, index) => ({
|
||||
ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null),
|
||||
paneId: reusableSlot ?? -(index + 1),
|
||||
leafId,
|
||||
drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0
|
||||
}))
|
||||
}
|
||||
|
||||
// Why: unmount captures and layout fallbacks must resolve identically for the
|
||||
// watcher start path and the park-eligibility coverage check, or a tab could
|
||||
// pass the check and then start with different (uncoverable) candidates.
|
||||
function resolveParkedTerminalPaneCandidates(
|
||||
tab: ParkableTerminalTabModel,
|
||||
state: ParkedPaneFallbackState
|
||||
): ParkedTerminalPaneCapture[] {
|
||||
const captured = capturedPanesByTabId.get(tab.id)
|
||||
// Why: a capture that no longer mentions the tab's current PTY is stale
|
||||
// (the PTY was re-minted since the unmount); fall back to the layout.
|
||||
const capturedIsCurrent =
|
||||
captured !== undefined &&
|
||||
captured.panes.length > 0 &&
|
||||
(tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId))
|
||||
return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the parked byte watchers can fully cover this tab's PTYs (some
|
||||
* candidate exists and every candidate has a snapshot-backed PTY bound to a
|
||||
* valid leaf). Hosts must refuse to park a tab that fails this check —
|
||||
* parking it would silently drop bell/title/completion side effects, the
|
||||
* exact failure that sank the first parking attempt.
|
||||
*/
|
||||
// Extraction-only guard (standalone parking PR, not in PR #7214): this build
|
||||
// ships no main-process side-effect emitter, so a persisted
|
||||
// terminalMainSideEffectAuthority=true (for example settings carried back
|
||||
// from a newer build) would put parked watchers in fact mode on a
|
||||
// pty:sideEffect channel that never emits — silent notification loss, the
|
||||
// failure that sank the first parking attempt. Refusing coverage keeps such
|
||||
// tabs mounted (status quo). Once the emitter lands, the channel exists and
|
||||
// this never trips; reconcile by deleting it with #7214.
|
||||
function wouldParkedWatcherFactModeBeSilent(): boolean {
|
||||
// Why hydrated-settings-only: park decisions cannot precede settings
|
||||
// hydration (cold-park hysteresis is 30s), so the pre-hydration sync-read
|
||||
// path never parks and this check stays a pure "explicitly configured on
|
||||
// without the emitter" tripwire.
|
||||
const settings = useAppStore.getState().settings
|
||||
return (
|
||||
settings != null &&
|
||||
settings.terminalMainSideEffectAuthority !== false &&
|
||||
typeof (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect !== 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export function canWatcherCoverParkedTerminalTab(
|
||||
worktreeId: string,
|
||||
tab: ParkableTerminalTabModel
|
||||
): boolean {
|
||||
if (wouldParkedWatcherFactModeBeSilent()) {
|
||||
return false
|
||||
}
|
||||
const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState())
|
||||
return (
|
||||
panes.length > 0 &&
|
||||
panes.every(
|
||||
(pane) =>
|
||||
pane.ptyId !== null &&
|
||||
isTerminalLeafId(pane.leafId) &&
|
||||
isSnapshotBackedTerminalPty(pane.ptyId, worktreeId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void {
|
||||
const state = useAppStore.getState()
|
||||
const panes = resolveParkedTerminalPaneCandidates(tab, state)
|
||||
const disposersByPtyId = new Map<string, () => void>()
|
||||
const paneIdByPtyId = new Map<string, number>()
|
||||
for (const pane of panes) {
|
||||
const ptyId = pane.ptyId
|
||||
// Why: the park policy already excludes non-snapshot-backed PTYs, but the
|
||||
// tab model can change between the park decision and this effect — guard
|
||||
// again so remote-runtime/SSH PTYs never get a local watcher. Legacy
|
||||
// non-UUID leaf ids are skipped because makePaneKey throws on them.
|
||||
if (
|
||||
!ptyId ||
|
||||
disposersByPtyId.has(ptyId) ||
|
||||
!isTerminalLeafId(pane.leafId) ||
|
||||
!isSnapshotBackedTerminalPty(ptyId, worktreeId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId]
|
||||
const disposeWatcher = startParkedTerminalByteWatcher({
|
||||
ptyId,
|
||||
tabId: tab.id,
|
||||
worktreeId,
|
||||
leafId: pane.leafId,
|
||||
paneId: pane.paneId,
|
||||
drivesTabTitle: pane.drivesTabTitle,
|
||||
// Why: seed the watcher's agent tracker with the pane's last known
|
||||
// title so an agent already working at park time still notifies when
|
||||
// it finishes while parked.
|
||||
...(initialTitle !== undefined ? { initialTitle } : {}),
|
||||
// Why: no pane transport exists while parked; write straight to the
|
||||
// PTY, the same channel background agent launches use.
|
||||
sendInput: (data) => window.api.pty.write(ptyId, data)
|
||||
})
|
||||
// Why: a PTY that exits while parked has no pane to run exit cleanup; at
|
||||
// minimum its watcher must not outlive it.
|
||||
const unsubscribeExit = subscribeToPtyExit(ptyId, () => {
|
||||
disposersByPtyId.get(ptyId)?.()
|
||||
disposersByPtyId.delete(ptyId)
|
||||
// Why: with the last watcher gone there is nothing left to watch or
|
||||
// dispose; dropping the entry keeps the registry bounded to parked
|
||||
// tabs that still hold live PTYs.
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) {
|
||||
parkedWatchersByTabId.delete(tab.id)
|
||||
}
|
||||
})
|
||||
paneIdByPtyId.set(ptyId, pane.paneId)
|
||||
disposersByPtyId.set(ptyId, () => {
|
||||
unsubscribeExit()
|
||||
disposeWatcher()
|
||||
})
|
||||
}
|
||||
// Why: tracked even with zero watchers so parked-state introspection
|
||||
// (window.__terminalParkingDebug) reflects every parked tab.
|
||||
parkedWatchersByTabId.set(tab.id, {
|
||||
worktreeId,
|
||||
tabPtyId: tab.ptyId,
|
||||
paneIdByPtyId,
|
||||
disposersByPtyId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts call this from their onPtyExit handlers before closing the tab.
|
||||
* Returns true when the close must be deferred: a parked tab has no
|
||||
* PaneManager to promote split siblings, so the live exit path degenerates to
|
||||
* "close the whole tab" — which would kill the surviving sibling panes. The
|
||||
* reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs
|
||||
* return false so exit→closeTab parity is preserved. Also clears the dead
|
||||
* leaf's runtime-title slot so a stale title cannot pin worktree status.
|
||||
*/
|
||||
export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean {
|
||||
const entry = parkedWatchersByTabId.get(tabId)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
const paneId = entry.paneIdByPtyId.get(ptyId)
|
||||
if (paneId !== undefined) {
|
||||
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
|
||||
}
|
||||
const remaining = entry.disposersByPtyId.size
|
||||
if (remaining === 0) {
|
||||
return false
|
||||
}
|
||||
// Why: this runs from the PTY exit handler, before the exit sidecar above
|
||||
// removes the dead PTY's watcher — so the watcher count still includes the
|
||||
// exiting PTY. More than one watcher (or an exit for an unwatched PTY)
|
||||
// means live sibling leaves remain.
|
||||
return remaining > 1 || !entry.disposersByPtyId.has(ptyId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles watchers for one worktree against its rendered parked set.
|
||||
* Callers run this from an effect keyed on the committed render state, so
|
||||
* disposal lands in the same effect flush as a reveal remount (before any
|
||||
* PTY data IPC can be delivered) and start lands after the park unmount.
|
||||
*/
|
||||
export function syncParkedTerminalTabWatchers(args: {
|
||||
worktreeId: string
|
||||
tabs: readonly ParkableTerminalTabModel[]
|
||||
parkedTabIds: ReadonlySet<string>
|
||||
}): void {
|
||||
const liveTabIds = new Set(args.tabs.map((tab) => tab.id))
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (entry.worktreeId !== args.worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
// Why: captures for closed tabs have no future park/reveal; drop them so
|
||||
// the registry stays bounded by live tabs.
|
||||
for (const [tabId, capture] of capturedPanesByTabId) {
|
||||
if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) {
|
||||
capturedPanesByTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
for (const tab of args.tabs) {
|
||||
if (!args.parkedTabIds.has(tab.id)) {
|
||||
continue
|
||||
}
|
||||
const entry = parkedWatchersByTabId.get(tab.id)
|
||||
if (entry && entry.tabPtyId !== tab.ptyId) {
|
||||
disposeParkedTabWatchers(tab.id)
|
||||
}
|
||||
if (!parkedWatchersByTabId.has(tab.id)) {
|
||||
startParkedTabWatchers(args.worktreeId, tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Parked terminal watcher registry (store-free bookkeeping).
|
||||
*
|
||||
* Why a separate module: shutdownWorktreeTerminals (a store slice) must
|
||||
* synchronously dispose parked watchers, but the watcher lifecycle module
|
||||
* imports the store — a slice importing it would re-enter store creation
|
||||
* mid-evaluation. Keeping the maps and pure disposal here lets the slice
|
||||
* import cycle-free, mirroring how pty-dispatcher exports its handler maps.
|
||||
*/
|
||||
|
||||
export type ParkedTerminalPaneCapture = {
|
||||
ptyId: string | null
|
||||
/** PaneManager numeric pane id the live pane used for runtime titles. */
|
||||
paneId: number
|
||||
/** Stable terminal-layout leaf UUID (paneKey attribution). */
|
||||
leafId: string
|
||||
drivesTabTitle: boolean
|
||||
}
|
||||
|
||||
export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] }
|
||||
|
||||
export const capturedPanesByTabId = new Map<string, CapturedTabPanes>()
|
||||
|
||||
// Why: PaneManager pane ids die with the unmounted pane, but the watcher must
|
||||
// keep writing the exact runtime-title slots the live pane used — a different
|
||||
// slot would strand a stale "working" title that pins worktree status.
|
||||
// TerminalPane unmount records the identities here for the park wiring.
|
||||
export function captureParkedTerminalPaneCandidates(
|
||||
tabId: string,
|
||||
worktreeId: string,
|
||||
panes: ParkedTerminalPaneCapture[]
|
||||
): void {
|
||||
capturedPanesByTabId.set(tabId, { worktreeId, panes })
|
||||
}
|
||||
|
||||
export type ParkedTabWatcherEntry = {
|
||||
worktreeId: string
|
||||
/** Tab-level ptyId at watcher start; a change means the PTY was re-minted
|
||||
* (e.g. wake respawn) and the watchers must restart against fresh ids. */
|
||||
tabPtyId: string | null
|
||||
/** Runtime-title slot each watcher writes, so parked PTY-exit handling can
|
||||
* clear the dead leaf's slot (no live pane will ever overwrite it). */
|
||||
paneIdByPtyId: Map<string, number>
|
||||
disposersByPtyId: Map<string, () => void>
|
||||
}
|
||||
|
||||
export const parkedWatchersByTabId = new Map<string, ParkedTabWatcherEntry>()
|
||||
|
||||
export function getParkedTerminalWatcherTabIds(): string[] {
|
||||
return Array.from(parkedWatchersByTabId.keys())
|
||||
}
|
||||
|
||||
export function disposeParkedTabWatchers(tabId: string): void {
|
||||
const entry = parkedWatchersByTabId.get(tabId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
parkedWatchersByTabId.delete(tabId)
|
||||
for (const dispose of entry.disposersByPtyId.values()) {
|
||||
dispose()
|
||||
}
|
||||
entry.disposersByPtyId.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously disposes any parked watcher subscribed to these PTYs.
|
||||
* shutdownWorktreeTerminals silences the live transports' final teardown
|
||||
* flush via unregisterPtyDataHandlers, but parked watchers ride the
|
||||
* dispatcher SIDECAR channel that call does not touch — without this, the
|
||||
* flush still marks unread and arms notification timers for a worktree that
|
||||
* is already sleeping or deleted. The tab entries are kept so a sleeping
|
||||
* parked tab does not restart watchers against its stale PTY ids; wake
|
||||
* re-mints the ids and the sync path restarts watchers then.
|
||||
*/
|
||||
export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void {
|
||||
for (const entry of parkedWatchersByTabId.values()) {
|
||||
for (const ptyId of ptyIds) {
|
||||
const dispose = entry.disposersByPtyId.get(ptyId)
|
||||
if (dispose) {
|
||||
entry.disposersByPtyId.delete(ptyId)
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void {
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (entry.worktreeId === worktreeId) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops watchers and captures for worktrees that no longer exist. */
|
||||
export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet<string>): void {
|
||||
for (const [tabId, entry] of parkedWatchersByTabId) {
|
||||
if (!liveWorktreeIds.has(entry.worktreeId)) {
|
||||
disposeParkedTabWatchers(tabId)
|
||||
}
|
||||
}
|
||||
for (const [tabId, capture] of capturedPanesByTabId) {
|
||||
if (!liveWorktreeIds.has(capture.worktreeId)) {
|
||||
capturedPanesByTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Byte-mode pinning guard for the standalone hidden-view-parking extraction.
|
||||
*
|
||||
* This build ships NO main-process side-effect emitter (that arrives with PR
|
||||
* #7214), so the parked-tab watcher must always run in byte-parser mode. If
|
||||
* terminalMainSideEffectAuthority ever resolves true here, the watcher would
|
||||
* consume a pty:sideEffect channel that never emits and parked tabs would
|
||||
* silently lose bell/title/completion notifications — the exact failure that
|
||||
* got the first parking attempt reverted. These tests pin the three levers:
|
||||
* the shipped defaults, the mode predicates under those defaults, and the
|
||||
* park-eligibility tripwire for misconfigured (authority-on) settings.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const TAB_ID = 'guard-tab-1'
|
||||
const PTY_ID = `${WORKTREE_ID}@@session-guard`
|
||||
const LEAF_ID = '31111111-1111-4111-8111-111111111111'
|
||||
|
||||
vi.mock('./parked-terminal-byte-watcher', () => ({
|
||||
startParkedTerminalByteWatcher: (_options: ParkedTerminalByteWatcherOptions) => vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./pty-dispatcher', () => ({
|
||||
subscribeToPtyExit: () => vi.fn()
|
||||
}))
|
||||
|
||||
type MockStoreState = {
|
||||
settings?: { terminalMainSideEffectAuthority?: boolean } | null
|
||||
terminalLayoutsByTabId: Record<
|
||||
string,
|
||||
{
|
||||
root: unknown
|
||||
activeLeafId: string | null
|
||||
expandedLeafId: string | null
|
||||
ptyIdsByLeafId?: Record<string, string>
|
||||
}
|
||||
>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let mockStoreState: MockStoreState
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: () => mockStoreState }
|
||||
}))
|
||||
|
||||
import {
|
||||
isMainTerminalSideEffectAuthorityForPty,
|
||||
_resetTerminalSideEffectFactConsumersForTest
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
import {
|
||||
isRendererHiddenPtyDeliveryGateEnabled,
|
||||
_resetHiddenPtyDeliveryGateFlagCacheForTest
|
||||
} from './terminal-hidden-delivery-gate'
|
||||
import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers'
|
||||
|
||||
const originalWindow = (globalThis as { window?: unknown }).window
|
||||
|
||||
function setWindowApi(api: unknown): void {
|
||||
;(globalThis as { window?: unknown }).window = api === undefined ? undefined : { api }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
_resetHiddenPtyDeliveryGateFlagCacheForTest()
|
||||
mockStoreState = {
|
||||
settings: null,
|
||||
terminalLayoutsByTabId: {
|
||||
[TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: PTY_ID }
|
||||
}
|
||||
},
|
||||
runtimePaneTitlesByTabId: {},
|
||||
clearRuntimePaneTitle: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
;(globalThis as { window?: unknown }).window = originalWindow
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
_resetHiddenPtyDeliveryGateFlagCacheForTest()
|
||||
})
|
||||
|
||||
describe('shipped defaults pin byte mode', () => {
|
||||
it('ships all four terminal authority/gate/parking flags OFF', () => {
|
||||
const defaults = getDefaultSettings('/home/guard-test')
|
||||
expect(defaults.terminalHiddenViewParking).toBe(false)
|
||||
expect(defaults.terminalMainSideEffectAuthority).toBe(false)
|
||||
expect(defaults.terminalHiddenDeliveryGate).toBe(false)
|
||||
expect(defaults.terminalModelQueryAuthority).toBe(false)
|
||||
})
|
||||
|
||||
it('selects byte-parser mode for parked watchers under the shipped defaults', () => {
|
||||
// The watcher's mode ternary is exactly this predicate: false → byte mode
|
||||
// (registers byte parsers), true → fact mode (dead channel in this build).
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: getDefaultSettings('/home/guard-test'),
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the hidden-delivery gate off under the shipped defaults', () => {
|
||||
expect(isRendererHiddenPtyDeliveryGateEnabled(getDefaultSettings('/home/guard-test'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fact mode requires the pty:sideEffect channel', () => {
|
||||
it('refuses park coverage when authority is configured on without the channel', () => {
|
||||
// Misconfiguration scenario: settings carried back from a build that had
|
||||
// the main emitter (authority=true) running on this emitter-less build.
|
||||
mockStoreState.settings = { terminalMainSideEffectAuthority: true }
|
||||
setWindowApi({ pty: {} })
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(false)
|
||||
})
|
||||
|
||||
it('allows park coverage for authority-on settings once the channel exists', () => {
|
||||
mockStoreState.settings = { terminalMainSideEffectAuthority: true }
|
||||
setWindowApi({ pty: { onSideEffect: () => () => {} } })
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(true)
|
||||
})
|
||||
|
||||
it('allows park coverage in byte mode without any channel', () => {
|
||||
mockStoreState.settings = { terminalMainSideEffectAuthority: false }
|
||||
setWindowApi({ pty: {} })
|
||||
expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null }
|
||||
|
||||
let mockE2EConfig: MockE2EConfig
|
||||
|
||||
vi.mock('@/lib/e2e-config', () => ({
|
||||
get e2eConfig() {
|
||||
return mockE2EConfig
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-parked-tab-watchers', () => ({
|
||||
getParkedTerminalWatcherTabIds: () => ['tab-parked']
|
||||
}))
|
||||
|
||||
const originalWindow = (globalThis as { window?: unknown }).window
|
||||
|
||||
type TerminalParkingE2EOverridesModule = {
|
||||
getTerminalParkingPolicyOverrides: () => {
|
||||
coldParkDelayMs?: number
|
||||
hotRetainMs?: number
|
||||
hotRetainLimit?: number
|
||||
}
|
||||
registerTerminalParkingDebugHandle: () => void
|
||||
}
|
||||
|
||||
async function importOverridesModule(): Promise<TerminalParkingE2EOverridesModule> {
|
||||
vi.resetModules()
|
||||
return import('./terminal-parking-e2e-overrides')
|
||||
}
|
||||
|
||||
describe('getTerminalParkingPolicyOverrides', () => {
|
||||
beforeEach(() => {
|
||||
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
;(globalThis as { window?: unknown }).window = originalWindow
|
||||
})
|
||||
|
||||
it('ignores the delay override outside e2e (exposeStore off)', async () => {
|
||||
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 }
|
||||
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
|
||||
expect(getTerminalParkingPolicyOverrides()).toEqual({})
|
||||
})
|
||||
|
||||
it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => {
|
||||
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
|
||||
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
|
||||
expect(getTerminalParkingPolicyOverrides()).toEqual({
|
||||
coldParkDelayMs: 500,
|
||||
hotRetainMs: 500
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no overrides when no delay is configured', async () => {
|
||||
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null }
|
||||
const { getTerminalParkingPolicyOverrides } = await importOverridesModule()
|
||||
expect(getTerminalParkingPolicyOverrides()).toEqual({})
|
||||
})
|
||||
|
||||
it('registers window.__terminalParkingDebug on import under exposeStore', async () => {
|
||||
mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 }
|
||||
const testWindow: {
|
||||
__terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] }
|
||||
} = {}
|
||||
;(globalThis as { window?: unknown }).window = testWindow
|
||||
await importOverridesModule()
|
||||
expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500)
|
||||
expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked'])
|
||||
})
|
||||
|
||||
it('does not register the debug handle outside e2e', async () => {
|
||||
mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null }
|
||||
const testWindow: { __terminalParkingDebug?: unknown } = {}
|
||||
;(globalThis as { window?: unknown }).window = testWindow
|
||||
await importOverridesModule()
|
||||
expect(testWindow.__terminalParkingDebug).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
type TerminalColdParkPolicyOverrides
|
||||
} from './terminal-hidden-view-parking'
|
||||
import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
|
||||
|
||||
// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park
|
||||
// hysteresis and the hot-retain window — recently hidden tabs otherwise sit
|
||||
// in the hot-retain working set for 5 minutes and never park within a test
|
||||
// run. Gated on exposeStore so packaged builds ignore stray env vars.
|
||||
export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides {
|
||||
const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null
|
||||
return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0
|
||||
? { coldParkDelayMs: delayMs, hotRetainMs: delayMs }
|
||||
: {}
|
||||
}
|
||||
|
||||
export function registerTerminalParkingDebugHandle(): void {
|
||||
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.__terminalParkingDebug = {
|
||||
parkDelayMs:
|
||||
getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
parkedTabIds: () => getParkedTerminalWatcherTabIds()
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the parking e2e spec gates on window.__terminalParkingDebug existing
|
||||
// shortly after launch. This module is statically imported by the park
|
||||
// wiring, so registering at module load makes the handle visible before any
|
||||
// tab parks.
|
||||
registerTerminalParkingDebugHandle()
|
||||
@@ -0,0 +1,509 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts'
|
||||
import {
|
||||
_dispatchTerminalSideEffectBatchForTest,
|
||||
_resetTerminalSideEffectFactConsumersForTest,
|
||||
isMainTerminalSideEffectAuthorityForPty,
|
||||
registerTerminalSideEffectFactConsumer,
|
||||
type TerminalSideEffectFactConsumerCallbacks
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
|
||||
const PTY_ID = 'wt-1#1'
|
||||
|
||||
function createCallbackRecorder(): {
|
||||
callbacks: TerminalSideEffectFactConsumerCallbacks
|
||||
events: unknown[][]
|
||||
} {
|
||||
const events: unknown[][] = []
|
||||
return {
|
||||
events,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle, rawTitle) =>
|
||||
events.push(['title', normalizedTitle, rawTitle]),
|
||||
onBell: () => events.push(['bell']),
|
||||
onAgentBecameIdle: (title) => events.push(['idle', title]),
|
||||
onAgentBecameWorking: () => events.push(['working']),
|
||||
onAgentExited: () => events.push(['exited'])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function batch(
|
||||
facts: TerminalSideEffectBatch['facts'],
|
||||
options: Partial<TerminalSideEffectBatch> = {}
|
||||
): TerminalSideEffectBatch {
|
||||
return { ptyId: PTY_ID, seq: 0, facts, ...options }
|
||||
}
|
||||
|
||||
describe('isMainTerminalSideEffectAuthorityForPty', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
function setPersistedSettingsSync(settings: unknown): void {
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { settings: { getSync: () => settings } }
|
||||
}
|
||||
}
|
||||
|
||||
it('is on by default for PTYs whose bytes transit local main', () => {
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
// Why: settings hydrate asynchronously; the default-on switch must not
|
||||
// flip authority off during the null-settings startup window.
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is off for remote-runtime PTYs regardless of the setting', () => {
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: { terminalMainSideEffectAuthority: true },
|
||||
runtimeEnvironmentId: 'env-1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is off when the kill switch is disabled', () => {
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: { terminalMainSideEffectAuthority: false },
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors the persisted kill switch before settings hydrate', () => {
|
||||
// Why: the authority decision is made once at transport creation; a pane
|
||||
// bound during startup must not pick main authority when the user
|
||||
// persisted the switch off.
|
||||
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
|
||||
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('stays on pre-hydration when the persisted switch is on or unset', () => {
|
||||
setPersistedSettingsSync({ terminalMainSideEffectAuthority: true })
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
setPersistedSettingsSync({})
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('prefers hydrated settings over the persisted sync read', () => {
|
||||
setPersistedSettingsSync({ terminalMainSideEffectAuthority: false })
|
||||
|
||||
expect(
|
||||
isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: { terminalMainSideEffectAuthority: true },
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('caches the sync read so panes do not re-block per bind', () => {
|
||||
const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false }))
|
||||
;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } }
|
||||
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null })
|
||||
|
||||
expect(getSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('registerTerminalSideEffectFactConsumer', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
beforeEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetTerminalSideEffectFactConsumersForTest()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('routes live facts to the registered consumer in batch order', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' },
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
|
||||
{ kind: 'agent-idle', title: '✳ Claude' },
|
||||
{ kind: 'bell' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['title', '⠋ Claude', '⠋ Claude'],
|
||||
['working'],
|
||||
['title', '✳ Claude', '✳ Claude'],
|
||||
['idle', '✳ Claude'],
|
||||
['bell']
|
||||
])
|
||||
})
|
||||
|
||||
it('routes command-finished and pr-link facts to the registered consumer', () => {
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
|
||||
onPrLink: (link) => events.push(['pr', link.url, link.number])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'command-finished', exitCode: 130 },
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 42
|
||||
}
|
||||
},
|
||||
{ kind: 'command-finished', exitCode: null }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['finished', 130],
|
||||
['pr', 'https://github.com/acme/orca/pull/42', 42],
|
||||
['finished', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('routes command-code scrape facts to the registered consumer', () => {
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
|
||||
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
|
||||
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['cc-working', 'Fix the spinner'],
|
||||
['cc-done', 'Fix the spinner']
|
||||
])
|
||||
})
|
||||
|
||||
it('never replays command-code scrape facts', () => {
|
||||
// Why: a replayed working/done seed would resurrect a finished turn's
|
||||
// status row — replay batches restore title state only.
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
|
||||
onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]),
|
||||
onCommandCodeDone: (prompt) => events.push(['cc-done', prompt])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
|
||||
{ kind: 'command-code-working', prompt: 'Fix the spinner' },
|
||||
{ kind: 'command-code-done', prompt: 'Fix the spinner' }
|
||||
],
|
||||
{ replay: true, seq: 5 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(events).toEqual([['title', 'restored']])
|
||||
})
|
||||
|
||||
it('routes 2031-subscribe facts to the registered consumer but never replays them', () => {
|
||||
// Why: the fact lets hidden-delivery-gated views answer the color-scheme
|
||||
// query without byte access; a replayed subscribe would re-answer a query
|
||||
// the snapshot already satisfied.
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
|
||||
onMode2031Subscribe: () => events.push(['2031-subscribe'])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }]))
|
||||
expect(events).toEqual([['2031-subscribe']])
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
|
||||
{ kind: '2031-subscribe' }
|
||||
],
|
||||
{ replay: true, seq: 5 }
|
||||
)
|
||||
)
|
||||
expect(events).toEqual([['2031-subscribe'], ['title', 'restored']])
|
||||
})
|
||||
|
||||
it('never replays command-finished or pr-link facts', () => {
|
||||
// Why: like bells and agent transitions, command/PR facts are attention
|
||||
// signals — replay snapshots restore title state only.
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]),
|
||||
onCommandFinished: (exitCode) => events.push(['finished', exitCode]),
|
||||
onPrLink: (link) => events.push(['pr', link.url])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' },
|
||||
{ kind: 'command-finished', exitCode: 0 },
|
||||
{
|
||||
kind: 'pr-link',
|
||||
link: {
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
slug: { owner: 'acme', repo: 'orca' },
|
||||
number: 42
|
||||
}
|
||||
}
|
||||
],
|
||||
{ replay: true, seq: 5 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(events).toEqual([['title', 'restored']])
|
||||
})
|
||||
|
||||
it('passes stale-clear provenance through to the title and idle callbacks', () => {
|
||||
const events: unknown[][] = []
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: {
|
||||
onTitleChange: (normalizedTitle, _rawTitle, meta) =>
|
||||
events.push(['title', normalizedTitle, meta]),
|
||||
onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta])
|
||||
}
|
||||
})
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([
|
||||
{ kind: 'agent-idle', title: 'Codex done' },
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Codex',
|
||||
rawTitle: 'Codex',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true }
|
||||
])
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['idle', 'Codex done', undefined],
|
||||
['title', 'Codex', { staleWorkingTitleClear: true }],
|
||||
['idle', 'Codex', { staleWorkingTitleClear: true }]
|
||||
])
|
||||
})
|
||||
|
||||
it('drops batches for PTYs without a registered consumer', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' }))
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('applies only title facts from replay batches — no attention replay', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' },
|
||||
{ kind: 'bell' },
|
||||
{ kind: 'agent-idle', title: '✳ Claude' }
|
||||
],
|
||||
{ replay: true, seq: 10 }
|
||||
)
|
||||
)
|
||||
|
||||
expect(events).toEqual([['title', '✳ Claude', '✳ Claude']])
|
||||
})
|
||||
|
||||
it('drops a replay title not newer than the last applied live title', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 })
|
||||
)
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], {
|
||||
replay: true,
|
||||
seq: 20
|
||||
})
|
||||
)
|
||||
_dispatchTerminalSideEffectBatchForTest(
|
||||
batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], {
|
||||
replay: true,
|
||||
seq: 21
|
||||
})
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
['title', 'live', 'live'],
|
||||
['title', 'newer', 'newer']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps exactly one consumer per PTY: a new registration replaces the old', () => {
|
||||
const first = createCallbackRecorder()
|
||||
const second = createCallbackRecorder()
|
||||
const disposeFirst = registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: first.callbacks
|
||||
})
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
|
||||
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
|
||||
// A stale registration's dispose must not evict the live consumer.
|
||||
disposeFirst()
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
|
||||
|
||||
expect(first.events).toEqual([])
|
||||
expect(second.events).toEqual([['bell'], ['bell']])
|
||||
})
|
||||
|
||||
it('stops routing after the consumer unregisters', () => {
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks })
|
||||
|
||||
dispose()
|
||||
_dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }]))
|
||||
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('subscribes to the channel once and routes IPC batches', () => {
|
||||
let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null
|
||||
const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => {
|
||||
channelCallback = callback
|
||||
return () => {
|
||||
channelCallback = null
|
||||
}
|
||||
})
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { pty: { onSideEffect } }
|
||||
}
|
||||
const first = createCallbackRecorder()
|
||||
const second = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks })
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks })
|
||||
|
||||
expect(onSideEffect).toHaveBeenCalledTimes(1)
|
||||
channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' }))
|
||||
expect(second.events).toEqual([['bell']])
|
||||
})
|
||||
|
||||
it('applies the title snapshot on register unless the registration was replaced', async () => {
|
||||
let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {}
|
||||
const getSideEffectSnapshot = vi.fn(
|
||||
() =>
|
||||
new Promise<TerminalSideEffectBatch | null>((resolve) => {
|
||||
resolveSnapshot = resolve
|
||||
})
|
||||
)
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { pty: { getSideEffectSnapshot } }
|
||||
}
|
||||
|
||||
const first = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks: first.callbacks,
|
||||
restoreTitleOnRegister: true
|
||||
})
|
||||
expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID)
|
||||
|
||||
// Replace before the snapshot resolves: the slow snapshot must not fire
|
||||
// into the superseded registration.
|
||||
const second = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks })
|
||||
resolveSnapshot(
|
||||
batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
|
||||
replay: true,
|
||||
seq: 5
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(first.events).toEqual([])
|
||||
expect(second.events).toEqual([])
|
||||
})
|
||||
|
||||
it('restores the snapshot title for a live registration', async () => {
|
||||
const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], {
|
||||
replay: true,
|
||||
seq: 5
|
||||
})
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } }
|
||||
}
|
||||
const { callbacks, events } = createCallbackRecorder()
|
||||
registerTerminalSideEffectFactConsumer({
|
||||
ptyId: PTY_ID,
|
||||
callbacks,
|
||||
restoreTitleOnRegister: true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(events).toEqual([['title', 'restored', 'restored']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Renderer consumer registry for the `pty:sideEffect` channel.
|
||||
*
|
||||
* Why: with main as the side-effect parser for local-daemon/SSH PTYs
|
||||
* (docs/reference/terminal-side-effect-authority.md), the renderer no longer
|
||||
* derives title/bell/agent facts from bytes for those PTYs. This module is
|
||||
* the single channel subscriber; mounted panes and parked-tab watchers
|
||||
* register exactly one fact consumer per PTY (their existing policy
|
||||
* callbacks), so every fact has exactly one policy consumer regardless of
|
||||
* whether the tab is mounted, hidden, or parked. Facts for PTYs without a
|
||||
* registered consumer are dropped — mirroring today's eager-buffer behavior
|
||||
* where pre-mount output produces no attention side effects.
|
||||
*/
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import type {
|
||||
TerminalSideEffectBatch,
|
||||
TerminalSideEffectFact
|
||||
} from '../../../../shared/terminal-side-effect-facts'
|
||||
|
||||
// Why: cached once per session — the blocking read should only ever run on
|
||||
// the pre-hydration startup path, never per pane bind.
|
||||
let persistedAuthorityFlagCache: boolean | null | undefined
|
||||
|
||||
function readPersistedSideEffectAuthorityFlagSync(): boolean | null {
|
||||
if (persistedAuthorityFlagCache === undefined) {
|
||||
try {
|
||||
const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync
|
||||
persistedAuthorityFlagCache =
|
||||
typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null
|
||||
} catch {
|
||||
persistedAuthorityFlagCache = null
|
||||
}
|
||||
}
|
||||
return persistedAuthorityFlagCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural authority predicate: main owns side effects for a PTY when its
|
||||
* bytes transit local main (everything except remote-runtime PTYs) and the
|
||||
* kill switch is on. Decided at transport/watcher creation — never per chunk —
|
||||
* so each fact has one consumer with no race.
|
||||
*/
|
||||
export function isMainTerminalSideEffectAuthorityForPty(args: {
|
||||
settings: Pick<GlobalSettings, 'terminalMainSideEffectAuthority'> | null
|
||||
/** Remote-runtime owner environment; null means bytes transit local main. */
|
||||
runtimeEnvironmentId: string | null
|
||||
}): boolean {
|
||||
if (args.runtimeEnvironmentId !== null) {
|
||||
return false
|
||||
}
|
||||
if (args.settings !== null) {
|
||||
return args.settings.terminalMainSideEffectAuthority !== false
|
||||
}
|
||||
// Why: settings hydrate asynchronously, and the authority decision made
|
||||
// here at transport/watcher creation is never revisited. A pane bound
|
||||
// before hydration must honor the persisted kill switch — otherwise a user
|
||||
// who turned main authority off gets startup panes with no byte parsers
|
||||
// and a fact consumer they disabled. Surfaces without the sync read (web
|
||||
// remote clients, tests) keep the default-on behavior.
|
||||
return readPersistedSideEffectAuthorityFlagSync() !== false
|
||||
}
|
||||
|
||||
export type TerminalSideEffectFactConsumerCallbacks = {
|
||||
/** `meta.staleWorkingTitleClear` marks facts derived from main's 3s
|
||||
* stale-title timer — policy must clear title/cache state without
|
||||
* scheduling task-complete notifications or unread attention. */
|
||||
onTitleChange?: (
|
||||
normalizedTitle: string,
|
||||
rawTitle: string,
|
||||
meta?: { staleWorkingTitleClear?: boolean }
|
||||
) => void
|
||||
onBell?: () => void
|
||||
onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void
|
||||
onAgentBecameWorking?: () => void
|
||||
onAgentExited?: () => void
|
||||
/** OSC 133;D — same policy hook the byte-mode commandLifecycle drove
|
||||
* (stale agent-status row drop + interrupt-inference coordination). */
|
||||
onCommandFinished?: (bestEffortExitCode: number | null) => void
|
||||
onPrLink?: (link: TerminalGitHubPRLink) => void
|
||||
/** Command Code output scrape (no hooks): working seeds the status row;
|
||||
* done is settle-checked by the pane policy before completing the turn. */
|
||||
onCommandCodeWorking?: (prompt: string) => void
|
||||
onCommandCodeDone?: (prompt: string) => void
|
||||
/** DECSET 2031 subscribe observed by main's tracker. Registered only by
|
||||
* hidden-delivery-gated consumers (their bytes never arrive); the theme
|
||||
* reply is sent renderer-side — query authority stays with the view. */
|
||||
onMode2031Subscribe?: () => void
|
||||
}
|
||||
|
||||
type ConsumerEntry = {
|
||||
callbacks: TerminalSideEffectFactConsumerCallbacks
|
||||
/** Output sequence of the last live title fact applied. Replay snapshots at
|
||||
* or before this point are stale and must not regress the title state. */
|
||||
lastLiveTitleSeq: number | null
|
||||
}
|
||||
|
||||
const consumersByPtyId = new Map<string, ConsumerEntry>()
|
||||
let channelUnsubscribe: (() => void) | null = null
|
||||
|
||||
function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void {
|
||||
switch (fact.kind) {
|
||||
case 'title':
|
||||
entry.lastLiveTitleSeq = seq
|
||||
entry.callbacks.onTitleChange?.(
|
||||
fact.normalizedTitle,
|
||||
fact.rawTitle,
|
||||
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
|
||||
)
|
||||
return
|
||||
case 'bell':
|
||||
entry.callbacks.onBell?.()
|
||||
return
|
||||
case 'agent-working':
|
||||
entry.callbacks.onAgentBecameWorking?.()
|
||||
return
|
||||
case 'agent-idle':
|
||||
entry.callbacks.onAgentBecameIdle?.(
|
||||
fact.title,
|
||||
fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined
|
||||
)
|
||||
return
|
||||
case 'agent-exited':
|
||||
entry.callbacks.onAgentExited?.()
|
||||
return
|
||||
case 'command-finished':
|
||||
entry.callbacks.onCommandFinished?.(fact.exitCode)
|
||||
return
|
||||
case 'pr-link':
|
||||
entry.callbacks.onPrLink?.(fact.link)
|
||||
return
|
||||
case 'command-code-working':
|
||||
entry.callbacks.onCommandCodeWorking?.(fact.prompt)
|
||||
return
|
||||
case 'command-code-done':
|
||||
entry.callbacks.onCommandCodeDone?.(fact.prompt)
|
||||
return
|
||||
case '2031-subscribe':
|
||||
entry.callbacks.onMode2031Subscribe?.()
|
||||
}
|
||||
}
|
||||
|
||||
function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void {
|
||||
if (batch.replay) {
|
||||
// Why: the no-attention-replay rule — (re)attach snapshots restore title
|
||||
// state only; historical bells/completions must never fire again. A replay
|
||||
// older (by output sequence) than the last live title fact is stale.
|
||||
if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) {
|
||||
return
|
||||
}
|
||||
for (const fact of batch.facts) {
|
||||
if (fact.kind === 'title') {
|
||||
entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for (const fact of batch.facts) {
|
||||
applyLiveFact(entry, fact, batch.seq)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSideEffectBatch(batch: TerminalSideEffectBatch): void {
|
||||
const entry = consumersByPtyId.get(batch.ptyId)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
applyBatchToConsumer(entry, batch)
|
||||
}
|
||||
|
||||
function ensureSideEffectChannelSubscription(): void {
|
||||
if (channelUnsubscribe !== null) {
|
||||
return
|
||||
}
|
||||
// Why: optional-chained from globalThis so unit tests (and any non-preload
|
||||
// surface) without window.api degrade to "no channel" instead of throwing.
|
||||
const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect
|
||||
if (typeof onSideEffect !== 'function') {
|
||||
return
|
||||
}
|
||||
channelUnsubscribe = onSideEffect(handleSideEffectBatch)
|
||||
}
|
||||
|
||||
export type TerminalSideEffectFactConsumerOptions = {
|
||||
ptyId: string
|
||||
callbacks: TerminalSideEffectFactConsumerCallbacks
|
||||
/** Pull main's title-only replay snapshot on registration. Pane transports
|
||||
* use this in place of deriving titles from eager-buffer byte replay;
|
||||
* parked watchers skip it because the pane's runtime title slot is already
|
||||
* current at park time. */
|
||||
restoreTitleOnRegister?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the single fact consumer for a PTY. A new registration replaces a
|
||||
* stale one for the same PTY (same semantics as the parked watcher registry):
|
||||
* two consumers would double-fire bell/completion policy for the same bytes.
|
||||
*/
|
||||
export function registerTerminalSideEffectFactConsumer(
|
||||
options: TerminalSideEffectFactConsumerOptions
|
||||
): () => void {
|
||||
ensureSideEffectChannelSubscription()
|
||||
const entry: ConsumerEntry = {
|
||||
callbacks: options.callbacks,
|
||||
lastLiveTitleSeq: null
|
||||
}
|
||||
consumersByPtyId.set(options.ptyId, entry)
|
||||
|
||||
if (options.restoreTitleOnRegister) {
|
||||
const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot
|
||||
if (typeof getSnapshot === 'function') {
|
||||
void getSnapshot(options.ptyId)
|
||||
.then((batch) => {
|
||||
// Why: apply only while this registration is still the live
|
||||
// consumer; a slow snapshot must not fire into a replaced one.
|
||||
if (batch && consumersByPtyId.get(options.ptyId) === entry) {
|
||||
applyBatchToConsumer(entry, { ...batch, replay: true })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (consumersByPtyId.get(options.ptyId) === entry) {
|
||||
consumersByPtyId.delete(options.ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: deliver a batch as if it arrived on the channel. */
|
||||
export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void {
|
||||
handleSideEffectBatch(batch)
|
||||
}
|
||||
|
||||
/** Test seam: reset module state between tests. */
|
||||
export function _resetTerminalSideEffectFactConsumersForTest(): void {
|
||||
consumersByPtyId.clear()
|
||||
channelUnsubscribe?.()
|
||||
channelUnsubscribe = null
|
||||
persistedAuthorityFlagCache = undefined
|
||||
}
|
||||
@@ -97,6 +97,7 @@ import {
|
||||
syncTerminalScrollIntentSoon
|
||||
} from '@/lib/pane-manager/terminal-scroll-intent'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import {
|
||||
PRIMARY_SELECTION_MAX_LENGTH,
|
||||
@@ -1656,6 +1657,19 @@ export function useTerminalPaneLifecycle({
|
||||
disposable.dispose()
|
||||
}
|
||||
imeNativeTextForwarderDisposables.clear()
|
||||
// Why: hidden-view parking starts pane-less byte watchers right after
|
||||
// this unmount; record pane identities before transports detach so the
|
||||
// watchers write the same runtime-title slots the live panes used.
|
||||
captureParkedTerminalPaneCandidates(
|
||||
tabId,
|
||||
worktreeId,
|
||||
manager.getPanes().map((capturedPane) => ({
|
||||
ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null,
|
||||
paneId: capturedPane.id,
|
||||
leafId: capturedPane.leafId,
|
||||
drivesTabTitle: manager.getActivePane()?.id === capturedPane.id
|
||||
}))
|
||||
)
|
||||
for (const transport of paneTransports.values()) {
|
||||
const ptyId = transport.getPtyId()
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Per-tab hidden-view parking for TerminalPaneOverlayLayer.
|
||||
*
|
||||
* Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck
|
||||
* timers, parked-set selection) and the parked byte-watcher reconciliation so
|
||||
* the overlay layer only consumes the final parked tab set when deciding to
|
||||
* render a slot as null. See docs/reference/terminal-hidden-view-parking.md.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { TerminalTab } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
findActivityTerminalPortal,
|
||||
type ActivityTerminalPortalTarget
|
||||
} from '../activity/activity-terminal-portal'
|
||||
import {
|
||||
getTerminalTabColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalTabs,
|
||||
type TerminalTabColdParkCandidate
|
||||
} from './terminal-hidden-view-parking'
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
disposeParkedTerminalWatchersForWorktree,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-parked-tab-watchers'
|
||||
|
||||
type TerminalOverlayTabAssignment = {
|
||||
groupId: string
|
||||
isActiveInGroup: boolean
|
||||
}
|
||||
|
||||
function haveSameTerminalTabIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of left) {
|
||||
if (!right.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function useTerminalTabColdParking(args: {
|
||||
worktreeId: string
|
||||
terminalTabs: readonly TerminalTab[]
|
||||
assignments: ReadonlyMap<string, TerminalOverlayTabAssignment>
|
||||
isWorktreeActive: boolean
|
||||
/** Worktree-level park verdict from Terminal.tsx. */
|
||||
coldParkTerminalPanes: boolean
|
||||
/** Hidden-measuring startup probe from Terminal.tsx — the panes must stay
|
||||
* mounted for their first xterm fit, mirroring the worktree-level guard. */
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
}): ReadonlySet<string> {
|
||||
const {
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes,
|
||||
shouldMeasureHiddenWorktree,
|
||||
activityTerminalPortals
|
||||
} = args
|
||||
const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore(
|
||||
(state) => state.settings?.terminalHiddenViewParking !== false
|
||||
)
|
||||
const terminalTabHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalTabParkingTimersRef = useRef(new Map<string, number>())
|
||||
const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0)
|
||||
const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalTabParkingTimersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set
|
||||
// selection, and one recheck timer per still-pending deadline so React
|
||||
// re-renders exactly when the hysteresis elapses instead of polling.
|
||||
useEffect(() => {
|
||||
const timers = terminalTabParkingTimersRef.current
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
|
||||
const nowMs = Date.now()
|
||||
const overrides = getTerminalParkingPolicyOverrides()
|
||||
const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id))
|
||||
const portalTabIds = new Set(
|
||||
activityTerminalPortals
|
||||
.filter((portal) => portal.worktreeId === worktreeId)
|
||||
.map((portal) => portal.tabId)
|
||||
)
|
||||
for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) {
|
||||
if (!currentTerminalTabIds.has(tabId)) {
|
||||
terminalTabHiddenSinceRef.current.delete(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => {
|
||||
const assignment = assignments.get(terminalTab.id)
|
||||
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
|
||||
const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id)
|
||||
// Why: hidden-measuring counts as visibility — the startup probe needs
|
||||
// mounted panes, so the hidden clock must not run during it.
|
||||
if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) {
|
||||
terminalTabHiddenSinceRef.current.delete(terminalTab.id)
|
||||
} else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) {
|
||||
terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs)
|
||||
}
|
||||
return {
|
||||
id: terminalTab.id,
|
||||
ptyId: terminalTab.ptyId,
|
||||
pendingActivationSpawn: terminalTab.pendingActivationSpawn,
|
||||
isVisible,
|
||||
hasActivityTerminalPortal,
|
||||
hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null
|
||||
}
|
||||
})
|
||||
|
||||
const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({
|
||||
worktreeId,
|
||||
terminalTabs: candidates,
|
||||
pendingStartupByTabId,
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
// Why: a tab the byte watchers cannot cover (no capture, no layout
|
||||
// snapshot, legacy leaf ids) must never park — it would go silent for
|
||||
// bells/titles/completions, the failure that sank the first attempt.
|
||||
for (const terminalTab of terminalTabs) {
|
||||
if (
|
||||
nextColdParkedTerminalTabIds.has(terminalTab.id) &&
|
||||
!canWatcherCoverParkedTerminalTab(worktreeId, terminalTab)
|
||||
) {
|
||||
nextColdParkedTerminalTabIds.delete(terminalTab.id)
|
||||
}
|
||||
}
|
||||
setColdParkedTerminalTabIds((current) =>
|
||||
haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds)
|
||||
? current
|
||||
: nextColdParkedTerminalTabIds
|
||||
)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
candidate.isVisible ||
|
||||
candidate.hasActivityTerminalPortal ||
|
||||
nextColdParkedTerminalTabIds.has(candidate.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const delayMs = getTerminalTabColdParkRecheckDelayMs({
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
hiddenSinceMs: candidate.hiddenSinceMs,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
if (delayMs !== null && delayMs > 0) {
|
||||
const tabId = candidate.id
|
||||
const timer = window.setTimeout(() => {
|
||||
timers.delete(tabId)
|
||||
setTerminalTabParkingRevision((revision) => revision + 1)
|
||||
}, delayMs)
|
||||
timers.set(tabId, timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activityTerminalPortals,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
pendingStartupByTabId,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalTabParkingRevision,
|
||||
terminalTabs,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
// Why: the rendered park verdict — worktree-level park (prop from
|
||||
// Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and
|
||||
// the watcher-sync effect must share this exact set so watcher lifecycle
|
||||
// tracks the committed unmounts.
|
||||
const parkedTerminalTabIds = useMemo(() => {
|
||||
const parked = new Set<string>()
|
||||
for (const terminalTab of terminalTabs) {
|
||||
const assignment = assignments.get(terminalTab.id)
|
||||
const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup)
|
||||
const hasActivityTerminalPortal =
|
||||
findActivityTerminalPortal(activityTerminalPortals, {
|
||||
worktreeId,
|
||||
tabId: terminalTab.id
|
||||
}) !== null
|
||||
if (
|
||||
(coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) &&
|
||||
!hasActivityTerminalPortal &&
|
||||
// Why: the hidden-measuring startup probe needs mounted panes; gate
|
||||
// here too so the reveal lands in the same render that starts it.
|
||||
!shouldMeasureHiddenWorktree
|
||||
) {
|
||||
parked.add(terminalTab.id)
|
||||
}
|
||||
}
|
||||
return parked
|
||||
}, [
|
||||
activityTerminalPortals,
|
||||
assignments,
|
||||
coldParkTerminalPanes,
|
||||
coldParkedTerminalTabIds,
|
||||
isWorktreeActive,
|
||||
shouldMeasureHiddenWorktree,
|
||||
terminalTabs,
|
||||
worktreeId
|
||||
])
|
||||
|
||||
// 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
|
||||
// pane's unmount capture.
|
||||
useEffect(() => {
|
||||
syncParkedTerminalTabWatchers({
|
||||
worktreeId,
|
||||
tabs: terminalTabs,
|
||||
parkedTabIds: parkedTerminalTabIds
|
||||
})
|
||||
}, [parkedTerminalTabIds, terminalTabs, worktreeId])
|
||||
|
||||
useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId])
|
||||
|
||||
return parkedTerminalTabIds
|
||||
}
|
||||
Vendored
+4
@@ -67,6 +67,10 @@ declare global {
|
||||
interface Window {
|
||||
__paneManagers?: Map<string, PaneManager>
|
||||
__onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps
|
||||
__terminalParkingDebug?: {
|
||||
parkDelayMs: number
|
||||
parkedTabIds: () => string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Why: the parsing core moved to shared so main's terminal side-effect
|
||||
// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3).
|
||||
// Re-exported here so renderer consumers keep their '@/lib' import path.
|
||||
// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link-
|
||||
// picker input policy, not parsing.
|
||||
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from '../../../shared/github-links'
|
||||
|
||||
import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds'
|
||||
|
||||
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
|
||||
|
||||
export type RepoSlug = {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
export * from '../../../shared/github-links'
|
||||
|
||||
export type GitHubLinkQuery = {
|
||||
query: string
|
||||
@@ -13,97 +15,6 @@ export type GitHubLinkQuery = {
|
||||
tooLarge?: boolean
|
||||
}
|
||||
|
||||
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
|
||||
if (!slug?.owner || !slug.repo) {
|
||||
return null
|
||||
}
|
||||
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
|
||||
}
|
||||
|
||||
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
|
||||
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
|
||||
}
|
||||
|
||||
function parseGitHubItemNumber(value: string): number | null {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a GitHub issue/PR reference from plain input.
|
||||
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRNumber(input: string): number | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
|
||||
if (/^\d+$/.test(numeric)) {
|
||||
return parseGitHubItemNumber(numeric)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseGitHubItemNumber(match[4])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
|
||||
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRLink(input: string): {
|
||||
slug: RepoSlug
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
} | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const number = parseGitHubItemNumber(match[4])
|
||||
if (number === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
slug: { owner: match[1], repo: match[2] },
|
||||
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
|
||||
number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes link-picker input so both raw issue/PR numbers and full GitHub
|
||||
* URLs resolve to a usable query + direct-number lookup.
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
WorktreeMeta,
|
||||
WorkspaceKey
|
||||
} from '../../../../shared/types'
|
||||
import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector'
|
||||
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationPhase
|
||||
|
||||
+20
-483
@@ -1,23 +1,27 @@
|
||||
/**
|
||||
* Shared agent detection utilities — used by both the main process (stats
|
||||
* collection) and the renderer (activity indicators, unread badges).
|
||||
* Compatibility barrel for shared terminal agent-title detection.
|
||||
*
|
||||
* Why shared: the main process needs the same OSC title extraction and agent
|
||||
* status detection for stat tracking that the renderer uses for UI indicators.
|
||||
* Duplicating this logic would risk drift between the two detection paths.
|
||||
* Why shared: main and renderer both consume OSC titles for facts, stats, and
|
||||
* UI state. Keep existing imports stable while the implementation stays split
|
||||
* into focused modules that satisfy max-lines.
|
||||
*/
|
||||
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
DROID_AGENT_NAME_RE,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
titleHasAgentName,
|
||||
titleHasAnyLegacyAgentName
|
||||
} from './agent-name-token-match'
|
||||
import {
|
||||
getPiCompatibleSyntheticAgentLabel,
|
||||
getPiCompatibleSyntheticAgentStatus
|
||||
} from './pi-compatible-synthetic-title'
|
||||
export type { AgentStatus } from './agent-title-core'
|
||||
export {
|
||||
isClaudeManagementTitle,
|
||||
isCursorNativeAgentTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiTerminalTitle,
|
||||
STRONG_IDLE_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE
|
||||
} from './agent-title-core'
|
||||
export { getAgentLabel, isClaudeAgent } from './agent-title-identity'
|
||||
export {
|
||||
clearWorkingIndicators,
|
||||
createAgentStatusTracker,
|
||||
detectAgentStatusFromTitle,
|
||||
normalizeTerminalTitle
|
||||
} from './agent-title-status'
|
||||
|
||||
// Re-export so existing `agent-detection` importers keep working.
|
||||
export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match'
|
||||
@@ -27,470 +31,3 @@ export {
|
||||
MAX_OSC_TITLE_CHARS
|
||||
} from './osc-title-extraction'
|
||||
export { isShellProcess } from './shell-process-detection'
|
||||
|
||||
export type AgentStatus = 'working' | 'permission' | 'idle'
|
||||
|
||||
const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix)
|
||||
const CLAUDE_MANAGEMENT_TITLE_RE =
|
||||
/^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i
|
||||
|
||||
const GEMINI_WORKING = '\u2726' // ✦
|
||||
const GEMINI_SILENT_WORKING = '\u23F2' // ⏲
|
||||
const GEMINI_IDLE = '\u25C7' // ◇
|
||||
const GEMINI_PERMISSION = '\u270B' // ✋
|
||||
|
||||
// Why: idle keywords used inside `detectAgentStatusFromTitle` to map titles
|
||||
// like "Codex done", "OpenCode ready", "Aider idle" to AgentStatus 'idle'.
|
||||
// `as const` so consumers receive literal-union types.
|
||||
const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const
|
||||
|
||||
// Why: working keywords used inside `detectAgentStatusFromTitle` to map
|
||||
// titles like "Codex working", "Aider thinking", "OpenCode running" to
|
||||
// AgentStatus 'working'. Shared with `clearWorkingIndicators` so both stay
|
||||
// in lock-step when stripping working indicators from stale titles.
|
||||
const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const
|
||||
|
||||
// Why: match STRONG_IDLE_KEYWORDS only when not adjacent to characters that
|
||||
// would make the "keyword" part of a larger token. Plain `\b` alone is
|
||||
// insufficient because `-` is a non-word character in JS regex, so `\bready\b`
|
||||
// still matches inside "is-ready-cap" (a `\b` boundary falls between `-` and
|
||||
// `r`).
|
||||
//
|
||||
// Lookarounds are intentionally ASYMMETRIC:
|
||||
// - LEFT: reject `[\w./\\-]` so path fragments like `~/codex/ready`,
|
||||
// Windows `C:\codex\ready`, and `codex.ready` cannot mint a strong idle
|
||||
// signal by having the agent name sit earlier in the same path and the
|
||||
// keyword land right after a path separator. Orca is a cross-platform
|
||||
// Electron app, so Windows path separators must be handled too.
|
||||
// - RIGHT: reject only `[\w\-]` so legitimate sentence-style titles like
|
||||
// "Codex done." / "Aider idle." / "OpenCode ready!" still match — path
|
||||
// separators after the keyword are not a false-positive vector in
|
||||
// practice and blocking them would regress trailing-punctuation titles.
|
||||
//
|
||||
// Also rejects hyphenated compounds ("is-ready-cap", "re-done") and plain
|
||||
// substring false positives ("already"/"redone"/"idleness").
|
||||
export const STRONG_IDLE_KEYWORDS_RE = new RegExp(
|
||||
`(?<![\\w./\\\\-])(${STRONG_IDLE_KEYWORDS.join('|')})(?![\\w\\-])`,
|
||||
'i'
|
||||
)
|
||||
|
||||
// Why: mirrors STRONG_IDLE_KEYWORDS_RE — plain substring matching on the
|
||||
// working keywords caused the symmetric class of false positives, e.g.
|
||||
// "reworking" ⊃ "working", "overthinking" ⊃ "thinking", "rerunning" ⊃
|
||||
// "running", hyphenated compounds like "is-thinking-cap", AND cwd-path
|
||||
// fragments like "~/codex/working" or "C:\codex\working". Uses the same
|
||||
// asymmetric lookarounds as STRONG_IDLE_KEYWORDS_RE (path separators blocked
|
||||
// on the left only so "Codex working." still matches). A false 'working'
|
||||
// classification is worse than the idle one because it drives active-agent
|
||||
// UI (spinners, counts), so word-char- and left-path-separator-aware
|
||||
// matching is required here too.
|
||||
export const STRONG_WORKING_KEYWORDS_RE = new RegExp(
|
||||
`(?<![\\w./\\\\-])(${STRONG_WORKING_KEYWORDS.join('|')})(?![\\w\\-])`,
|
||||
'i'
|
||||
)
|
||||
|
||||
// Why: global-flag companion of STRONG_WORKING_KEYWORDS_RE used by
|
||||
// clearWorkingIndicators to strip ALL occurrences in a single pass. Keeps
|
||||
// clearing and detection in lock-step — both use identical [\w\-] lookarounds,
|
||||
// so `clearWorkingIndicators` no longer strips keywords out of hyphenated
|
||||
// compounds like "is-working-cap" that `detectAgentStatusFromTitle` would
|
||||
// correctly refuse to classify as working.
|
||||
const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi')
|
||||
const PI_IDLE_PREFIX = '\u03c0 - ' // π - (Pi titlebar extension idle format)
|
||||
|
||||
export function isGeminiTerminalTitle(title: string): boolean {
|
||||
return (
|
||||
title.includes(GEMINI_PERMISSION) ||
|
||||
title.includes(GEMINI_WORKING) ||
|
||||
title.includes(GEMINI_SILENT_WORKING) ||
|
||||
title.includes(GEMINI_IDLE) ||
|
||||
title.toLowerCase().includes('gemini')
|
||||
)
|
||||
}
|
||||
|
||||
export function isPiTerminalTitle(title: string): boolean {
|
||||
return title.startsWith(PI_IDLE_PREFIX)
|
||||
}
|
||||
|
||||
function isPiAgentTitle(title: string): boolean {
|
||||
return (
|
||||
isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX))
|
||||
)
|
||||
}
|
||||
|
||||
function containsBrailleSpinner(title: string): boolean {
|
||||
for (const char of title) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function containsAgentName(title: string): boolean {
|
||||
return (
|
||||
titleHasAnyLegacyAgentName(title) ||
|
||||
AGY_AGENT_NAME_RE.test(title) ||
|
||||
DROID_AGENT_NAME_RE.test(title) ||
|
||||
HERMES_AGENT_NAME_RE.test(title)
|
||||
)
|
||||
}
|
||||
|
||||
function containsAny(title: string, words: readonly string[]): boolean {
|
||||
const lower = title.toLowerCase()
|
||||
return words.some((word) => lower.includes(word))
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip working-status indicators from a title so that
|
||||
* `detectAgentStatusFromTitle` will no longer return 'working'.
|
||||
* Used to clear stale titles when an agent exits without resetting its title.
|
||||
*/
|
||||
export function clearWorkingIndicators(title: string): string {
|
||||
let cleaned = title
|
||||
|
||||
// Gemini working symbols
|
||||
cleaned = cleaned.replace(GEMINI_WORKING, '')
|
||||
cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '')
|
||||
|
||||
// Braille spinner characters (U+2800–U+28FF)
|
||||
// eslint-disable-next-line no-control-regex -- intentional unicode range
|
||||
cleaned = cleaned.replace(/[\u2800-\u28FF]/g, '')
|
||||
|
||||
// Claude Code ". " working prefix
|
||||
if (cleaned.startsWith('. ')) {
|
||||
cleaned = cleaned.slice(2)
|
||||
}
|
||||
|
||||
// Strip working keywords that detectAgentStatusFromTitle would pick up
|
||||
// when the title also contains an agent name.
|
||||
if (containsAgentName(cleaned)) {
|
||||
cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '')
|
||||
}
|
||||
|
||||
// Collapse whitespace after removals
|
||||
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
|
||||
|
||||
return cleaned || title
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks agent status transitions from terminal title changes.
|
||||
* Fires `onBecameIdle` when an agent transitions from working to idle/permission,
|
||||
* like haunt's attention flag — the key trigger for unread notifications.
|
||||
*/
|
||||
export function createAgentStatusTracker(
|
||||
onBecameIdle: (title: string) => void,
|
||||
onBecameWorking?: () => void,
|
||||
onAgentExited?: () => void
|
||||
): {
|
||||
handleTitle: (title: string) => void
|
||||
/** Clear accumulated status so a stale working→idle transition cannot fire
|
||||
* after the owning transport is torn down. */
|
||||
reset: () => void
|
||||
} {
|
||||
let lastStatus: AgentStatus | null = null
|
||||
|
||||
return {
|
||||
handleTitle(title: string): void {
|
||||
const newStatus = detectAgentStatusFromTitle(title)
|
||||
if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') {
|
||||
onBecameIdle(title)
|
||||
}
|
||||
if (lastStatus !== 'working' && newStatus === 'working') {
|
||||
onBecameWorking?.()
|
||||
}
|
||||
// Why: when the title reverts to a plain shell prompt (e.g., "bash", "zsh"),
|
||||
// detectAgentStatusFromTitle returns null. If we were idle or in a permission
|
||||
// prompt, this means the user exited the agent — clear session-tied state
|
||||
// (like the prompt-cache countdown). We intentionally do NOT fire this when
|
||||
// lastStatus is 'working', because active agents can briefly flash shell
|
||||
// titles during internal operations without actually exiting.
|
||||
if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) {
|
||||
lastStatus = null
|
||||
onAgentExited?.()
|
||||
}
|
||||
if (newStatus !== null) {
|
||||
lastStatus = newStatus
|
||||
}
|
||||
},
|
||||
reset(): void {
|
||||
lastStatus = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize high-churn agent titles into stable display labels before storing
|
||||
* them in app state. Gemini CLI can emit per-keystroke title updates, which
|
||||
* otherwise causes broad rerenders and visible flashing.
|
||||
*/
|
||||
export function normalizeTerminalTitle(title: string): string {
|
||||
if (!title) {
|
||||
return title
|
||||
}
|
||||
|
||||
if (isGeminiTerminalTitle(title)) {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status === 'permission') {
|
||||
return `${GEMINI_PERMISSION} Gemini CLI`
|
||||
}
|
||||
if (status === 'working') {
|
||||
return `${GEMINI_WORKING} Gemini CLI`
|
||||
}
|
||||
if (status === 'idle') {
|
||||
return `${GEMINI_IDLE} Gemini CLI`
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Pi's titlebar extension animates every 80ms with different braille
|
||||
// frames. Collapsing those frames into one stable label avoids renderer
|
||||
// churn while preserving the working/idle transition Orca keys off.
|
||||
if (isPiAgentTitle(title)) {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status === 'working') {
|
||||
return '\u280b Pi'
|
||||
}
|
||||
if (status === 'idle') {
|
||||
return 'Pi'
|
||||
}
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the terminal title matches Claude Code's title conventions.
|
||||
* Used to scope prompt-cache-timer behavior to Claude sessions only — other
|
||||
* agents have different (or no) caching semantics.
|
||||
*/
|
||||
export function isClaudeAgent(title: string): boolean {
|
||||
if (!title || isClaudeManagementTitle(title)) {
|
||||
return false
|
||||
}
|
||||
const lower = title.toLowerCase()
|
||||
|
||||
// Why: Claude Code titles are prefixed with status indicators (✳, ". ", "* ",
|
||||
// braille spinners) followed by the task description. The task text can
|
||||
// legitimately mention other agents, so Claude-specific prefixes must win.
|
||||
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
|
||||
return true
|
||||
}
|
||||
// Why: ". " (working) and "* " (idle) are Claude Code title conventions.
|
||||
// Other supported agents do not use them, and rejecting titles that mention
|
||||
// another agent in the task text caused false negatives for real Claude tabs.
|
||||
if (title.startsWith('. ') || title.startsWith('* ')) {
|
||||
return true
|
||||
}
|
||||
if (containsBrailleSpinner(title)) {
|
||||
// Why: named non-Claude agents can carry braille spinners too; Claude-only
|
||||
// prompt-cache paths must not fire for those explicit agent titles.
|
||||
return !lower.includes('cursor') && !lower.includes('openclaude')
|
||||
}
|
||||
// Why: permission/action-required Claude titles can omit the usual prefixes.
|
||||
// Token-match so cwd/worktree titles like "claude-scratch" do not become
|
||||
// Claude tabs, while task text that merely mentions Claude still stays out.
|
||||
const trimmedTitle = title.trimStart()
|
||||
if (
|
||||
trimmedTitle.toLowerCase().startsWith('claude') &&
|
||||
titleHasAgentName(trimmedTitle, 'claude')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isClaudeManagementTitle(title: string): boolean {
|
||||
return CLAUDE_MANAGEMENT_TITLE_RE.test(title)
|
||||
}
|
||||
|
||||
export function getAgentLabel(title: string): string | null {
|
||||
if (isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: Claude Code title text is often the task title. If that task mentions
|
||||
// another CLI, the Claude-specific prefix is the identity signal, not the words.
|
||||
if (
|
||||
title.startsWith(`${CLAUDE_IDLE} `) ||
|
||||
title === CLAUDE_IDLE ||
|
||||
title.startsWith('. ') ||
|
||||
title.startsWith('* ')
|
||||
) {
|
||||
return 'Claude Code'
|
||||
}
|
||||
if (isGeminiTerminalTitle(title)) {
|
||||
return 'Gemini CLI'
|
||||
}
|
||||
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
|
||||
// generic agent-title heuristics would otherwise claim first.
|
||||
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
|
||||
if (piCompatibleSyntheticAgentLabel) {
|
||||
return piCompatibleSyntheticAgentLabel
|
||||
}
|
||||
// Why: Pi working titles include a braille spinner prefix, which would be
|
||||
// mistaken for Claude Code if we checked `isClaudeAgent` first.
|
||||
if (isPiAgentTitle(title)) {
|
||||
return 'Pi'
|
||||
}
|
||||
// Why: Codex/OpenCode/Aider can also use braille spinner prefixes while
|
||||
// working. Prefer explicit name matches before Claude's generic spinner
|
||||
// heuristic so mixed-agent hovercards stay truthful. Token-match (not
|
||||
// substring) so cwd/worktree titles like "opencode-blinker" don't mint a
|
||||
// false agent identity.
|
||||
if (titleHasAgentName(title, 'codex')) {
|
||||
return 'Codex'
|
||||
}
|
||||
if (titleHasAgentName(title, 'openclaude')) {
|
||||
return 'OpenClaude'
|
||||
}
|
||||
if (titleHasAgentName(title, 'copilot')) {
|
||||
return 'GitHub Copilot'
|
||||
}
|
||||
if (titleHasAgentName(title, 'grok')) {
|
||||
return 'Grok'
|
||||
}
|
||||
if (titleHasAgentName(title, 'devin')) {
|
||||
return 'Devin'
|
||||
}
|
||||
if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) {
|
||||
return 'Antigravity'
|
||||
}
|
||||
if (titleHasAgentName(title, 'opencode')) {
|
||||
return 'OpenCode'
|
||||
}
|
||||
if (titleHasAgentName(title, 'mimo')) {
|
||||
return 'MiMo Code'
|
||||
}
|
||||
if (titleHasAgentName(title, 'aider')) {
|
||||
return 'Aider'
|
||||
}
|
||||
// Why: the cursor-agent native title is the literal string "Cursor Agent"
|
||||
// (verified against the 2026.04.17 release) — Orca synthesizes the same
|
||||
// label from hook events so the braille-spinner + agent-name path lights
|
||||
// up working/permission/idle transitions in the renderer. Match before
|
||||
// `isClaudeAgent` because Claude's generic braille heuristic would
|
||||
// otherwise claim every "⠋ Cursor Agent" frame as Claude. Token-match so a
|
||||
// cwd like "~/cursor-rules" can't masquerade as a Cursor agent.
|
||||
if (titleHasAgentName(title, 'cursor')) {
|
||||
return 'Cursor'
|
||||
}
|
||||
// Why: synthesized "⠋ Droid" working title needs to be matched before Claude's braille heuristic.
|
||||
// Token matching avoids labeling ordinary Android terminal titles as Droid.
|
||||
if (DROID_AGENT_NAME_RE.test(title)) {
|
||||
return 'Droid'
|
||||
}
|
||||
// Why: synthesized "⠋ Hermes" working titles need to be matched before
|
||||
// Claude's generic braille-spinner heuristic.
|
||||
if (HERMES_AGENT_NAME_RE.test(title)) {
|
||||
return 'Hermes'
|
||||
}
|
||||
if (isClaudeAgent(title)) {
|
||||
return 'Claude Code'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: cursor-agent's native OSC title is the literal string "Cursor Agent"
|
||||
// across the entire turn — it carries zero working/idle information. Orca
|
||||
// synthesizes its own titles ("⠋ Cursor Agent" for working, "Cursor -
|
||||
// action required" for permission) from cursor's hook events; the bare
|
||||
// native title must be a no-op so cursor's per-turn re-emissions cannot
|
||||
// stomp the synthesized state back to idle.
|
||||
const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent'
|
||||
|
||||
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
||||
if (!title) {
|
||||
return null
|
||||
}
|
||||
if (isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's
|
||||
// native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor -
|
||||
// action required") is either an Orca-synthesized working/permission title
|
||||
// or a tighter match worth classifying.
|
||||
if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Gemini CLI symbols are the most specific and should take precedence.
|
||||
if (title.includes(GEMINI_PERMISSION)) {
|
||||
return 'permission'
|
||||
}
|
||||
if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) {
|
||||
return 'working'
|
||||
}
|
||||
if (title.includes(GEMINI_IDLE)) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
|
||||
// Pi and braille-spinner checks below.
|
||||
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
|
||||
if (piCompatibleSyntheticAgentStatus) {
|
||||
return piCompatibleSyntheticAgentStatus
|
||||
}
|
||||
|
||||
// Claude Code uses ✳ prefix for idle — must check before braille/agent-name
|
||||
// because the title text is the task description, not "Claude Code".
|
||||
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
if (isPiTerminalTitle(title)) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
if (containsBrailleSpinner(title)) {
|
||||
return 'working'
|
||||
}
|
||||
|
||||
const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title)
|
||||
const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title)
|
||||
const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title)
|
||||
const hasLegacyAgentName = titleHasAnyLegacyAgentName(title)
|
||||
if (hasLegacyAgentName || hasDroidAgentName || hasHermesAgentName || hasAgyAgentName) {
|
||||
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
|
||||
return 'permission'
|
||||
}
|
||||
// Why: hyphen/word-char-aware boundary match (not plain substring, and
|
||||
// stricter than `\b` — which treats `-` as a boundary) so titles like
|
||||
// "~/codex already built" do not classify as idle via the substring
|
||||
// "already" ⊃ "ready". See STRONG_IDLE_KEYWORDS_RE comment.
|
||||
if (STRONG_IDLE_KEYWORDS_RE.test(title)) {
|
||||
return 'idle'
|
||||
}
|
||||
// Why: hyphen/word-char-aware boundary match (not plain substring, and
|
||||
// stricter than `\b`) so titles like "~/codex reworking diff" or
|
||||
// "is-thinking-cap" do not classify as working via the substrings
|
||||
// "reworking" ⊃ "working" or the `-`-adjacent "thinking" in
|
||||
// "is-thinking-cap". Mirrors STRONG_IDLE_KEYWORDS_RE for symmetry; a
|
||||
// false 'working' is worse than a false 'idle' because it drives
|
||||
// active-agent UI (spinners, counts).
|
||||
if (STRONG_WORKING_KEYWORDS_RE.test(title)) {
|
||||
return 'working'
|
||||
}
|
||||
|
||||
// Claude Code title prefixes: ". " = working, "* " = idle
|
||||
if (title.startsWith('. ')) {
|
||||
return 'working'
|
||||
}
|
||||
if (title.startsWith('* ')) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// Why: Factory Droid can publish native titles like "Factory Droid needs
|
||||
// input" while an Execute tool is still sleeping. Droid's hook events are
|
||||
// authoritative; don't turn a name-only native title into a completion.
|
||||
if (hasDroidAgentName && !hasLegacyAgentName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
DROID_AGENT_NAME_RE,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
titleHasAgentName,
|
||||
titleHasAnyLegacyAgentName
|
||||
} from './agent-name-token-match'
|
||||
|
||||
export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName }
|
||||
|
||||
export type AgentStatus = 'working' | 'permission' | 'idle'
|
||||
|
||||
export const CLAUDE_IDLE = '\u2733' // ✳
|
||||
const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?`
|
||||
export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp(
|
||||
String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\s+agents\s*$`,
|
||||
'i'
|
||||
)
|
||||
|
||||
export const GEMINI_WORKING = '\u2726' // ✦
|
||||
export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲
|
||||
export const GEMINI_IDLE = '\u25c7' // ◇
|
||||
export const GEMINI_PERMISSION = '\u270b' // ✋
|
||||
|
||||
const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const
|
||||
const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const
|
||||
|
||||
// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as
|
||||
// "~/codex/ready"; the left side also blocks path separators for Windows/Unix.
|
||||
export const STRONG_IDLE_KEYWORDS_RE = new RegExp(
|
||||
`(?<![\\w./\\\\-])(${STRONG_IDLE_KEYWORDS.join('|')})(?![\\w\\-])`,
|
||||
'i'
|
||||
)
|
||||
|
||||
// Why: mirrors the idle matcher so titles like "reworking" or
|
||||
// "is-thinking-cap" do not drive false active-agent UI.
|
||||
export const STRONG_WORKING_KEYWORDS_RE = new RegExp(
|
||||
`(?<![\\w./\\\\-])(${STRONG_WORKING_KEYWORDS.join('|')})(?![\\w\\-])`,
|
||||
'i'
|
||||
)
|
||||
|
||||
export const STRONG_WORKING_KEYWORDS_RE_GLOBAL = new RegExp(STRONG_WORKING_KEYWORDS_RE.source, 'gi')
|
||||
|
||||
export const PI_IDLE_PREFIX = '\u03c0 - ' // π -
|
||||
export const CURSOR_NATIVE_TITLE_LOWER = 'cursor agent'
|
||||
|
||||
// eslint-disable-next-line no-control-regex -- intentional unicode range
|
||||
export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g
|
||||
|
||||
export function isGeminiTerminalTitle(title: string): boolean {
|
||||
return (
|
||||
title.includes(GEMINI_PERMISSION) ||
|
||||
title.includes(GEMINI_WORKING) ||
|
||||
title.includes(GEMINI_SILENT_WORKING) ||
|
||||
title.includes(GEMINI_IDLE) ||
|
||||
title.toLowerCase().includes('gemini')
|
||||
)
|
||||
}
|
||||
|
||||
export function isPiTerminalTitle(title: string): boolean {
|
||||
return title.startsWith(PI_IDLE_PREFIX)
|
||||
}
|
||||
|
||||
export function isPiAgentTitle(title: string): boolean {
|
||||
return (
|
||||
isPiTerminalTitle(title) || (containsBrailleSpinner(title) && title.includes(PI_IDLE_PREFIX))
|
||||
)
|
||||
}
|
||||
|
||||
export function containsBrailleSpinner(title: string): boolean {
|
||||
for (const char of title) {
|
||||
const codePoint = char.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function containsLegacyAgentName(title: string): boolean {
|
||||
return titleHasAnyLegacyAgentName(title)
|
||||
}
|
||||
|
||||
export function containsAgentName(title: string): boolean {
|
||||
return (
|
||||
containsLegacyAgentName(title) ||
|
||||
AGY_AGENT_NAME_RE.test(title) ||
|
||||
DROID_AGENT_NAME_RE.test(title) ||
|
||||
HERMES_AGENT_NAME_RE.test(title)
|
||||
)
|
||||
}
|
||||
|
||||
export function containsAny(title: string, words: readonly string[]): boolean {
|
||||
const lower = title.toLowerCase()
|
||||
return words.some((word) => lower.includes(word))
|
||||
}
|
||||
|
||||
export function isClaudeManagementTitle(title: string): boolean {
|
||||
return CLAUDE_MANAGEMENT_TITLE_RE.test(title)
|
||||
}
|
||||
|
||||
export function isCursorNativeAgentTitle(title: string): boolean {
|
||||
return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
CLAUDE_IDLE,
|
||||
DROID_AGENT_NAME_RE,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
containsBrailleSpinner,
|
||||
isClaudeManagementTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiAgentTitle,
|
||||
titleHasAgentName
|
||||
} from './agent-title-core'
|
||||
import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title'
|
||||
|
||||
/**
|
||||
* Returns true when the terminal title matches Claude Code's title conventions.
|
||||
* Used to scope prompt-cache-timer behavior to Claude sessions only.
|
||||
*/
|
||||
export function isClaudeAgent(title: string): boolean {
|
||||
if (!title || isClaudeManagementTitle(title)) {
|
||||
return false
|
||||
}
|
||||
const lower = title.toLowerCase()
|
||||
|
||||
// Why: Claude title prefixes are stronger than task text, which can mention
|
||||
// other agents without changing the owning CLI.
|
||||
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
|
||||
return true
|
||||
}
|
||||
if (title.startsWith('. ') || title.startsWith('* ')) {
|
||||
return true
|
||||
}
|
||||
if (containsBrailleSpinner(title)) {
|
||||
return !lower.includes('cursor') && !lower.includes('openclaude')
|
||||
}
|
||||
|
||||
const trimmedTitle = title.trimStart()
|
||||
return (
|
||||
trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude')
|
||||
)
|
||||
}
|
||||
|
||||
export function getAgentLabel(title: string): string | null {
|
||||
if (isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: Claude task titles can mention another CLI; the prefix is the identity
|
||||
// signal, not arbitrary task text.
|
||||
if (
|
||||
title.startsWith(`${CLAUDE_IDLE} `) ||
|
||||
title === CLAUDE_IDLE ||
|
||||
title.startsWith('. ') ||
|
||||
title.startsWith('* ')
|
||||
) {
|
||||
return 'Claude Code'
|
||||
}
|
||||
if (isGeminiTerminalTitle(title)) {
|
||||
return 'Gemini CLI'
|
||||
}
|
||||
// Why: Pi-compatible synthetic titles can carry braille spinners, which the
|
||||
// generic agent-title heuristics would otherwise claim first.
|
||||
const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title)
|
||||
if (piCompatibleSyntheticAgentLabel) {
|
||||
return piCompatibleSyntheticAgentLabel
|
||||
}
|
||||
if (isPiAgentTitle(title)) {
|
||||
return 'Pi'
|
||||
}
|
||||
|
||||
if (titleHasAgentName(title, 'codex')) {
|
||||
return 'Codex'
|
||||
}
|
||||
if (titleHasAgentName(title, 'openclaude')) {
|
||||
return 'OpenClaude'
|
||||
}
|
||||
if (titleHasAgentName(title, 'copilot')) {
|
||||
return 'GitHub Copilot'
|
||||
}
|
||||
if (titleHasAgentName(title, 'grok')) {
|
||||
return 'Grok'
|
||||
}
|
||||
if (titleHasAgentName(title, 'devin')) {
|
||||
return 'Devin'
|
||||
}
|
||||
if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) {
|
||||
return 'Antigravity'
|
||||
}
|
||||
if (titleHasAgentName(title, 'opencode')) {
|
||||
return 'OpenCode'
|
||||
}
|
||||
if (titleHasAgentName(title, 'mimo')) {
|
||||
return 'MiMo Code'
|
||||
}
|
||||
if (titleHasAgentName(title, 'aider')) {
|
||||
return 'Aider'
|
||||
}
|
||||
// Why: match explicit names before Claude's generic braille heuristic.
|
||||
if (titleHasAgentName(title, 'cursor')) {
|
||||
return 'Cursor'
|
||||
}
|
||||
if (DROID_AGENT_NAME_RE.test(title)) {
|
||||
return 'Droid'
|
||||
}
|
||||
if (HERMES_AGENT_NAME_RE.test(title)) {
|
||||
return 'Hermes'
|
||||
}
|
||||
if (isClaudeAgent(title)) {
|
||||
return 'Claude Code'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
AGY_AGENT_NAME_RE,
|
||||
BRAILLE_SPINNER_RE,
|
||||
CLAUDE_IDLE,
|
||||
CURSOR_NATIVE_TITLE_LOWER,
|
||||
DROID_AGENT_NAME_RE,
|
||||
GEMINI_IDLE,
|
||||
GEMINI_PERMISSION,
|
||||
GEMINI_SILENT_WORKING,
|
||||
GEMINI_WORKING,
|
||||
HERMES_AGENT_NAME_RE,
|
||||
STRONG_IDLE_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE,
|
||||
STRONG_WORKING_KEYWORDS_RE_GLOBAL,
|
||||
containsAgentName,
|
||||
containsAny,
|
||||
containsBrailleSpinner,
|
||||
containsLegacyAgentName,
|
||||
isClaudeManagementTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiAgentTitle,
|
||||
isPiTerminalTitle
|
||||
} from './agent-title-core'
|
||||
import type { AgentStatus } from './agent-title-core'
|
||||
import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title'
|
||||
|
||||
/**
|
||||
* Strip working-status indicators so stale exit titles stop reporting working.
|
||||
*/
|
||||
export function clearWorkingIndicators(title: string): string {
|
||||
let cleaned = title
|
||||
|
||||
cleaned = cleaned.replace(GEMINI_WORKING, '')
|
||||
cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '')
|
||||
cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '')
|
||||
if (cleaned.startsWith('. ')) {
|
||||
cleaned = cleaned.slice(2)
|
||||
}
|
||||
if (containsAgentName(cleaned)) {
|
||||
cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '')
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/\s{2,}/g, ' ').trim()
|
||||
return cleaned || title
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks agent status transitions from terminal title changes.
|
||||
*/
|
||||
export function createAgentStatusTracker(
|
||||
onBecameIdle: (title: string) => void,
|
||||
onBecameWorking?: () => void,
|
||||
onAgentExited?: () => void,
|
||||
initialTitle?: string
|
||||
): {
|
||||
handleTitle: (title: string) => void
|
||||
seedTitle: (title: string) => void
|
||||
reset: () => void
|
||||
} {
|
||||
// Why: trackers restored mid-session need a last-known status without firing
|
||||
// callbacks, or a hidden working agent can miss its later idle transition.
|
||||
let lastStatus: AgentStatus | null =
|
||||
initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null
|
||||
|
||||
return {
|
||||
handleTitle(title: string): void {
|
||||
const newStatus = detectAgentStatusFromTitle(title)
|
||||
if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') {
|
||||
onBecameIdle(title)
|
||||
}
|
||||
if (lastStatus !== 'working' && newStatus === 'working') {
|
||||
onBecameWorking?.()
|
||||
}
|
||||
// Why: reverting to a plain shell prompt after idle/permission means the
|
||||
// agent exited; while working it can just be a transient internal title.
|
||||
if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) {
|
||||
lastStatus = null
|
||||
onAgentExited?.()
|
||||
}
|
||||
if (newStatus !== null) {
|
||||
lastStatus = newStatus
|
||||
}
|
||||
},
|
||||
seedTitle(title: string): void {
|
||||
lastStatus = detectAgentStatusFromTitle(title)
|
||||
},
|
||||
reset(): void {
|
||||
lastStatus = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize high-churn agent titles into stable display labels before storage.
|
||||
*/
|
||||
export function normalizeTerminalTitle(title: string): string {
|
||||
if (!title) {
|
||||
return title
|
||||
}
|
||||
|
||||
if (isGeminiTerminalTitle(title)) {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status === 'permission') {
|
||||
return `${GEMINI_PERMISSION} Gemini CLI`
|
||||
}
|
||||
if (status === 'working') {
|
||||
return `${GEMINI_WORKING} Gemini CLI`
|
||||
}
|
||||
if (status === 'idle') {
|
||||
return `${GEMINI_IDLE} Gemini CLI`
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Pi animates every 80ms; collapse frames while preserving status.
|
||||
if (isPiAgentTitle(title)) {
|
||||
const status = detectAgentStatusFromTitle(title)
|
||||
if (status === 'working') {
|
||||
return '\u280b Pi'
|
||||
}
|
||||
if (status === 'idle') {
|
||||
return 'Pi'
|
||||
}
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
|
||||
if (!title || isClaudeManagementTitle(title)) {
|
||||
return null
|
||||
}
|
||||
if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (title.includes(GEMINI_PERMISSION)) {
|
||||
return 'permission'
|
||||
}
|
||||
if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) {
|
||||
return 'working'
|
||||
}
|
||||
if (title.includes(GEMINI_IDLE)) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// Why: resolve synthetic Pi/OMP permission/idle labels before the broader
|
||||
// Pi and braille-spinner checks below.
|
||||
const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title)
|
||||
if (piCompatibleSyntheticAgentStatus) {
|
||||
return piCompatibleSyntheticAgentStatus
|
||||
}
|
||||
|
||||
if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) {
|
||||
return 'idle'
|
||||
}
|
||||
if (isPiTerminalTitle(title)) {
|
||||
return 'idle'
|
||||
}
|
||||
if (containsBrailleSpinner(title)) {
|
||||
return 'working'
|
||||
}
|
||||
|
||||
const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title)
|
||||
const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title)
|
||||
const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title)
|
||||
const hasLegacyAgentName = containsLegacyAgentName(title)
|
||||
if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) {
|
||||
return null
|
||||
}
|
||||
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
|
||||
return 'permission'
|
||||
}
|
||||
// Why: boundary-aware regexes avoid cwd/path and substring false positives.
|
||||
if (STRONG_IDLE_KEYWORDS_RE.test(title)) {
|
||||
return 'idle'
|
||||
}
|
||||
if (STRONG_WORKING_KEYWORDS_RE.test(title)) {
|
||||
return 'working'
|
||||
}
|
||||
if (title.startsWith('. ')) {
|
||||
return 'working'
|
||||
}
|
||||
if (title.startsWith('* ')) {
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
// Why: Droid hook events are authoritative; native name-only titles should
|
||||
// not turn a still-sleeping execute tool into completion.
|
||||
if (hasDroidAgentName && !hasLegacyAgentName) {
|
||||
return null
|
||||
}
|
||||
|
||||
return 'idle'
|
||||
}
|
||||
@@ -305,6 +305,16 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
||||
claudeManagedAccounts: [],
|
||||
activeClaudeManagedAccountId: null,
|
||||
terminalScopeHistoryByWorktree: true,
|
||||
// Why these four differ from PR #7214 (their source): this build has no
|
||||
// main-process side-effect emitter, so the parked watcher MUST stay in
|
||||
// byte mode (authority/gate/query-authority false) or parked panes would
|
||||
// silently lose bell/title/completion notifications. Parking itself ships
|
||||
// default-OFF (opt-in) until the memory win is confirmed in a soak.
|
||||
// When #7214 lands its emitter, reconcile by taking its `true` values.
|
||||
terminalHiddenViewParking: false,
|
||||
terminalMainSideEffectAuthority: false,
|
||||
terminalHiddenDeliveryGate: false,
|
||||
terminalModelQueryAuthority: false,
|
||||
defaultTuiAgent: null,
|
||||
disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS],
|
||||
claudeAgentTeamsDefaultDisabledMigrated: true,
|
||||
|
||||
@@ -3,23 +3,34 @@ export type E2EConfig = {
|
||||
headless: boolean
|
||||
exposeStore: boolean
|
||||
userDataDir: string | null
|
||||
/** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the
|
||||
* terminal hidden-view parking delays. null means use production timing. */
|
||||
terminalParkingDelayMs: number | null
|
||||
}
|
||||
|
||||
type E2EConfigInput = {
|
||||
headless?: boolean
|
||||
exposeStore?: boolean
|
||||
userDataDir?: string | null
|
||||
terminalParkingDelayMs?: number | null
|
||||
}
|
||||
|
||||
export function createE2EConfig(input: E2EConfigInput): E2EConfig {
|
||||
const userDataDir = input.userDataDir?.trim() || null
|
||||
const headless = Boolean(input.headless)
|
||||
const exposeStore = Boolean(input.exposeStore)
|
||||
const terminalParkingDelayMs =
|
||||
typeof input.terminalParkingDelayMs === 'number' &&
|
||||
Number.isFinite(input.terminalParkingDelayMs) &&
|
||||
input.terminalParkingDelayMs > 0
|
||||
? input.terminalParkingDelayMs
|
||||
: null
|
||||
|
||||
return {
|
||||
enabled: headless || exposeStore || userDataDir !== null,
|
||||
headless,
|
||||
exposeStore,
|
||||
userDataDir
|
||||
userDataDir,
|
||||
terminalParkingDelayMs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Why shared: main's terminal side-effect tracker emits pr-link facts
|
||||
// (terminal-side-effect-authority.md, slice 3) and needs the same GitHub URL
|
||||
// parsing core the renderer link picker uses.
|
||||
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
|
||||
|
||||
export type RepoSlug = {
|
||||
owner: string
|
||||
repo: string
|
||||
}
|
||||
|
||||
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
|
||||
if (!slug?.owner || !slug.repo) {
|
||||
return null
|
||||
}
|
||||
return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
|
||||
}
|
||||
|
||||
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
|
||||
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
|
||||
}
|
||||
|
||||
function parseGitHubItemNumber(value: string): number | null {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a GitHub issue/PR reference from plain input.
|
||||
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRNumber(input: string): number | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
|
||||
if (/^\d+$/.test(numeric)) {
|
||||
return parseGitHubItemNumber(numeric)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseGitHubItemNumber(match[4])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
|
||||
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRLink(input: string): {
|
||||
slug: RepoSlug
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
} | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const number = parseGitHubItemNumber(match[4])
|
||||
if (number === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
slug: { owner: match[1], repo: match[2] },
|
||||
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
|
||||
number
|
||||
}
|
||||
}
|
||||
+9
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* Chunk-boundary-safe GitHub PR URL scan over PTY output.
|
||||
*
|
||||
* Why shared: terminal-side-effect-authority.md (slice 3) makes main emit
|
||||
* `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the
|
||||
* renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off
|
||||
* path. Both paths must share the carry/dedupe semantics or links split across
|
||||
* chunks would resolve differently per authority mode.
|
||||
*/
|
||||
import type { RepoSlug } from './github-links'
|
||||
import { parseGitHubIssueOrPRLink } from './github-links'
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Derived terminal side-effect facts carried on the `pty:sideEffect` channel
|
||||
* (main → renderer). Events are facts, not decisions: main parses every
|
||||
* local-daemon/SSH PTY byte exactly once and emits what it observed; the
|
||||
* renderer store handler owns notification/unread policy.
|
||||
* See docs/reference/terminal-side-effect-authority.md.
|
||||
*/
|
||||
|
||||
import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector'
|
||||
|
||||
/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not
|
||||
* observed bytes. Renderer policy clears title/cache state from them but
|
||||
* must not schedule task-complete notifications or unread attention — a
|
||||
* merely-paused agent (>3s silent mid-task) is not a completion. */
|
||||
export type TerminalSideEffectFact =
|
||||
| { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean }
|
||||
| { kind: 'bell' }
|
||||
| { kind: 'agent-working' }
|
||||
| { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean }
|
||||
| { kind: 'agent-exited' }
|
||||
/** OSC 133;D — foreground shell command exited (exit code best-effort). */
|
||||
| { kind: 'command-finished'; exitCode: number | null }
|
||||
/** Carries the parsed link so the renderer store consumer never re-parses
|
||||
* the URL (parse drift would break the per-PTY dedupe contract). */
|
||||
| { kind: 'pr-link'; link: TerminalGitHubPRLink }
|
||||
/** Command Code output scrape (that CLI lacks hooks). Working seeds the
|
||||
* agent-status row immediately; done is a hint the renderer settle-checks
|
||||
* against its live status row before completing the turn. */
|
||||
| { kind: 'command-code-working'; prompt: string }
|
||||
| { kind: 'command-code-done'; prompt: string }
|
||||
/** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted
|
||||
* so hidden-delivery-gated views (whose bytes never arrive) can still send
|
||||
* the theme reply — the reply stays renderer-side because query authority
|
||||
* belongs to the view (model/view contract invariant 6). */
|
||||
| { kind: '2031-subscribe' }
|
||||
|
||||
export type TerminalSideEffectBatch = {
|
||||
ptyId: string
|
||||
/** PTY output byte sequence at emission. Replay batches carry the sequence
|
||||
* their title state was current at, so the handler can drop a replay title
|
||||
* older than the last live title fact it applied. */
|
||||
seq: number
|
||||
/** Facts from one chunk, in byte order: titles in sequence, then bell.
|
||||
* Command Code scrape facts trail the chunk's parser facts — their policy
|
||||
* (status-row seeding) never interacts with title/bell ordering. */
|
||||
facts: TerminalSideEffectFact[]
|
||||
/** True for (re)attach snapshots. Replay batches restore title state only —
|
||||
* attention facts (bell, agent transitions) never replay. */
|
||||
replay?: boolean
|
||||
/** Main-known attribution from runtime leaf/PTY records (same resolution as
|
||||
* agent-status events). Absent when main has no binding for the PTY yet. */
|
||||
worktreeId?: string
|
||||
tabId?: string
|
||||
paneKey?: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
@@ -2706,6 +2706,28 @@ export type GlobalSettings = {
|
||||
* does not surface commands from other worktrees. Defaults to true.
|
||||
* Disable to revert to shared global shell history. */
|
||||
terminalScopeHistoryByWorktree: boolean
|
||||
/** Kill switch for hidden terminal view parking — unmounting long-hidden
|
||||
* terminal panes while a pane-less watcher keeps PTY side effects alive.
|
||||
* Defaults to true; `false` disables parking entirely.
|
||||
* See docs/reference/terminal-hidden-view-parking.md. */
|
||||
terminalHiddenViewParking?: boolean
|
||||
/** Kill switch for main-process terminal side-effect authority: when true
|
||||
* (default), local-daemon/SSH PTY title/bell/agent facts are consumed from
|
||||
* the `pty:sideEffect` channel and renderer byte parsers stay unregistered
|
||||
* for those PTYs; `false` restores renderer byte parsing.
|
||||
* See docs/reference/terminal-side-effect-authority.md. */
|
||||
terminalMainSideEffectAuthority?: boolean
|
||||
/** Kill switch for main's hidden-delivery gate (Phase 4): when true
|
||||
* (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte
|
||||
* delivery to hidden renderer views after model ingestion; reveal restores
|
||||
* from the model snapshot. `false` restores hidden byte delivery. */
|
||||
terminalHiddenDeliveryGate?: boolean
|
||||
/** Kill switch for the main model query responder (Phase 5): when true
|
||||
* (default) AND both Phase-4 gate switches are on, main answers terminal
|
||||
* queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the
|
||||
* runtime emulator. `false` silences the responder without changing drops.
|
||||
* See docs/reference/terminal-query-authority.md. */
|
||||
terminalModelQueryAuthority?: boolean
|
||||
/** Which agent to pre-select in the new-workspace composer.
|
||||
* - null: auto (first detected agent)
|
||||
* - 'blank': blank terminal (no agent launched)
|
||||
|
||||
@@ -51,6 +51,14 @@ type OrcaTestFixtures = {
|
||||
// Why: most E2E specs need a ready project before assertions start. Golden
|
||||
// first-run specs opt out so they can prove the zero-project onboarding path.
|
||||
seedTestRepo: boolean
|
||||
// Why: spec-scoped launch env. Mutating process.env at spec module scope
|
||||
// leaks into other specs when a worker reloads files without replaying the
|
||||
// first spec's afterAll; per-test launch env cannot leak.
|
||||
orcaAppExtraEnv: Record<string, string>
|
||||
// Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for
|
||||
// memory benchmarks). Prepended before the main entry so Electron forwards
|
||||
// them to Chromium without affecting other specs' launches.
|
||||
orcaAppExtraArgs: string[]
|
||||
// Why: a few IPC repro specs need to launch the Electron app with a scoped
|
||||
// PATH/token environment. Keep this fixture-owned so tests never mutate the
|
||||
// developer's shell or already-running Orca instance.
|
||||
@@ -203,7 +211,11 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
],
|
||||
|
||||
// Test-scoped: one Electron app per test
|
||||
electronApp: async ({ dismissOnboarding, launchEnv }, provideFixture, testInfo) => {
|
||||
electronApp: async (
|
||||
{ dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs },
|
||||
provideFixture,
|
||||
testInfo
|
||||
) => {
|
||||
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
|
||||
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-'))
|
||||
|
||||
@@ -240,7 +252,7 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
mkdirSync(recordVideoDir, { recursive: true })
|
||||
}
|
||||
const app = await electron.launch({
|
||||
args: getOrcaElectronLaunchArgs(mainPath, headful),
|
||||
args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)],
|
||||
...(slowMo > 0 ? { slowMo } : {}),
|
||||
...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}),
|
||||
// Why: keep NODE_ENV=development so window.__store is exposed and
|
||||
@@ -265,7 +277,8 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
!cleanEnv.ORCA_RELAY_PATH
|
||||
? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') }
|
||||
: {}),
|
||||
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
|
||||
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }),
|
||||
...orcaAppExtraEnv
|
||||
}
|
||||
})
|
||||
forwardElectronProcessLogs(app, testInfo)
|
||||
@@ -281,6 +294,8 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
dismissOnboarding: [true, { option: true }],
|
||||
seedTestRepo: [true, { option: true }],
|
||||
launchEnv: [{}, { option: true }],
|
||||
orcaAppExtraEnv: [{}, { option: true }],
|
||||
orcaAppExtraArgs: [[], { option: true }],
|
||||
|
||||
// Test-scoped: grab the first BrowserWindow, add the test repo, and wait
|
||||
// until the session is fully ready with a worktree active.
|
||||
@@ -314,38 +329,23 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
|
||||
await window.api.repos.add({ path: repoPath })
|
||||
}, repoPath)
|
||||
|
||||
// Fetch repos in the renderer store so it picks up the new repo, then opt
|
||||
// this disposable repo into showing external worktrees.
|
||||
// Why: repos.add() fires a repos:changed echo that triggers a *concurrent*
|
||||
// fetchRepos() in the renderer; the store's generation guard can then drop
|
||||
// this awaited fetch's result, leaving `repos` briefly stale. Poll the
|
||||
// public fetch path until the repo lands instead of asserting on the first
|
||||
// tick (mirrors the seeded-worktree poll below). updateRepo is idempotent,
|
||||
// so running it once the repo appears is safe across poll ticks.
|
||||
await playwrightExpect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(async (repoPath) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
await store.getState().fetchRepos()
|
||||
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
if (!repo) {
|
||||
return false
|
||||
}
|
||||
// Why: the fixture deliberately creates external Git worktrees. New
|
||||
// repos hide those by default after the visibility rollout.
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
return true
|
||||
}, repoPath),
|
||||
{
|
||||
timeout: 30_000,
|
||||
message: `Expected e2e repo to be loaded: ${repoPath}`
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
// Fetch repos in the renderer store so it picks up the new repo
|
||||
await page.evaluate(async (repoPath) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return
|
||||
}
|
||||
|
||||
await store.getState().fetchRepos()
|
||||
const repo = store.getState().repos.find((candidate) => candidate.path === repoPath)
|
||||
if (!repo) {
|
||||
throw new Error(`Expected e2e repo to be loaded: ${repoPath}`)
|
||||
}
|
||||
// Why: the fixture deliberately creates external Git worktrees. New
|
||||
// repos hide those by default after the visibility rollout, so opt this
|
||||
// disposable repo into showing them before specs assert on worktree state.
|
||||
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
|
||||
}, repoPath)
|
||||
|
||||
// Wait for the repo to appear and fetch its worktrees
|
||||
await page.evaluate(async () => {
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveTabId,
|
||||
getWorktreeTabs,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneIdentitySnapshot
|
||||
} from './helpers/terminal'
|
||||
|
||||
// Why: the parking wiring registers this handle (dev/exposeStore builds only)
|
||||
// so tests can detect that hidden-view parking is compiled in and which delay
|
||||
// override the app actually applied.
|
||||
type ParkingDebugWindow = Window & {
|
||||
__terminalParkingDebug?: {
|
||||
parkDelayMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain
|
||||
// window. The fast-park override must be scoped to THIS spec's app launches —
|
||||
// mutating process.env at module scope leaked into later specs when a worker
|
||||
// reloaded files without replaying this file's afterAll.
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }
|
||||
})
|
||||
|
||||
const PARKED_FRAME_SCRIPT_DELAY_MS = 750
|
||||
const PARKED_FRAME_COUNT = 25
|
||||
|
||||
function parkedTuiFrame(runId: string, frame: number): string {
|
||||
const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}`
|
||||
const rows = [
|
||||
'╭────────────────────────────────────────────────────────────────────╮',
|
||||
`│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`,
|
||||
'├──────────────┬──────────────────────┬──────────────────────────────┤',
|
||||
`│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`,
|
||||
`│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`,
|
||||
`│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`,
|
||||
'╰──────────────┴──────────────────────┴──────────────────────────────╯',
|
||||
`PARKED_RESTORE_FINAL_${runId}_${frame}`
|
||||
]
|
||||
return [
|
||||
'\x1b[?2026h',
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H',
|
||||
'\x1b[?25l',
|
||||
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
|
||||
'\x1b[10;18H\x1b[?25h',
|
||||
'\x1b[?2026l'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function writeParkedFrameScript(scriptPath: string, runId: string): void {
|
||||
const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) =>
|
||||
parkedTuiFrame(runId, frame)
|
||||
)
|
||||
mkdirSync(path.dirname(scriptPath), { recursive: true })
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n`
|
||||
)
|
||||
}
|
||||
|
||||
// Deterministic static alt-screen frame for the park/reveal cycle test: rich
|
||||
// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot
|
||||
// restore, painted once and held so the on-screen content is stable across
|
||||
// cycles. No spinner/progress churn — the frame must be byte-identical every
|
||||
// reveal so drift is detectable.
|
||||
function cycleReferenceFrame(runId: string): string {
|
||||
const rows = [
|
||||
'╭──────────────────────────────────────────────────────────╮',
|
||||
`│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`,
|
||||
'├───────────────┬──────────────────────────────────────────┤',
|
||||
`│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`,
|
||||
`│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`,
|
||||
'╰───────────────┴──────────────────────────────────────────╯',
|
||||
`CYCLE_REFERENCE_${runId}`
|
||||
]
|
||||
return [
|
||||
'\x1b[?1049h',
|
||||
'\x1b[2J\x1b[H',
|
||||
'\x1b[?25l',
|
||||
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
|
||||
'\x1b[?25h'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function writeCycleReferenceScript(scriptPath: string, runId: string): void {
|
||||
mkdirSync(path.dirname(scriptPath), { recursive: true })
|
||||
// Paint the frame once, then hold the process open so the alt-screen TUI
|
||||
// stays on screen (and the parkable PTY session stays alive) across cycles.
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n`
|
||||
)
|
||||
}
|
||||
|
||||
// Why: serialize() re-emits the buffer with cursor-restore trailer sequences
|
||||
// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any
|
||||
// visible change. Compare the CONTENT rows, not the trailer — strip trailing
|
||||
// control sequences and normalize whitespace-only tail lines.
|
||||
function terminalContentRows(serialized: string): string[] {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
return stripped
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/\s+$/, ''))
|
||||
.filter((line) => line.length > 0)
|
||||
}
|
||||
|
||||
async function readParkingWiring(
|
||||
page: Page
|
||||
): Promise<{ present: boolean; parkDelayMs: number | null }> {
|
||||
return page.evaluate(() => {
|
||||
const debug = (window as ParkingDebugWindow).__terminalParkingDebug
|
||||
return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null }
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the spec lands ahead of the feature wiring. Skip (rather than fail)
|
||||
// when the app under test does not expose the parking debug handle so this
|
||||
// file is safe to merge in any order with the wiring branch.
|
||||
async function skipUnlessParkingWired(page: Page): Promise<void> {
|
||||
const deadline = Date.now() + 2_000
|
||||
let wiring = await readParkingWiring(page)
|
||||
while (!wiring.present && Date.now() < deadline) {
|
||||
await page.waitForTimeout(250)
|
||||
wiring = await readParkingWiring(page)
|
||||
}
|
||||
test.skip(
|
||||
!wiring.present,
|
||||
'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)'
|
||||
)
|
||||
}
|
||||
|
||||
// Why: this standalone extraction ships terminalHiddenViewParking default-OFF
|
||||
// (opt-in until the memory win is soak-confirmed); PR #7214 later flips the
|
||||
// default to true, making this enable a no-op. Same mutation path as the
|
||||
// kill-switch test in terminal-parked-memory.spec.ts.
|
||||
async function enableTerminalParking(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('parking spec: window.__store is unavailable')
|
||||
}
|
||||
await store.getState().updateSettings({ terminalHiddenViewParking: true })
|
||||
})
|
||||
}
|
||||
|
||||
type TerminalTabViewState = {
|
||||
hasManager: boolean
|
||||
paneCount: number
|
||||
}
|
||||
|
||||
async function readTerminalTabViewState(page: Page, tabId: string): Promise<TerminalTabViewState> {
|
||||
return page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
return {
|
||||
hasManager: manager !== undefined,
|
||||
paneCount: manager?.getPanes?.().length ?? 0
|
||||
}
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a
|
||||
// missing manager is the observable signal that the tab's xterm was parked.
|
||||
async function waitForTabParked(page: Page, tabId: string): Promise<number> {
|
||||
const parkWaitStartedAt = Date.now()
|
||||
await expect
|
||||
.poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, {
|
||||
timeout: Math.max(20_000, PARKING_DELAY_MS * 10),
|
||||
message: `terminal tab ${tabId} did not park (pane manager still mounted)`
|
||||
})
|
||||
.toBe(false)
|
||||
return Date.now() - parkWaitStartedAt
|
||||
}
|
||||
|
||||
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
||||
await page.evaluate((targetTabId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('activateTerminalTab: window.__store is unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
state.setActiveTabType('terminal')
|
||||
state.setActiveTab(targetTabId)
|
||||
}, tabId)
|
||||
|
||||
await expect
|
||||
.poll(() => getActiveTabId(page), {
|
||||
timeout: 5_000,
|
||||
message: `terminal tab ${tabId} did not become active`
|
||||
})
|
||||
.toBe(tabId)
|
||||
}
|
||||
|
||||
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<string> {
|
||||
const tabId = await page.evaluate((worktreeId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('createActiveTerminalTab: window.__store is unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return tab.id
|
||||
}, worktreeId)
|
||||
|
||||
await expect
|
||||
.poll(() => getActiveTabId(page), {
|
||||
timeout: 5_000,
|
||||
message: 'newly created terminal tab did not become active'
|
||||
})
|
||||
.toBe(tabId)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
await waitForPaneIdentitySnapshot(page, 1)
|
||||
return tabId
|
||||
}
|
||||
|
||||
async function getUnreadTerminalTabIds(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return []
|
||||
}
|
||||
return Object.keys(store.getState().unreadTerminalTabs)
|
||||
})
|
||||
}
|
||||
|
||||
async function isWorktreeUnread(page: Page, worktreeId: string): Promise<boolean> {
|
||||
return page.evaluate((worktreeId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
const worktree = Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === worktreeId)
|
||||
return worktree?.isUnread === true
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
async function getTerminalTabTitle(
|
||||
page: Page,
|
||||
worktreeId: string,
|
||||
tabId: string
|
||||
): Promise<string | null> {
|
||||
const tabs = await getWorktreeTabs(page, worktreeId)
|
||||
return tabs.find((tab) => tab.id === tabId)?.title ?? null
|
||||
}
|
||||
|
||||
async function hasPendingStartupCommand(page: Page, tabId: string): Promise<boolean> {
|
||||
return page.evaluate((tabId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return false
|
||||
}
|
||||
return store.getState().pendingStartupByTabId[tabId] !== undefined
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
type ParkableTabSetup = {
|
||||
worktreeId: string
|
||||
tabAId: string
|
||||
tabAPtyId: string
|
||||
}
|
||||
|
||||
// Why: every scenario starts from the same shape — tab A live in the active
|
||||
// worktree; callers then create more tabs on top so tab A goes hidden.
|
||||
async function setUpParkableTabA(page: Page): Promise<ParkableTabSetup> {
|
||||
const worktreeId = await waitForActiveWorktree(page)
|
||||
await skipUnlessParkingWired(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1)
|
||||
const tabAPtyId = tabASnapshot.panes[0]?.ptyId
|
||||
if (!tabAPtyId) {
|
||||
throw new Error('parking spec tab A did not bind a PTY')
|
||||
}
|
||||
return {
|
||||
worktreeId,
|
||||
tabAId: tabASnapshot.tabId,
|
||||
tabAPtyId
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Terminal hidden view parking', () => {
|
||||
test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await enableTerminalParking(orcaPage)
|
||||
const setup = await setUpParkableTabA(orcaPage)
|
||||
const { worktreeId, tabAId, tabAPtyId } = setup
|
||||
|
||||
const runId = randomUUID()
|
||||
const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`)
|
||||
writeParkedFrameScript(scriptPath, runId)
|
||||
try {
|
||||
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 15_000,
|
||||
message: 'rich TUI final frame did not render while tab A was visible'
|
||||
})
|
||||
.toContain(finalMarker)
|
||||
|
||||
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId)
|
||||
const wiring = await readParkingWiring(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'terminal-parking',
|
||||
description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}`
|
||||
})
|
||||
|
||||
// Why: parking must be scoped to the hidden tab — the visible tab keeps
|
||||
// a live pane manager and xterm.
|
||||
const tabBState = await readTerminalTabViewState(orcaPage, tabBId)
|
||||
expect(tabBState.hasManager).toBe(true)
|
||||
expect(tabBState.paneCount).toBeGreaterThan(0)
|
||||
|
||||
await activateTerminalTab(orcaPage, tabAId)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
expect(revealedSnapshot.tabId).toBe(tabAId)
|
||||
// Why: parking only tears down the renderer view; the PTY session must
|
||||
// survive so reveal reattaches to the same shell.
|
||||
expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId)
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 15_000,
|
||||
message: 'parked rich TUI frame did not restore when the tab was revealed'
|
||||
})
|
||||
.toContain(finalMarker)
|
||||
|
||||
const content = await getTerminalContent(orcaPage, 12_000)
|
||||
expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`)
|
||||
expect(content).toContain('╭')
|
||||
expect(content).toContain('├')
|
||||
expect(content).toContain('█')
|
||||
expect(content).not.toContain('Orca skipped hidden terminal output')
|
||||
|
||||
// Why: the typed marker only appears joined in command *output*, so this
|
||||
// proves the revealed terminal accepts input end-to-end, not just echo.
|
||||
const typedMarker = `PARKED_TYPED_OK_${runId}`
|
||||
const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')`
|
||||
await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 10_000,
|
||||
message: 'revealed terminal did not execute and display typed input'
|
||||
})
|
||||
.toContain(typedMarker)
|
||||
|
||||
const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('parked-tab-restore-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps bell and title side effects live while parked', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await enableTerminalParking(orcaPage)
|
||||
const setup = await setUpParkableTabA(orcaPage)
|
||||
const { worktreeId, tabAId, tabAPtyId } = setup
|
||||
|
||||
await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await waitForTabParked(orcaPage, tabAId)
|
||||
|
||||
const runId = randomUUID()
|
||||
const parkedTitle = `Parked side effects ${runId}`
|
||||
const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}`
|
||||
// Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL
|
||||
// must not count as a bell), then a content marker for the reveal check.
|
||||
// The 30s keep-alive stops the shell prompt from overwriting the title
|
||||
// before the store assertion lands.
|
||||
const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n`
|
||||
const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)`
|
||||
await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`)
|
||||
|
||||
await expect
|
||||
.poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), {
|
||||
timeout: 10_000,
|
||||
message: 'parked OSC 0 title did not update the tab title in the store'
|
||||
})
|
||||
.toBe(parkedTitle)
|
||||
await expect
|
||||
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), {
|
||||
timeout: 10_000,
|
||||
message: 'parked BEL did not mark the terminal tab unread'
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => isWorktreeUnread(orcaPage, worktreeId), {
|
||||
timeout: 10_000,
|
||||
message: 'parked BEL did not mark the worktree unread'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
// Why: side effects must come from the pane-less watcher — the burst must
|
||||
// not have woken the parked view back up.
|
||||
expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false)
|
||||
|
||||
await activateTerminalTab(orcaPage, tabAId)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 15_000,
|
||||
message: 'parked side-effect marker did not restore when the tab was revealed'
|
||||
})
|
||||
.toContain(marker)
|
||||
})
|
||||
|
||||
test('does not park excluded tabs', async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await enableTerminalParking(orcaPage)
|
||||
const setup = await setUpParkableTabA(orcaPage)
|
||||
const { worktreeId, tabAId } = setup
|
||||
|
||||
// Tab C: parking-excluded because it has a pending startup command. Queue
|
||||
// it after the pane mounted so the mount-time consume cannot drain it.
|
||||
const tabCId = await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await orcaPage.evaluate((tabId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('parking exclusion spec: window.__store is unavailable')
|
||||
}
|
||||
store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' })
|
||||
}, tabCId)
|
||||
expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true)
|
||||
|
||||
// Tab B on top hides both A and C.
|
||||
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await expect
|
||||
.poll(() => getActiveTabId(orcaPage), {
|
||||
timeout: 5_000,
|
||||
message: 'tab B did not stay active while waiting on the parking window'
|
||||
})
|
||||
.toBe(tabBId)
|
||||
|
||||
// Why: tab A parking proves the machinery ran past the delay in this app
|
||||
// instance, so the tab C assertion below is not vacuously green.
|
||||
await waitForTabParked(orcaPage, tabAId)
|
||||
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
|
||||
|
||||
// Premise guard: nothing consumed the pending startup while hidden.
|
||||
expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true)
|
||||
const tabCState = await readTerminalTabViewState(orcaPage, tabCId)
|
||||
expect(tabCState.hasManager).toBe(true)
|
||||
expect(tabCState.paneCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Drives 25 deterministic park→reveal cycles on a static rich TUI frame and
|
||||
// asserts every reveal reproduces the SAME content the tab showed while it was
|
||||
// continuously visible (the never-parked reference). This is the field-garble
|
||||
// guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator
|
||||
// snapshot restore + PTY reattach path the fuzz suites model in isolation, and
|
||||
// fails if any single cycle — or accumulated drift across 25 — garbles a cell.
|
||||
test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await enableTerminalParking(orcaPage)
|
||||
const setup = await setUpParkableTabA(orcaPage)
|
||||
const { worktreeId, tabAId, tabAPtyId } = setup
|
||||
|
||||
const runId = randomUUID()
|
||||
const marker = `CYCLE_REFERENCE_${runId}`
|
||||
const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`)
|
||||
writeCycleReferenceScript(scriptPath, runId)
|
||||
try {
|
||||
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 15_000,
|
||||
message: 'cycle reference frame did not render while tab A was visible'
|
||||
})
|
||||
.toContain(marker)
|
||||
|
||||
// Tab B stays visible whenever tab A is parked; toggling the active tab
|
||||
// between them is the deterministic hide/reveal driver.
|
||||
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
|
||||
// One park/reveal cycle to run the frame through the snapshot restore for a
|
||||
// baseline. Why not compare against the visible-before-park content: an
|
||||
// alt-screen restore deliberately drops the normal-buffer scrollback
|
||||
// (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the
|
||||
// pre-park serialize carries the shell command echo the restore correctly
|
||||
// omits — that is contract, not garble. Baselining after one reveal makes
|
||||
// both sides pass through identical machinery, so any later diff is drift.
|
||||
const runOneParkRevealCycle = async (cycle: number): Promise<string[]> => {
|
||||
await activateTerminalTab(orcaPage, tabBId)
|
||||
await waitForTabParked(orcaPage, tabAId)
|
||||
await activateTerminalTab(orcaPage, tabAId)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
|
||||
expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(orcaPage, 12_000), {
|
||||
timeout: 15_000,
|
||||
message: `cycle ${cycle}: reference frame did not restore on reveal`
|
||||
})
|
||||
.toContain(marker)
|
||||
const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000))
|
||||
// Garble sentinel: the hidden-skip banner must never appear.
|
||||
expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output')
|
||||
return rows
|
||||
}
|
||||
|
||||
const referenceRows = await runOneParkRevealCycle(0)
|
||||
expect(referenceRows.join('\n')).toContain(marker)
|
||||
expect(referenceRows.join('\n')).toContain('╭')
|
||||
|
||||
// waitForTabParked (inside runOneParkRevealCycle) throws if the tab never
|
||||
// parked, so reaching here means the machinery ran every cycle — no
|
||||
// separate premise guard needed for a vacuous-green check.
|
||||
const CYCLES = 25
|
||||
const mismatches: string[] = []
|
||||
for (let cycle = 1; cycle < CYCLES; cycle++) {
|
||||
const rows = await runOneParkRevealCycle(cycle)
|
||||
if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) {
|
||||
mismatches.push(
|
||||
`cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'terminal-parking-cycles',
|
||||
description: `cycles=${CYCLES} mismatches=${mismatches.length}`
|
||||
})
|
||||
expect(
|
||||
mismatches,
|
||||
`park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}`
|
||||
).toEqual([])
|
||||
|
||||
const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png')
|
||||
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
|
||||
await testInfo.attach('park-reveal-25-cycles-final.png', {
|
||||
path: screenshotPath,
|
||||
contentType: 'image/png'
|
||||
})
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,373 @@
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getActiveTabId,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneIdentitySnapshot
|
||||
} from './helpers/terminal'
|
||||
|
||||
// Why: production cold-park hysteresis is 30s. The fast-park env override is
|
||||
// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as
|
||||
// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs.
|
||||
const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500
|
||||
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) },
|
||||
// Why: without this switch Chromium quantizes performance.memory and only
|
||||
// refreshes it every ~20 minutes, so both scenarios report the same stale
|
||||
// launch-time bucket instead of a comparable heap figure.
|
||||
orcaAppExtraArgs: ['--enable-precise-memory-info']
|
||||
})
|
||||
|
||||
// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit
|
||||
// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS
|
||||
// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the
|
||||
// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden
|
||||
// past hotRetainMs before the retain-count limit is even consulted. So all 8
|
||||
// park without needing 14 tabs or extra policy knobs.
|
||||
const SCROLLBACK_TAB_COUNT = 8
|
||||
const SCROLLBACK_LINE_COUNT = 3000
|
||||
const PARK_SETTLE_MS = 2_000
|
||||
const HEAP_SAMPLE_COUNT = 5
|
||||
const HEAP_SAMPLE_INTERVAL_MS = 250
|
||||
// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of
|
||||
// scrollback each, then waits out the parking window — well past the default
|
||||
// 120s per-test budget.
|
||||
const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000
|
||||
|
||||
// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes
|
||||
// each xterm hold realistic narrow+wide buffer rows, so released parked-tab
|
||||
// memory reflects real agent output rather than uniform filler.
|
||||
function writeScrollbackFillScript(scriptPath: string, runId: string): void {
|
||||
const script = [
|
||||
`const tabIndex = process.argv[2] ?? '0'`,
|
||||
`const wide = '統合端末記憶計測'`,
|
||||
`const emoji = ['🟢', '🟡', '🔵', '🟣']`,
|
||||
`const lines = []`,
|
||||
`for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`,
|
||||
` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`,
|
||||
` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`,
|
||||
` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`,
|
||||
`}`,
|
||||
`process.stdout.write(lines.join('\\n') + '\\n')`,
|
||||
`process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')`
|
||||
].join('\n')
|
||||
writeFileSync(scriptPath, `${script}\n`)
|
||||
}
|
||||
|
||||
// Why: the spec lands ahead of the feature wiring in some merge orders. Skip
|
||||
// (rather than fail) when the app under test does not expose the parking
|
||||
// debug handle, mirroring terminal-hidden-view-parking.spec.ts.
|
||||
async function skipUnlessParkingWired(page: Page): Promise<void> {
|
||||
const deadline = Date.now() + 2_000
|
||||
let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
|
||||
while (!present && Date.now() < deadline) {
|
||||
await page.waitForTimeout(250)
|
||||
present = await page.evaluate(() => window.__terminalParkingDebug !== undefined)
|
||||
}
|
||||
test.skip(
|
||||
!present,
|
||||
'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)'
|
||||
)
|
||||
}
|
||||
|
||||
type TerminalTabViewState = {
|
||||
hasManager: boolean
|
||||
paneCount: number
|
||||
}
|
||||
|
||||
// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so
|
||||
// a missing manager is the observable signal that the tab's xterm was parked.
|
||||
async function readTerminalTabViewState(page: Page, tabId: string): Promise<TerminalTabViewState> {
|
||||
return page.evaluate((tabId) => {
|
||||
const manager = window.__paneManagers?.get(tabId)
|
||||
return {
|
||||
hasManager: manager !== undefined,
|
||||
paneCount: manager?.getPanes?.().length ?? 0
|
||||
}
|
||||
}, tabId)
|
||||
}
|
||||
|
||||
async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise<number> {
|
||||
return page.evaluate(
|
||||
(tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length,
|
||||
tabIds
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForTabsParked(page: Page, tabIds: string[]): Promise<void> {
|
||||
await expect
|
||||
.poll(() => countMountedPaneManagers(page, tabIds), {
|
||||
timeout: Math.max(30_000, PARKING_DELAY_MS * 10),
|
||||
message: 'hidden scrollback tabs did not all park (pane managers still mounted)'
|
||||
})
|
||||
.toBe(0)
|
||||
}
|
||||
|
||||
type ScrollbackTab = {
|
||||
tabId: string
|
||||
ptyId: string
|
||||
}
|
||||
|
||||
async function createActiveTerminalTab(page: Page, worktreeId: string): Promise<ScrollbackTab> {
|
||||
const tabId = await page.evaluate((worktreeId) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('createActiveTerminalTab: window.__store is unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
const tab = state.createTab(worktreeId, undefined, undefined, { activate: true })
|
||||
state.setActiveTab(tab.id)
|
||||
state.setActiveTabType('terminal')
|
||||
return tab.id
|
||||
}, worktreeId)
|
||||
|
||||
await expect
|
||||
.poll(() => getActiveTabId(page), {
|
||||
timeout: 5_000,
|
||||
message: 'newly created terminal tab did not become active'
|
||||
})
|
||||
.toBe(tabId)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
const snapshot = await waitForPaneIdentitySnapshot(page, 1)
|
||||
const ptyId = snapshot.panes[0]?.ptyId
|
||||
if (snapshot.tabId !== tabId || !ptyId) {
|
||||
throw new Error('createActiveTerminalTab: new tab did not bind a PTY')
|
||||
}
|
||||
return { tabId, ptyId }
|
||||
}
|
||||
|
||||
async function fillActiveTerminalWithScrollback(
|
||||
page: Page,
|
||||
ptyId: string,
|
||||
scriptPath: string,
|
||||
tabIndex: number,
|
||||
runId: string
|
||||
): Promise<void> {
|
||||
await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`)
|
||||
await expect
|
||||
.poll(() => getTerminalContent(page, 4_000), {
|
||||
timeout: 30_000,
|
||||
message: `scrollback fill marker for tab ${tabIndex} did not render`
|
||||
})
|
||||
.toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`)
|
||||
}
|
||||
|
||||
type ScrollbackTabSetup = {
|
||||
worktreeId: string
|
||||
scrollbackTabs: ScrollbackTab[]
|
||||
}
|
||||
|
||||
// Why: each tab generates its scrollback while visible, so every xterm holds
|
||||
// the full buffer before going hidden — the hidden-delivery gate never gets a
|
||||
// chance to drop the output the memory comparison depends on.
|
||||
async function setUpScrollbackTabs(
|
||||
page: Page,
|
||||
scriptPath: string,
|
||||
runId: string
|
||||
): Promise<ScrollbackTabSetup> {
|
||||
const worktreeId = await waitForActiveWorktree(page)
|
||||
await skipUnlessParkingWired(page)
|
||||
await ensureTerminalVisible(page)
|
||||
await waitForActiveTerminalManager(page, 30_000)
|
||||
const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1)
|
||||
const baselinePtyId = baselineSnapshot.panes[0]?.ptyId
|
||||
if (!baselinePtyId) {
|
||||
throw new Error('parked memory spec: baseline terminal tab did not bind a PTY')
|
||||
}
|
||||
|
||||
const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }]
|
||||
await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId)
|
||||
for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) {
|
||||
const tab = await createActiveTerminalTab(page, worktreeId)
|
||||
scrollbackTabs.push(tab)
|
||||
await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId)
|
||||
}
|
||||
return { worktreeId, scrollbackTabs }
|
||||
}
|
||||
|
||||
type ParkedMemoryMetrics = {
|
||||
heapUsedMB: number
|
||||
liveTerminals: number
|
||||
livePaneManagers: number
|
||||
}
|
||||
|
||||
// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best
|
||||
// effort) and take the min of several settled samples — the min reflects
|
||||
// retained heap instead of allocation noise between collections. Note xterm
|
||||
// buffer rows are typed-array backing stores outside the V8 heap, so the
|
||||
// liveTerminals/livePaneManagers counts are the strong release signal and the
|
||||
// heap figure tracks only the on-heap share.
|
||||
async function sampleParkedMemoryMetrics(page: Page): Promise<ParkedMemoryMetrics> {
|
||||
await page.waitForTimeout(PARK_SETTLE_MS)
|
||||
try {
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send('HeapProfiler.collectGarbage')
|
||||
await session.detach()
|
||||
} catch {
|
||||
// GC over CDP is a measurement-fidelity improvement, not a gate.
|
||||
}
|
||||
|
||||
let minHeapBytes: number | null = null
|
||||
for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) {
|
||||
const heapBytes = await page.evaluate(() => {
|
||||
const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory
|
||||
return memory?.usedJSHeapSize ?? null
|
||||
})
|
||||
if (heapBytes !== null) {
|
||||
minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes)
|
||||
}
|
||||
await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
if (minHeapBytes === null) {
|
||||
throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable')
|
||||
}
|
||||
|
||||
const liveCounts = await page.evaluate(() => ({
|
||||
liveTerminals: document.querySelectorAll('.xterm').length,
|
||||
livePaneManagers: window.__paneManagers?.size ?? 0
|
||||
}))
|
||||
return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts }
|
||||
}
|
||||
|
||||
function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string {
|
||||
return [
|
||||
`panes=${SCROLLBACK_TAB_COUNT}`,
|
||||
`parkedTabs=${parkedTabs}`,
|
||||
`heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`,
|
||||
`liveTerminals=${metrics.liveTerminals}`,
|
||||
`livePaneManagers=${metrics.livePaneManagers}`
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
// Why: this standalone extraction ships terminalHiddenViewParking default-OFF
|
||||
// (opt-in until the memory win is soak-confirmed); PR #7214 later flips the
|
||||
// default to true, making this enable a no-op. Same mutation path as the
|
||||
// kill-switch test in terminal-parked-memory.spec.ts.
|
||||
async function enableTerminalParking(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('parking spec: window.__store is unavailable')
|
||||
}
|
||||
await store.getState().updateSettings({ terminalHiddenViewParking: true })
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Terminal parked memory', () => {
|
||||
test('releases renderer terminal memory when hidden tabs park', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
|
||||
await waitForSessionReady(orcaPage)
|
||||
await enableTerminalParking(orcaPage)
|
||||
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
|
||||
writeScrollbackFillScript(scriptPath, runId)
|
||||
try {
|
||||
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
|
||||
|
||||
// A fresh 9th tab hides all 8 scrollback tabs.
|
||||
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
await waitForTabsParked(
|
||||
orcaPage,
|
||||
scrollbackTabs.map((tab) => tab.tabId)
|
||||
)
|
||||
|
||||
const metrics = await sampleParkedMemoryMetrics(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'opencode-parked-memory',
|
||||
description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length)
|
||||
})
|
||||
|
||||
// Structural assertions: all 8 parked (managers gone), and the only
|
||||
// live xterm/pane manager belongs to the visible tab.
|
||||
for (const tab of scrollbackTabs) {
|
||||
expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false)
|
||||
}
|
||||
const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId)
|
||||
expect(visibleState.hasManager).toBe(true)
|
||||
expect(visibleState.paneCount).toBeGreaterThan(0)
|
||||
// Why: design invariant 5 — renderer terminal views scale with visible
|
||||
// panes, so parked tabs must leave no xterm DOM behind.
|
||||
expect(metrics.liveTerminals).toBe(visibleState.paneCount)
|
||||
expect(metrics.livePaneManagers).toBe(1)
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('retains terminal views when parking is disabled', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo: TestInfo) => {
|
||||
test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS)
|
||||
await waitForSessionReady(orcaPage)
|
||||
|
||||
// Why: settings.terminalHiddenViewParking === false is the design-doc
|
||||
// kill switch. updateSettings persists it through window.api.settings.set
|
||||
// and updates the store slice the cold-park hook subscribes to — the same
|
||||
// mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env
|
||||
// wiring is needed.
|
||||
await orcaPage.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('parked memory spec: window.__store is unavailable')
|
||||
}
|
||||
await store.getState().updateSettings({ terminalHiddenViewParking: false })
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking),
|
||||
{ timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' }
|
||||
)
|
||||
.toBe(false)
|
||||
|
||||
const runId = randomUUID()
|
||||
const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`)
|
||||
writeScrollbackFillScript(scriptPath, runId)
|
||||
try {
|
||||
const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId)
|
||||
const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId)
|
||||
|
||||
const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId)
|
||||
// Why: with parking enabled these tabs park within ~1x the collapsed
|
||||
// delay (the first test proves the machinery in this app build), so
|
||||
// surviving 3x the delay shows the kill switch held.
|
||||
await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3)
|
||||
expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT)
|
||||
|
||||
const metrics = await sampleParkedMemoryMetrics(orcaPage)
|
||||
testInfo.annotations.push({
|
||||
type: 'opencode-parked-memory-disabled',
|
||||
description: formatParkedMemoryAnnotation(metrics, 0)
|
||||
})
|
||||
|
||||
// Structural assertions: every hidden tab keeps its pane manager and
|
||||
// xterm; nothing parked even after the settle + sampling window.
|
||||
for (const tab of scrollbackTabs) {
|
||||
const state = await readTerminalTabViewState(orcaPage, tab.tabId)
|
||||
expect(state.hasManager).toBe(true)
|
||||
expect(state.paneCount).toBeGreaterThan(0)
|
||||
}
|
||||
expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true)
|
||||
expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1)
|
||||
expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1)
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
ensureTerminalVisible,
|
||||
getAllWorktreeIds,
|
||||
switchToWorktree,
|
||||
waitForActiveWorktree,
|
||||
waitForSessionReady
|
||||
} from './helpers/store'
|
||||
import {
|
||||
getTerminalContent,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
|
||||
type SleepWakeTerminalDebug = {
|
||||
activeTabId: string | null
|
||||
activeWorktreeId: string | null
|
||||
tabs: {
|
||||
id: string
|
||||
ptyId?: string
|
||||
generation?: number
|
||||
pendingActivationSpawn?: boolean | number
|
||||
}[]
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
ptyIdsByLeafIdByTabId: Record<string, Record<string, string>>
|
||||
}
|
||||
|
||||
async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise<void> {
|
||||
await page.evaluate(async (id) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('store unavailable')
|
||||
}
|
||||
const state = store.getState()
|
||||
await state.shutdownWorktreeBrowsers(id)
|
||||
await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true })
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise<number> {
|
||||
return page.evaluate((id) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return 0
|
||||
}
|
||||
const state = store.getState()
|
||||
const tabs = state.tabsByWorktree[id] ?? []
|
||||
return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0)
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
async function readSleepWakeTerminalDebug(
|
||||
page: Page,
|
||||
worktreeId: string
|
||||
): Promise<SleepWakeTerminalDebug> {
|
||||
return page.evaluate((id) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return {
|
||||
activeTabId: null,
|
||||
activeWorktreeId: null,
|
||||
tabs: [],
|
||||
ptyIdsByTabId: {},
|
||||
ptyIdsByLeafIdByTabId: {}
|
||||
}
|
||||
}
|
||||
const state = store.getState()
|
||||
const tabs = state.tabsByWorktree[id] ?? []
|
||||
return {
|
||||
activeTabId: state.activeTabId,
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
tabs: tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
ptyId: tab.ptyId,
|
||||
generation: tab.generation,
|
||||
pendingActivationSpawn: tab.pendingActivationSpawn
|
||||
})),
|
||||
ptyIdsByTabId: Object.fromEntries(
|
||||
tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []])
|
||||
),
|
||||
ptyIdsByLeafIdByTabId: Object.fromEntries(
|
||||
tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}])
|
||||
)
|
||||
}
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise<boolean> {
|
||||
return page.evaluate(
|
||||
async ({ targetPtyId, expectedText }) => {
|
||||
const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, {
|
||||
scrollbackRows: 200
|
||||
})
|
||||
return snapshot?.data.includes(expectedText) ?? false
|
||||
},
|
||||
{ targetPtyId: ptyId, expectedText: text }
|
||||
)
|
||||
}
|
||||
|
||||
function richSleepWakePayload(runId: string): string {
|
||||
const shortId = runId.slice(0, 8)
|
||||
return [
|
||||
'\x1b[?2026h',
|
||||
'\x1b[2J\x1b[H',
|
||||
'╭────────────────────────────────────────────╮',
|
||||
`│ sleep wake restore ${shortId} 😀 │`,
|
||||
'├────────────┬───────────────┬───────────────┤',
|
||||
'│ agent │ status │ output │',
|
||||
'├────────────┼───────────────┼───────────────┤',
|
||||
`│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`,
|
||||
'│ opencode │ streaming │ unicode ✓ │',
|
||||
'│ shell │ idle │ prompt ready │',
|
||||
'╰────────────┴───────────────┴───────────────╯',
|
||||
`SLEEP_WAKE_RESTORE_${runId}`,
|
||||
`SLEEP_WAKE_TABLE_${runId}`,
|
||||
'\x1b[?2026l'
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
function sleepWakeExpectedMarkers(runId: string): string[] {
|
||||
return [
|
||||
`SLEEP_WAKE_RESTORE_${runId}`,
|
||||
`SLEEP_WAKE_TABLE_${runId}`,
|
||||
'box/table ok',
|
||||
'unicode ✓',
|
||||
'prompt ready'
|
||||
]
|
||||
}
|
||||
|
||||
function writeSleepWakePayloadScript(scriptPath: string, payload: string): void {
|
||||
const encodedPayload = Buffer.from(payload, 'utf8').toString('base64')
|
||||
writeFileSync(
|
||||
scriptPath,
|
||||
`process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`,
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Terminal sleep wake restore', () => {
|
||||
test('restores slept terminal output and accepts fresh input after wake', async ({
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
const firstWorktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find(
|
||||
(id) => id !== firstWorktreeId
|
||||
)
|
||||
test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree')
|
||||
if (!secondWorktreeId) {
|
||||
return
|
||||
}
|
||||
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const runId = randomUUID()
|
||||
const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}`
|
||||
const freshMarker = `SLEEP_WAKE_FRESH_${runId}`
|
||||
const expectedMarkers = sleepWakeExpectedMarkers(runId)
|
||||
const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`)
|
||||
writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId))
|
||||
try {
|
||||
await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`)
|
||||
await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000)
|
||||
const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
|
||||
for (const marker of expectedMarkers) {
|
||||
expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true)
|
||||
}
|
||||
|
||||
await switchToWorktree(orcaPage, firstWorktreeId)
|
||||
await sleepWorktreeTerminals(orcaPage, secondWorktreeId)
|
||||
const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
|
||||
await expect
|
||||
.poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), {
|
||||
timeout: 10_000,
|
||||
message: 'sleep did not release live PTYs for the background worktree'
|
||||
})
|
||||
.toBe(0)
|
||||
|
||||
await switchToWorktree(orcaPage, secondWorktreeId)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const awakePtyId = await waitForActivePanePtyId(orcaPage)
|
||||
const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId)
|
||||
const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000)
|
||||
for (const marker of expectedMarkers) {
|
||||
expect
|
||||
.soft(awakeTerminalContent.includes(marker), {
|
||||
message: JSON.stringify(
|
||||
{
|
||||
missingMarker: marker,
|
||||
ptyId,
|
||||
awakePtyId,
|
||||
beforeSleepDebug,
|
||||
afterSleepDebug,
|
||||
afterWakeDebug,
|
||||
terminalTail: awakeTerminalContent.slice(-2000)
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000)
|
||||
await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`)
|
||||
await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000)
|
||||
} finally {
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user