66//
77
88import SwiftUI
9+ import AppKit
910
1011@MainActor
1112struct PineSettingsView : View {
1213 let lspSettings : LSPSettings
1314 let handoffSettings : AgentHandoffSettings
15+ let notificationController : AgentNotificationController
16+ let agentTasks : AgentTaskRegistry
1417 let shellSettings : ShellSettings
1518 let editorSettings : EditorSettings
1619 let terminalThemeSettings : TerminalThemeSettings
@@ -22,13 +25,17 @@ struct PineSettingsView: View {
2225 init (
2326 lspSettings: LSPSettings ,
2427 handoffSettings: AgentHandoffSettings ,
28+ notificationController: AgentNotificationController ,
29+ agentTasks: AgentTaskRegistry ,
2530 shellSettings: ShellSettings ,
2631 editorSettings: EditorSettings ,
2732 terminalThemeSettings: TerminalThemeSettings = . shared,
2833 quickTerminalSettings: QuickTerminalSettings = . shared
2934 ) {
3035 self . lspSettings = lspSettings
3136 self . handoffSettings = handoffSettings
37+ self . notificationController = notificationController
38+ self . agentTasks = agentTasks
3239 self . shellSettings = shellSettings
3340 self . editorSettings = editorSettings
3441 self . terminalThemeSettings = terminalThemeSettings
@@ -65,7 +72,11 @@ struct PineSettingsView: View {
6572 }
6673 . tag ( SettingsPane . SettingsPaneID. languages)
6774
68- AgentHandoffSettingsView ( settings: handoffSettings)
75+ AgentSettingsView (
76+ handoffSettings: handoffSettings,
77+ notificationController: notificationController,
78+ agentTasks: agentTasks
79+ )
6980 . tabItem {
7081 Label (
7182 Strings . settingsAgentHandoffTab,
@@ -89,6 +100,203 @@ struct PineSettingsView: View {
89100 nonisolated static let selectedPaneKey = " settings.selectedPane "
90101}
91102
103+ @MainActor
104+ private struct AgentSettingsView : View {
105+ let handoffSettings : AgentHandoffSettings
106+ let notificationController : AgentNotificationController
107+ let agentTasks : AgentTaskRegistry
108+
109+ var body : some View {
110+ ScrollView {
111+ VStack ( alignment: . leading, spacing: 22 ) {
112+ AgentNotificationSettingsView (
113+ controller: notificationController,
114+ agentTasks: agentTasks
115+ )
116+ Divider ( )
117+ AgentHandoffSettingsView ( settings: handoffSettings)
118+ . frame ( height: 380 )
119+ }
120+ . padding ( 20 )
121+ }
122+ . frame ( width: 720 , height: 500 )
123+ }
124+ }
125+
126+ @MainActor
127+ private struct AgentNotificationSettingsView : View {
128+ let controller : AgentNotificationController
129+ let agentTasks : AgentTaskRegistry
130+
131+ private var agentOptions : [ ( String , String ) ] {
132+ let values = agentTasks. tasks. map {
133+ ( $0. descriptor. typeIdentifier, $0. descriptor. agentType. displayName)
134+ }
135+ return Dictionary ( values, uniquingKeysWith: { current, _ in current } )
136+ . sorted { $0. value. localizedStandardCompare ( $1. value) == . orderedAscending }
137+ }
138+
139+ private var projectOptions : [ ( String , String ) ] {
140+ let values = agentTasks. tasks. map {
141+ ( $0. project. canonicalProjectPath, URL ( fileURLWithPath: $0. project. canonicalProjectPath) . lastPathComponent)
142+ }
143+ return Dictionary ( values, uniquingKeysWith: { current, _ in current } )
144+ . sorted { $0. value. localizedStandardCompare ( $1. value) == . orderedAscending }
145+ }
146+
147+ private var taskOptions : [ ( UUID , String ) ] {
148+ agentTasks. tasks
149+ . filter { $0. lifecycle != . dismissed }
150+ . map { task in
151+ let fallback = task. descriptor. agentType. displayName
152+ return ( task. id, task. title ?? fallback)
153+ }
154+ . sorted { $0. 1 . localizedStandardCompare ( $1. 1 ) == . orderedAscending }
155+ }
156+
157+ var body : some View {
158+ VStack ( alignment: . leading, spacing: 14 ) {
159+ Text ( Strings . agentNotificationsSettingsTitle)
160+ . font ( . title2. weight ( . semibold) )
161+ Text ( Strings . agentNotificationsPermissionExplanation)
162+ . font ( . callout)
163+ . foregroundStyle ( . secondary)
164+
165+ permissionControl
166+
167+ GroupBox ( Strings . agentNotificationsEvents) {
168+ VStack ( alignment: . leading, spacing: 10 ) {
169+ eventToggle ( . waitingInput, Strings . agentNotificationsWaiting)
170+ eventToggle ( . failed, Strings . agentNotificationsFailed)
171+ eventToggle ( . completed, Strings . agentNotificationsCompleted)
172+ eventToggle ( . processEnded, Strings . agentNotificationsProcessEnded)
173+ }
174+ . frame ( maxWidth: . infinity, alignment: . leading)
175+ . padding ( . vertical, 4 )
176+ }
177+ . disabled ( !controller. settings. isEnabled)
178+
179+ if !agentOptions. isEmpty {
180+ preferenceGroup (
181+ Strings . agentNotificationsAgents,
182+ options: agentOptions,
183+ isEnabled: { !controller. settings. disabledAgentIDs. contains ( $0) } ,
184+ setEnabled: controller. settings. setAgent
185+ )
186+ }
187+ if !projectOptions. isEmpty {
188+ preferenceGroup (
189+ Strings . agentNotificationsProjects,
190+ options: projectOptions,
191+ isEnabled: { !controller. settings. disabledProjectPaths. contains ( $0) } ,
192+ setEnabled: controller. settings. setProject
193+ )
194+ }
195+ if !taskOptions. isEmpty {
196+ GroupBox ( Strings . agentNotificationsTasks) {
197+ VStack ( alignment: . leading, spacing: 8 ) {
198+ ForEach ( taskOptions, id: \. 0 ) { taskID, displayName in
199+ Toggle (
200+ displayName,
201+ isOn: Binding (
202+ get: {
203+ !controller. settings. mutedTaskIDs. contains ( taskID)
204+ } ,
205+ set: {
206+ controller. settings. setTask ( taskID, enabled: $0)
207+ }
208+ )
209+ )
210+ }
211+ }
212+ . frame ( maxWidth: . infinity, alignment: . leading)
213+ . padding ( . vertical, 4 )
214+ }
215+ . disabled ( !controller. settings. isEnabled)
216+ }
217+
218+ Text ( Strings . agentNotificationsFocusHelp)
219+ . font ( . caption)
220+ . foregroundStyle ( . secondary)
221+ }
222+ . accessibilityIdentifier ( AccessibilityID . agentNotificationSettings)
223+ . task { await controller. refreshAuthorizationStatus ( ) }
224+ }
225+
226+ @ViewBuilder
227+ private var permissionControl : some View {
228+ switch controller. authorizationStatus {
229+ case . notDetermined:
230+ Button ( Strings . agentNotificationsEnable) {
231+ Task { await controller. requestAuthorization ( ) }
232+ }
233+ . accessibilityIdentifier ( AccessibilityID . agentNotificationEnableButton)
234+ case . denied:
235+ HStack {
236+ Label ( Strings . agentNotificationsDenied, systemImage: " bell.slash " )
237+ . foregroundStyle ( . secondary)
238+ Spacer ( )
239+ Button ( Strings . agentNotificationsOpenSystemSettings) {
240+ guard let url = URL ( string: " x-apple.systempreferences:com.apple.Notifications-Settings.extension " ) else { return }
241+ NSWorkspace . shared. open ( url)
242+ }
243+ }
244+ case . authorized:
245+ Toggle (
246+ Strings . agentNotificationsMainToggle,
247+ isOn: Binding (
248+ get: { controller. settings. isEnabled } ,
249+ set: {
250+ if $0 {
251+ controller. settings. setEnabled ( true )
252+ } else {
253+ controller. disable ( )
254+ }
255+ }
256+ )
257+ )
258+ . accessibilityIdentifier ( AccessibilityID . agentNotificationMainToggle)
259+ }
260+ }
261+
262+ private func eventToggle(
263+ _ kind: AgentNotificationEventKind ,
264+ _ title: LocalizedStringKey
265+ ) -> some View {
266+ Toggle (
267+ title,
268+ isOn: Binding (
269+ get: { controller. settings. enabledEvents. contains ( kind) } ,
270+ set: { controller. settings. setEvent ( kind, enabled: $0) }
271+ )
272+ )
273+ }
274+
275+ private func preferenceGroup(
276+ _ title: LocalizedStringKey ,
277+ options: [ ( String , String ) ] ,
278+ isEnabled: @escaping ( String ) -> Bool ,
279+ setEnabled: @escaping ( String , Bool ) -> Void
280+ ) -> some View {
281+ GroupBox ( title) {
282+ VStack ( alignment: . leading, spacing: 8 ) {
283+ ForEach ( options, id: \. 0 ) { identifier, displayName in
284+ Toggle (
285+ displayName,
286+ isOn: Binding (
287+ get: { isEnabled ( identifier) } ,
288+ set: { setEnabled ( identifier, $0) }
289+ )
290+ )
291+ }
292+ }
293+ . frame ( maxWidth: . infinity, alignment: . leading)
294+ . padding ( . vertical, 4 )
295+ }
296+ . disabled ( !controller. settings. isEnabled)
297+ }
298+ }
299+
92300/// Identifiers for the Settings scene panes (issue #337). The raw value is
93301/// persisted so the last-selected pane is restored on reopen.
94302enum SettingsPane {
@@ -169,7 +377,6 @@ struct AgentHandoffSettingsView: View {
169377 Spacer ( minLength: 0 )
170378 }
171379 . padding ( 20 )
172- . frame ( width: 720 , height: 500 )
173380 }
174381
175382 private func handoffGuarantee(
0 commit comments