From c7e416b4a80c9540e3335634a6caa52e20949b95 Mon Sep 17 00:00:00 2001 From: Mateusz Urban Date: Fri, 26 Jun 2026 13:20:04 +0200 Subject: [PATCH 1/2] Refactor polling to event-driven architecture for CPU usage fix Replaces the 250ms polling timer with event-driven status tracking using libghostty callbacks. Tab names refresh lazily on visibility changes, and a single deferred work item handles quiet settle (3s after last activity). - Removes processNameTimer polling loop entirely - Status updates flow through onCommandFinished/onProgressStarted/onProgressFinished - Tab names refresh on tabDidBecomeVisible/focusDidChange + app activation - scheduleQuietSettle() uses DispatchWorkItem with 3s defer, not repeating - App activation observers pause/resume activity tracking Fixes #110 --- Macterm/App/AppState.swift | 257 +++++++++++++++++++++++---- MactermTests/App/AppStateTests.swift | 43 +++++ 2 files changed, 264 insertions(+), 36 deletions(-) diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 91ba437..67d341d 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -70,23 +70,27 @@ final class AppState { private let workspaceStore: WorkspaceStore private var autoTileObserver: Any? + @ObservationIgnored + nonisolated(unsafe) private var appActivationObservers: [NSObjectProtocol] = [] - /// Periodically re-reads each pane's foreground process so tab names track - /// the running command (`hx`, `btop`, …). This polls like tmux's - /// `automatic-rename`, rather than relying on terminal title escapes: with - /// shell integration (Starship/ghostty) the OSC title is prompt/cwd churn, - /// not the command, and a program may never emit a usable title at all (a - /// layout-spawned or eager-warmed pane, a process that sets no title). A - /// poll catches every case — manual launches, layout restores, quits — - /// within one interval, regardless of titles. - /// - /// The interval (250ms) is a responsiveness choice, not a cost one: it's a - /// run-loop timer (the thread parks between ticks, no busy-loop), and each - /// tick is one `proc_pidinfo` per pane — ~0.24µs/call, so even 20 panes - /// 4×/sec is ~0.002% of a core. A pane only republishes (→ re-render) when - /// its name actually changes, so idle panes are free. + // MARK: - Truly Event-Driven Architecture + + // Status indicators are fully event-driven via libghostty callbacks. + // Tab names use lazy refresh (on focus/visibility) with minimal fallback. + // No polling loops. Timers only for deferred actions (quiet settle). + + /// Single settle timer that runs 3s after the last terminal activity. + /// Resets on each activity, so idle panes naturally transition to .done. + @ObservationIgnored + nonisolated(unsafe) private var quietSettleWorkItem: DispatchWorkItem? + + /// Track the last visibly active tab for lazy name refresh. @ObservationIgnored - private var processNameTimer: Timer? + private var lastVisibleTabID: UUID? + + /// Whether the app is currently active (in the foreground). + @ObservationIgnored + private(set) var isAppActive = true init(workspaceStore: WorkspaceStore = WorkspaceStore()) { self.workspaceStore = workspaceStore @@ -101,39 +105,212 @@ final class AppState { .compactMap { UUID(uuidString: $0) } projectRecency = RecencyStack(limit: 50, items: restored) - let timer = Timer(timeInterval: 0.25, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { self?.refreshAllForegroundProcesses() } + setupAppActivationObservers() + } + + deinit { + // Clean up notification observers + for observer in appActivationObservers { + NotificationCenter.default.removeObserver(observer) + } + quietSettleWorkItem?.cancel() + } + + // MARK: - App Activation (Event-Driven) + + private func setupAppActivationObservers() { + let becameActive = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.appDidBecomeActive() + } + } + + let resignedActive = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.appDidResignActive() + } + } + + appActivationObservers = [becameActive, resignedActive] + } + + func appDidBecomeActive() { + guard !isAppActive else { return } + logger.info("App became active, refreshing tab names") + isAppActive = true + // Lazy refresh: refresh tab names once when becoming active + refreshAllTabNames() + } + + func appDidResignActive() { + guard isAppActive else { return } + logger.info("App resigned active") + isAppActive = false + // No polling when inactive - zero CPU + } + + // MARK: - Tab Name Refresh (Lazy, Event-Driven) + + /// Refresh tab names (process names only, no execution state tracking). + /// Lightweight operation using proc_pidinfo for process names. + private func refreshAllTabNames() { + for (_, ws) in workspaces { + for tab in ws.tabs { + for pane in tab.splitRoot.allPanes() { + pane.refreshForegroundProcess(trackExecution: false) + } + } } - RunLoop.main.add(timer, forMode: .common) - processNameTimer = timer } - /// Re-read the foreground process name of every live pane across all - /// workspaces. Each pane only republishes (and triggers a tab re-render) - /// when its name actually changes, so this is cheap when nothing's moving. - func refreshAllForegroundProcesses() { - // Shell/raw-mode detection (KERN_PROCARGS2 + open/tcgetattr per pane) - // and the quiet-settle only matter when the status indicator is shown; - // skip them in icon mode so the default poll stays as cheap as before - // this feature. - let trackExecution = Preferences.shared.showTabStatusIndicator + /// Refresh tab name for a specific tab when it becomes visible/focused. + func refreshTabName(forTabID tabID: UUID) { + guard let ws = workspaces.values.first(where: { $0.tabs.contains(where: { $0.id == tabID }) }), + let tab = ws.tabs.first(where: { $0.id == tabID }) + else { return } + + for pane in tab.splitRoot.allPanes() { + pane.refreshForegroundProcess(trackExecution: false) + } + } + + // MARK: - Status Indicators (Event-Driven + Quiet Settle) + + /// Schedule quiet settle check 3s from now. Resets on each activity. + /// This is the ONLY timer in the new architecture - a deferred work item, + /// not a polling loop. It fires once after 3s of quiet, then reschedules + /// if there's still activity. + func scheduleQuietSettle() { + // Cancel any existing settle timer + quietSettleWorkItem?.cancel() + + // Only settle if status indicator is enabled + guard Preferences.shared.showTabStatusIndicator else { return } + + // Create new work item + let workItem = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { + self?.settleAllQuietPanes() + } + } + + quietSettleWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem) + } + + /// Settle all quiet panes to .done state after 3s of no activity. + /// Called by the deferred work item; reschedules if any panes are still running. + private func settleAllQuietPanes() { var didAcknowledgeCompletion = false + var anyStillRunning = false + for (projectID, ws) in workspaces { for tab in ws.tabs { for pane in tab.splitRoot.allPanes() { - pane.refreshForegroundProcess(trackExecution: trackExecution) - if trackExecution { - pane.settleTerminalActivityIfQuiet() + let wasRunning = pane.executionState == .running + pane.settleTerminalActivityIfQuiet() + + // Check if still running after settle attempt + if pane.executionState == .running { + anyStillRunning = true + } + + // If this pane just transitioned to .done, acknowledge completion + if wasRunning, pane.executionState == .done { + didAcknowledgeCompletion = acknowledgeFinishedCommandIfActive( + paneID: pane.id, + projectID: projectID, + saveImmediately: false + ) || didAcknowledgeCompletion } - didAcknowledgeCompletion = acknowledgeFinishedCommandIfActive( - paneID: pane.id, - projectID: projectID, - saveImmediately: false - ) || didAcknowledgeCompletion } } } + if didAcknowledgeCompletion { saveWorkspaces() } + + // If any panes are still running, reschedule the settle check + if anyStillRunning { + scheduleQuietSettle() + } + } + + // MARK: - Event Handlers (Called by libghostty callbacks) + + /// Called when terminal activity is detected (output, bell, etc.). + /// Marks the pane as running and schedules a quiet settle check. + func handleTerminalActivity(for paneID: UUID) { + // Find the pane and mark activity + for (_, ws) in workspaces { + for tab in ws.tabs { + if let pane = tab.splitRoot.findPane(id: paneID) { + pane.markTerminalActivity() + scheduleQuietSettle() + return + } + } + } + } + + /// Called when a command finishes (OSC 133;D). + func handleCommandFinished(for paneID: UUID, exitCode: Int16, duration: UInt64) { + for (_, ws) in workspaces { + for tab in ws.tabs { + if let pane = tab.splitRoot.findPane(id: paneID) { + pane.markCommandFinished() + // Command finished is immediate .done, no need to settle + return + } + } + } + } + + /// Called when progress starts (OSC 9;4). + func handleProgressStarted(for paneID: UUID) { + for (_, ws) in workspaces { + for tab in ws.tabs { + if let pane = tab.splitRoot.findPane(id: paneID) { + pane.markCommandRunning() + scheduleQuietSettle() + return + } + } + } + } + + /// Called when progress finishes (OSC 9;4). + func handleProgressFinished(for paneID: UUID) { + for (_, ws) in workspaces { + for tab in ws.tabs { + if let pane = tab.splitRoot.findPane(id: paneID) { + pane.markProgressFinished() + scheduleQuietSettle() + return + } + } + } + } + + // MARK: - Tab Visibility Tracking + + /// Called when a tab becomes visible/focused. Refreshes its name immediately. + func tabDidBecomeVisible(_ tabID: UUID) { + lastVisibleTabID = tabID + refreshTabName(forTabID: tabID) + } + + /// Called when tab focus changes within the active workspace. + func tabFocusDidChange(toTabID tabID: UUID) { + lastVisibleTabID = tabID + refreshTabName(forTabID: tabID) } private func recordProjectVisit(_ projectID: UUID) { @@ -357,6 +534,8 @@ final class AppState { let didAcknowledgeCompletion = ws.selectTab(tabID) if ws.activeTabID != before || didAcknowledgeCompletion { saveWorkspaces() + // Refresh tab name when switching to it + tabFocusDidChange(toTabID: tabID) } } @@ -389,6 +568,9 @@ final class AppState { let didAcknowledgeCompletion = ws.selectNextTab() if ws.activeTabID != before || didAcknowledgeCompletion { saveWorkspaces() + if let newTabID = ws.activeTabID { + tabFocusDidChange(toTabID: newTabID) + } } } @@ -398,6 +580,9 @@ final class AppState { let didAcknowledgeCompletion = ws.selectPreviousTab() if ws.activeTabID != before || didAcknowledgeCompletion { saveWorkspaces() + if let newTabID = ws.activeTabID { + tabFocusDidChange(toTabID: newTabID) + } } } diff --git a/MactermTests/App/AppStateTests.swift b/MactermTests/App/AppStateTests.swift index 7a284a9..66f1d2b 100644 --- a/MactermTests/App/AppStateTests.swift +++ b/MactermTests/App/AppStateTests.swift @@ -652,4 +652,47 @@ struct AppStateTests { let ws = Workspace(projectID: pid, tabs: [tab], activeTabID: tab.id) #expect(AppState.panesToWarm(in: ws).isEmpty) } + + // MARK: - Event-Driven Architecture (CPU Usage) + + @Test + func app_starts_active_with_no_polling_timers() { + let state = makeAppState() + // App starts active but with no polling timers + #expect(state.isAppActive) + // The new architecture has no Timer properties - purely event-driven + } + + @Test + func active_state_can_be_toggled() { + let state = makeAppState() + #expect(state.isAppActive) + + state.appDidResignActive() + #expect(!state.isAppActive) + + state.appDidBecomeActive() + #expect(state.isAppActive) + } + + @Test + func settle_state_can_be_scheduled() { + let state = makeAppState() + // scheduleQuietSettle() should not crash + state.scheduleQuietSettle() + } + + @Test + func tab_visibility_tracking() throws { + let state = makeAppState() + let p = seedProject(state) + let workspace = try #require(state.workspaces[p.id]) + let tabID = try #require(workspace.activeTabID) + + // Should not crash when tracking tab visibility + state.tabDidBecomeVisible(tabID) + state.tabFocusDidChange(toTabID: tabID) + + // Test passes if no crash occurs - implementation details are private + } } From dd111e110a84cbc955de1dec5f06272f2785117f Mon Sep 17 00:00:00 2001 From: Mateusz Urban Date: Wed, 1 Jul 2026 22:17:10 +0200 Subject: [PATCH 2/2] Make terminal activity indicators reliable --- Macterm/App/AppState.swift | 39 ++- Macterm/App/MactermApp.swift | 15 ++ Macterm/Ghostty/GhosttyApp.swift | 8 + Macterm/Ghostty/GhosttyCallbacks.swift | 6 + Macterm/Model/SplitNode.swift | 78 +++--- .../Terminal/GhosttyTerminalNSView.swift | 31 ++- Macterm/Views/TerminalPane.swift | 20 +- MactermTests/Model/PaneTests.swift | 240 +++++++++++++++++- .../Model/TerminalExecutionTrackerTests.swift | 146 +++++++++++ MactermTests/Model/TerminalTabTests.swift | 59 +++++ 10 files changed, 569 insertions(+), 73 deletions(-) create mode 100644 MactermTests/Model/TerminalExecutionTrackerTests.swift diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 67d341d..ce59c37 100644 --- a/Macterm/App/AppState.swift +++ b/Macterm/App/AppState.swift @@ -73,14 +73,14 @@ final class AppState { @ObservationIgnored nonisolated(unsafe) private var appActivationObservers: [NSObjectProtocol] = [] - // MARK: - Truly Event-Driven Architecture + // MARK: - Event-Driven Status Tracking - // Status indicators are fully event-driven via libghostty callbacks. - // Tab names use lazy refresh (on focus/visibility) with minimal fallback. - // No polling loops. Timers only for deferred actions (quiet settle). + // Status indicators are driven by libghostty callbacks. Tab names refresh + // lazily on focus/visibility changes instead of a repeating process poll. - /// Single settle timer that runs 3s after the last terminal activity. - /// Resets on each activity, so idle panes naturally transition to .done. + /// Single deferred settle task that runs 3s after terminal activity. + /// Resets on each accepted output/render heartbeat, so activity-sourced panes + /// naturally transition to .done without a repeating poll loop. @ObservationIgnored nonisolated(unsafe) private var quietSettleWorkItem: DispatchWorkItem? @@ -154,7 +154,7 @@ final class AppState { guard isAppActive else { return } logger.info("App resigned active") isAppActive = false - // No polling when inactive - zero CPU + // Status tracking does no polling while inactive. } // MARK: - Tab Name Refresh (Lazy, Event-Driven) @@ -185,9 +185,9 @@ final class AppState { // MARK: - Status Indicators (Event-Driven + Quiet Settle) /// Schedule quiet settle check 3s from now. Resets on each activity. - /// This is the ONLY timer in the new architecture - a deferred work item, - /// not a polling loop. It fires once after 3s of quiet, then reschedules - /// if there's still activity. + /// This is a deferred work item, not a polling loop. It fires once after 3s + /// of quiet, then reschedules only for activity-sourced panes that still need + /// quiet-settle. func scheduleQuietSettle() { // Cancel any existing settle timer quietSettleWorkItem?.cancel() @@ -210,7 +210,7 @@ final class AppState { /// Called by the deferred work item; reschedules if any panes are still running. private func settleAllQuietPanes() { var didAcknowledgeCompletion = false - var anyStillRunning = false + var anyNeedsQuietSettle = false for (projectID, ws) in workspaces { for tab in ws.tabs { @@ -218,10 +218,7 @@ final class AppState { let wasRunning = pane.executionState == .running pane.settleTerminalActivityIfQuiet() - // Check if still running after settle attempt - if pane.executionState == .running { - anyStillRunning = true - } + if pane.needsTerminalActivityQuietSettle { anyNeedsQuietSettle = true } // If this pane just transitioned to .done, acknowledge completion if wasRunning, pane.executionState == .done { @@ -237,8 +234,9 @@ final class AppState { if didAcknowledgeCompletion { saveWorkspaces() } - // If any panes are still running, reschedule the settle check - if anyStillRunning { + // If any activity-sourced panes are still running, reschedule the settle check. + // Foreground- and progress-sourced runs complete from explicit callbacks. + if anyNeedsQuietSettle { scheduleQuietSettle() } } @@ -252,8 +250,9 @@ final class AppState { for (_, ws) in workspaces { for tab in ws.tabs { if let pane = tab.splitRoot.findPane(id: paneID) { - pane.markTerminalActivity() - scheduleQuietSettle() + if pane.markTerminalActivity() { + scheduleQuietSettle() + } return } } @@ -279,7 +278,6 @@ final class AppState { for tab in ws.tabs { if let pane = tab.splitRoot.findPane(id: paneID) { pane.markCommandRunning() - scheduleQuietSettle() return } } @@ -292,7 +290,6 @@ final class AppState { for tab in ws.tabs { if let pane = tab.splitRoot.findPane(id: paneID) { pane.markProgressFinished() - scheduleQuietSettle() return } } diff --git a/Macterm/App/MactermApp.swift b/Macterm/App/MactermApp.swift index 9a9d3e7..68f8c79 100644 --- a/Macterm/App/MactermApp.swift +++ b/Macterm/App/MactermApp.swift @@ -176,6 +176,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var windowObserver: Any? private var activateObserver: Any? + private var appFocusObservers: [Any] = [] private var mainAppResponder: MainAppResponder? private var hasInstalledResponders = false @@ -214,6 +215,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + let focusCenter = NotificationCenter.default + appFocusObservers = [ + focusCenter.addObserver( + forName: NSApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { _ in MainActor.assumeIsolated { GhosttyApp.shared.setAppFocus(true) } }, + focusCenter.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { _ in MainActor.assumeIsolated { GhosttyApp.shared.setAppFocus(false) } }, + ] + windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeMainNotification, object: nil, diff --git a/Macterm/Ghostty/GhosttyApp.swift b/Macterm/Ghostty/GhosttyApp.swift index 58d8570..6a1a331 100644 --- a/Macterm/Ghostty/GhosttyApp.swift +++ b/Macterm/Ghostty/GhosttyApp.swift @@ -100,6 +100,14 @@ final class GhosttyApp { ghostty_app_tick(app) } + /// Propagate app focus to libghostty so idle animations such as cursor blink + /// can pause when Macterm is not the active app. Visible surfaces still render + /// real terminal output; per-surface occlusion handles hidden/off-screen panes. + func setAppFocus(_ focused: Bool) { + guard let app else { return } + ghostty_app_set_focus(app, focused) + } + // MARK: - Config /// Result of a config (re)load. `missingUserConfigPath` is populated when diff --git a/Macterm/Ghostty/GhosttyCallbacks.swift b/Macterm/Ghostty/GhosttyCallbacks.swift index cc006a9..fad9a00 100644 --- a/Macterm/Ghostty/GhosttyCallbacks.swift +++ b/Macterm/Ghostty/GhosttyCallbacks.swift @@ -68,6 +68,12 @@ final class GhosttyCallbacks: @unchecked Sendable { let s = action.action.scrollbar DispatchQueue.main.async { view.surfaceDidUpdateScrollbar(total: s.total, offset: s.offset, len: s.len) } return true + case GHOSTTY_ACTION_RENDER: + guard let view = surfaceView(from: target) else { return false } + DispatchQueue.main.async { view.surfaceDidRender() } + // Keep libghostty's render path unchanged; this callback is only an + // activity signal for Macterm's tab status indicator. + return false case GHOSTTY_ACTION_RELOAD_CONFIG: // libghostty fires this (with soft = true) when a surface's // conditional state changes — notably on set_color_scheme, which diff --git a/Macterm/Model/SplitNode.swift b/Macterm/Model/SplitNode.swift index ae46c50..ca0791e 100644 --- a/Macterm/Model/SplitNode.swift +++ b/Macterm/Model/SplitNode.swift @@ -52,6 +52,11 @@ enum TerminalExecutionState: Equatable { case done } +enum TerminalActivityKind { + case output + case render +} + private struct ForegroundProcessKey: Equatable { let name: String let pid: pid_t? @@ -64,7 +69,7 @@ private struct ForegroundProcessKey: Equatable { } } -private struct TerminalExecutionTracker { +struct TerminalExecutionTracker { init(hasUserInteraction: Bool = false) { self.hasUserInteraction = hasUserInteraction } @@ -82,25 +87,20 @@ private struct TerminalExecutionTracker { /// True while an OSC 9;4 progress report owns the pane; output and /// foreground changes are ignored while it's active. private var progressActive = false - /// After progress clears, the foreground process that owned it is - /// "quiesced": its own output and re-polls are ignored until the - /// foreground moves away, so a settled program that reported progress - /// doesn't flip back to running on its own render output. - /// `pendingProgressQuiesce` covers the race where progress started and - /// cleared before any foreground poll — the first process to appear is - /// quiesced instead of marked running. - private var progressQuiesced: ForegroundProcessKey? - private var pendingProgressQuiesce = false /// Output is ignored until the user has interacted with the pane, so a /// freshly-restored shell's startup prompt doesn't show as activity. private var hasUserInteraction = false + var needsQuietSettle: Bool { + lastActivityAt != nil && !progressActive + } + mutating func recordUserInteraction() { hasUserInteraction = true } mutating func markProgressStarted(currentState: TerminalExecutionState) -> TerminalExecutionState { - guard hasUserInteraction else { return currentState } + hasUserInteraction = true progressActive = true lastActivityAt = nil return .running @@ -115,35 +115,35 @@ private struct TerminalExecutionTracker { // persist as a spurious checkmark after restart). guard currentState == .running else { return currentState } progressActive = false - progressQuiesced = nil - pendingProgressQuiesce = false lastActivityAt = nil return .done } mutating func markProgressFinished(currentState: TerminalExecutionState) -> TerminalExecutionState { - guard hasUserInteraction || progressActive else { return currentState } + guard progressActive else { return currentState } progressActive = false - if let lastForeground { - progressQuiesced = lastForeground - } else { - pendingProgressQuiesce = true - } lastActivityAt = nil return currentState == .running ? .done : currentState } mutating func markTerminalActivity( at date: Date, + kind: TerminalActivityKind = .output, currentState: TerminalExecutionState ) -> TerminalExecutionState { guard !progressActive else { return currentState } - if let progressQuiesced, progressQuiesced == lastForeground { return currentState } - // Output only counts after user interaction (or a declarative `run:`, - // which seeds `hasUserInteraction`). Fresh/restored shells can emit - // startup banners or shell-integration redraws before the user does + // Output/render only counts after trusted activity (user input, + // declarative `run:`, or explicit progress). Fresh/restored shells can + // emit startup banners or shell-integration redraws before the user does // anything; those must not become persisted completion indicators. guard hasUserInteraction else { return currentState } + // Render-only callbacks are weak evidence: they can be prompt redraws or + // input echo. Let them start/restart a spinner only while a real non-shell + // foreground process is known; otherwise they can only keep an already + // running activity-sourced command alive. + if kind == .render, currentState != .running, lastForeground == nil { + return currentState + } lastActivityAt = date return .running } @@ -171,24 +171,6 @@ private struct TerminalExecutionTracker { ) -> TerminalExecutionState { let newKey = foregroundIsShell ? nil : ForegroundProcessKey(name: name, pid: pid) - // Resolve a pending progress quiesce: the first foreground process - // after a progress race (progress cleared before any poll) is quiesced - // rather than marked running. A shell arriving first cancels it. - if pendingProgressQuiesce { - if let newKey { - progressQuiesced = newKey - pendingProgressQuiesce = false - lastForeground = newKey - return currentState - } - pendingProgressQuiesce = false - } - - // Drop the quiesce once the foreground moves off the quiesced process. - if let progressQuiesced, progressQuiesced != newKey { - self.progressQuiesced = nil - } - let changed = newKey != lastForeground lastForeground = newKey @@ -219,6 +201,10 @@ private struct TerminalExecutionTracker { if currentState == .running, lastActivityAt == nil { return .done } + if changed, currentState != .running { + lastActivityAt = Date() + return .running + } return currentState } @@ -337,11 +323,14 @@ final class Pane: Identifiable { executionState = executionTracker.markProgressFinished(currentState: executionState) } - func markTerminalActivity(at date: Date = Date()) { + @discardableResult + func markTerminalActivity(at date: Date = Date(), kind: TerminalActivityKind = .output) -> Bool { executionState = executionTracker.markTerminalActivity( at: date, + kind: kind, currentState: executionState ) + return executionTracker.needsQuietSettle } func settleTerminalActivityIfQuiet(now: Date = Date(), quietInterval: TimeInterval = 3) { @@ -352,6 +341,10 @@ final class Pane: Identifiable { ) } + var needsTerminalActivityQuietSettle: Bool { + executionTracker.needsQuietSettle + } + @discardableResult func acknowledgeCommandCompletion() -> Bool { guard executionState == .done else { return false } @@ -459,6 +452,7 @@ final class Pane: Identifiable { view.onProgressStarted = nil view.onProgressFinished = nil view.onTerminalActivity = nil + view.onTerminalRender = nil view.onScrollbarUpdate = nil view.onScrollWheel = nil view.destroySurface() diff --git a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift index 7b4034d..208052e 100644 --- a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift +++ b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift @@ -59,6 +59,10 @@ final class GhosttyTerminalNSView: NSView { } } + func surfaceDidRender() { + onTerminalRender?() + } + func surfaceDidUpdateScrollbar(total: UInt64, offset: UInt64, len: UInt64) { let snapshot = ScrollbarSnapshot(total: total, offset: offset, len: len) if let lastScrollbarSnapshot, total > lastScrollbarSnapshot.total { @@ -83,6 +87,7 @@ final class GhosttyTerminalNSView: NSView { var onProgressStarted: (() -> Void)? var onProgressFinished: (() -> Void)? var onTerminalActivity: (() -> Void)? + var onTerminalRender: (() -> Void)? /// libghostty pushes scrollback geometry (all values in rows) whenever the /// viewport, scrollback size, or visible row count changes. /// `(total, offset, len)`: total rows including scrollback, the first @@ -213,6 +218,17 @@ final class GhosttyTerminalNSView: NSView { ghostty_surface_set_display_id(surface, displayID) } ghostty_surface_set_focus(surface, isFocused) + syncOcclusion() + } + + /// Keep libghostty's renderer parked whenever this surface's pixels are not + /// actually visible. The pty keeps running; this only stops continuous Metal + /// redraws for off-screen tabs, incubated panes, hidden/minimized windows, or + /// covered windows. + private func syncOcclusion() { + guard let surface else { return } + let visible = window?.occlusionState.contains(.visible) ?? false + ghostty_surface_set_occlusion(surface, visible) } func destroySurface() { @@ -264,7 +280,10 @@ final class GhosttyTerminalNSView: NSView { } windowObservers.removeAll() - guard let window else { return } + guard let window else { + syncOcclusion() + return + } if surface == nil { createSurface() } else { @@ -298,7 +317,15 @@ final class GhosttyTerminalNSView: NSView { queue: .main, using: handler ) - windowObservers = [backing, screen] + let occlusion = NotificationCenter.default.addObserver( + forName: NSWindow.didChangeOcclusionStateNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.syncOcclusion() } + } + windowObservers = [backing, screen, occlusion] + syncOcclusion() } override func setFrameSize(_ newSize: NSSize) { diff --git a/Macterm/Views/TerminalPane.swift b/Macterm/Views/TerminalPane.swift index 9606fab..7fcc783 100644 --- a/Macterm/Views/TerminalPane.swift +++ b/Macterm/Views/TerminalPane.swift @@ -12,6 +12,9 @@ struct TerminalPane: View { let onSplitRequest: (SplitDirection, SplitPosition) -> Void let onZoomRequest: () -> Void + @Environment(AppState.self) + private var appState + var body: some View { // The search bar sits above the terminal surface in a VStack, so showing // it pushes the terminal content down rather than overlaying it. @@ -40,7 +43,8 @@ struct TerminalPane: View { onProcessExit: onProcessExit, onCommandFinished: onCommandFinished, onSplitRequest: onSplitRequest, - onZoomRequest: onZoomRequest + onZoomRequest: onZoomRequest, + onActivityNeedsQuietSettle: { appState.scheduleQuietSettle() } ) } } @@ -59,6 +63,7 @@ private struct TerminalSurface: NSViewRepresentable { let onCommandFinished: () -> Void let onSplitRequest: (SplitDirection, SplitPosition) -> Void let onZoomRequest: () -> Void + let onActivityNeedsQuietSettle: () -> Void final class Coordinator { var wasFocused = false @@ -199,7 +204,18 @@ private struct TerminalSurface: NSViewRepresentable { view.onTerminalActivity = { [weak pane] in guard let pane, Preferences.shared.showTabStatusIndicator else { return } pane.refreshForegroundProcess() - pane.markTerminalActivity() + if pane.markTerminalActivity(kind: .output) { + onActivityNeedsQuietSettle() + } + } + view.onTerminalRender = { [weak pane] in + guard let pane, Preferences.shared.showTabStatusIndicator else { return } + if pane.executionState != .running { + pane.refreshForegroundProcess() + } + if pane.markTerminalActivity(kind: .render) { + onActivityNeedsQuietSettle() + } } view.onCommandFinished = { [weak pane, weak view] exitCode, durationNs in guard let pane else { return } diff --git a/MactermTests/Model/PaneTests.swift b/MactermTests/Model/PaneTests.swift index d94335a..173c551 100644 --- a/MactermTests/Model/PaneTests.swift +++ b/MactermTests/Model/PaneTests.swift @@ -79,11 +79,12 @@ struct PaneTests { } @Test - func progressBeforeUserInteraction_staysIdle() { + func progressBeforeUserInteraction_tracksRunningThenDone() { let p = Pane(projectPath: "/", projectID: UUID()) p.markCommandRunning() + #expect(p.executionState == .running) p.markProgressFinished() - #expect(p.executionState == .idle) + #expect(p.executionState == .done) } @Test @@ -149,25 +150,25 @@ struct PaneTests { } @Test - func progressFinished_settlesNextForegroundProcessWhenNoneWasCaptured() { + func progressFinished_allowsNextForegroundProcessWhenNoneWasCaptured() { let p = Pane(projectPath: "/", projectID: UUID()) p.recordUserInteraction() p.markCommandRunning() p.markProgressFinished() #expect(p.executionState == .done) p.applyForegroundRefresh(name: "node", foregroundPID: 42) - #expect(p.executionState == .done) + #expect(p.executionState == .running) } @Test - func progressFinished_ignoresOutputFromSettledForegroundProcess() { + func progressFinished_allowsOutputFromSettledForegroundProcess() { let p = Pane(projectPath: "/", projectID: UUID()) p.recordUserInteraction() p.applyForegroundRefresh(name: "node", foregroundPID: 42) p.markCommandRunning() p.markProgressFinished() p.markTerminalActivity() - #expect(p.executionState == .done) + #expect(p.executionState == .running) } @Test @@ -266,6 +267,212 @@ struct PaneTests { #expect(p.executionState == .done) } + @Test + func rawForegroundProcess_renderOnlyRefreshStartsSpinner() { + // Alternate-screen TUIs such as btop/htop/vim can repaint continuously + // without increasing scrollback. That arrives as the render callback path, + // so a render-only foreground refresh must be enough to show activity. + let p = Pane(projectPath: "/", projectID: UUID()) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "btop", foregroundPID: 42, terminalInputIsRaw: true) + + simulateTerminalRender( + for: p, + name: "btop", + foregroundPID: 42, + terminalInputIsRaw: true, + at: Date(timeIntervalSince1970: 100) + ) + + #expect(p.executionState == .running) + } + + @Test + func layoutRawCommand_renderOnlyRefreshStartsSpinner() { + // Declarative `run:` panes seed interaction because the app injected the + // command. A layout that starts an alternate-screen program still misses + // the spinner when the program only repaints. + let p = Pane(projectPath: "/", projectID: UUID(), command: "btop") + p.applyForegroundRefresh(name: "btop", foregroundPID: 42, terminalInputIsRaw: true) + + simulateTerminalRender( + for: p, + name: "btop", + foregroundPID: 42, + terminalInputIsRaw: true, + at: Date(timeIntervalSince1970: 100) + ) + + #expect(p.executionState == .running) + } + + @Test + func rawModeTransition_renderOnlyRefreshRestartsSpinner() { + // Interactive CLIs often start in canonical mode, then switch the tty to + // raw/cbreak once their UI is active. Repaints after that transition must + // bring the spinner back. + let p = Pane(projectPath: "/", projectID: UUID()) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "claude", foregroundPID: 42) + p.applyForegroundRefresh(name: "claude", foregroundPID: 42, terminalInputIsRaw: true) + #expect(p.executionState == .done) + + simulateTerminalRender( + for: p, + name: "claude", + foregroundPID: 42, + terminalInputIsRaw: true, + at: Date(timeIntervalSince1970: 100) + ) + + #expect(p.executionState == .running) + } + + @Test + func rawModeTransition_outputActivityRestartsSpinner() { + // Same raw-mode transition, but with explicit output/scrollback activity + // after the pane has already switched to `.done`. + let p = Pane(projectPath: "/", projectID: UUID()) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "claude", foregroundPID: 42) + p.applyForegroundRefresh(name: "claude", foregroundPID: 42, terminalInputIsRaw: true) + #expect(p.executionState == .done) + + p.markTerminalActivity(at: Date(timeIntervalSince1970: 100)) + + #expect(p.executionState == .running) + } + + @Test + func rawForegroundProcess_resumedOutputAfterQuietRestartsSpinner() { + // A long-lived raw-mode program can go quiet for a few seconds and then + // repaint/output again. New terminal activity from the same live program + // should restart the spinner after the prior heartbeat settled to `.done`. + let p = Pane(projectPath: "/", projectID: UUID()) + let start = Date(timeIntervalSince1970: 100) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "watch", foregroundPID: 42, terminalInputIsRaw: true) + p.markTerminalActivity(at: start) + p.settleTerminalActivityIfQuiet(now: start.addingTimeInterval(3), quietInterval: 3) + #expect(p.executionState == .done) + + p.markTerminalActivity(at: start.addingTimeInterval(10)) + + #expect(p.executionState == .running) + } + + @Test + func newRawForegroundWhileDone_renderOnlyRefreshStartsSpinner() { + // A stale done indicator should not hide a fresh raw-mode foreground + // program. This simulates a pane with an uncleared completion badge, then + // a new alternate-screen program repainting. + let p = Pane(projectPath: "/", projectID: UUID()) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "sleep", foregroundPID: 42) + p.applyForegroundRefresh(name: shellName(), foregroundPID: 43, foregroundIsShell: true) + #expect(p.executionState == .done) + + simulateTerminalRender( + for: p, + name: "btop", + foregroundPID: 44, + terminalInputIsRaw: true, + at: Date(timeIntervalSince1970: 100) + ) + + #expect(p.executionState == .running) + } + + @Test + func newRawForegroundWhileDone_outputActivityStartsSpinner() { + // Same stale done state, but through the output activity callback rather + // than the render callback. The new live foreground must override the + // stale `.done` state. + let p = Pane(projectPath: "/", projectID: UUID()) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "sleep", foregroundPID: 42) + p.applyForegroundRefresh(name: shellName(), foregroundPID: 43, foregroundIsShell: true) + #expect(p.executionState == .done) + + p.applyForegroundRefresh(name: "watch", foregroundPID: 44, terminalInputIsRaw: true) + p.markTerminalActivity(at: Date(timeIntervalSince1970: 100)) + + #expect(p.executionState == .running) + } + + @Test + func progressReportBeforeLocalInteractionStartsSpinner() { + // OSC progress is an explicit "this terminal is doing work" signal, even + // if Macterm has not first recorded local key/mouse interaction. + let p = Pane(projectPath: "/", projectID: UUID()) + + p.markCommandRunning() + + #expect(p.executionState == .running) + } + + @Test + func progressReportAfterRestoredDoneStartsSpinner() { + // Restoring a background completion badge should not block a later + // explicit progress report from the terminal program. + let p = Pane(projectPath: "/", projectID: UUID()) + p.restoreNeedsAttention() + + p.markCommandRunning() + + #expect(p.executionState == .running) + } + + @Test + func progressReportWithForegroundBeforeLocalInteractionStartsSpinner() { + // Even before local interaction has been recorded, a known foreground's + // explicit progress signal should show activity. + let p = Pane(projectPath: "/", projectID: UUID()) + p.applyForegroundRefresh(name: "npm", foregroundPID: 42) + + p.markCommandRunning() + + #expect(p.executionState == .running) + } + + @Test + func progressFinishedForeground_outputActivityRestartsSpinner() { + // Some tools clear OSC progress before post-processing or follow-up output + // is actually finished. The same foreground process must be able to show + // the spinner again when fresh activity arrives. + let p = Pane(projectPath: "/", projectID: UUID()) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "node", foregroundPID: 42) + p.markCommandRunning() + p.markProgressFinished() + #expect(p.executionState == .done) + + p.markTerminalActivity(at: Date(timeIntervalSince1970: 100)) + + #expect(p.executionState == .running) + } + + @Test + func progressFinishedForeground_renderOnlyRefreshRestartsSpinner() { + // Same progress-quiesced foreground, but with a render-only refresh. This + // models an in-place progress UI that repaints after clearing OSC progress. + let p = Pane(projectPath: "/", projectID: UUID()) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "node", foregroundPID: 42) + p.markCommandRunning() + p.markProgressFinished() + #expect(p.executionState == .done) + + simulateTerminalRender( + for: p, + name: "node", + foregroundPID: 42, + at: Date(timeIntervalSince1970: 100) + ) + + #expect(p.executionState == .running) + } + @Test func terminalActivityWithoutUserInteraction_staysIdle() { let p = Pane(projectPath: "/", projectID: UUID()) @@ -326,4 +533,25 @@ struct PaneTests { p.destroySurface() // idempotent #expect(p.nsView == nil) } + + private func simulateTerminalRender( + for pane: Pane, + name: String, + foregroundPID: pid_t, + foregroundIsShell: Bool = false, + terminalInputIsRaw: Bool = false, + at date: Date + ) { + // Mirrors TerminalPane.onTerminalRender: refresh foreground context when + // needed, then pass a weak render signal to the tracker. + if pane.executionState != .running { + pane.applyForegroundRefresh( + name: name, + foregroundPID: foregroundPID, + foregroundIsShell: foregroundIsShell, + terminalInputIsRaw: terminalInputIsRaw + ) + } + pane.markTerminalActivity(at: date, kind: .render) + } } diff --git a/MactermTests/Model/TerminalExecutionTrackerTests.swift b/MactermTests/Model/TerminalExecutionTrackerTests.swift new file mode 100644 index 0000000..6e63226 --- /dev/null +++ b/MactermTests/Model/TerminalExecutionTrackerTests.swift @@ -0,0 +1,146 @@ +import Foundation +@testable import Macterm +import Testing + +/// Direct unit tests for `TerminalExecutionTracker`, the state machine behind a +/// pane's tab status spinner. +struct TerminalExecutionTrackerTests { + @Test + func markTerminalActivity_fromIdleWithoutInteraction_staysIdle() { + var tracker = TerminalExecutionTracker() + let state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 1), currentState: .idle) + #expect(state == .idle) + } + + @Test + func markTerminalActivity_fromIdleWithInteraction_startsRun() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + let state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 1), currentState: .idle) + #expect(state == .running) + } + + @Test + func renderActivity_fromDoneWithoutForeground_staysDone() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + + let state = tracker.markTerminalActivity( + at: Date(timeIntervalSince1970: 100), + kind: .render, + currentState: .done + ) + + #expect(state == .done) + } + + @Test + func markTerminalActivity_fromRunning_keepsAliveAndRefreshesTimestamp() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + let start = Date(timeIntervalSince1970: 100) + var state = tracker.markTerminalActivity(at: start, currentState: .idle) + #expect(state == .running) + + state = tracker.markTerminalActivity(at: start.addingTimeInterval(2), currentState: state) + #expect(state == .running) + state = tracker.settleIfQuiet(now: start.addingTimeInterval(3), quietInterval: 3, currentState: state) + #expect(state == .running) + + state = tracker.settleIfQuiet(now: start.addingTimeInterval(5), quietInterval: 3, currentState: state) + #expect(state == .done) + } + + @Test + func activitySourcedRun_settlesAfterQuietInterval() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + let start = Date(timeIntervalSince1970: 100) + var state = tracker.markTerminalActivity(at: start, currentState: .idle) + #expect(state == .running) + + state = tracker.settleIfQuiet(now: start.addingTimeInterval(2), quietInterval: 3, currentState: state) + #expect(state == .running) + state = tracker.settleIfQuiet(now: start.addingTimeInterval(3), quietInterval: 3, currentState: state) + #expect(state == .done) + } + + @Test + func progressStarted_withoutInteraction_isStillRunningSignal() { + var tracker = TerminalExecutionTracker() + + let state = tracker.markProgressStarted(currentState: .idle) + + #expect(state == .running) + } + + @Test + func progressStarted_replacesRestoredDoneWithoutInteraction() { + var tracker = TerminalExecutionTracker() + + let state = tracker.markProgressStarted(currentState: .done) + + #expect(state == .running) + } + + @Test + func terminalActivity_fromDoneWithInteractionRestartsRun() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + + let state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 100), currentState: .done) + + #expect(state == .running) + } + + @Test + func rawForegroundChange_fromDoneIsRunningSignal() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + var state = tracker.refreshForeground( + name: "sleep", + pid: 42, + foregroundIsShell: false, + terminalInputIsRaw: false, + currentState: .idle + ) + state = tracker.refreshForeground( + name: "zsh", + pid: 43, + foregroundIsShell: true, + terminalInputIsRaw: false, + currentState: state + ) + #expect(state == .done) + + state = tracker.refreshForeground( + name: "btop", + pid: 44, + foregroundIsShell: false, + terminalInputIsRaw: true, + currentState: state + ) + + #expect(state == .running) + } + + @Test + func progressFinishedForeground_laterActivityRestartsRun() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + var state = tracker.refreshForeground( + name: "node", + pid: 42, + foregroundIsShell: false, + terminalInputIsRaw: false, + currentState: .idle + ) + state = tracker.markProgressStarted(currentState: state) + state = tracker.markProgressFinished(currentState: state) + #expect(state == .done) + + state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 100), currentState: state) + + #expect(state == .running) + } +} diff --git a/MactermTests/Model/TerminalTabTests.swift b/MactermTests/Model/TerminalTabTests.swift index a274ee4..e007891 100644 --- a/MactermTests/Model/TerminalTabTests.swift +++ b/MactermTests/Model/TerminalTabTests.swift @@ -177,6 +177,46 @@ struct TerminalTabTests { #expect(tab.executionState == .idle) } + @Test + func executionState_rawRenderOnlyPaneMakesTabRunning() throws { + // The sidebar spinner reads the tab aggregate. If a raw-mode pane only + // repaints through render callbacks, the pane and tab should still become + // `.running`. + let (tab, ids) = makeTab(H(pane("a"), pane("b")), focused: "a") + let bID = try #require(ids["b"]) + let b = try #require(tab.splitRoot.findPane(id: bID)) + b.recordUserInteraction() + b.applyForegroundRefresh(name: "btop", foregroundPID: 42, terminalInputIsRaw: true) + + simulateTerminalRender( + for: b, + name: "btop", + foregroundPID: 42, + terminalInputIsRaw: true, + at: Date(timeIntervalSince1970: 100) + ) + + #expect(tab.executionState == .running) + } + + @Test + func executionState_freshRawActivityOverridesStaleDone() throws { + // A stale `.done` pane should not keep the tab in done/checkmark state + // after a new raw foreground process emits activity. + let (tab, ids) = makeTab(pane("a"), focused: "a") + let aID = try #require(ids["a"]) + let p = try #require(tab.splitRoot.findPane(id: aID)) + p.recordUserInteraction() + p.applyForegroundRefresh(name: "sleep", foregroundPID: 42) + p.applyForegroundRefresh(name: "zsh", foregroundPID: 43, foregroundIsShell: true) + #expect(tab.executionState == .done) + + p.applyForegroundRefresh(name: "watch", foregroundPID: 44, terminalInputIsRaw: true) + p.markTerminalActivity(at: Date(timeIntervalSince1970: 100)) + + #expect(tab.executionState == .running) + } + // MARK: - toggleZoom @Test @@ -378,4 +418,23 @@ struct TerminalTabTests { #expect(tab.focusedPaneID != nil) #expect(try remaining.contains(#require(tab.focusedPaneID))) } + + private func simulateTerminalRender( + for pane: Pane, + name: String, + foregroundPID: pid_t, + foregroundIsShell: Bool = false, + terminalInputIsRaw: Bool = false, + at date: Date + ) { + if pane.executionState != .running { + pane.applyForegroundRefresh( + name: name, + foregroundPID: foregroundPID, + foregroundIsShell: foregroundIsShell, + terminalInputIsRaw: terminalInputIsRaw + ) + } + pane.markTerminalActivity(at: date, kind: .render) + } }