mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 03:25:50 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user