diff --git a/Macterm/App/AppState.swift b/Macterm/App/AppState.swift index 91ba437..ce59c37 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: - Event-Driven Status Tracking + + // Status indicators are driven by libghostty callbacks. Tab names refresh + // lazily on focus/visibility changes instead of a repeating process poll. + + /// 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? + + /// 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,209 @@ 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) } - RunLoop.main.add(timer, forMode: .common) - processNameTimer = timer + quietSettleWorkItem?.cancel() } - /// 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 + // 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 + // Status tracking does no polling while inactive. + } + + // 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) + } + } + } + } + + /// 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 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() + + // 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 anyNeedsQuietSettle = 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() + + if pane.needsTerminalActivityQuietSettle { anyNeedsQuietSettle = 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 activity-sourced panes are still running, reschedule the settle check. + // Foreground- and progress-sourced runs complete from explicit callbacks. + if anyNeedsQuietSettle { + 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) { + if 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() + 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() + 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 +531,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 +565,9 @@ final class AppState { let didAcknowledgeCompletion = ws.selectNextTab() if ws.activeTabID != before || didAcknowledgeCompletion { saveWorkspaces() + if let newTabID = ws.activeTabID { + tabFocusDidChange(toTabID: newTabID) + } } } @@ -398,6 +577,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/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/Model/SplitNode.swift b/Macterm/Model/SplitNode.swift index b3432b0..829565e 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? @@ -84,23 +89,20 @@ struct TerminalExecutionTracker { /// progress run until a completion/foreground transition; activity is a /// render/output heartbeat and quiet-settles. private var runningSource: TerminalExecutionSource? - /// 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. - 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 { + if case .activity = runningSource { true } else { false } + } + mutating func recordUserInteraction() { hasUserInteraction = true } - mutating func markProgressStarted(currentState: TerminalExecutionState) -> TerminalExecutionState { - guard hasUserInteraction else { return currentState } + mutating func markProgressStarted(currentState _: TerminalExecutionState) -> TerminalExecutionState { + hasUserInteraction = true runningSource = .progress return .running } @@ -114,41 +116,39 @@ struct TerminalExecutionTracker { // persist as a spurious checkmark after restart). guard currentState == .running else { return currentState } runningSource = nil - progressQuiesced = nil - pendingProgressQuiesce = false return .done } mutating func markProgressFinished(currentState: TerminalExecutionState) -> TerminalExecutionState { - guard hasUserInteraction || runningSource == .progress else { return currentState } - if let lastForeground { - progressQuiesced = lastForeground - } else { - pendingProgressQuiesce = true - } + guard runningSource == .progress else { return currentState } runningSource = nil return currentState == .running ? .done : currentState } mutating func markTerminalActivity( at date: Date, + kind: TerminalActivityKind = .output, currentState: TerminalExecutionState ) -> TerminalExecutionState { - // A render/output heartbeat can keep an already-running command active, - // but it must never (re)start one. From `.done` — a finished command - // whose checkmark is showing — output (e.g. a background job) must not - // flip the pane back to running; only a new foreground process or an - // explicit progress marker can. Pinned by TerminalExecutionTrackerTests - // so a refactor of the onTerminalRender closure can't silently - // reintroduce the "prompt redraw keeps spinning" bug. - guard currentState != .done else { return currentState } guard runningSource != .progress else { return currentState } - if let progressQuiesced, progressQuiesced == lastForeground { return currentState } - // Output/render only counts after user interaction (or a declarative - // `run:`, which seeds `hasUserInteraction`). Fresh/restored shells can + // 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 + } + // A finished foreground command leaves `.done` visible. Prompt redraws + // or background output at an idle shell must not resurrect it, but fresh + // activity from a known non-shell foreground process should. + if currentState == .done, lastForeground == nil { + return currentState + } runningSource = .activity(date) return .running } @@ -175,24 +175,6 @@ 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 @@ -221,6 +203,10 @@ struct TerminalExecutionTracker { runningSource = nil return .done } + if changed, currentState != .running { + runningSource = .activity(Date()) + return .running + } return currentState } @@ -282,14 +268,14 @@ final class Pane: Identifiable { private var executionTracker = TerminalExecutionTracker() /// Re-read the foreground process name from the process table and publish it - /// only when it changed (so a steady poll doesn't churn `@Observable` and - /// re-render the sidebar every tick). Driven by `AppState`'s poll. + /// only when it changed (so repeated refreshes don't churn `@Observable` + /// and re-render the sidebar). Driven lazily from focus/visibility changes + /// and terminal callbacks. /// /// `trackExecution` gates the expensive shell/raw-mode syscalls /// (`foregroundProcessIsShell` / `terminalInputIsRaw`) that only feed the - /// status indicator. Callers on the hot poll pass a precomputed value so - /// the pref is read once; the default reads `Preferences` for ad-hoc - /// callers (OSC title, output/progress callbacks) so they stay gated too. + /// status indicator. The default reads `Preferences` for ad-hoc callers + /// (OSC title, output/progress callbacks) so they stay gated too. func refreshForegroundProcess(trackExecution: Bool? = nil) { let track = trackExecution ?? Preferences.shared.showTabStatusIndicator applyForegroundRefresh( @@ -339,11 +325,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) { @@ -354,6 +343,10 @@ final class Pane: Identifiable { ) } + var needsTerminalActivityQuietSettle: Bool { + executionTracker.needsQuietSettle + } + @discardableResult func acknowledgeCommandCompletion() -> Bool { guard executionState == .done else { return false } diff --git a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift index e0bb262..62ae984 100644 --- a/Macterm/Views/Terminal/GhosttyTerminalNSView.swift +++ b/Macterm/Views/Terminal/GhosttyTerminalNSView.swift @@ -218,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() { @@ -269,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 { @@ -303,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 96b05a9..d65410d 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,18 +204,20 @@ 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 } - // Renders also happen for prompt redraws and input echo. Use them to - // keep an already-detected command active (including in-place - // spinners), but don't let a render alone start the status spinner. + // Renders also happen for prompt redraws and input echo. The tracker + // treats them as weak activity, so they can keep a known foreground + // alive without letting idle prompt redraws resurrect a done badge. if pane.executionState != .running { pane.refreshForegroundProcess() } - if pane.executionState == .running { - pane.markTerminalActivity() + if pane.markTerminalActivity(kind: .render) { + onActivityNeedsQuietSettle() } } view.onCommandFinished = { [weak pane, weak view] exitCode, durationNs in 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 + } } 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 index 4b05945..ef58397 100644 --- a/MactermTests/Model/TerminalExecutionTrackerTests.swift +++ b/MactermTests/Model/TerminalExecutionTrackerTests.swift @@ -5,19 +5,13 @@ import Testing /// Direct unit tests for `TerminalExecutionTracker`, the state machine behind a /// pane's tab status spinner. /// -/// These pin the core invariant surfaced in the activity-detection fix: a -/// render/output heartbeat can keep an already-running command active (so -/// in-place spinners that repaint with carriage returns stay alive), but it can -/// never start or resurrect the spinner on its own. The "keep-alive only" rule -/// is enforced at the call site (`onTerminalRender` only calls the heartbeat -/// while already `.running`), and these tests lock the matching guarantee into -/// the tracker itself — otherwise a future refactor could silently reintroduce -/// the "prompt redraw keeps spinning" bug with nothing to catch it. +/// The tracker separates where "running" came from: foreground transitions, +/// explicit OSC progress, and output/render activity. That keeps prompt redraws +/// from resurrecting a finished command while still allowing real foreground +/// work, raw-mode repainting, and progress reports to show activity. struct TerminalExecutionTrackerTests { @Test func markTerminalActivity_fromIdleWithoutInteraction_staysIdle() { - // No prior user interaction: a fresh/restored shell's startup output - // must not register as activity. var tracker = TerminalExecutionTracker() let state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 1), currentState: .idle) #expect(state == .idle) @@ -25,10 +19,6 @@ struct TerminalExecutionTrackerTests { @Test func markTerminalActivity_fromIdleWithInteraction_startsRun() { - // Output activity (scrollback) after interaction is a genuine signal - // that something is running, so — unlike a render heartbeat — it *may* - // start the spinner from idle. Pinned here so the "render can't start" - // guard isn't over-corrected into "no activity can ever start". var tracker = TerminalExecutionTracker() tracker.recordUserInteraction() let state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 1), currentState: .idle) @@ -36,10 +26,7 @@ struct TerminalExecutionTrackerTests { } @Test - func markTerminalActivity_fromDone_doesNotReturnToRunning() { - // A finished command (`.done`, checkmark showing) must not be flipped - // back to running by a render/output heartbeat — e.g. a background job - // printing while the foreground command is already settled. + func markTerminalActivity_fromDoneWithoutForeground_doesNotReturnToRunning() { var tracker = TerminalExecutionTracker() tracker.recordUserInteraction() var state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 100), currentState: .idle) @@ -47,48 +34,140 @@ struct TerminalExecutionTrackerTests { #expect(state == .done) state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 110), currentState: state) + + #expect(state == .done) + } + + @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() { - // While running, each heartbeat refreshes the activity timestamp so the - // quiet-settle window restarts. This is "a render can keep a spinner - // alive" — the fix for in-place spinners that repaint the same line. var tracker = TerminalExecutionTracker() tracker.recordUserInteraction() let start = Date(timeIntervalSince1970: 100) var state = tracker.markTerminalActivity(at: start, currentState: .idle) #expect(state == .running) - // A heartbeat at start+2 restarts the window; settling at start+3 (only - // 1s after the last heartbeat) must still be 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) - // After the full quiet interval elapses past the last heartbeat, it settles. state = tracker.settleIfQuiet(now: start.addingTimeInterval(5), quietInterval: 3, currentState: state) #expect(state == .done) } @Test func activitySourcedRun_settlesAfterQuietInterval() { - // An activity-sourced run is not held forever: once output goes quiet - // for the interval it decays to `.done` (foreground- and progress-sourced - // runs are not subject to this timer). var tracker = TerminalExecutionTracker() tracker.recordUserInteraction() let start = Date(timeIntervalSince1970: 100) var state = tracker.markTerminalActivity(at: start, currentState: .idle) #expect(state == .running) - // Just under the quiet interval: still running. state = tracker.settleIfQuiet(now: start.addingTimeInterval(2), quietInterval: 3, currentState: state) #expect(state == .running) - // At the quiet interval: settles to done. 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_fromDoneWithForegroundRestartsRun() { + var tracker = TerminalExecutionTracker() + tracker.recordUserInteraction() + var state = tracker.refreshForeground( + name: "node", + pid: 42, + foregroundIsShell: false, + terminalInputIsRaw: false, + currentState: .idle + ) + state = tracker.markCommandFinished(currentState: state) + #expect(state == .done) + + state = tracker.markTerminalActivity(at: Date(timeIntervalSince1970: 100), currentState: state) + + #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) + } }