mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-19 14:00:29 +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.
|
/// Determine if a given notification should be presented to the user when Ghostty is running in the foreground.
|
||||||
func shouldPresentNotification(notification: UNNotification) -> Bool {
|
func shouldPresentNotification(notification: UNNotification) -> Bool {
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
|
// We always require the notification to be attached to a surface.
|
||||||
guard let uuidString = userInfo["surface"] as? String,
|
guard let uuidString = userInfo["surface"] as? String,
|
||||||
let uuid = UUID(uuidString: uuidString),
|
let uuid = UUID(uuidString: uuidString),
|
||||||
let surface = delegate?.findSurface(forUUID: uuid),
|
let surface = delegate?.findSurface(forUUID: uuid),
|
||||||
let window = surface.window else { return false }
|
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
|
return !window.isKeyWindow || !surface.focused
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +639,9 @@ extension Ghostty {
|
|||||||
case GHOSTTY_ACTION_SEARCH_SELECTED:
|
case GHOSTTY_ACTION_SEARCH_SELECTED:
|
||||||
searchSelected(app, target: target, v: action.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:
|
case GHOSTTY_ACTION_PRESENT_TERMINAL:
|
||||||
return presentTerminal(app, target: target)
|
return presentTerminal(app, target: target)
|
||||||
|
|
||||||
@@ -1353,7 +1363,7 @@ extension Ghostty {
|
|||||||
n: ghostty_action_desktop_notification_s) {
|
n: ghostty_action_desktop_notification_s) {
|
||||||
switch target.tag {
|
switch target.tag {
|
||||||
case GHOSTTY_TARGET_APP:
|
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
|
return
|
||||||
|
|
||||||
case GHOSTTY_TARGET_SURFACE:
|
case GHOSTTY_TARGET_SURFACE:
|
||||||
@@ -1361,17 +1371,105 @@ extension Ghostty {
|
|||||||
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
||||||
guard let title = String(cString: n.title!, encoding: .utf8) else { return }
|
guard let title = String(cString: n.title!, encoding: .utf8) else { return }
|
||||||
guard let body = String(cString: n.body!, 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()
|
default:
|
||||||
center.requestAuthorization(options: [.alert, .sound]) { _, error in
|
assertionFailure()
|
||||||
if let error = error {
|
}
|
||||||
Ghostty.logger.error("Error while requesting notification authorization: \(error)")
|
}
|
||||||
}
|
|
||||||
|
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
|
// Determine if the command was slow enough
|
||||||
guard settings.authorizationStatus == .authorized else { return }
|
let duration = Duration.nanoseconds(v.duration)
|
||||||
surfaceView.showUserNotification(title: title, body: body)
|
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:
|
default:
|
||||||
|
|||||||
@@ -134,6 +134,32 @@ extension Ghostty {
|
|||||||
return .init(rawValue: v)
|
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 {
|
var splitPreserveZoom: SplitPreserveZoom {
|
||||||
guard let config = self.config else { return .init() }
|
guard let config = self.config else { return .init() }
|
||||||
var v: CUnsignedInt = 0
|
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
|
/// 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()
|
let content = UNMutableNotificationContent()
|
||||||
content.title = title
|
content.title = title
|
||||||
content.subtitle = self.title
|
content.subtitle = self.title
|
||||||
content.body = body
|
content.body = body
|
||||||
content.sound = UNNotificationSound.default
|
content.sound = UNNotificationSound.default
|
||||||
content.categoryIdentifier = Ghostty.userNotificationCategory
|
content.categoryIdentifier = Ghostty.userNotificationCategory
|
||||||
content.userInfo = ["surface": self.id.uuidString]
|
content.userInfo = [
|
||||||
|
"surface": self.id.uuidString,
|
||||||
|
"requireFocus": requireFocus,
|
||||||
|
]
|
||||||
|
|
||||||
let uuid = UUID().uuidString
|
let uuid = UUID().uuidString
|
||||||
let request = UNNotificationRequest(
|
let request = UNNotificationRequest(
|
||||||
|
|||||||
@@ -1207,8 +1207,6 @@ command: ?Command = null,
|
|||||||
/// notifications for a single command, overriding the `never` and `unfocused`
|
/// notifications for a single command, overriding the `never` and `unfocused`
|
||||||
/// options.
|
/// options.
|
||||||
///
|
///
|
||||||
/// GTK only.
|
|
||||||
///
|
|
||||||
/// Available since 1.3.0.
|
/// Available since 1.3.0.
|
||||||
@"notify-on-command-finish": NotifyOnCommandFinish = .never,
|
@"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
|
/// 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`.
|
/// can be negated by prefixing them with `no-`. For example `no-bell,notify`.
|
||||||
///
|
///
|
||||||
/// GTK only.
|
|
||||||
///
|
|
||||||
/// Available since 1.3.0.
|
/// Available since 1.3.0.
|
||||||
@"notify-on-command-finish-action": NotifyOnCommandFinishAction = .{
|
@"notify-on-command-finish-action": NotifyOnCommandFinishAction = .{
|
||||||
.bell = true,
|
.bell = true,
|
||||||
@@ -1262,8 +1258,6 @@ command: ?Command = null,
|
|||||||
/// The maximum value is `584y 49w 23h 34m 33s 709ms 551µs 615ns`. Any
|
/// 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.
|
/// value larger than this will be clamped to the maximum value.
|
||||||
///
|
///
|
||||||
/// GTK only.
|
|
||||||
///
|
|
||||||
/// Available since 1.3.0
|
/// Available since 1.3.0
|
||||||
@"notify-on-command-finish-after": Duration = .{ .duration = 5 * std.time.ns_per_s },
|
@"notify-on-command-finish-after": Duration = .{ .duration = 5 * std.time.ns_per_s },
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user