From e2452df9290ad545774ae5e99df494505233a85c Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 27 Sep 2026 13:33:41 +0800 Subject: [PATCH] feat: add terminal notificaitons --- .../TerminalCommandMonitorTests.swift | 132 +++++++++ Frameworks/Resources/Localizable.xcstrings | 272 ++++++++++++++++++ Frameworks/Resources/command-finished.wav | Bin 0 -> 6660 bytes .../CommandNotificationSending.swift | 11 + .../TermindApplication/SettingsKey.swift | 1 + .../TerminalCommandMonitor.swift | 91 ++++++ .../CommandNotificationPreferences.swift | 16 ++ .../Sessions/TerminalPreferences.swift | 5 +- .../SystemCommandNotifications.swift | 68 +++++ .../TerminalPreferences+Defaults.swift | 6 + .../Strings/CommandNotifications.swift | 33 +++ .../Terminal/TerminalAppearance.swift | 4 +- .../TerminalCommandNotificationFields.swift | 101 +++++++ .../Terminal/TerminalCommandObservation.swift | 17 ++ TFI/App/MobileAppModel.swift | 41 +++ TFI/App/MobileSceneModel.swift | 3 +- .../Settings/MobileSettingsView.swift | 2 +- .../Settings/MobileTerminalSettingsView.swift | 4 + TFI/Features/Workspaces/MobileWorkspace.swift | 7 + .../Workspaces/MobileWorkspaceListView.swift | 8 + .../TerminalNotificationPlatform.swift | 21 ++ ...ileCommandNotificationRenderingTests.swift | 56 ++++ TFITests/MobileTerminalFontTests.swift | 7 +- TFITests/MobileWorkspaceSessionTests.swift | 33 +++ TFM/App/AppModel.swift | 68 +++++ TFM/Features/Settings/SettingsPageView.swift | 3 + TFM/Features/Terminal/SSHSessionModel.swift | 8 + .../Terminal/SerialSessionModel.swift | 9 +- .../Terminal/TelnetSessionModel.swift | 9 +- TFM/Features/Workspaces/Pane.swift | 13 + TFM/Features/Workspaces/WindowModel.swift | 6 + TFM/Features/Workspaces/Workspace.swift | 6 + .../Workspaces/WorkspaceNavigatorView.swift | 6 + TFM/Platform/MainWindowCoordinator.swift | 27 ++ .../TerminalNotificationPlatform.swift | 15 + .../CommandNotificationRenderingTests.swift | 57 ++++ TFMTests/CommandNotificationTests.swift | 104 +++++++ 37 files changed, 1263 insertions(+), 7 deletions(-) create mode 100644 FrameworkTests/TerminalCommandMonitorTests.swift create mode 100644 Frameworks/Resources/command-finished.wav create mode 100644 Frameworks/TermindApplication/CommandNotificationSending.swift create mode 100644 Frameworks/TermindApplication/TerminalCommandMonitor.swift create mode 100644 Frameworks/TermindDomain/Sessions/CommandNotificationPreferences.swift create mode 100644 Frameworks/TermindInfrastructure/SystemCommandNotifications.swift create mode 100644 Frameworks/TermindPresentation/Strings/CommandNotifications.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalCommandNotificationFields.swift create mode 100644 Frameworks/TermindPresentation/Terminal/TerminalCommandObservation.swift create mode 100644 TFI/Platform/TerminalNotificationPlatform.swift create mode 100644 TFITests/MobileCommandNotificationRenderingTests.swift create mode 100644 TFM/Platform/TerminalNotificationPlatform.swift create mode 100644 TFMTests/CommandNotificationRenderingTests.swift create mode 100644 TFMTests/CommandNotificationTests.swift diff --git a/FrameworkTests/TerminalCommandMonitorTests.swift b/FrameworkTests/TerminalCommandMonitorTests.swift new file mode 100644 index 00000000..d963e41a --- /dev/null +++ b/FrameworkTests/TerminalCommandMonitorTests.swift @@ -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()) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index fe22bf8a..0f9fa76d 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/Resources/command-finished.wav b/Frameworks/Resources/command-finished.wav new file mode 100644 index 0000000000000000000000000000000000000000..67de0395125da7c50bb3d8f5ff4bba166e97e078 GIT binary patch literal 6660 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 CommandNotificationAuthorization + func send(sessionID: UUID, title: String, body: String) async +} diff --git a/Frameworks/TermindApplication/SettingsKey.swift b/Frameworks/TermindApplication/SettingsKey.swift index dbb147ac..ae3ceb5e 100644 --- a/Frameworks/TermindApplication/SettingsKey.swift +++ b/Frameworks/TermindApplication/SettingsKey.swift @@ -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 diff --git a/Frameworks/TermindApplication/TerminalCommandMonitor.swift b/Frameworks/TermindApplication/TerminalCommandMonitor.swift new file mode 100644 index 00000000..5cea1f87 --- /dev/null +++ b/Frameworks/TermindApplication/TerminalCommandMonitor.swift @@ -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) + } +} diff --git a/Frameworks/TermindDomain/Sessions/CommandNotificationPreferences.swift b/Frameworks/TermindDomain/Sessions/CommandNotificationPreferences.swift new file mode 100644 index 00000000..7f3399ec --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/CommandNotificationPreferences.swift @@ -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 } +} diff --git a/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift b/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift index a5bfbc2e..9d83c329 100644 --- a/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift +++ b/Frameworks/TermindDomain/Sessions/TerminalPreferences.swift @@ -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 diff --git a/Frameworks/TermindInfrastructure/SystemCommandNotifications.swift b/Frameworks/TermindInfrastructure/SystemCommandNotifications.swift new file mode 100644 index 00000000..a8d6f3c7 --- /dev/null +++ b/Frameworks/TermindInfrastructure/SystemCommandNotifications.swift @@ -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 } + } + } +} diff --git a/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift b/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift index 0216adcd..4f038a45 100644 --- a/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift +++ b/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift @@ -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) diff --git a/Frameworks/TermindPresentation/Strings/CommandNotifications.swift b/Frameworks/TermindPresentation/Strings/CommandNotifications.swift new file mode 100644 index 00000000..ef5c9009 --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/CommandNotifications.swift @@ -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)" + } + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift index 552e325f..213d4381 100644 --- a/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift +++ b/Frameworks/TermindPresentation/Terminal/TerminalAppearance.swift @@ -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) }) diff --git a/Frameworks/TermindPresentation/Terminal/TerminalCommandNotificationFields.swift b/Frameworks/TermindPresentation/Terminal/TerminalCommandNotificationFields.swift new file mode 100644 index 00000000..aeef71d6 --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalCommandNotificationFields.swift @@ -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(_ path: WritableKeyPath) -> Binding { + 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) + } +} diff --git a/Frameworks/TermindPresentation/Terminal/TerminalCommandObservation.swift b/Frameworks/TermindPresentation/Terminal/TerminalCommandObservation.swift new file mode 100644 index 00000000..bc7d39ae --- /dev/null +++ b/Frameworks/TermindPresentation/Terminal/TerminalCommandObservation.swift @@ -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) + } + } +} diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 9b66db73..074254ac 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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) diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 7daac6a8..be4ca4f8 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -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 diff --git a/TFI/Features/Settings/MobileSettingsView.swift b/TFI/Features/Settings/MobileSettingsView.swift index e5644b70..bb008624 100644 --- a/TFI/Features/Settings/MobileSettingsView.swift +++ b/TFI/Features/Settings/MobileSettingsView.swift @@ -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") } diff --git a/TFI/Features/Settings/MobileTerminalSettingsView.swift b/TFI/Features/Settings/MobileTerminalSettingsView.swift index 7cd7e868..33c1a20c 100644 --- a/TFI/Features/Settings/MobileTerminalSettingsView.swift +++ b/TFI/Features/Settings/MobileTerminalSettingsView.swift @@ -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)) } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index fa26e526..ad70ab95 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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() diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift index 5a1995d7..9700bad8 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceListView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -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, diff --git a/TFI/Platform/TerminalNotificationPlatform.swift b/TFI/Platform/TerminalNotificationPlatform.swift new file mode 100644 index 00000000..6be08a73 --- /dev/null +++ b/TFI/Platform/TerminalNotificationPlatform.swift @@ -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) + } + } +} diff --git a/TFITests/MobileCommandNotificationRenderingTests.swift b/TFITests/MobileCommandNotificationRenderingTests.swift new file mode 100644 index 00000000..d837bd7b --- /dev/null +++ b/TFITests/MobileCommandNotificationRenderingTests.swift @@ -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 {} + } +} diff --git a/TFITests/MobileTerminalFontTests.swift b/TFITests/MobileTerminalFontTests.swift index c5faf9f1..f6064750 100644 --- a/TFITests/MobileTerminalFontTests.swift +++ b/TFITests/MobileTerminalFontTests.swift @@ -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 {} +} diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index cad84e8a..2c844d3b 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -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() } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 4ee40831..325b7022 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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() diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 63d81487..7b27c114 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -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) { diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index a22334e3..ff410485 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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("/") { diff --git a/TFM/Features/Terminal/SerialSessionModel.swift b/TFM/Features/Terminal/SerialSessionModel.swift index b0b16096..b69a41dd 100644 --- a/TFM/Features/Terminal/SerialSessionModel.swift +++ b/TFM/Features/Terminal/SerialSessionModel.swift @@ -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 diff --git a/TFM/Features/Terminal/TelnetSessionModel.swift b/TFM/Features/Terminal/TelnetSessionModel.swift index 79c679f8..aa3e4372 100644 --- a/TFM/Features/Terminal/TelnetSessionModel.swift +++ b/TFM/Features/Terminal/TelnetSessionModel.swift @@ -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( diff --git a/TFM/Features/Workspaces/Pane.swift b/TFM/Features/Workspaces/Pane.swift index bb0427f0..49bdc704 100644 --- a/TFM/Features/Workspaces/Pane.swift +++ b/TFM/Features/Workspaces/Pane.swift @@ -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 diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 0c72b024..f51b8869 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -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() diff --git a/TFM/Features/Workspaces/Workspace.swift b/TFM/Features/Workspaces/Workspace.swift index 9bd30868..2ab1f9b0 100644 --- a/TFM/Features/Workspaces/Workspace.swift +++ b/TFM/Features/Workspaces/Workspace.swift @@ -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 } } diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index 3631ac11..a85ccf7a 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -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( diff --git a/TFM/Platform/MainWindowCoordinator.swift b/TFM/Platform/MainWindowCoordinator.swift index 0e1775b5..ddf0cb78 100644 --- a/TFM/Platform/MainWindowCoordinator.swift +++ b/TFM/Platform/MainWindowCoordinator.swift @@ -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 }), diff --git a/TFM/Platform/TerminalNotificationPlatform.swift b/TFM/Platform/TerminalNotificationPlatform.swift new file mode 100644 index 00000000..c0a95cc9 --- /dev/null +++ b/TFM/Platform/TerminalNotificationPlatform.swift @@ -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) + } + } +} diff --git a/TFMTests/CommandNotificationRenderingTests.swift b/TFMTests/CommandNotificationRenderingTests.swift new file mode 100644 index 00000000..4c210c3b --- /dev/null +++ b/TFMTests/CommandNotificationRenderingTests.swift @@ -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") + } +} diff --git a/TFMTests/CommandNotificationTests.swift b/TFMTests/CommandNotificationTests.swift new file mode 100644 index 00000000..87f41c31 --- /dev/null +++ b/TFMTests/CommandNotificationTests.swift @@ -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) {} + } +}