native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #35 from onevcat/feature/command-finished-notification

Add command finished notification for long-running commands

authored by

Wei Wang and committed by
GitHub
51dc7373 c13d5c5f

+294 -1
+1
supacode/Clients/Terminal/TerminalClient.swift
··· 23 23 case endSearch(Worktree) 24 24 case prune(Set<Worktree.ID>) 25 25 case setNotificationsEnabled(Bool) 26 + case setCommandFinishedNotification(enabled: Bool, threshold: Int) 26 27 case setSelectedWorktreeID(Worktree.ID?) 27 28 } 28 29
+8
supacode/Features/App/Reducer/AppFeature.swift
··· 310 310 await terminalClient.send(.setNotificationsEnabled(settings.inAppNotificationsEnabled)) 311 311 }, 312 312 .run { _ in 313 + await terminalClient.send( 314 + .setCommandFinishedNotification( 315 + enabled: settings.commandFinishedNotificationEnabled, 316 + threshold: settings.commandFinishedNotificationThreshold 317 + ) 318 + ) 319 + }, 320 + .run { _ in 313 321 await worktreeInfoWatcher.send( 314 322 .setPullRequestTrackingEnabled(settings.githubIntegrationEnabled) 315 323 )
+14
supacode/Features/Settings/Models/GlobalSettings.swift
··· 9 9 var notificationSoundEnabled: Bool 10 10 var systemNotificationsEnabled: Bool 11 11 var moveNotifiedWorktreeToTop: Bool 12 + var commandFinishedNotificationEnabled: Bool 13 + var commandFinishedNotificationThreshold: Int 12 14 var analyticsEnabled: Bool 13 15 var crashReportsEnabled: Bool 14 16 var githubIntegrationEnabled: Bool ··· 28 30 notificationSoundEnabled: true, 29 31 systemNotificationsEnabled: false, 30 32 moveNotifiedWorktreeToTop: true, 33 + commandFinishedNotificationEnabled: true, 34 + commandFinishedNotificationThreshold: 10, 31 35 analyticsEnabled: true, 32 36 crashReportsEnabled: true, 33 37 githubIntegrationEnabled: true, ··· 48 52 notificationSoundEnabled: Bool, 49 53 systemNotificationsEnabled: Bool = false, 50 54 moveNotifiedWorktreeToTop: Bool, 55 + commandFinishedNotificationEnabled: Bool = true, 56 + commandFinishedNotificationThreshold: Int = 10, 51 57 analyticsEnabled: Bool, 52 58 crashReportsEnabled: Bool, 53 59 githubIntegrationEnabled: Bool, ··· 66 72 self.notificationSoundEnabled = notificationSoundEnabled 67 73 self.systemNotificationsEnabled = systemNotificationsEnabled 68 74 self.moveNotifiedWorktreeToTop = moveNotifiedWorktreeToTop 75 + self.commandFinishedNotificationEnabled = commandFinishedNotificationEnabled 76 + self.commandFinishedNotificationThreshold = commandFinishedNotificationThreshold 69 77 self.analyticsEnabled = analyticsEnabled 70 78 self.crashReportsEnabled = crashReportsEnabled 71 79 self.githubIntegrationEnabled = githubIntegrationEnabled ··· 101 109 moveNotifiedWorktreeToTop = 102 110 try container.decodeIfPresent(Bool.self, forKey: .moveNotifiedWorktreeToTop) 103 111 ?? Self.default.moveNotifiedWorktreeToTop 112 + commandFinishedNotificationEnabled = 113 + try container.decodeIfPresent(Bool.self, forKey: .commandFinishedNotificationEnabled) 114 + ?? Self.default.commandFinishedNotificationEnabled 115 + commandFinishedNotificationThreshold = 116 + try container.decodeIfPresent(Int.self, forKey: .commandFinishedNotificationThreshold) 117 + ?? Self.default.commandFinishedNotificationThreshold 104 118 analyticsEnabled = 105 119 try container.decodeIfPresent(Bool.self, forKey: .analyticsEnabled) 106 120 ?? Self.default.analyticsEnabled
+18
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 15 15 var notificationSoundEnabled: Bool 16 16 var systemNotificationsEnabled: Bool 17 17 var moveNotifiedWorktreeToTop: Bool 18 + var commandFinishedNotificationEnabled: Bool 19 + var commandFinishedNotificationThreshold: Int 18 20 var analyticsEnabled: Bool 19 21 var crashReportsEnabled: Bool 20 22 var githubIntegrationEnabled: Bool ··· 38 40 notificationSoundEnabled = settings.notificationSoundEnabled 39 41 systemNotificationsEnabled = settings.systemNotificationsEnabled 40 42 moveNotifiedWorktreeToTop = settings.moveNotifiedWorktreeToTop 43 + commandFinishedNotificationEnabled = settings.commandFinishedNotificationEnabled 44 + commandFinishedNotificationThreshold = settings.commandFinishedNotificationThreshold 41 45 analyticsEnabled = settings.analyticsEnabled 42 46 crashReportsEnabled = settings.crashReportsEnabled 43 47 githubIntegrationEnabled = settings.githubIntegrationEnabled ··· 60 64 notificationSoundEnabled: notificationSoundEnabled, 61 65 systemNotificationsEnabled: systemNotificationsEnabled, 62 66 moveNotifiedWorktreeToTop: moveNotifiedWorktreeToTop, 67 + commandFinishedNotificationEnabled: commandFinishedNotificationEnabled, 68 + commandFinishedNotificationThreshold: commandFinishedNotificationThreshold, 63 69 analyticsEnabled: analyticsEnabled, 64 70 crashReportsEnabled: crashReportsEnabled, 65 71 githubIntegrationEnabled: githubIntegrationEnabled, ··· 78 84 case settingsLoaded(GlobalSettings) 79 85 case setSelection(SettingsSection?) 80 86 case setSystemNotificationsEnabled(Bool) 87 + case setCommandFinishedNotificationThreshold(String) 81 88 case showNotificationPermissionAlert(errorMessage: String?) 82 89 case repositorySettings(RepositorySettingsFeature.Action) 83 90 case alert(PresentationAction<Alert>) ··· 133 140 state.notificationSoundEnabled = normalizedSettings.notificationSoundEnabled 134 141 state.systemNotificationsEnabled = normalizedSettings.systemNotificationsEnabled 135 142 state.moveNotifiedWorktreeToTop = normalizedSettings.moveNotifiedWorktreeToTop 143 + state.commandFinishedNotificationEnabled = normalizedSettings.commandFinishedNotificationEnabled 144 + state.commandFinishedNotificationThreshold = normalizedSettings.commandFinishedNotificationThreshold 136 145 state.analyticsEnabled = normalizedSettings.analyticsEnabled 137 146 state.crashReportsEnabled = normalizedSettings.crashReportsEnabled 138 147 state.githubIntegrationEnabled = normalizedSettings.githubIntegrationEnabled ··· 145 154 return .send(.delegate(.settingsChanged(normalizedSettings))) 146 155 147 156 case .binding: 157 + state.commandFinishedNotificationThreshold = min(max(state.commandFinishedNotificationThreshold, 0), 600) 148 158 let defaultWorktreeBaseDirectoryPath = state.globalSettings.defaultWorktreeBaseDirectoryPath 149 159 state.repositorySettings?.globalDefaultWorktreeBaseDirectoryPath = 150 160 defaultWorktreeBaseDirectoryPath 161 + return persist(state) 162 + 163 + case .setCommandFinishedNotificationThreshold(let text): 164 + if let parsed = Int(text) { 165 + state.commandFinishedNotificationThreshold = min(max(parsed, 0), 600) 166 + } else { 167 + state.commandFinishedNotificationThreshold = 10 168 + } 151 169 return persist(state) 152 170 153 171 case .setSystemNotificationsEnabled(let isEnabled):
+29
supacode/Features/Settings/Views/NotificationsSettingsView.swift
··· 3 3 4 4 struct NotificationsSettingsView: View { 5 5 @Bindable var store: StoreOf<SettingsFeature> 6 + @State private var thresholdText = "" 6 7 7 8 var body: some View { 8 9 VStack(alignment: .leading) { ··· 28 29 isOn: $store.moveNotifiedWorktreeToTop 29 30 ) 30 31 .help("Bring the worktree to the top when the terminal receives a notification") 32 + } 33 + Section("Command Finished") { 34 + Toggle( 35 + "Notify when long-running commands finish", 36 + isOn: $store.commandFinishedNotificationEnabled 37 + ) 38 + .help("Show a notification when a command exceeds the duration threshold") 39 + if store.commandFinishedNotificationEnabled { 40 + LabeledContent("Duration threshold") { 41 + HStack(spacing: 4) { 42 + TextField("", text: $thresholdText) 43 + .frame(width: 40) 44 + .multilineTextAlignment(.trailing) 45 + .onSubmit { 46 + store.send(.setCommandFinishedNotificationThreshold(thresholdText)) 47 + } 48 + .onChange(of: store.commandFinishedNotificationThreshold) { _, newValue in 49 + thresholdText = String(newValue) 50 + } 51 + Text("seconds") 52 + .foregroundStyle(.secondary) 53 + } 54 + } 55 + .help("Minimum command duration in seconds before a notification is shown") 56 + .onAppear { 57 + thresholdText = String(store.commandFinishedNotificationThreshold) 58 + } 59 + } 31 60 } 32 61 } 33 62 .formStyle(.grouped)
+16
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 9 9 private let runtime: GhosttyRuntime 10 10 private var states: [Worktree.ID: WorktreeTerminalState] = [:] 11 11 private var notificationsEnabled = true 12 + private var commandFinishedNotificationEnabled = true 13 + private var commandFinishedNotificationThreshold = 10 12 14 private var lastNotificationIndicatorCount: Int? 13 15 private var eventContinuation: AsyncStream<TerminalClient.Event>.Continuation? 14 16 private var pendingEvents: [TerminalClient.Event] = [] ··· 103 105 prune(keeping: ids) 104 106 case .setNotificationsEnabled(let enabled): 105 107 setNotificationsEnabled(enabled) 108 + case .setCommandFinishedNotification(let enabled, let threshold): 109 + setCommandFinishedNotification(enabled: enabled, threshold: threshold) 106 110 case .setSelectedWorktreeID(let id): 107 111 guard id != selectedWorktreeID else { return } 108 112 if let previousID = selectedWorktreeID, let previousState = states[previousID] { ··· 151 155 runSetupScript: runSetupScript 152 156 ) 153 157 state.setNotificationsEnabled(notificationsEnabled) 158 + state.setCommandFinishedNotification( 159 + enabled: commandFinishedNotificationEnabled, 160 + threshold: commandFinishedNotificationThreshold 161 + ) 154 162 state.isSelected = { [weak self] in 155 163 self?.selectedWorktreeID == worktree.id 156 164 } ··· 252 260 state.setNotificationsEnabled(enabled) 253 261 } 254 262 emitNotificationIndicatorCountIfNeeded() 263 + } 264 + 265 + func setCommandFinishedNotification(enabled: Bool, threshold: Int) { 266 + commandFinishedNotificationEnabled = enabled 267 + commandFinishedNotificationThreshold = threshold 268 + for state in states.values { 269 + state.setCommandFinishedNotification(enabled: enabled, threshold: threshold) 270 + } 255 271 } 256 272 257 273 func hasUnseenNotifications(for worktreeID: Worktree.ID) -> Bool {
+43
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 29 29 private var lastEmittedFocusSurfaceId: UUID? 30 30 var notifications: [WorktreeTerminalNotification] = [] 31 31 var notificationsEnabled = true 32 + private var commandFinishedNotificationEnabled = true 33 + private var commandFinishedNotificationThreshold = 10 32 34 var hasUnseenNotification: Bool { 33 35 notifications.contains { !$0.isRead } 34 36 } ··· 530 532 } 531 533 } 532 534 535 + func setCommandFinishedNotification(enabled: Bool, threshold: Int) { 536 + commandFinishedNotificationEnabled = enabled 537 + commandFinishedNotificationThreshold = threshold 538 + } 539 + 533 540 func clearNotificationIndicator() { 534 541 markAllNotificationsRead() 535 542 } ··· 656 663 view.bridge.onDesktopNotification = { [weak self, weak view] title, body in 657 664 guard let self, let view else { return } 658 665 self.appendNotification(title: title, body: body, surfaceId: view.id) 666 + } 667 + view.bridge.onCommandFinished = { [weak self, weak view] exitCode, durationNs in 668 + guard let self, let view else { return } 669 + self.handleCommandFinished(exitCode: exitCode, durationNs: durationNs, surfaceId: view.id) 659 670 } 660 671 view.bridge.onCloseRequest = { [weak self, weak view] processAlive in 661 672 guard let self, let view else { return } ··· 808 819 emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) 809 820 } 810 821 onNotificationReceived?(trimmedTitle, trimmedBody) 822 + } 823 + 824 + func handleCommandFinished(exitCode: Int?, durationNs: UInt64, surfaceId: UUID) { 825 + guard commandFinishedNotificationEnabled else { return } 826 + let durationSeconds = Int(durationNs / 1_000_000_000) 827 + guard durationSeconds >= commandFinishedNotificationThreshold else { return } 828 + // Skip user-initiated termination (Ctrl+C / kill signal) 829 + if let code = exitCode, code == 130 || code == 143 { return } 830 + 831 + let title = (exitCode == nil || exitCode == 0) ? "Command finished" : "Command failed" 832 + let formattedDuration = Self.formatDuration(durationSeconds) 833 + let body: String 834 + if let code = exitCode, code != 0 { 835 + body = "Failed (exit code \(code)) after \(formattedDuration)" 836 + } else { 837 + body = "Completed in \(formattedDuration)" 838 + } 839 + appendNotification(title: title, body: body, surfaceId: surfaceId) 840 + } 841 + 842 + static func formatDuration(_ seconds: Int) -> String { 843 + if seconds < 60 { 844 + return "\(seconds)s" 845 + } 846 + let minutes = seconds / 60 847 + let remainingSeconds = seconds % 60 848 + if minutes < 60 { 849 + return remainingSeconds > 0 ? "\(minutes)m \(remainingSeconds)s" : "\(minutes)m" 850 + } 851 + let hours = minutes / 60 852 + let remainingMinutes = minutes % 60 853 + return remainingMinutes > 0 ? "\(hours)h \(remainingMinutes)m" : "\(hours)h" 811 854 } 812 855 813 856 private func removeTree(for tabId: TerminalTabID) {
+4 -1
supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift
··· 17 17 var onCommandPaletteToggle: (() -> Bool)? 18 18 var onProgressReport: ((ghostty_action_progress_report_state_e) -> Void)? 19 19 var onDesktopNotification: ((String, String) -> Void)? 20 + var onCommandFinished: ((Int?, UInt64) -> Void)? 20 21 var onPromptTitle: ((ghostty_action_prompt_title_e) -> Void)? 21 22 private var progressResetTask: Task<Void, Never>? 22 23 ··· 241 242 242 243 case GHOSTTY_ACTION_COMMAND_FINISHED: 243 244 let info = action.action.command_finished 244 - state.commandExitCode = info.exit_code == -1 ? nil : Int(info.exit_code) 245 + let exitCode = info.exit_code == -1 ? nil : Int(info.exit_code) 246 + state.commandExitCode = exitCode 245 247 state.commandDuration = info.duration 248 + onCommandFinished?(exitCode, info.duration) 246 249 return true 247 250 248 251 case GHOSTTY_ACTION_SHOW_CHILD_EXITED:
+161
supacodeTests/CommandFinishedNotificationTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + @MainActor 7 + struct CommandFinishedNotificationTests { 8 + private let surfaceId = UUID() 9 + 10 + // MARK: - Notification Generation 11 + 12 + @Test func generatesNotificationWhenThresholdExceeded() { 13 + let state = makeState() 14 + state.handleCommandFinished(exitCode: 0, durationNs: 15_000_000_000, surfaceId: surfaceId) 15 + 16 + #expect(state.notifications.count == 1) 17 + #expect(state.notifications.first?.title == "Command finished") 18 + #expect(state.notifications.first?.body == "Completed in 15s") 19 + #expect(state.notifications.first?.surfaceId == surfaceId) 20 + } 21 + 22 + @Test func doesNotGenerateNotificationUnderThreshold() { 23 + let state = makeState() 24 + state.handleCommandFinished(exitCode: 0, durationNs: 5_000_000_000, surfaceId: surfaceId) 25 + 26 + #expect(state.notifications.isEmpty) 27 + } 28 + 29 + @Test func doesNotGenerateNotificationAtExactThreshold() { 30 + let state = makeState(threshold: 10) 31 + state.handleCommandFinished(exitCode: 0, durationNs: 10_000_000_000, surfaceId: surfaceId) 32 + 33 + #expect(state.notifications.count == 1) 34 + } 35 + 36 + // MARK: - Exit Code Filtering 37 + 38 + @Test func doesNotGenerateNotificationForSIGINT() { 39 + let state = makeState() 40 + state.handleCommandFinished(exitCode: 130, durationNs: 60_000_000_000, surfaceId: surfaceId) 41 + 42 + #expect(state.notifications.isEmpty) 43 + } 44 + 45 + @Test func doesNotGenerateNotificationForSIGTERM() { 46 + let state = makeState() 47 + state.handleCommandFinished(exitCode: 143, durationNs: 60_000_000_000, surfaceId: surfaceId) 48 + 49 + #expect(state.notifications.isEmpty) 50 + } 51 + 52 + @Test func generatesFailureNotificationForNonZeroExitCode() { 53 + let state = makeState() 54 + state.handleCommandFinished(exitCode: 1, durationNs: 30_000_000_000, surfaceId: surfaceId) 55 + 56 + #expect(state.notifications.count == 1) 57 + #expect(state.notifications.first?.title == "Command failed") 58 + #expect(state.notifications.first?.body == "Failed (exit code 1) after 30s") 59 + } 60 + 61 + @Test func generatesNotificationForNilExitCode() { 62 + let state = makeState() 63 + state.handleCommandFinished(exitCode: nil, durationNs: 20_000_000_000, surfaceId: surfaceId) 64 + 65 + #expect(state.notifications.count == 1) 66 + #expect(state.notifications.first?.title == "Command finished") 67 + #expect(state.notifications.first?.body == "Completed in 20s") 68 + } 69 + 70 + // MARK: - Feature Toggle 71 + 72 + @Test func doesNotGenerateNotificationWhenDisabled() { 73 + let state = makeState() 74 + state.setCommandFinishedNotification(enabled: false, threshold: 10) 75 + state.handleCommandFinished(exitCode: 0, durationNs: 60_000_000_000, surfaceId: surfaceId) 76 + 77 + #expect(state.notifications.isEmpty) 78 + } 79 + 80 + @Test func respectsUpdatedThreshold() { 81 + let state = makeState() 82 + state.setCommandFinishedNotification(enabled: true, threshold: 30) 83 + state.handleCommandFinished(exitCode: 0, durationNs: 20_000_000_000, surfaceId: surfaceId) 84 + 85 + #expect(state.notifications.isEmpty) 86 + 87 + state.handleCommandFinished(exitCode: 0, durationNs: 31_000_000_000, surfaceId: surfaceId) 88 + 89 + #expect(state.notifications.count == 1) 90 + } 91 + 92 + // MARK: - Duration Formatting 93 + 94 + @Test func formatDurationSeconds() { 95 + #expect(WorktreeTerminalState.formatDuration(5) == "5s") 96 + #expect(WorktreeTerminalState.formatDuration(59) == "59s") 97 + } 98 + 99 + @Test func formatDurationMinutes() { 100 + #expect(WorktreeTerminalState.formatDuration(60) == "1m") 101 + #expect(WorktreeTerminalState.formatDuration(90) == "1m 30s") 102 + #expect(WorktreeTerminalState.formatDuration(3599) == "59m 59s") 103 + } 104 + 105 + @Test func formatDurationHours() { 106 + #expect(WorktreeTerminalState.formatDuration(3600) == "1h") 107 + #expect(WorktreeTerminalState.formatDuration(3660) == "1h 1m") 108 + #expect(WorktreeTerminalState.formatDuration(7200) == "2h") 109 + } 110 + 111 + // MARK: - Manager Propagation 112 + 113 + @Test func managerPropagatesSettingsToExistingStates() { 114 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 115 + let worktree = makeWorktree() 116 + let state = manager.state(for: worktree) 117 + 118 + manager.setCommandFinishedNotification(enabled: true, threshold: 30) 119 + state.handleCommandFinished(exitCode: 0, durationNs: 20_000_000_000, surfaceId: surfaceId) 120 + 121 + #expect(state.notifications.isEmpty) 122 + 123 + state.handleCommandFinished(exitCode: 0, durationNs: 31_000_000_000, surfaceId: surfaceId) 124 + 125 + #expect(state.notifications.count == 1) 126 + } 127 + 128 + @Test func managerPropagatesSettingsToNewStates() { 129 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 130 + manager.setCommandFinishedNotification(enabled: true, threshold: 60) 131 + 132 + let worktree = makeWorktree() 133 + let state = manager.state(for: worktree) 134 + 135 + state.handleCommandFinished(exitCode: 0, durationNs: 30_000_000_000, surfaceId: surfaceId) 136 + #expect(state.notifications.isEmpty) 137 + 138 + state.handleCommandFinished(exitCode: 0, durationNs: 61_000_000_000, surfaceId: surfaceId) 139 + #expect(state.notifications.count == 1) 140 + } 141 + 142 + // MARK: - Helpers 143 + 144 + private func makeState(threshold: Int = 10) -> WorktreeTerminalState { 145 + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 146 + let worktree = makeWorktree() 147 + let state = manager.state(for: worktree) 148 + state.setCommandFinishedNotification(enabled: true, threshold: threshold) 149 + return state 150 + } 151 + 152 + private func makeWorktree() -> Worktree { 153 + Worktree( 154 + id: "/tmp/repo/wt-\(UUID().uuidString)", 155 + name: "wt-test", 156 + detail: "detail", 157 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-test"), 158 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), 159 + ) 160 + } 161 + }