diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 91ba437..dd94c82 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -88,6 +88,21 @@ final class AppState { @ObservationIgnored private var processNameTimer: Timer? + /// Whether a pane's surface is occluded — its renderer parked by + /// `ghostty_surface_set_occlusion`, so render/scrollbar heartbeats are + /// suppressed and silence says nothing about completion. Injectable for + /// tests. "No window" counts as occluded, which also covers panes + /// incubated off-screen (the incubator window is never visible). + @ObservationIgnored + var paneIsOccluded: (Pane) -> Bool = { pane in + !(pane.nsView?.window?.occlusionState.contains(.visible) ?? false) + } + + /// Panes that were occluded on the previous poll tick, so the visible + /// transition can restart their quiet window before settling resumes. + @ObservationIgnored + private var previouslyOccludedPanes: Set = [] + init(workspaceStore: WorkspaceStore = WorkspaceStore()) { self.workspaceStore = workspaceStore autoTileObserver = NotificationCenter.default.addObserver( @@ -118,12 +133,14 @@ final class AppState { // this feature. let trackExecution = Preferences.shared.showTabStatusIndicator var didAcknowledgeCompletion = false + var seenPanes: Set = [] for (projectID, ws) in workspaces { for tab in ws.tabs { for pane in tab.splitRoot.allPanes() { + seenPanes.insert(pane.id) pane.refreshForegroundProcess(trackExecution: trackExecution) if trackExecution { - pane.settleTerminalActivityIfQuiet() + settleIfVisible(pane) } didAcknowledgeCompletion = acknowledgeFinishedCommandIfActive( paneID: pane.id, @@ -133,9 +150,30 @@ final class AppState { } } } + previouslyOccludedPanes.formIntersection(seenPanes) if didAcknowledgeCompletion { saveWorkspaces() } } + /// Quiet-settle only while the surface actually renders: an occluded pane + /// emits no activity heartbeats (its renderer is parked), so settling it + /// would misread suppressed output as completion. On the occluded→visible + /// edge the quiet window restarts, giving a still-running program time to + /// deliver heartbeats again before the settle can fire. + /// + /// Not private so tests can drive the guard directly (`paneIsOccluded` is + /// injectable) without a live surface or mutating the `Preferences` + /// singleton the poll reads. + func settleIfVisible(_ pane: Pane) { + if paneIsOccluded(pane) { + previouslyOccludedPanes.insert(pane.id) + return + } + if previouslyOccludedPanes.remove(pane.id) != nil { + pane.refreshTerminalActivityWindow() + } + pane.settleTerminalActivityIfQuiet() + } + private func recordProjectVisit(_ projectID: UUID) { projectRecency.push(projectID) UserDefaults.standard.set(projectRecency.items.map(\.uuidString), forKey: recencyKey) diff --git a/Macterm/Model/SplitNode.swift b/Macterm/Model/SplitNode.swift index b3432b0..fec5aee 100644 --- a/Macterm/Model/SplitNode.swift +++ b/Macterm/Model/SplitNode.swift @@ -166,6 +166,16 @@ struct TerminalExecutionTracker { return .done } + /// Restart the quiet window of an activity-sourced run. Used on the + /// occluded→visible edge: a parked renderer emits no heartbeats, so the + /// elapsed silence says nothing about completion — and a false `.done` + /// would stick, because activity can never revive `.done` (see + /// `markTerminalActivity`). + mutating func refreshActivityWindow(now: Date) { + guard case .activity = runningSource else { return } + runningSource = .activity(now) + } + mutating func refreshForeground( name: String?, pid: pid_t?, @@ -354,6 +364,10 @@ final class Pane: Identifiable { ) } + func refreshTerminalActivityWindow(now: Date = Date()) { + executionTracker.refreshActivityWindow(now: now) + } + @discardableResult func acknowledgeCommandCompletion() -> Bool { guard executionState == .done else { return false } diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index 7a284a9..7dcf648 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -652,4 +652,68 @@ struct AppStateTests { let ws = Workspace(projectID: pid, tabs: [tab], activeTabID: tab.id) #expect(AppState.panesToWarm(in: ws).isEmpty) } + + // MARK: - Occlusion-aware quiet-settle + + // These drive `AppState.settleIfVisible` directly with an injected + // occlusion closure, rather than the full `refreshAllForegroundProcesses` + // tick. The tick also re-reads each pane's real foreground process (nil in + // a unit test with no live surface, which clears the run source) and is + // gated on the `Preferences.shared.showTabStatusIndicator` singleton — + // mutating that global races the parallel test runner. Testing the guard + // in isolation is both deterministic and a truer unit of what PR adds. + + /// A pane whose activity went quiet long ago (past the 3s settle window), + /// so a visible settle resolves it to `.done` and an occluded one holds. + private func quietRunningPane() -> Pane { + let pane = Pane(projectPath: "/tmp", projectID: UUID()) + pane.recordUserInteraction() + pane.markTerminalActivity(at: Date().addingTimeInterval(-10)) + #expect(pane.executionState == .running) + return pane + } + + @Test + func occluded_pane_does_not_quiet_settle() { + let state = makeAppState() + let pane = quietRunningPane() + state.paneIsOccluded = { _ in true } + + state.settleIfVisible(pane) + // 10s of silence, but the renderer was parked — silence proves + // nothing, so the pane must stay running. + #expect(pane.executionState == .running) + } + + @Test + func visible_pane_still_quiet_settles() { + let state = makeAppState() + let pane = quietRunningPane() + state.paneIsOccluded = { _ in false } + + state.settleIfVisible(pane) + #expect(pane.executionState == .done) + } + + @Test + func deoccluded_pane_gets_fresh_quiet_window_before_settling() { + let state = makeAppState() + let pane = quietRunningPane() + + // Occluded: no settle, and the pane is marked as having been occluded. + state.paneIsOccluded = { _ in true } + state.settleIfVisible(pane) + #expect(pane.executionState == .running) + + // Now visible. The stale 10s-old activity timestamp must not settle it + // instantly — a false `.done` would stick, since activity can never + // revive a done pane. The window restarts instead. + state.paneIsOccluded = { _ in false } + state.settleIfVisible(pane) + #expect(pane.executionState == .running) + + // With genuine quiet now elapsing from the reset window, it settles. + pane.settleTerminalActivityIfQuiet(now: Date().addingTimeInterval(4)) + #expect(pane.executionState == .done) + } }