macOS: add "command finished" notifications (#10934)

fixes https://github.com/ghostty-org/ghostty/issues/10840

Implement command finished notifications for MacOS. Building on the work
of #8992

### AI Tools Used
* Cursor
* Models
    * Opus 4.6
    * Composer 1.5
This commit is contained in:
Mitchell Hashimoto
2026-02-26 13:47:24 -08:00
committed by GitHub
4 changed files with 151 additions and 17 deletions

View File

@@ -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
}
@@ -632,6 +639,9 @@ 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)
@@ -1353,7 +1363,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 +1371,105 @@ 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,
requireFocus: Bool = true) {
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,
requireFocus: requireFocus
)
}
}
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 }
// 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
case .unfocused:
if surfaceView.focused { return }
case .always:
break
}
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else { return }
surfaceView.showUserNotification(title: title, body: body)
// 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
if actions.contains(.bell) {
NotificationCenter.default.post(
name: .ghosttyBellDidRing,
object: surfaceView
)
}
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 = duration.formatted(
.units(
allowed: [.hours, .minutes, .seconds, .milliseconds],
width: .abbreviated,
fractionalPart: .hide
)
)
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,
requireFocus: false
)
}
default:

View File

@@ -134,6 +134,32 @@ extension Ghostty {
return .init(rawValue: v)
}
var notifyOnCommandFinish: NotifyOnCommandFinish {
guard let config = self.config else { return .never }
var v: UnsafePointer<Int8>?
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 {
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 defaultValue }
return .init(rawValue: v)
}
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 .milliseconds(v)
}
var splitPreserveZoom: SplitPreserveZoom {
guard let config = self.config else { return .init() }
var v: CUnsignedInt = 0
@@ -842,4 +868,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)
}
}

View File

@@ -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(

View File

@@ -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 },