Skip to content
Open
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
254 changes: 218 additions & 36 deletions Macterm/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -101,39 +105,209 @@ final class AppState {
.compactMap { UUID(uuidString: $0) }
projectRecency = RecencyStack<UUID>(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) {
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
}

Expand All @@ -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)
}
}
}

Expand Down
15 changes: 15 additions & 0 deletions Macterm/App/MactermApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions Macterm/Ghostty/GhosttyApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading