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