native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517import Clocksimport Foundationimport GhosttyKitimport ProwlCLISharedimport Testing
@testable import Prowl
/// Undo for pane and tab closes (docs-ai 069): the manager-level flow from a/// close through ⌘Z / ⌘⇧Z, expiry, and invalidation.@MainActor@Suite(.serialized)struct WorktreeTerminalUndoCloseTests { @Test func closedTabComesBackAtItsIndexWithTheSameSurface() throws { let fixture = makeFixture() let state = fixture.state let first = try #require(state.createTab()) let second = try #require(state.createTab()) let third = try #require(state.createTab()) state.selectTab(second) let surfaceID = try #require(state.focusedSurfaceId(in: second)) let view = try #require(state.surfaceView(for: surfaceID)) let handleBefore = state.paneHandle(for: surfaceID)
#expect(state.closeTab(second)) #expect(state.tabManager.tabs.map(\.id) == [first, third]) #expect(state.surfaceView(for: surfaceID) == nil) #expect(view.isPendingClose) #expect(fixture.manager.closeUndoStack.canUndo)
#expect(fixture.manager.undoClose())
#expect(state.tabManager.tabs.map(\.id) == [first, second, third]) #expect(state.tabManager.selectedTabId == second) #expect(state.surfaceView(for: surfaceID) === view) #expect(state.focusedSurfaceId(in: second) == surfaceID) #expect(!view.isPendingClose) #expect(state.paneHandle(for: surfaceID) != handleBefore) #expect(!fixture.manager.closeUndoStack.canUndo) #expect(fixture.manager.closeUndoStack.canRedo) }
@Test func closedPaneComesBackInItsSplitPosition() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let anchor = try #require(state.focusedSurfaceId(in: tab)) let pane = try state.createSplit(of: anchor, direction: .right, initialInput: nil).get() let before = state.splitTree(for: tab) let view = try #require(state.surfaceView(for: pane))
#expect(state.closeSurface(id: pane)) #expect(state.splitTree(for: tab).leaves().map(\.id) == [anchor]) #expect(state.focusedSurfaceId(in: tab) == anchor)
#expect(fixture.manager.undoClose())
let after = state.splitTree(for: tab) #expect(after.structuralIdentity == before.structuralIdentity) #expect(after.leaves().map(\.id) == [anchor, pane]) #expect(state.surfaceView(for: pane) === view) #expect(state.focusedSurfaceId(in: tab) == pane) }
@Test func undoWithNothingRecordedFallsThrough() { let fixture = makeFixture() #expect(!fixture.manager.undoClose()) #expect(!fixture.manager.redoClose()) }
@Test func closeExpiresAfterTheGhosttyTimeout() async throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID))
#expect(state.closeTab(tab)) #expect(view.isPendingClose)
await fixture.clock.advance(by: .seconds(5)) await settle()
#expect(!fixture.manager.closeUndoStack.canUndo) #expect(!view.isPendingClose) #expect(!fixture.manager.undoClose()) #expect(state.tabManager.tabs.isEmpty) }
@Test func processExitDuringGraceDropsTheRecord() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID))
#expect(state.closeTab(tab)) view.bridge.closeSurface(processAlive: false)
#expect(!fixture.manager.closeUndoStack.canUndo) #expect(!view.isPendingClose) #expect(!fixture.manager.undoClose()) }
/// Ghostty's close callback carries `needsConfirmQuit()`, which is false for /// an idle shell at its prompt: that is the plain ⌘W case and must be undoable. @Test func idleShellCloseRequestIsRecorded() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID))
#expect(state.handleCloseRequest(for: view, processAlive: false))
#expect(view.isPendingClose) #expect(fixture.manager.closeUndoStack.canUndo) #expect(fixture.manager.undoClose()) #expect(state.surfaceView(for: surfaceID) === view) }
/// A shell that exited reports `show_child_exited` before its close request. @Test func deadProcessCloseIsNotRecorded() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID)) reportChildExit(on: view)
#expect(state.handleCloseRequest(for: view, processAlive: false))
#expect(!view.isPendingClose) #expect(!fixture.manager.closeUndoStack.canUndo) }
@Test func autoCloseOnSuccessIsNotRecorded() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID))
#expect(state.handleCloseRequest(for: view, processAlive: false, retainForUndo: false))
#expect(!view.isPendingClose) #expect(!fixture.manager.closeUndoStack.canUndo) }
@Test func zeroTimeoutKeepsFreeOnClose() throws { let fixture = makeFixture() let state = fixture.state state.undoCloseTimeout = .zero let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID))
#expect(state.closeTab(tab))
#expect(!view.isPendingClose) #expect(!fixture.manager.closeUndoStack.canUndo) }
@Test func batchCloseUndoesAsOneEntry() throws { let fixture = makeFixture() let state = fixture.state let first = try #require(state.createTab()) let second = try #require(state.createTab()) let third = try #require(state.createTab()) state.selectTab(first)
state.closeOtherTabs(keeping: first) #expect(state.tabManager.tabs.map(\.id) == [first])
#expect(fixture.manager.undoClose())
#expect(state.tabManager.tabs.map(\.id) == [first, second, third]) #expect(state.tabManager.selectedTabId == first) #expect(!fixture.manager.closeUndoStack.canUndo) }
@Test func redoClosesAgainAndStaysUndoable() throws { let fixture = makeFixture() let state = fixture.state let first = try #require(state.createTab()) let second = try #require(state.createTab())
#expect(state.closeTab(second)) #expect(fixture.manager.undoClose()) #expect(fixture.manager.redoClose())
#expect(state.tabManager.tabs.map(\.id) == [first]) #expect(fixture.manager.closeUndoStack.canUndo) #expect(!fixture.manager.closeUndoStack.canRedo)
#expect(fixture.manager.undoClose()) #expect(state.tabManager.tabs.map(\.id) == [first, second]) }
@Test func paneRecordIsDroppedWhenTheTabChangedShape() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let anchor = try #require(state.focusedSurfaceId(in: tab)) let pane = try state.createSplit(of: anchor, direction: .right, initialInput: nil).get() let view = try #require(state.surfaceView(for: pane))
#expect(state.closeSurface(id: pane)) _ = try state.createSplit(of: anchor, direction: .down, initialInput: nil).get()
#expect(!fixture.manager.undoClose()) #expect(!view.isPendingClose) #expect(state.splitTree(for: tab).leaves().count == 2) #expect(!fixture.manager.closeUndoStack.canUndo) }
@Test func pruneFreesRetainedSurfacesOfRemovedWorktrees() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID)) #expect(state.closeTab(tab))
fixture.manager.prune(keeping: [])
#expect(!view.isPendingClose) #expect(!fixture.manager.closeUndoStack.canUndo) }
@Test func restoringTheLastTabReopensTheWorktreeAndReportsTheTab() async throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let stream = fixture.manager.eventStream() #expect(state.closeTab(tab))
#expect(fixture.manager.undoClose())
var seen: [TerminalClient.Event] = [] for await event in stream { seen.append(event) if case .tabRestored = event { break } } #expect(seen.contains(.tabCreated(worktreeID: fixture.worktree.id))) #expect(seen.last == .tabRestored(worktreeID: fixture.worktree.id, tabID: tab)) }
@Test func batchRestoreReportsTheTabThatWasSelected() async throws { let fixture = makeFixture() let state = fixture.state let first = try #require(state.createTab()) let second = try #require(state.createTab()) state.selectTab(second) let stream = fixture.manager.eventStream() state.closeAllTabs()
#expect(fixture.manager.undoClose())
var reported: TerminalTabID? for await event in stream { if case .tabRestored(_, let tabID) = event { reported = tabID break } } #expect(reported == second) #expect(state.tabManager.tabs.map(\.id) == [first, second]) #expect(state.tabManager.selectedTabId == second) }
// Round 1 review findings (docs-ai 069): pinned red before the fixes.
@Test func restoringAPaneFromAnotherTabSelectsItsTab() throws { let fixture = makeFixture() let state = fixture.state let first = try #require(state.createTab()) let anchor = try #require(state.focusedSurfaceId(in: first)) let pane = try state.createSplit(of: anchor, direction: .right, initialInput: nil).get() let second = try #require(state.createTab())
state.selectTab(first) #expect(state.closeSurface(id: pane)) state.selectTab(second)
#expect(fixture.manager.undoClose())
#expect(state.tabManager.selectedTabId == first) #expect(state.focusedSurfaceId(in: first) == pane) }
@Test func restoredProfileSurfaceKeepsItsLaunchProfileAndHook() throws { let fixture = makeFixture() let state = fixture.state _ = try #require(state.createTab()) let launched = try launchProfile(in: state, cwd: fixture.worktree.workingDirectory) let profile = try #require(state.launchProfilesBySurface[launched.surfaceID]) #expect(fixture.manager.hasManagedHookForTesting(surfaceID: launched.surfaceID))
#expect(state.closeTab(launched.tabID)) #expect(state.launchProfilesBySurface[launched.surfaceID] == nil) #expect(!fixture.manager.hasManagedHookForTesting(surfaceID: launched.surfaceID))
#expect(fixture.manager.undoClose())
#expect(state.launchProfilesBySurface[launched.surfaceID] == profile) #expect(fixture.manager.hasManagedHookForTesting(surfaceID: launched.surfaceID)) }
@Test func paneRecordIsDroppedWhenPanesWereRearranged() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let paneA = try #require(state.focusedSurfaceId(in: tab)) let paneB = try state.createSplit(of: paneA, direction: .right, initialInput: nil).get() let paneC = try state.createSplit(of: paneB, direction: .right, initialInput: nil).get() let view = try #require(state.surfaceView(for: paneC))
#expect(state.closeSurface(id: paneC)) state.performSplitOperation(.drop(payloadId: paneB, destinationId: paneA, zone: .left), in: tab) let rearranged = state.splitTree(for: tab) #expect(rearranged.leaves().map(\.id) == [paneB, paneA])
#expect(!fixture.manager.undoClose()) #expect(!view.isPendingClose) #expect(state.splitTree(for: tab).structuralIdentity == rearranged.structuralIdentity) }
@Test func layoutResetDiscardsRetainedRecords() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID)) #expect(state.closeTab(tab)) #expect(fixture.manager.closeUndoStack.canUndo)
state.closeAllSurfaces()
#expect(!fixture.manager.closeUndoStack.canUndo) #expect(!view.isPendingClose) #expect(!fixture.manager.undoClose()) }
// Round 2 review findings.
@Test func retainedProfileCloseKeepsTheForwardingRecordUntilTheSurfaceIsFreed() async throws { let base = FileManager.default.temporaryDirectory.appending(path: "undo-forwarding-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: base) } let fixture = makeFixture(forwardingRecordBaseDirectory: base) let state = fixture.state let store = try #require(fixture.manager.forwardingRecordStoreForTesting()) let record = try store.create(argv: ["/tmp/notifier"]) let launched = try launchProfile(in: state, cwd: fixture.worktree.workingDirectory, forwardingRecord: record)
#expect(state.closeTab(launched.tabID)) #expect(!store.isRetired(record))
#expect(fixture.manager.undoClose()) #expect(!store.isRetired(record)) #expect(fixture.manager.hasManagedHookForTesting(surfaceID: launched.surfaceID))
#expect(state.closeTab(launched.tabID)) await fixture.clock.advance(by: .seconds(5)) await settle() #expect(store.isRetired(record)) }
@Test func restoringAPaneInCanvasRequestsVisibleOcclusion() throws { let fixture = makeFixture() let state = fixture.state state.isCanvasManaged = true let tab = try #require(state.createTab()) let anchor = try #require(state.focusedSurfaceId(in: tab)) let pane = try state.createSplit(of: anchor, direction: .right, initialInput: nil).get() let view = try #require(state.surfaceView(for: pane)) view.attachmentStateForTesting = { (hasSuperview: true, hasWindow: true) } var applied: [Bool] = [] view.onOcclusionAppliedForTesting = { applied.append($0) }
#expect(state.closeSurface(id: pane)) #expect(applied.last == false)
#expect(fixture.manager.undoClose()) #expect(applied.last == true) }
// Round 4 review finding: child exit without a close request // (Ghostty `wait-after-command`).
@Test func childExitDuringGraceDropsTheRecord() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID))
#expect(state.closeTab(tab)) reportChildExit(on: view)
#expect(!fixture.manager.closeUndoStack.canUndo) #expect(!view.isPendingClose) #expect(!fixture.manager.undoClose()) }
@Test func closingAnExitedTerminalIsNotRecorded() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let surfaceID = try #require(state.focusedSurfaceId(in: tab)) let view = try #require(state.surfaceView(for: surfaceID)) reportChildExit(on: view)
#expect(state.closeTab(tab))
#expect(!view.isPendingClose) #expect(!fixture.manager.closeUndoStack.canUndo) }
@Test func closingATabKeepsOnlyItsLivingPanesRestorable() throws { let fixture = makeFixture() let state = fixture.state let tab = try #require(state.createTab()) let anchor = try #require(state.focusedSurfaceId(in: tab)) let exited = try state.createSplit(of: anchor, direction: .right, initialInput: nil).get() let exitedView = try #require(state.surfaceView(for: exited)) let livingView = try #require(state.surfaceView(for: anchor)) reportChildExit(on: exitedView)
#expect(state.closeTab(tab)) #expect(!exitedView.isPendingClose) #expect(livingView.isPendingClose)
#expect(fixture.manager.undoClose()) #expect(state.splitTree(for: tab).leaves().map(\.id) == [anchor]) #expect(state.surfaceView(for: exited) == nil) #expect(state.focusedSurfaceId(in: tab) == anchor) }
private func reportChildExit(on view: GhosttySurfaceView) { var action = ghostty_action_s() action.tag = GHOSTTY_ACTION_SHOW_CHILD_EXITED action.action.child_exited.exit_code = 0 _ = view.bridge.handleAction(target: ghostty_target_s(), action: action) }
private struct Fixture { let manager: WorktreeTerminalManager let state: WorktreeTerminalState let worktree: Worktree let clock: TestClock<Duration> }
private func makeFixture(forwardingRecordBaseDirectory: URL? = nil) -> Fixture { let clock = TestClock() let manager: WorktreeTerminalManager if let forwardingRecordBaseDirectory { manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), forwardingRecordBaseDirectory: forwardingRecordBaseDirectory, undoCloseClock: clock, skipsSurfaceCreationForTesting: true ) } else { manager = WorktreeTerminalManager( runtime: GhosttyRuntime(), undoCloseClock: clock, skipsSurfaceCreationForTesting: true ) } let worktree = Worktree( id: "/tmp/repo/wt-1", name: "wt-1", detail: "detail", workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-1"), repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") ) return Fixture(manager: manager, state: manager.state(for: worktree), worktree: worktree, clock: clock) }
private func launchProfile( in state: WorktreeTerminalState, cwd: URL, forwardingRecord: CodexForwardingRecord? = nil ) throws -> LaunchedSurface { let registration = AgentHookLaunchRegistration( token: "token-undo", runtime: .codex, launchCWD: cwd, nativeEvents: ["agent-turn-complete": .turnEnded], coveredEvents: [.turnEnded], forwardingRecord: forwardingRecord ) let plan = AgentProfileLaunchPlan( profileID: UUID(), profileName: "Codex · Bound", runtime: .codex, invocation: AgentInvocation(executable: "codex", arguments: []), hookRegistration: registration, commandEnvironmentTokens: [], placement: .tab, splitDirection: .right, surfaceEnvironment: [:], dedicatedHome: nil ) return try state.launchAgentProfile( AgentProfileLaunchRequest(plan: plan, placement: .tab(background: false)) ).get() }
private func settle() async { for _ in 0..<10 { await Task.yield() } }}