Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion Macterm/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> = []

init(workspaceStore: WorkspaceStore = WorkspaceStore()) {
self.workspaceStore = workspaceStore
autoTileObserver = NotificationCenter.default.addObserver(
Expand Down Expand Up @@ -118,12 +133,14 @@ final class AppState {
// this feature.
let trackExecution = Preferences.shared.showTabStatusIndicator
var didAcknowledgeCompletion = false
var seenPanes: Set<UUID> = []
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,
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions Macterm/Model/SplitNode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand Down Expand Up @@ -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 }
Expand Down
64 changes: 64 additions & 0 deletions MactermTests/App/AppStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}