From 3b5a7b77d3feedbee460f70a644b64edc5d8a6a4 Mon Sep 17 00:00:00 2001 From: Joseph Martinsen Date: Sat, 21 Feb 2026 16:25:58 -0600 Subject: [PATCH 1/3] macos: implement notify on command finish --- macos/Sources/Ghostty/Ghostty.App.swift | 107 +++++++++++++++++++-- macos/Sources/Ghostty/Ghostty.Config.swift | 38 ++++++++ src/config/Config.zig | 6 -- 3 files changed, 136 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 89b2f18f1..f864e1033 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -632,9 +632,13 @@ extension Ghostty { case GHOSTTY_ACTION_SEARCH_SELECTED: searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_COMMAND_FINISHED: + commandFinished(app, target: target, v: action.action.command_finished) + case GHOSTTY_ACTION_PRESENT_TERMINAL: return presentTerminal(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1353,7 +1357,7 @@ extension Ghostty { n: ghostty_action_desktop_notification_s) { switch target.tag { case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("toggle split zoom does nothing with an app target") + Ghostty.logger.warning("desktop notification does nothing with an app target") return case GHOSTTY_TARGET_SURFACE: @@ -1361,17 +1365,82 @@ extension Ghostty { guard let surfaceView = self.surfaceView(from: surface) else { return } guard let title = String(cString: n.title!, encoding: .utf8) else { return } guard let body = String(cString: n.body!, encoding: .utf8) else { return } + showDesktopNotification(surfaceView, title: title, body: body) - let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound]) { _, error in - if let error = error { - Ghostty.logger.error("Error while requesting notification authorization: \(error)") - } + default: + assertionFailure() + } + } + + private static func showDesktopNotification( + _ surfaceView: SurfaceView, + title: String, + body: String) { + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound]) { _, error in + if let error = error { + Ghostty.logger.error("Error while requesting notification authorization: \(error)") + } + } + + center.getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { return } + surfaceView.showUserNotification(title: title, body: body) + } + } + + private static func commandFinished( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_command_finished_s + ) { + switch target.tag { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("command finished does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + guard let appState = (NSApplication.shared.delegate as? AppDelegate)?.ghostty else { return } + let config = appState.config + + let mode = config.notifyOnCommandFinish + if mode == .never { return } + if mode == .unfocused && surfaceView.focused { return } + + let durationMs = v.duration / 1_000_000 + if durationMs < config.notifyOnCommandFinishAfter { return } + + let actions = config.notifyOnCommandFinishAction + + if actions.contains(.bell) { + NotificationCenter.default.post( + name: .ghosttyBellDidRing, + object: surfaceView + ) } - center.getNotificationSettings { settings in - guard settings.authorizationStatus == .authorized else { return } - surfaceView.showUserNotification(title: title, body: body) + if actions.contains(.notify) { + let title: String + if v.exit_code < 0 { + title = "Command Finished" + } else if v.exit_code == 0 { + title = "Command Succeeded" + } else { + title = "Command Failed" + } + + let body: String + let formattedDuration = Self.formatDuration(ns: v.duration) + if v.exit_code < 0 { + body = "Command took \(formattedDuration)." + } else { + body = "Command took \(formattedDuration) and exited with code \(v.exit_code)." + } + + showDesktopNotification(surfaceView, title: title, body: body) } default: @@ -1379,6 +1448,26 @@ extension Ghostty { } } + private static func formatDuration(ns: UInt64) -> String { + let totalSeconds = ns / 1_000_000_000 + let ms = (ns / 1_000_000) % 1000 + + if totalSeconds == 0 { + return "\(ms)ms" + } + + let seconds = totalSeconds % 60 + let minutes = (totalSeconds / 60) % 60 + let hours = totalSeconds / 3600 + + var parts: [String] = [] + if hours > 0 { parts.append("\(hours)h") } + if minutes > 0 { parts.append("\(minutes)m") } + if seconds > 0 || (hours == 0 && minutes == 0) { parts.append("\(seconds)s") } + + return parts.joined(separator: " ") + } + private static func toggleFloatWindow( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index d65bac27f..11d1d4d8e 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -134,6 +134,31 @@ extension Ghostty { return .init(rawValue: v) } + var notifyOnCommandFinish: NotifyOnCommandFinish { + guard let config = self.config else { return .never } + var v: UnsafePointer? + let key = "notify-on-command-finish" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .never } + guard let ptr = v else { return .never } + return NotifyOnCommandFinish(rawValue: String(cString: ptr)) ?? .never + } + + var notifyOnCommandFinishAction: NotifyOnCommandFinishAction { + guard let config = self.config else { return .bell } + var v: CUnsignedInt = 0 + let key = "notify-on-command-finish-action" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .bell } + return .init(rawValue: v) + } + + var notifyOnCommandFinishAfter: UInt { + guard let config = self.config else { return 5000 } + var v: UInt = 0 + let key = "notify-on-command-finish-after" + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return v + } + var splitPreserveZoom: SplitPreserveZoom { guard let config = self.config else { return .init() } var v: CUnsignedInt = 0 @@ -842,4 +867,17 @@ extension Ghostty.Config { } } } + + enum NotifyOnCommandFinish: String { + case never + case unfocused + case always + } + + struct NotifyOnCommandFinishAction: OptionSet { + let rawValue: CUnsignedInt + + static let bell = NotifyOnCommandFinishAction(rawValue: 1 << 0) + static let notify = NotifyOnCommandFinishAction(rawValue: 1 << 1) + } } diff --git a/src/config/Config.zig b/src/config/Config.zig index fbac290ad..eac2b8dbb 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1207,8 +1207,6 @@ command: ?Command = null, /// notifications for a single command, overriding the `never` and `unfocused` /// options. /// -/// GTK only. -/// /// Available since 1.3.0. @"notify-on-command-finish": NotifyOnCommandFinish = .never, @@ -1223,8 +1221,6 @@ command: ?Command = null, /// Options can be combined by listing them as a comma separated list. Options /// can be negated by prefixing them with `no-`. For example `no-bell,notify`. /// -/// GTK only. -/// /// Available since 1.3.0. @"notify-on-command-finish-action": NotifyOnCommandFinishAction = .{ .bell = true, @@ -1262,8 +1258,6 @@ command: ?Command = null, /// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any /// value larger than this will be clamped to the maximum value. /// -/// GTK only. -/// /// Available since 1.3.0 @"notify-on-command-finish-after": Duration = .{ .duration = 5 * std.time.ns_per_s }, From f4ddddc4b797e5bc185bbfb486f74231a331dfff Mon Sep 17 00:00:00 2001 From: Joseph Martinsen Date: Sat, 21 Feb 2026 16:51:32 -0600 Subject: [PATCH 2/3] macos: refactor command finish notification duration handling --- macos/Sources/Ghostty/Ghostty.App.swift | 50 +++++++++------------- macos/Sources/Ghostty/Ghostty.Config.swift | 11 ++--- 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f864e1033..913bb36f9 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -638,7 +638,6 @@ extension Ghostty { case GHOSTTY_ACTION_PRESENT_TERMINAL: return presentTerminal(app, target: target) - case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: @@ -1403,15 +1402,22 @@ extension Ghostty { guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } - guard let appState = (NSApplication.shared.delegate as? AppDelegate)?.ghostty else { return } - let config = appState.config + // Determine if we even care about command finish notifications + guard let config = (NSApplication.shared.delegate as? AppDelegate)?.ghostty.config else { return } + switch config.notifyOnCommandFinish { + case .never: + return - let mode = config.notifyOnCommandFinish - if mode == .never { return } - if mode == .unfocused && surfaceView.focused { return } + case .unfocused: + if surfaceView.focused { return } - let durationMs = v.duration / 1_000_000 - if durationMs < config.notifyOnCommandFinishAfter { return } + case .always: + break + } + + // Determine if the command was slow enough + let duration = Duration.nanoseconds(v.duration) + guard Duration.nanoseconds(v.duration) >= config.notifyOnCommandFinishAfter else { return } let actions = config.notifyOnCommandFinishAction @@ -1433,7 +1439,13 @@ extension Ghostty { } let body: String - let formattedDuration = Self.formatDuration(ns: v.duration) + let formattedDuration = duration.formatted( + .units( + allowed: [.hours, .minutes, .seconds, .milliseconds], + width: .abbreviated, + fractionalPart: .hide + ) + ) if v.exit_code < 0 { body = "Command took \(formattedDuration)." } else { @@ -1448,26 +1460,6 @@ extension Ghostty { } } - private static func formatDuration(ns: UInt64) -> String { - let totalSeconds = ns / 1_000_000_000 - let ms = (ns / 1_000_000) % 1000 - - if totalSeconds == 0 { - return "\(ms)ms" - } - - let seconds = totalSeconds % 60 - let minutes = (totalSeconds / 60) % 60 - let hours = totalSeconds / 3600 - - var parts: [String] = [] - if hours > 0 { parts.append("\(hours)h") } - if minutes > 0 { parts.append("\(minutes)m") } - if seconds > 0 || (hours == 0 && minutes == 0) { parts.append("\(seconds)s") } - - return parts.joined(separator: " ") - } - private static func toggleFloatWindow( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 11d1d4d8e..a26f41038 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -144,19 +144,20 @@ extension Ghostty { } var notifyOnCommandFinishAction: NotifyOnCommandFinishAction { - guard let config = self.config else { return .bell } + let defaultValue = NotifyOnCommandFinishAction.bell + guard let config = self.config else { return defaultValue } var v: CUnsignedInt = 0 let key = "notify-on-command-finish-action" - guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return .bell } + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } return .init(rawValue: v) } - var notifyOnCommandFinishAfter: UInt { - guard let config = self.config else { return 5000 } + var notifyOnCommandFinishAfter: Duration { + guard let config = self.config else { return .seconds(5) } var v: UInt = 0 let key = "notify-on-command-finish-after" _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) - return v + return .milliseconds(v) } var splitPreserveZoom: SplitPreserveZoom { From a5909dfa1dabd005073ccab9d5c57ce8496addc6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Feb 2026 13:40:34 -0800 Subject: [PATCH 3/3] macos: command finished notifications always show up --- macos/Sources/Ghostty/Ghostty.App.swift | 23 ++++++++++++++++--- .../Surface View/SurfaceView_AppKit.swift | 7 ++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 913bb36f9..82b3ad35c 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -433,10 +433,17 @@ extension Ghostty { /// Determine if a given notification should be presented to the user when Ghostty is running in the foreground. func shouldPresentNotification(notification: UNNotification) -> Bool { let userInfo = notification.request.content.userInfo + + // We always require the notification to be attached to a surface. guard let uuidString = userInfo["surface"] as? String, let uuid = UUID(uuidString: uuidString), let surface = delegate?.findSurface(forUUID: uuid), let window = surface.window else { return false } + + // If we don't require focus then we're good! + let requireFocus = userInfo["requireFocus"] as? Bool ?? true + if !requireFocus { return true } + return !window.isKeyWindow || !surface.focused } @@ -1374,7 +1381,8 @@ extension Ghostty { private static func showDesktopNotification( _ surfaceView: SurfaceView, title: String, - body: String) { + body: String, + requireFocus: Bool = true) { let center = UNUserNotificationCenter.current() center.requestAuthorization(options: [.alert, .sound]) { _, error in if let error = error { @@ -1384,7 +1392,11 @@ extension Ghostty { center.getNotificationSettings { settings in guard settings.authorizationStatus == .authorized else { return } - surfaceView.showUserNotification(title: title, body: body) + surfaceView.showUserNotification( + title: title, + body: body, + requireFocus: requireFocus + ) } } @@ -1452,7 +1464,12 @@ extension Ghostty { body = "Command took \(formattedDuration) and exited with code \(v.exit_code)." } - showDesktopNotification(surfaceView, title: title, body: body) + showDesktopNotification( + surfaceView, + title: title, + body: body, + requireFocus: false + ) } default: diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index e45480a20..fb3b7f9df 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -1632,14 +1632,17 @@ extension Ghostty { } /// Show a user notification and associate it with this surface - func showUserNotification(title: String, body: String) { + func showUserNotification(title: String, body: String, requireFocus: Bool = true) { let content = UNMutableNotificationContent() content.title = title content.subtitle = self.title content.body = body content.sound = UNNotificationSound.default content.categoryIdentifier = Ghostty.userNotificationCategory - content.userInfo = ["surface": self.id.uuidString] + content.userInfo = [ + "surface": self.id.uuidString, + "requireFocus": requireFocus, + ] let uuid = UUID().uuidString let request = UNNotificationRequest(