macos: implement notify on command finish

This commit is contained in:
Joseph Martinsen
2026-02-21 16:25:58 -06:00
committed by Mitchell Hashimoto
parent 9d6a8d0fc1
commit 3b5a7b77d3
3 changed files with 136 additions and 15 deletions

View File

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

View File

@@ -134,6 +134,31 @@ 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 {
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)
}
}

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