feat: add terminal notificaitons

This commit is contained in:
Kaede Akino committed 2026-09-27 13:33:41 +08:00
1 parent b6ff9fd7d1
commit e2452df929
37 files changed
+1263 -7

No files matched your search

@@ -0,0 +1,132 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite @MainActor
struct TerminalCommandMonitorTests {
private let available = SSHShellIntegrationEvent.available(shell: "zsh", systemInfo: nil, homeDirectory: nil)
@Test func notificationsDefaultOffButEveryBackgroundCompletionMarksUnread() {
let monitor = TerminalCommandMonitor()
var alerts = 0
monitor.alert = { _, _ in alerts += 1 }
monitor.beginConnection()
monitor.receiveOSC133(exitCode: 1, durationNanos: 1)
#expect(monitor.hasUnreadCompletion)
#expect(alerts == 0)
monitor.markRead()
#expect(!monitor.hasUnreadCompletion)
monitor.receiveOSC133(exitCode: nil, durationNanos: 0)
#expect(monitor.hasUnreadCompletion)
}
@Test(arguments: CommandNotificationPreferences.Mode.allCases)
func modeAndThresholdUseFocusAtCompletion(_ mode: CommandNotificationPreferences.Mode) {
let monitor = TerminalCommandMonitor()
var settings = CommandNotificationPreferences()
settings.mode = mode
var focused = false
var completions: [TerminalCommandCompletion] = []
monitor.preferences = { settings }
monitor.isWorkspaceFocused = { focused }
monitor.alert = { completion, _ in completions.append(completion) }
monitor.beginConnection()
monitor.receiveOSC133(exitCode: 0, durationNanos: 4_999_999_999)
#expect(completions.isEmpty)
monitor.receiveOSC133(exitCode: 0, durationNanos: 5_000_000_000)
#expect(completions.count == (mode == .never ? 0 : 1))
focused = true
monitor.receiveOSC133(exitCode: nil, durationNanos: 6_000_000_000)
#expect(!monitor.hasUnreadCompletion)
#expect(completions.count == (mode == .always ? 2 : mode == .unfocused ? 1 : 0))
}
@Test func privateIntegrationWinsAndDuplicateFinishesAreIgnoredWithoutHistory() {
var clock: TimeInterval = 100
let monitor = TerminalCommandMonitor(now: { clock })
var settings = CommandNotificationPreferences()
settings.mode = .always
monitor.preferences = { settings }
var completions: [TerminalCommandCompletion] = []
monitor.alert = { event, _ in completions.append(event) }
monitor.beginConnection()
monitor.receiveShellEvent(available)
monitor.receiveShellEvent(.commandStarted("sleep 9"))
monitor.receiveOSC133(exitCode: 0, durationNanos: 9_000_000_000)
clock = 109
monitor.receiveShellEvent(.commandFinished(2, duration: nil))
monitor.receiveShellEvent(.commandFinished(2, duration: nil))
#expect(completions == [.init(command: "sleep 9", exitCode: 2, duration: 9)])
monitor.receiveShellEvent(.commandStarted("sleep 7"))
clock = 120
monitor.receiveShellEvent(.commandFinished(0, duration: 7))
#expect(completions.last?.duration == 7)
}
@Test func disconnectDiscardsActiveCommandAndReconnectReselectsEventSource() {
let monitor = TerminalCommandMonitor()
monitor.beginConnection()
monitor.receiveShellEvent(available)
monitor.receiveShellEvent(.commandStarted("old command"))
monitor.endConnection()
monitor.receiveShellEvent(.commandFinished(0, duration: 20))
monitor.receiveOSC133(exitCode: 0, durationNanos: 20_000_000_000)
#expect(!monitor.hasUnreadCompletion)
monitor.beginConnection()
monitor.receiveShellEvent(.commandFinished(0, duration: 20))
#expect(!monitor.hasUnreadCompletion)
monitor.receiveOSC133(exitCode: 0, durationNanos: 1)
#expect(monitor.hasUnreadCompletion)
}
@Test(arguments: [false, true], [false, true])
func notificationAndBellOptionsAreIndependent(notify: Bool, bell: Bool) {
let monitor = TerminalCommandMonitor()
var settings = CommandNotificationPreferences()
settings.mode = .always
settings.after = 0
settings.notify = notify
settings.bell = bell
monitor.preferences = { settings }
var actions: [CommandNotificationPreferences] = []
monitor.alert = { _, value in actions.append(value) }
monitor.beginConnection()
monitor.receiveOSC133(exitCode: 0, durationNanos: 0)
#expect(actions == (notify || bell ? [settings] : []))
#expect(monitor.hasUnreadCompletion)
}
@Test func commandPreviewRemovesControlsAndBoundsUnicodeText() {
let completion = TerminalCommandCompletion(command: "echo\n\thello\u{1b}\u{0} world", exitCode: 0, duration: 1)
#expect(completion.commandPreview == "echo hello world")
let long = TerminalCommandCompletion(command: String(repeating: "字", count: 170), exitCode: nil, duration: 0)
#expect(long.commandPreview?.count == 160)
#expect(long.commandPreview?.last == "…")
#expect(TerminalCommandCompletion(exitCode: nil, duration: 0).commandPreview == nil)
}
@Test func preferencesRoundTripAndOldPayloadsUseDefaults() throws {
var preferences = TerminalPreferences()
preferences.commandNotifications.mode = .unfocused
preferences.commandNotifications.after = 10.5
preferences.commandNotifications.bell = true
let data = try JSONEncoder().encode(preferences)
#expect(try JSONDecoder().decode(TerminalPreferences.self, from: data) == preferences)
let row = try PersistentTerminalPreferences(preferences)
#expect(try row.load() == preferences)
let domain = "CommandPreferences.\(UUID())"
let defaults = try #require(UserDefaults(suiteName: domain))
defer { defaults.removePersistentDomain(forName: domain) }
preferences.mirror(to: defaults)
#expect(TerminalPreferences(defaults: defaults) == preferences)
let payload = SettingsRecordPayload.terminal(preferences)
#expect(try JSONDecoder().decode(SettingsRecordPayload.self, from: JSONEncoder().encode(payload)) == payload)
var old = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
old.removeValue(forKey: "commandNotifications")
let decoded = try JSONDecoder().decode(
TerminalPreferences.self, from: JSONSerialization.data(withJSONObject: old))
#expect(decoded.commandNotifications == CommandNotificationPreferences())
}
}
+272
View File
@@ -3074,6 +3074,23 @@
}
}
},
"Always" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "总是"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "一律"
}
}
}
},
"Always Ask" : {
"extractionState" : "manual",
"localizations" : {
@@ -5847,6 +5864,23 @@
}
}
},
"Command Completion" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "命令完成"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "指令完成"
}
}
}
},
"Command Generator" : {
"extractionState" : "manual",
"localizations" : {
@@ -5983,6 +6017,23 @@
}
}
},
"Command completed" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "命令已完成"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "指令已完成"
}
}
}
},
"Commands" : {
"extractionState" : "manual",
"localizations" : {
@@ -6159,6 +6210,23 @@
}
}
},
"Completed · %@" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已完成·%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "已完成·%@"
}
}
}
},
"Concurrent Strategy" : {
"extractionState" : "manual",
"localizations" : {
@@ -10881,6 +10949,23 @@
}
}
},
"Exit code %lld · %@" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出码%lld·%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "結束代碼%lld·%@"
}
}
}
},
"Expand" : {
"extractionState" : "manual",
"localizations" : {
@@ -15408,6 +15493,23 @@
}
}
},
"Minimum Duration (seconds)" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "最短运行时间(秒)"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "最短執行時間(秒)"
}
}
}
},
"Mode" : {
"extractionState" : "manual",
"localizations" : {
@@ -17542,6 +17644,57 @@
}
}
},
"Notifications are disabled in system settings." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "系统设置中已关闭通知。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "系統設定中已關閉通知。"
}
}
}
},
"Notifications can be delayed while iOS suspends Termind." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "iOS挂起Termind时,通知可能延迟。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "iOS暫停Termind時,通知可能延遲。"
}
}
}
},
"Notify When" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "通知时机"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "通知時機"
}
}
}
},
"OK" : {
"extractionState" : "manual",
"localizations" : {
@@ -17957,6 +18110,23 @@
}
}
},
"Open Notification Settings" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "打开通知设置"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "打開通知設定"
}
}
}
},
"Open System Settings" : {
"extractionState" : "manual",
"localizations" : {
@@ -21703,6 +21873,23 @@
}
}
},
"Requires shell integration or OSC 133. Workspace dots also mark shorter commands." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要Shell集成或OSC 133。工作区红点也会标记较短的命令。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要Shell整合或OSC 133。工作區紅點也會標記較短的指令。"
}
}
}
},
"Requires user action" : {
"extractionState" : "manual",
"localizations" : {
@@ -25579,6 +25766,23 @@
}
}
},
"Succeeded · %@" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "成功·%@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "成功·%@"
}
}
}
},
"Suggested Command" : {
"extractionState" : "manual",
"localizations" : {
@@ -25802,6 +26006,23 @@
}
}
},
"System Notification" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "系统通知"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "系統通知"
}
}
}
},
"Tab" : {
"extractionState" : "manual",
"localizations" : {
@@ -26046,6 +26267,23 @@
}
}
},
"Terminal Bell" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "终端铃声"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "終端機鈴聲"
}
}
}
},
"Terminal Input" : {
"extractionState" : "manual",
"localizations" : {
@@ -30450,6 +30688,23 @@
}
}
},
"Unread command completion" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "有未读的命令完成消息"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "有未讀的指令完成訊息"
}
}
}
},
"Unrecognized additional host key" : {
"extractionState" : "manual",
"localizations" : {
@@ -31893,6 +32148,23 @@
}
}
},
"Workspace Is Unfocused" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "工作区未聚焦时"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "工作區未聚焦時"
}
}
}
},
"Workspace Name" : {
"extractionState" : "manual",
"localizations" : {
Binary file not shown.
@@ -0,0 +1,11 @@
import Foundation
public nonisolated enum CommandNotificationAuthorization: Sendable {
case notDetermined, allowed, denied
}
@MainActor
public protocol CommandNotificationSending: AnyObject {
func authorization(request: Bool) async -> CommandNotificationAuthorization
func send(sessionID: UUID, title: String, body: String) async
}
@@ -32,6 +32,7 @@ public nonisolated enum SettingsKey {
public static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
public static let settingsSyncEnabled = "settings.sync.enabled"
public static let settingsSyncEnvironment = "settings.sync.environment"
public static let commandNotifications = "terminal.commandNotifications"
public static let shellIntegration = "terminal.shellIntegration"
public static let sshMaxSessions = "ssh.maxSessions"
public static let defaultSSHMaxSessions = SSHPreferences.defaultMaxSessions
@@ -0,0 +1,91 @@
import Foundation
import Observation
import TermindDomain
public nonisolated struct TerminalCommandCompletion: Equatable, Sendable {
public var command: String?
public var exitCode: Int?
public var duration: TimeInterval
public init(command: String? = nil, exitCode: Int?, duration: TimeInterval) {
self.command = command
self.exitCode = exitCode
self.duration = duration
}
public var commandPreview: String? {
guard let command else { return nil }
let cleaned = command.unicodeScalars.map { scalar in
CharacterSet.controlCharacters.contains(scalar) ? " " : String(scalar)
}.joined().split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
guard !cleaned.isEmpty else { return nil }
return cleaned.count > 160 ? String(cleaned.prefix(159)) + "…" : cleaned
}
}
/// Per-terminal state, independent of history and transient view mounting.
@MainActor @Observable
public final class TerminalCommandMonitor {
public private(set) var hasUnreadCompletion = false
public var preferences: () -> CommandNotificationPreferences = { .init() }
public var isWorkspaceFocused: () -> Bool = { false }
public var alert: (TerminalCommandCompletion, CommandNotificationPreferences) -> Void = { _, _ in }
private let now: () -> TimeInterval
private var active = false
private var usesShellIntegration = false
private var running: (command: String, started: TimeInterval)?
public init(now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
self.now = now
}
public func beginConnection() {
active = true
usesShellIntegration = false
running = nil
}
public func endConnection() {
active = false
running = nil
usesShellIntegration = false
}
public func markRead() { hasUnreadCompletion = false }
public func receiveShellEvent(_ event: SSHShellIntegrationEvent) {
guard active else { return }
switch event {
case .available:
usesShellIntegration = true
case .commandStarted(let command):
guard usesShellIntegration else { return }
running = (command, now())
case .commandFinished(let status, let reported):
guard usesShellIntegration, let command = running else { return }
running = nil
let duration =
reported.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
?? max(0, now() - command.started)
complete(.init(command: command.command, exitCode: Int(status), duration: duration))
default: break
}
}
public func receiveOSC133(exitCode: Int?, durationNanos: UInt64) {
guard active, !usesShellIntegration else { return }
complete(.init(exitCode: exitCode, duration: Double(durationNanos) / 1_000_000_000))
}
private func complete(_ completion: TerminalCommandCompletion) {
let focused = isWorkspaceFocused()
hasUnreadCompletion = !focused
let settings = preferences()
guard settings.mode != .never,
settings.mode == .always || !focused,
completion.duration >= settings.threshold,
settings.notify || settings.bell
else { return }
alert(completion, settings)
}
}
@@ -0,0 +1,16 @@
import Foundation
public nonisolated struct CommandNotificationPreferences: Codable, Hashable, Sendable {
public enum Mode: String, Codable, CaseIterable, Sendable {
case never, unfocused, always
}
public var mode: Mode = .never
public var notify = true
public var bell = false
public var after: TimeInterval = 5
public init() {}
public var threshold: TimeInterval { after.isFinite && after >= 0 ? after : 5 }
}
@@ -11,6 +11,7 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
public var remoteWindowTitleInMenuBar = true
public var copyOnSelect = false
public var pasteOnRightClick = false
public var commandNotifications = CommandNotificationPreferences()
public init(
darkTheme: String = "Termind Dark",
lightTheme: String = "Termind Light", darkCustomEnabled: Bool = false, lightCustomEnabled: Bool = false,
@@ -32,7 +33,7 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
private enum CodingKeys: String, CodingKey {
case darkTheme, lightTheme, darkCustomEnabled, lightCustomEnabled
case darkCustomConfiguration, lightCustomConfiguration, shellIntegration, remoteWindowTitleInMenuBar
case copyOnSelect, pasteOnRightClick
case copyOnSelect, pasteOnRightClick, commandNotifications
}
public init(from decoder: any Decoder) throws {
@@ -45,6 +46,8 @@ public nonisolated struct TerminalPreferences: Codable, Hashable, Sendable {
lightCustomConfiguration = try values.decode(String.self, forKey: .lightCustomConfiguration)
shellIntegration = try values.decode(Bool.self, forKey: .shellIntegration)
remoteWindowTitleInMenuBar = try values.decode(Bool.self, forKey: .remoteWindowTitleInMenuBar)
commandNotifications =
try values.decodeIfPresent(CommandNotificationPreferences.self, forKey: .commandNotifications) ?? .init()
// Existing local blobs and Settings sync records predate the mouse preferences.
copyOnSelect = try values.decodeIfPresent(Bool.self, forKey: .copyOnSelect) ?? false
pasteOnRightClick = try values.decodeIfPresent(Bool.self, forKey: .pasteOnRightClick) ?? false
@@ -0,0 +1,68 @@
import Foundation
import TermindApplication
import UserNotifications
@MainActor
public final class SystemCommandNotifications: NSObject, CommandNotificationSending, UNUserNotificationCenterDelegate {
public var onOpenSession: ((UUID) -> Void)? {
didSet {
if let pendingSession, let onOpenSession {
self.pendingSession = nil
onOpenSession(pendingSession)
}
}
}
private var pendingSession: UUID?
private let center: UNUserNotificationCenter?
public override init() {
center =
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil
? UNUserNotificationCenter.current() : nil
super.init()
center?.delegate = self
}
public func authorization(request: Bool = false) async -> CommandNotificationAuthorization {
guard let center else { return .denied }
var status = await center.notificationSettings().authorizationStatus
if request, status == .notDetermined {
_ = try? await center.requestAuthorization(options: [.alert])
status = await center.notificationSettings().authorizationStatus
}
switch status {
case .authorized, .provisional, .ephemeral: return .allowed
case .notDetermined: return .notDetermined
default: return .denied
}
}
public func send(sessionID: UUID, title: String, body: String) async {
guard let center, await authorization() == .allowed else { return }
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.userInfo = ["commandSessionID": sessionID.uuidString]
content.threadIdentifier = sessionID.uuidString
// Sound is controlled separately by the terminal bell setting.
try? await center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
}
public nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
notification.request.content.userInfo["commandSessionID"] == nil ? [] : [.banner, .list]
}
public nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse
) async {
guard response.actionIdentifier == UNNotificationDefaultActionIdentifier,
let raw = response.notification.request.content.userInfo["commandSessionID"] as? String,
let id = UUID(uuidString: raw)
else { return }
await MainActor.run {
if let onOpenSession { onOpenSession(id) } else { pendingSession = id }
}
}
}
@@ -6,6 +6,11 @@ import TermindDomain
extension TerminalPreferences {
public nonisolated init(defaults: UserDefaults) {
self.init()
if let data = defaults.data(forKey: SettingsKey.commandNotifications),
let value = try? JSONDecoder().decode(CommandNotificationPreferences.self, from: data)
{
commandNotifications = value
}
darkTheme = defaults.string(forKey: SettingsKey.terminalThemeDark) ?? darkTheme
lightTheme = defaults.string(forKey: SettingsKey.terminalThemeLight) ?? lightTheme
darkCustomEnabled =
@@ -23,6 +28,7 @@ extension TerminalPreferences {
}
public nonisolated func mirror(to defaults: UserDefaults) {
defaults.set(try? JSONEncoder().encode(commandNotifications), forKey: SettingsKey.commandNotifications)
defaults.set(darkTheme, forKey: SettingsKey.terminalThemeDark)
defaults.set(lightTheme, forKey: SettingsKey.terminalThemeLight)
defaults.set(darkCustomEnabled, forKey: SettingsKey.terminalCustomConfigurationDarkEnabled)
@@ -0,0 +1,33 @@
import Foundation
extension AppStrings {
public nonisolated enum CommandNotifications {
public static var title: LocalizedStringResource { "Command Completion" }
public static var when: LocalizedStringResource { "Notify When" }
public static var never: LocalizedStringResource { "Never" }
public static var unfocused: LocalizedStringResource { "Workspace Is Unfocused" }
public static var always: LocalizedStringResource { "Always" }
public static var system: LocalizedStringResource { "System Notification" }
public static var bell: LocalizedStringResource { "Terminal Bell" }
public static var seconds: LocalizedStringResource { "Minimum Duration (seconds)" }
public static var openSettings: LocalizedStringResource { "Open Notification Settings" }
public static var denied: LocalizedStringResource { "Notifications are disabled in system settings." }
public static var unread: LocalizedStringResource { "Unread command completion" }
public static var explanation: LocalizedStringResource {
"Requires shell integration or OSC 133. Workspace dots also mark shorter commands."
}
public static var mobileExplanation: LocalizedStringResource {
"Notifications can be delayed while iOS suspends Termind."
}
public static var completed: LocalizedStringResource { "Command completed" }
public static func succeeded(_ duration: String) -> LocalizedStringResource {
"Succeeded · \(duration)"
}
public static func failed(_ code: Int, _ duration: String) -> LocalizedStringResource {
"Exit code \(code) · \(duration)"
}
public static func finished(_ duration: String) -> LocalizedStringResource {
"Completed · \(duration)"
}
}
}
@@ -30,7 +30,8 @@ nonisolated extension TerminalCustomConfiguration {
-> GhosttyTerminal.TerminalConfiguration
{
GhosttyTerminal.TerminalConfiguration(startingFrom: base) { builder in
for entry in entries {
// Command alerts are owned by the app's shared settings and dispatcher.
for entry in entries where entry.key != "notify-on-command-finish" {
builder.withCustom(entry.key, entry.value)
}
}
@@ -127,6 +128,7 @@ extension TerminalViewState {
setTheme(GhosttyTerminal.TerminalTheme(light: light, dark: dark))
setTerminalConfiguration(
GhosttyTerminal.TerminalConfiguration { builder in
builder.withCustom("notify-on-command-finish", "never")
builder.withWindowPaddingX(8)
builder.withWindowPaddingY(8)
})
@@ -0,0 +1,101 @@
import SwiftUI
import TermindApplication
import TermindDomain
public struct TerminalCommandNotificationFields: View {
private let model: TerminalSettingsModel
private let service: any CommandNotificationSending
private let openSettings: () -> Void
@State private var authorization = CommandNotificationAuthorization.notDetermined
@Environment(\.scenePhase) private var scenePhase
public init(
model: TerminalSettingsModel, service: any CommandNotificationSending, openSettings: @escaping () -> Void
) {
self.model = model
self.service = service
self.openSettings = openSettings
}
public var body: some View {
Section {
Picker(AppStrings.CommandNotifications.when, selection: binding(\.mode)) {
Text(AppStrings.CommandNotifications.never).tag(CommandNotificationPreferences.Mode.never)
Text(AppStrings.CommandNotifications.unfocused).tag(CommandNotificationPreferences.Mode.unfocused)
Text(AppStrings.CommandNotifications.always).tag(CommandNotificationPreferences.Mode.always)
}
Toggle(AppStrings.CommandNotifications.system, isOn: binding(\.notify))
Toggle(AppStrings.CommandNotifications.bell, isOn: binding(\.bell))
LabeledContent {
TextField(AppStrings.CommandNotifications.seconds, value: binding(\.after), format: .number)
.labelsHidden()
.multilineTextAlignment(.trailing)
.frame(width: 100)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
} label: {
Text(AppStrings.CommandNotifications.seconds)
}
if authorization == .denied {
Text(AppStrings.CommandNotifications.denied).foregroundStyle(.secondary)
Button(AppStrings.CommandNotifications.openSettings, action: openSettings)
}
} header: {
Text(AppStrings.CommandNotifications.title)
} footer: {
VStack(alignment: .leading) {
Text(AppStrings.CommandNotifications.explanation)
.fixedSize(horizontal: false, vertical: true)
#if os(iOS)
Text(AppStrings.CommandNotifications.mobileExplanation)
.fixedSize(horizontal: false, vertical: true)
#endif
}
}
.task { authorization = await service.authorization(request: false) }
.onChange(of: scenePhase) { _, phase in
if phase == .active { Task { authorization = await service.authorization(request: false) } }
}
}
private func binding<Value>(_ path: WritableKeyPath<CommandNotificationPreferences, Value>) -> Binding<Value> {
Binding(
get: { model.preferences.commandNotifications[keyPath: path] },
set: { value in
let previous = model.preferences.commandNotifications
var next = previous
next[keyPath: path] = value
guard next.after.isFinite, next.after >= 0,
model.set(next, for: \.commandNotifications)
else { return }
if next.notify, next.mode != .never, !previous.notify || previous.mode == .never {
Task { authorization = await service.authorization(request: true) }
}
})
}
}
public struct WorkspaceCommandBadge: View {
public init() {}
public var body: some View {
Circle().fill(.red).frame(width: 7, height: 7)
.overlay { Circle().stroke(.background, lineWidth: 1) }
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}
public enum CommandNotificationContent {
public static func body(_ completion: TerminalCommandCompletion) -> String {
let duration = Duration.seconds(completion.duration).formatted(.units(allowed: [.hours, .minutes, .seconds]))
let result: LocalizedStringResource
switch completion.exitCode {
case 0: result = AppStrings.CommandNotifications.succeeded(duration)
case .some(let code): result = AppStrings.CommandNotifications.failed(code, duration)
case nil: result = AppStrings.CommandNotifications.finished(duration)
}
return (completion.commandPreview ?? String(localized: AppStrings.CommandNotifications.completed))
+ "\n" + String(localized: result)
}
}
@@ -0,0 +1,17 @@
import Combine
import GhosttyTerminal
import TermindApplication
/// The duration publisher emits once per completion, including equal values.
/// Exit code is assigned first by Ghostty; capture both in the synchronous sink.
@MainActor
public final class TerminalCommandObservation {
private var observation: AnyCancellable?
public init(state: TerminalViewState, monitor: TerminalCommandMonitor) {
observation = state.$lastCommandDurationNanos.dropFirst().sink { [weak state, weak monitor] duration in
guard let duration else { return }
monitor?.receiveOSC133(exitCode: state?.lastCommandExitCode, durationNanos: duration)
}
}
}
+41
View File
@@ -42,6 +42,7 @@ final class MobileAppModel {
let forwardings: PortForwardingController
let sftpEdits: SFTPEditService
let transfers: SFTPTransferCoordinator
let commandNotifications = SystemCommandNotifications()
private var workspaceSessions: [MobileWorkspaceSessionModel] = []
private var paneScenes: [UUID: WeakPaneScene] = [:]
var workspaces: [MobileWorkspace] { workspaceSessions.flatMap(\.workspaces) }
@@ -54,6 +55,7 @@ final class MobileAppModel {
func registerPaneScene(_ scene: MobileSceneModel) {
paneScenes = paneScenes.filter { $0.value.value != nil }
paneScenes[scene.id] = WeakPaneScene(scene)
commandNotifications.onOpenSession = { [weak self] id in self?.openCommandNotification(id) }
}
func paneAccess(for source: MobileSceneModel) -> AgentPaneAccess {
@@ -509,6 +511,7 @@ final class MobileAppModel {
if let workspaceSessionID { sceneSessionIDs[id] = workspaceSessionID }
let wasBackground = allScenesAreBackgrounded
scenePhases[id] = phase
refreshCommandFocus()
let isBackground = allScenesAreBackgrounded
if isBackground && !wasBackground {
enterBackground()
@@ -704,7 +707,45 @@ final class MobileAppModel {
}
}
func isCommandWorkspaceFocused(_ id: UUID) -> Bool {
paneScenes.values.compactMap(\.value).contains {
scenePhases[$0.id] == .active && $0.activeWorkspace?.id == id
}
}
func refreshCommandFocus() {
for scene in paneScenes.values.compactMap(\.value) where scenePhases[scene.id] == .active {
scene.activeWorkspace?.commandMonitor.markRead()
}
}
private func openCommandNotification(_ id: UUID) {
guard workspaces.contains(where: { $0.id == id }), let owner = workspaceOwner(id) else { return }
if let scene = paneScenes.values.compactMap(\.value).first(where: { $0.workspaceSession.id == owner }) {
scene.selectWorkspace(id)
}
MobileWorkspaceSceneRouting.activate(sessionID: owner, workspaceID: id, unavailable: {})
}
func workspaceWillOpen(_ workspace: MobileWorkspace, close: @escaping (UUID) -> Void) {
workspace.commandMonitor.preferences = { [weak self] in
self?.terminalSettings.preferences.commandNotifications ?? .init()
}
workspace.commandMonitor.isWorkspaceFocused = { [weak self, weak workspace] in
guard let self, let workspace else { return false }
return self.isCommandWorkspaceFocused(workspace.id)
}
workspace.commandMonitor.alert = { [weak self, weak workspace] completion, settings in
guard let self, let workspace else { return }
if settings.bell { TerminalNotificationPlatform.ringBell() }
if settings.notify {
let title = workspace.descriptor.title
let body = CommandNotificationContent.body(completion)
let id = workspace.id
Task { await self.commandNotifications.send(sessionID: id, title: title, body: body) }
}
}
observeActivityState(of: workspace)
workspace.didRequestClose = close
startConnectionHistory(for: workspace)
+2 -1
View File
@@ -19,9 +19,10 @@ final class MobileSceneModel {
var selectedDestination: MobileDestination? = .vault {
didSet {
if selectedDestination != .workspaces { pendingWorkspaceID = nil }
app.refreshCommandFocus()
}
}
var workspaceID: MobileWorkspace.ID?
var workspaceID: MobileWorkspace.ID? { didSet { app.refreshCommandFocus() } }
var isConnectionPickerPresented = false
var isBatchStartupPresented = false
var isWorkspaceUnavailable = false
@@ -24,7 +24,7 @@ struct MobileSettingsView: View {
Label(AppStrings.Settings.app.swiftUIKey, systemImage: "app.badge")
}
NavigationLink {
MobileTerminalSettingsView(model: app.terminalSettings)
MobileTerminalSettingsView(model: app.terminalSettings, notifications: app.commandNotifications)
} label: {
Label(AppStrings.Settings.terminal.swiftUIKey, systemImage: "terminal")
}
@@ -5,6 +5,7 @@ import TermindPresentation
struct MobileTerminalSettingsView: View {
let model: TerminalSettingsModel
let notifications: any CommandNotificationSending
@State private var editingDark: Bool?
@State private var customText = ""
@TerminalFontSettings private var font
@@ -17,6 +18,9 @@ struct MobileTerminalSettingsView: View {
Section(AppStrings.Settings.font.swiftUIKey) {
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
}
TerminalCommandNotificationFields(
model: model, service: notifications,
openSettings: TerminalNotificationPlatform.openSettings)
Section(AppStrings.Settings.integration.swiftUIKey) {
Toggle(AppStrings.Settings.shellIntegration.swiftUIKey, isOn: binding(\.shellIntegration))
}
@@ -58,6 +58,7 @@ final class MobileWorkspace: Identifiable {
private(set) var status = String(localized: AppStrings.Connections.disconnected)
private(set) var connected = false {
didSet {
if !connected { commandMonitor.endConnection() }
guard activitySession.connected != connected else { return }
activitySession.connected = connected
didChangeState?(activitySession)
@@ -104,6 +105,8 @@ final class MobileWorkspace: Identifiable {
perform: { [weak self] in _ = self?.terminal.performBindingAction($0) },
focus: { [weak self] in self?.terminal.requestFocus() }
)
let commandMonitor = TerminalCommandMonitor()
private var commandObservation: TerminalCommandObservation?
private var activeHistoryID: UUID?
/// Resolved once at the beginning of each connection attempt.
private(set) var isShellIntegrationEnabled = true
@@ -302,6 +305,7 @@ final class MobileWorkspace: Identifiable {
relay?.owner?.receiveWorkingDirectory(path)
}
}
commandObservation = TerminalCommandObservation(state: terminal, monitor: commandMonitor)
surfaceOwner.bindFind(find)
}
@@ -553,6 +557,7 @@ final class MobileWorkspace: Identifiable {
resetCommandGeneratorShellState()
return
}
commandMonitor.receiveShellEvent(event)
switch event {
case .available(let shell, let systemInfo, _):
integratedShell = shell
@@ -617,6 +622,7 @@ final class MobileWorkspace: Identifiable {
let reconnecting = connection.isRecovering
let token = UUID()
generation = token
commandMonitor.beginConnection()
activeHistoryID = nil
resetCommandGeneratorShellState()
operationError = nil
@@ -1189,6 +1195,7 @@ final class MobileWorkspace: Identifiable {
}
func disconnect() {
commandMonitor.endConnection()
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
@@ -136,6 +136,14 @@ struct MobileWorkspaceListView: View {
HStack(spacing: MobileWorkspaceListMetrics.rowSpacing) {
ZStack(alignment: .bottomTrailing) {
hostIcon(item.host)
.overlay(alignment: .topTrailing) {
if item.workspace?.commandMonitor.hasUnreadCompletion == true {
WorkspaceCommandBadge()
}
}
.accessibilityValue(
item.workspace?.commandMonitor.hasUnreadCompletion == true
? String(localized: AppStrings.CommandNotifications.unread) : "")
if let workspace = item.workspace,
let indicator = MobileWorkspaceConnectionIndicator.resolve(
connected: workspace.connected,
@@ -0,0 +1,21 @@
import AudioToolbox
import UIKit
@MainActor
enum TerminalNotificationPlatform {
static func ringBell() {
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil,
let url = Bundle.main.url(forResource: "command-finished", withExtension: "wav")
else { return }
var sound: SystemSoundID = 0
guard AudioServicesCreateSystemSoundID(url as CFURL, &sound) == kAudioServicesNoError else { return }
let identifier = sound
AudioServicesPlaySystemSoundWithCompletion(identifier) { AudioServicesDisposeSystemSoundID(identifier) }
}
static func openSettings() {
if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
@@ -0,0 +1,56 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import UIKit
@testable import TermindIOS
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"), .serialized)
@MainActor
struct MobileCommandNotificationRenderingTests {
@Test(arguments: ["en", "zh-Hans"], [false, true])
func notificationSettingsAndBadge(_ language: String, dark: Bool) async throws {
let model = TerminalSettingsModel()
var settings = CommandNotificationPreferences()
settings.mode = .unfocused
model.set(settings, for: \.commandNotifications)
let view = Form {
Section {
Label {
Text("Build Server")
} icon: {
Image(systemName: "terminal")
.overlay(alignment: .topTrailing) { WorkspaceCommandBadge().offset(x: 3, y: -3) }
}
}
TerminalCommandNotificationFields(model: model, service: SilentNotifications(), openSettings: {})
}
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, dark ? .dark : .light)
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(rootView: view)
window.rootViewController = controller
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
}
try await Task.sleep(for: .milliseconds(350))
window.layoutIfNeeded()
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
let data = try #require(image.pngData())
Attachment.record(data, named: "command-notifications-ios-\(language)-\(dark).png")
}
private final class SilentNotifications: CommandNotificationSending {
func authorization(request: Bool) async -> CommandNotificationAuthorization { .notDetermined }
func send(sessionID: UUID, title: String, body: String) async {}
}
}
+6 -1
View File
@@ -55,7 +55,7 @@ struct MobileTerminalFontTests {
defaults.set(family, forKey: SettingsKey.terminalFontFamily)
defaults.set(18.0, forKey: SettingsKey.terminalFontSize)
let view = NavigationStack {
MobileTerminalSettingsView(model: TerminalSettingsModel())
MobileTerminalSettingsView(model: TerminalSettingsModel(), notifications: SilentCommandNotifications())
}
.defaultAppStorage(defaults)
.environment(\.locale, Locale(identifier: language))
@@ -97,3 +97,8 @@ struct MobileTerminalFontTests {
Attachment.record(data, named: "\(name).png")
}
}
@MainActor private final class SilentCommandNotifications: CommandNotificationSending {
func authorization(request: Bool) async -> CommandNotificationAuthorization { .notDetermined }
func send(sessionID: UUID, title: String, body: String) async {}
}
@@ -13,6 +13,39 @@ import struct TermindDomain.Host
@Suite @MainActor
struct MobileWorkspaceSessionTests {
@Test func commandCompletionDotsUseVisibleWorkspaceInItsOwningScene() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
let app = try fixture.makeApp()
let first = MobileSceneModel(app: app, sessionID: "command-a")
let second = MobileSceneModel(app: app, sessionID: "command-b")
first.open(hostID: fixture.host.id, kind: .ssh)
second.open(hostID: fixture.host.id, kind: .ssh)
first.activatePendingWorkspace()
second.activatePendingWorkspace()
let workspace = try #require(first.activeWorkspace)
app.sceneChanged(second.id, phase: .active)
app.sceneChanged(first.id, phase: .background)
workspace.commandMonitor.beginConnection()
workspace.commandMonitor.receiveOSC133(exitCode: 1, durationNanos: 1)
#expect(workspace.commandMonitor.hasUnreadCompletion)
#expect(!app.isCommandWorkspaceFocused(workspace.id))
#expect(!second.activeWorkspace!.commandMonitor.hasUnreadCompletion)
app.sceneChanged(first.id, phase: .active)
#expect(app.isCommandWorkspaceFocused(workspace.id))
#expect(!workspace.commandMonitor.hasUnreadCompletion)
workspace.commandMonitor.receiveOSC133(exitCode: 0, durationNanos: 1)
#expect(!workspace.commandMonitor.hasUnreadCompletion)
first.workspaceID = nil
workspace.commandMonitor.receiveOSC133(exitCode: nil, durationNanos: 0)
#expect(workspace.commandMonitor.hasUnreadCompletion)
first.selectWorkspace(workspace.id)
#expect(!workspace.commandMonitor.hasUnreadCompletion)
first.closeWorkspace(workspace.id)
workspace.commandMonitor.receiveOSC133(exitCode: 0, durationNanos: 1)
#expect(!workspace.commandMonitor.hasUnreadCompletion)
}
@Test func paneAccessExcludesDormantAndDisconnectedWorkspacesAcrossScenes() throws {
let fixture = try Fixture()
defer { fixture.cleanUp() }
+68
View File
@@ -71,6 +71,8 @@ final class AppModel {
let serverMetrics: ServerMetricsModel
let windowSizeStore: MainWindowSizeStore
let logging: any ApplicationLogManaging
let commandNotifications = SystemCommandNotifications()
@ObservationIgnored lazy var mainWindows = MainWindowCoordinator(app: self, sizes: windowSizeStore)
let connections: RemoteConnectionCoordinator
let serialPorts: any SerialPortServing
@@ -1097,7 +1099,73 @@ final class AppModel {
if let first = opened.first { window.select(first.id) }
}
func commandNotificationTarget(_ id: UUID) -> (WindowModel, Workspace, Pane)? {
for window in windows.values.compactMap(\.value) where !window.isClosed {
for workspace in window.workspaces {
if let pane = workspace.panes.first(where: { $0.commandSessionID == id }) {
return (window, workspace, pane)
}
}
}
return nil
}
func isCommandWorkspaceFocused(_ id: UUID, windowIsFocused: (WindowModel) -> Bool) -> Bool {
guard let (window, workspace, _) = commandNotificationTarget(id) else { return false }
return window.selectedWorkspace === workspace && windowIsFocused(window)
}
private func configureCommandMonitor(_ pane: Pane) {
guard let monitor = pane.commandMonitor, let id = pane.commandSessionID else { return }
monitor.preferences = { [weak self] in self?.terminalSettings.preferences.commandNotifications ?? .init() }
monitor.isWorkspaceFocused = { [weak self] in
guard let self else { return false }
return self.isCommandWorkspaceFocused(id, windowIsFocused: self.mainWindows.isFocused)
}
monitor.alert = { [weak self] completion, settings in
guard let self, let (_, workspace, _) = self.commandNotificationTarget(id) else { return }
if settings.bell { TerminalNotificationPlatform.ringBell() }
if settings.notify {
let title = workspace.title(in: self.hostLibrary, locale: .current)
let body = CommandNotificationContent.body(completion)
Task { await self.commandNotifications.send(sessionID: id, title: title, body: body) }
}
}
}
@discardableResult
func selectCommandNotificationTarget(_ id: UUID) -> WindowModel? {
guard let (window, workspace, pane) = commandNotificationTarget(id) else { return nil }
window.isToolbarFindFocused = false
window.isQuickOpenPresented = false
window.select(workspace.id)
workspace.focusedPaneID = pane.id
return window
}
private func openCommandNotification(_ id: UUID) {
guard let window = selectCommandNotificationTarget(id) else {
_ = mainWindows.openExisting()
NSApp.activate(ignoringOtherApps: true)
return
}
mainWindows.activate(window)
if let pane = commandNotificationTarget(id)?.2 {
pane.sshSession?.requestTerminalFocus()
pane.telnetSession?.requestTerminalFocus()
pane.serialSession?.requestTerminalFocus()
}
}
private func configureConnectionHistory(in window: WindowModel) {
commandNotifications.onOpenSession = { [weak self] id in self?.openCommandNotification(id) }
window.configureCommandNotifications = { [weak self] pane in self?.configureCommandMonitor(pane) }
window.commandFocusChanged = { [weak self, weak window] in
guard let self, let window, self.mainWindows.isFocused(window) else { return }
window.selectedWorkspace?.markCommandsRead()
}
window.workspaces.flatMap(\.panes).forEach(configureCommandMonitor)
window.hostStore = hosts
window.workspacePreferences = { [weak self] in
self?.workspaceSettings.preferences ?? WorkspacePreferences()
@@ -620,6 +620,9 @@ private struct TerminalSettings: View {
}
}
}
TerminalCommandNotificationFields(
model: app.terminalSettings, service: app.commandNotifications,
openSettings: TerminalNotificationPlatform.openSettings)
Section(AppStrings.Settings.integration.swiftUIKey) {
Toggle(isOn: binding(\.shellIntegration)) {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
@@ -38,6 +38,7 @@ final class SSHSessionModel: Identifiable {
var title: String
var status: ConnectionStatus = .disconnected {
didSet {
if !status.isLive, status != .connecting { commandMonitor.endConnection() }
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
if status.isLive != oldValue.isLive {
inputRouter?.sshSessionDidChangeRemoteWindowTitle(self)
@@ -62,6 +63,8 @@ final class SSHSessionModel: Identifiable {
func displayPath(_ path: String) -> String {
RemotePath.displayed(path, home: homeResolution)
}
let commandMonitor = TerminalCommandMonitor()
private var commandObservation: TerminalCommandObservation?
var hasUnreadOutput: Bool = false
/// History for an unsaved Quick Connect destination. Saved Hosts use the
/// app-wide CommandHistoryModel instead so every window sees the same list.
@@ -258,6 +261,7 @@ final class SSHSessionModel: Identifiable {
)
terminalSurface = surface
relay.owner = self
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
observeWorkingDirectory(from: surface.state, through: relay)
observeRemoteWindowTitle(from: surface.state, through: relay)
}
@@ -359,6 +363,7 @@ final class SSHSessionModel: Identifiable {
}
private func startConnection(using store: any RemoteSessionHostStore) {
commandMonitor.endConnection()
resetCommandGeneratorShellState()
remoteWindowTitle = nil
homeResolution = .unavailable
@@ -757,6 +762,7 @@ final class SSHSessionModel: Identifiable {
}
func disconnect() {
commandMonitor.endConnection()
startsOnMount = false
resetCommandGeneratorShellState()
remoteWindowTitle = nil
@@ -812,6 +818,7 @@ final class SSHSessionModel: Identifiable {
authenticationReference: authenticationReference
)
let terminalSurface = terminalSurface
commandMonitor.beginConnection()
let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel(
output: { data in terminalSurface.receive(data) },
exit: { [weak self] result in
@@ -1121,6 +1128,7 @@ final class SSHSessionModel: Identifiable {
func receiveShellIntegrationEvent(_ event: SSHShellIntegrationEvent) {
guard isShellIntegrationEnabled else { return }
commandMonitor.receiveShellEvent(event)
switch event {
case .available(let shell, let systemInfo, let homeDirectory):
if let homeDirectory, homeDirectory.hasPrefix("/") {
@@ -3,6 +3,7 @@ import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import TermindRemoteInfrastructure
@MainActor
@@ -26,7 +27,11 @@ nonisolated private final class WeakSerialSessionReference: @unchecked Sendable
final class SerialSessionModel: Identifiable {
let id = UUID()
private(set) var configuration: SerialConnectionConfiguration
var status: ConnectionStatus = .disconnected
var status: ConnectionStatus = .disconnected {
didSet { if !status.isLive, status != .connecting { commandMonitor.endConnection() } }
}
let commandMonitor = TerminalCommandMonitor()
private var commandObservation: TerminalCommandObservation?
var hasUnreadOutput = false
@ObservationIgnored lazy var find = TerminalFindController(
perform: { [weak self] in self?.performFind($0) },
@@ -67,6 +72,7 @@ final class SerialSessionModel: Identifiable {
resize: { _ in }
)
relay.owner = self
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
// Present the shared Journey before SwiftUI mounts the pane, preventing
// a one-frame flash of the empty Ghostty surface before `onAppear`
// starts the actual open attempt.
@@ -89,6 +95,7 @@ final class SerialSessionModel: Identifiable {
let previousConnection = connection
connection = nil
status = .connecting
commandMonitor.beginConnection()
connectionJourney.beginSerial(name: title, address: devicePath)
let service = service
let configuration = configuration
@@ -19,8 +19,13 @@ final class TelnetSessionModel: Identifiable {
let hostID: Host.ID
var title: String
var status: ConnectionStatus = .disconnected {
didSet { statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID) }
didSet {
if !status.isLive, status != .connecting { commandMonitor.endConnection() }
statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID)
}
}
let commandMonitor = TerminalCommandMonitor()
private var commandObservation: TerminalCommandObservation?
var hasUnreadOutput = false
@ObservationIgnored lazy var find = TerminalFindController(
perform: { [weak self] in self?.performFind($0) },
@@ -86,6 +91,7 @@ final class TelnetSessionModel: Identifiable {
}
)
relay.owner = self
commandObservation = TerminalCommandObservation(state: terminalSurface.state, monitor: commandMonitor)
connectionJourney.beginTelnet(
host: host, username: usernameHint, mode: .connection)
}
@@ -124,6 +130,7 @@ final class TelnetSessionModel: Identifiable {
mode: connectionController.hasConnectedOnce ? .reconnection : .connection)
status = connectionController.hasConnectedOnce ? .reconnecting : .connecting
commandMonitor.beginConnection()
let terminalSurface = terminalSurface
let token = UUID()
let connection = TelnetConnection(
+13
View File
@@ -96,6 +96,9 @@ nonisolated enum PaneKind: Hashable, Sendable {
@Observable
final class Pane: Identifiable {
let id = UUID()
// Reopening can reuse Telnet/serial models. Old notifications must still
// refer to the closed session, while moving a pane keeps this identity.
private var commandNotificationID = UUID()
var content: PaneContent
private var connectionHistoryEntryID: ConnectionHistoryEntry.ID?
private var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)?
@@ -217,6 +220,8 @@ final class Pane: Identifiable {
/// `disconnectConnection`. Keep the Pane itself so its name, duplicate
/// index, focus identity, and place in the split tree all survive.
func prepareForReopen() {
commandNotificationID = UUID()
commandMonitor?.markRead()
switch content {
case .sshSession(let sshSession):
sshSession.inputRouter = nil
@@ -253,6 +258,14 @@ final class Pane: Identifiable {
}
}
var commandSessionID: UUID? {
commandMonitor == nil ? nil : commandNotificationID
}
var commandMonitor: TerminalCommandMonitor? {
sshSession?.commandMonitor ?? telnetSession?.commandMonitor ?? serialSession?.commandMonitor
}
var hasUnreadOutput: Bool {
switch content {
case .sshSession(let sshSession): sshSession.hasUnreadOutput
@@ -40,6 +40,7 @@ final class WindowModel {
assistantReturnDestination = selectedDestination
}
reconcileSidebarMode()
commandFocusChanged?()
}
}
@ObservationIgnored weak var remoteWindowTitleSink: (any WindowRemoteTitleSink)?
@@ -304,6 +305,9 @@ final class WindowModel {
set { workspaceDeck.reopenStack = newValue }
}
var commandFocusChanged: (() -> Void)?
var configureCommandNotifications: ((Pane) -> Void)?
var recordConnectionHistory: ((Host.ID, ConnectionHistoryEntry.Kind) -> ConnectionHistoryEntry.ID?)?
var endConnectionHistory: ((ConnectionHistoryEntry.ID) -> Void)?
@@ -1020,6 +1024,7 @@ final class WindowModel {
}
private func configureConnectionHistory(_ pane: Pane) {
configureCommandNotifications?(pane)
guard !pane.hasActiveConnectionHistory, let hostID = pane.hostID else { return }
let kind: ConnectionHistoryEntry.Kind
switch pane.kind {
@@ -1562,6 +1567,7 @@ extension WindowModel: WorkspaceLifecycleRouting {
}
func workspaceContextDidChange(_ workspace: Workspace) {
commandFocusChanged?()
guard selectedWorkspace === workspace else { return }
refreshSessionContext()
reconcileSidebarMode()
+6
View File
@@ -117,6 +117,12 @@ final class Workspace: Identifiable {
var status: ConnectionStatus { focusedPane.status }
var hasUnreadCommandCompletion: Bool { panes.contains { $0.commandMonitor?.hasUnreadCompletion == true } }
func markCommandsRead() {
for pane in panes { pane.commandMonitor?.markRead() }
}
var hasUnreadOutput: Bool {
panes.contains { $0.hasUnreadOutput }
}
@@ -472,6 +472,12 @@ private struct WorkspaceNavigatorRow: View {
)
)
.frame(width: Metrics.Sidebar.rowIcon)
.overlay(alignment: .topTrailing) {
if workspace.hasUnreadCommandCompletion { WorkspaceCommandBadge().offset(x: 3, y: -3) }
}
.accessibilityValue(
workspace.hasUnreadCommandCompletion
? String(localized: AppStrings.CommandNotifications.unread) : "")
if isRenaming {
SidebarInlineRenameField(
+27
View File
@@ -76,6 +76,17 @@ final class MainWindowCoordinator {
init(app: AppModel, sizes: MainWindowSizeStore) {
self.app = app
self.sizes = sizes
observers.append(
NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
for entry in self.entries where entry.window?.isKeyWindow == true {
entry.model?.selectedWorkspace?.markCommandsRead()
}
}
})
for name in [
NSWindow.willCloseNotification, NSWindow.didBecomeKeyNotification,
NSWindow.didResizeNotification, NSWindow.didEndLiveResizeNotification,
@@ -293,6 +304,7 @@ final class MainWindowCoordinator {
// A restored scene has no pending request. Its system-restored frame
// must not be replaced with another window's shared fallback.
entry.isReady = true
if isFocused(model) { model.selectedWorkspace?.markCommandsRead() }
recordSize(entry)
if let width = model.sidebar.measuredWidth { recordSidebarWidth(width, for: model) }
synchronizePaywall()
@@ -382,6 +394,9 @@ final class MainWindowCoordinator {
}
private func becameKey(_ native: NSWindow) {
if NSApp.isActive {
entries.first(where: { $0.window === native })?.model?.selectedWorkspace?.markCommandsRead()
}
guard let index = entries.firstIndex(where: { $0.window === native }) else { return }
entries.append(entries.remove(at: index))
if let entry = entries.last {
@@ -392,6 +407,18 @@ final class MainWindowCoordinator {
synchronizePaywall()
}
func isFocused(_ model: WindowModel) -> Bool {
NSApp.isActive && entries.contains { $0.model === model && $0.window?.isKeyWindow == true }
}
func activate(_ model: WindowModel) {
guard let native = entries.first(where: { $0.model === model })?.window else { return }
native.deminiaturize(nil)
native.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
model.selectedWorkspace?.markCommandsRead()
}
@discardableResult
func openExisting(showsMetrics: Bool = false) -> Bool {
guard let entry = entries.reversed().first(where: { $0.window != nil && $0.model?.isClosed == false }),
@@ -0,0 +1,15 @@
import AppKit
@MainActor
enum TerminalNotificationPlatform {
static func ringBell() {
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
NSSound.beep()
}
static func openSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") {
NSWorkspace.shared.open(url)
}
}
}
@@ -0,0 +1,57 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite(.requiresVisibleWindow, .serialized) @MainActor
struct CommandNotificationRenderingTests {
@Test(arguments: ["en", "zh-Hans"], [false, true])
func notificationsAndWorkspaceDot(_ language: String, dark: Bool) async throws {
let host = Host(name: "Build Server", host: "build.example")
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
let windowModel = WindowModel()
app.registerWindow(windowModel)
let pane = Pane.sshSession(host: host)
windowModel.open(Workspace(pane: pane), select: false)
pane.commandMonitor?.beginConnection()
pane.commandMonitor?.receiveOSC133(exitCode: 0, durationNanos: 1)
var settings = CommandNotificationPreferences()
settings.mode = .unfocused
app.terminalSettings.set(settings, for: \.commandNotifications)
let view = HStack(spacing: 0) {
WorkspaceNavigatorView().frame(width: 230)
Form {
TerminalCommandNotificationFields(
model: app.terminalSettings,
service: app.commandNotifications, openSettings: {})
}.formStyle(.grouped).frame(width: 520)
}
.environment(app).environment(windowModel)
.environment(\.locale, Locale(identifier: language))
.environment(\.colorScheme, dark ? .dark : .light)
.frame(width: 750, height: 560)
let controller = NSHostingController(rootView: view)
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 750, height: 560),
styleMask: [.borderless], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentViewController = controller
window.orderFront(nil)
defer {
window.orderOut(nil)
window.contentViewController = nil
window.close()
app.shutdownForTermination()
}
try await Task.sleep(for: .milliseconds(350))
controller.view.layoutSubtreeIfNeeded()
let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay(in: controller.view.bounds))
controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap)
let data = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(data, named: "command-notifications-mac-\(language)-\(dark).png")
}
}
+104
View File
@@ -0,0 +1,104 @@
import Foundation
import GhosttyTerminal
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite @MainActor
struct CommandNotificationTests {
@Test func appOwnsCommandNotificationsWithoutChangingThemeOrFontPrecedence() {
let state = TerminalViewState(configSource: .none)
let preferences = TerminalPreferences(
darkCustomEnabled: true,
darkCustomConfiguration: "notify-on-command-finish = always\nbackground = 010203")
state.applyPreferences(preferences)
#expect(state.controller.terminalConfiguration.rendered.contains("notify-on-command-finish = never"))
#expect(!state.theme.dark.rendered.contains("notify-on-command-finish = always"))
#expect(state.theme.dark.rendered.hasSuffix("background = 010203"))
}
@Test func ghosttyPublishesEveryCompletionAndPairsItsExitCode() async {
let state = TerminalViewState(configSource: .none)
let monitor = TerminalCommandMonitor()
var preferences = CommandNotificationPreferences()
preferences.mode = .always
preferences.after = 0
monitor.preferences = { preferences }
var received: [TerminalCommandCompletion] = []
monitor.alert = { completion, _ in received.append(completion) }
monitor.beginConnection()
let observation = TerminalCommandObservation(state: state, monitor: monitor)
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1_000_000_000)
state.terminalDidFinishCommand(exitCode: 7, durationNanos: 1_000_000_000)
state.terminalDidFinishCommand(exitCode: nil, durationNanos: 1_000_000_000)
for _ in 0..<100 where received.count < 3 { try? await Task.sleep(for: .milliseconds(10)) }
#expect(received.map(\.exitCode) == [0, 7, nil])
#expect(received.map(\.duration) == [1, 1, 1])
withExtendedLifetime(observation) {}
}
@Test func workspaceFocusIncludesEveryPaneAndTargetsFollowMoves() throws {
let host = Host(name: "Build", host: "test.invalid")
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
let window = WindowModel()
let other = WindowModel()
app.registerWindow(window)
app.registerWindow(other)
let first = Pane.sshSession(host: host)
let second = Pane.sshSession(host: host)
let workspace = Workspace(pane: first)
workspace.splitFocusedPane(axis: .horizontal, with: second)
window.open(workspace)
let firstID = try #require(first.commandSessionID)
let secondID = try #require(second.commandSessionID)
#expect(workspace.focusedPane === second)
#expect(app.isCommandWorkspaceFocused(firstID, windowIsFocused: { $0 === window }))
#expect(app.isCommandWorkspaceFocused(secondID, windowIsFocused: { $0 === window }))
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in false }))
window.selectVault()
#expect(app.selectCommandNotificationTarget(firstID) === window)
#expect(window.selectedWorkspace === workspace)
#expect(workspace.focusedPane === first)
window.selectVault()
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in true }))
let monitor = try #require(first.commandMonitor)
monitor.beginConnection()
monitor.receiveOSC133(exitCode: 0, durationNanos: 0)
#expect(workspace.hasUnreadCommandCompletion)
let moved = try #require(window.takePaneForMove(first.id))
other.open(moved)
#expect(!workspace.hasUnreadCommandCompletion)
#expect(moved.hasUnreadCommandCompletion)
#expect(app.commandNotificationTarget(firstID)?.0 === other)
other.selectVault()
#expect(app.selectCommandNotificationTarget(firstID) === other)
#expect(other.selectedWorkspace === moved)
#expect(moved.focusedPane === first)
moved.markCommandsRead()
#expect(!moved.hasUnreadCommandCompletion)
other.close(moved.id)
#expect(app.commandNotificationTarget(firstID) == nil)
#expect(app.selectCommandNotificationTarget(firstID) == nil)
other.reopenLastClosed()
#expect(app.commandNotificationTarget(firstID) == nil)
#expect(first.commandSessionID != firstID)
}
@Test func ghosttyValuesAlreadyPublishedAreNotReplayedWhenObserving() async {
let state = TerminalViewState(configSource: .none)
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
try? await Task.sleep(for: .milliseconds(30))
let monitor = TerminalCommandMonitor()
monitor.beginConnection()
let observation = TerminalCommandObservation(state: state, monitor: monitor)
#expect(!monitor.hasUnreadCompletion)
monitor.endConnection()
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
try? await Task.sleep(for: .milliseconds(30))
#expect(!monitor.hasUnreadCompletion)
withExtendedLifetime(observation) {}
}
}