diff --git a/iTerm2.xcodeproj/project.pbxproj b/iTerm2.xcodeproj/project.pbxproj index 0fb4481dac..37e61ab68e 100644 --- a/iTerm2.xcodeproj/project.pbxproj +++ b/iTerm2.xcodeproj/project.pbxproj @@ -1210,6 +1210,7 @@ A61220782E0FA74700F48E64 /* distraction-removal-helpers.js in Resources */ = {isa = PBXBuildFile; fileRef = A61220722E0FA74700F48E64 /* distraction-removal-helpers.js */; }; A61220792E0FA74700F48E64 /* remove-element-at-point.js in Resources */ = {isa = PBXBuildFile; fileRef = A61220732E0FA74700F48E64 /* remove-element-at-point.js */; }; A61220E32E10489600F48E64 /* iTermFocusFollowsMouse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61220E22E10489000F48E64 /* iTermFocusFollowsMouse.swift */; }; + A6A1B2C32E50000100F48E64 /* AIMenuBarStatusController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A1B2C22E50000000F48E64 /* AIMenuBarStatusController.swift */; }; A61220EB2E106F9800F48E64 /* adblock-css-injection.js in Resources */ = {isa = PBXBuildFile; fileRef = A61220EA2E106F9800F48E64 /* adblock-css-injection.js */; }; A61220EC2E106F9800F48E64 /* adblock-css-injection.js in Resources */ = {isa = PBXBuildFile; fileRef = A61220EA2E106F9800F48E64 /* adblock-css-injection.js */; }; A61220ED2E106F9800F48E64 /* adblock-css-injection.js in Resources */ = {isa = PBXBuildFile; fileRef = A61220EA2E106F9800F48E64 /* adblock-css-injection.js */; }; @@ -6739,6 +6740,7 @@ A61220722E0FA74700F48E64 /* distraction-removal-helpers.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "distraction-removal-helpers.js"; sourceTree = ""; }; A61220732E0FA74700F48E64 /* remove-element-at-point.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "remove-element-at-point.js"; sourceTree = ""; }; A61220E22E10489000F48E64 /* iTermFocusFollowsMouse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTermFocusFollowsMouse.swift; sourceTree = ""; }; + A6A1B2C22E50000000F48E64 /* AIMenuBarStatusController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIMenuBarStatusController.swift; sourceTree = ""; }; A61220EA2E106F9800F48E64 /* adblock-css-injection.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "adblock-css-injection.js"; sourceTree = ""; }; A612E5C82DACB2890014F159 /* FileDescriptorMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDescriptorMonitor.swift; sourceTree = ""; }; A612E5CA2DACB5E50014F159 /* IOBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBuffer.swift; sourceTree = ""; }; @@ -10877,6 +10879,7 @@ 20D5CC6404E7AA0500000106 /* iTermApplicationDelegate.m */, A697CD702D973E370031583F /* iTermApplicationDelegate.swift */, 1179CD897A4780286BB29AAC /* iTermDockBadgeController.swift */, + A6A1B2C22E50000000F48E64 /* AIMenuBarStatusController.swift */, 1D407A2614BABE8700BD5035 /* iTerm.h */, E8CF757F026DDAD703A80106 /* main.m */, A6D441C62A91306400CA76E9 /* ProfileDocument.swift */, @@ -20814,6 +20817,7 @@ A6D10E3D27F7C9FA0026DB56 /* ComposerTextView.swift in Sources */, 530AB8BD20B3D3D000D2AA08 /* iTermWindowHacks.m in Sources */, A61220E32E10489600F48E64 /* iTermFocusFollowsMouse.swift in Sources */, + A6A1B2C32E50000100F48E64 /* AIMenuBarStatusController.swift in Sources */, A61F456F22FA52CD00E2054A /* iTermUnreadCountView.m in Sources */, A664334E2DE938AD0099F722 /* ResponsesAPIRequest.swift in Sources */, A6069F002DF0224800EE6CC7 /* SSHFilePanelSidebar.swift in Sources */, diff --git a/sources/AITerm/ChatListModel.swift b/sources/AITerm/ChatListModel.swift index d6ea3ec3f3..5fffa16e7d 100644 --- a/sources/AITerm/ChatListModel.swift +++ b/sources/AITerm/ChatListModel.swift @@ -310,4 +310,12 @@ class TypingStatusModel { let pc = PersonChat(participant: participant, chatID: chatID) return typing.contains(pc) } + + func chatIDs(forParticipant participant: Participant) -> Set { + var result = Set() + for pc in typing where pc.participant == participant { + result.insert(pc.chatID) + } + return result + } } diff --git a/sources/AppKit/AIMenuBarStatusController.swift b/sources/AppKit/AIMenuBarStatusController.swift new file mode 100644 index 0000000000..57ca4a018f --- /dev/null +++ b/sources/AppKit/AIMenuBarStatusController.swift @@ -0,0 +1,162 @@ +import AppKit +import Foundation + +@objc(iTermAIMenuBarStatusController) +class AIMenuBarStatusController: NSObject { + @objc(sharedInstance) static let instance = AIMenuBarStatusController() + + private var statusItem: NSStatusItem? + private let baseImage: NSImage? + private var sessionStatusObserverToken: NotifyingDictionaryObserverToken? + private var brokerSubscription: ChatBroker.Subscription? + + override init() { + let image = NSImage(named: "StatusItem") + image?.isTemplate = true + self.baseImage = image + super.init() + + NotificationCenter.default.addObserver( + self, + selector: #selector(refresh), + name: NSNotification.Name("iTermProcessTypeDidChangeNotification"), + object: nil) + + NotificationCenter.default.addObserver( + self, + selector: #selector(defaultsChanged), + name: UserDefaults.didChangeNotification, + object: nil) + + sessionStatusObserverToken = SessionStatusController.instance.addObserver { [weak self] _, _, _ in + self?.refresh() + } + + subscribeToBrokerIfPossible() + } + + private func subscribeToBrokerIfPossible() { + guard brokerSubscription == nil else { return } + guard let broker = ChatBroker.instance else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.subscribeToBrokerIfPossible() + } + return + } + brokerSubscription = broker.subscribe(chatID: nil, registrationProvider: nil) { [weak self] update in + switch update { + case .typingStatus(_, let participant): + guard participant == .agent else { return } + self?.refresh() + case .delivery: + break + } + } + } + + @objc func start() { + refresh() + } + + @objc private func defaultsChanged() { + refresh() + } + + @objc func refresh() { + DispatchQueue.main.async { [weak self] in + self?.refreshOnMain() + } + } + + private func refreshOnMain() { + let prefOn = iTermAdvancedSettingsModel.showMenuBarItem() + let legacyMode = iTermPreferences.bool(forKey: kPreferenceKeyUIElement) && + iTermAdvancedSettingsModel.statusBarIcon() + let shouldShow = prefOn || legacyMode + if shouldShow { + installStatusItemIfNeeded() + updateImage() + } else { + removeStatusItem() + } + } + + private func installStatusItemIfNeeded() { + guard statusItem == nil else { return } + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.title = "" + (item.button?.cell as? NSButtonCell)?.highlightsBy = .changeBackgroundCellMask + if let delegate = NSApp.delegate as? iTermApplicationDelegate { + item.menu = delegate.statusBarMenu() + } + statusItem = item + } + + private func removeStatusItem() { + if let item = statusItem { + NSStatusBar.system.removeStatusItem(item) + } + statusItem = nil + } + + private func busyCount() -> Int { + let tabIndicatorSessionIDs = SessionStatusController.instance.statuses.values + .filter { $0.hasIndicator } + .map { $0.sessionID } + let busyChatIDs = TypingStatusModel.instance.chatIDs(forParticipant: .agent) + var unique = Set() + for id in tabIndicatorSessionIDs { unique.insert("session:\(id)") } + for id in busyChatIDs { unique.insert("chat:\(id)") } + return unique.count + } + + private func updateImage() { + guard let button = statusItem?.button else { return } + let count = busyCount() + if count == 0 { + button.image = baseImage + button.image?.isTemplate = true + } else { + let image = Self.renderActiveImage(count: count, baseImage: baseImage) + image.isTemplate = false + button.image = image + } + } + + private static func renderActiveImage(count: Int, baseImage: NSImage?) -> NSImage { + let label = count > 9 ? "+" : "\(count)" + let color = NSColor.systemOrange + let size = baseImage?.size ?? NSSize(width: 28, height: 16) + return NSImage(size: size, flipped: false) { rect in + guard let baseImage else { return false } + + color.setFill() + rect.fill() + baseImage.draw( + in: rect, + from: NSRect(origin: .zero, size: baseImage.size), + operation: .destinationIn, + fraction: 1.0) + + let glyphClearRect = NSRect(x: 7.5, y: 3.0, width: 6.75, height: 11.5) + glyphClearRect.fill(using: .clear) + + let labelRect = NSRect(x: 7.0, y: 2.0, width: 7.75, height: 12.5) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .bold), + .foregroundColor: color, + .paragraphStyle: paragraphStyle + ] + let textSize = (label as NSString).size(withAttributes: attrs) + let textRect = NSRect( + x: labelRect.minX, + y: labelRect.midY - textSize.height / 2.0, + width: labelRect.width, + height: textSize.height) + (label as NSString).draw(in: textRect, withAttributes: attrs) + return true + } + } +} diff --git a/sources/AppKit/iTermApplication.m b/sources/AppKit/iTermApplication.m index 2eb9ca4ff2..000a8141cc 100644 --- a/sources/AppKit/iTermApplication.m +++ b/sources/AppKit/iTermApplication.m @@ -919,21 +919,8 @@ - (void)setIsUIElement:(BOOL)uiElement { DLog(@"uiElement=%@", @(uiElement)); [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; }); - - if ([iTermAdvancedSettingsModel statusBarIcon]) { - NSImage *image = [NSImage it_imageNamed:@"StatusItem" forClass:self.class]; - image.template = YES; - self.statusBarItem = [[NSStatusBar systemStatusBar] statusItemWithLength:image.size.width]; - _statusBarItem.button.title = @""; - _statusBarItem.button.image = image; - ((NSButtonCell *)_statusBarItem.button.cell).highlightsBy = NSChangeBackgroundCellMask; - - _statusBarItem.menu = [(id)[self delegate] statusBarMenu]; - } - } else if (_statusBarItem != nil) { - [[NSStatusBar systemStatusBar] removeStatusItem:_statusBarItem]; - self.statusBarItem = nil; } + [[iTermAIMenuBarStatusController sharedInstance] refresh]; } - (NSArray *)orderedWindowsPlusVisibleHotkeyPanels { diff --git a/sources/AppKit/iTermApplicationDelegate.m b/sources/AppKit/iTermApplicationDelegate.m index 7ef7635abc..1392cc2b1c 100644 --- a/sources/AppKit/iTermApplicationDelegate.m +++ b/sources/AppKit/iTermApplicationDelegate.m @@ -1642,6 +1642,7 @@ - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { [iTermWorkgroupMenu attachTo:_workgroupsMenuItem separator:_workgroupsSeparator]; } + [[iTermAIMenuBarStatusController sharedInstance] start]; #if DEBUG NSMenu *appMenu = [[[[NSApp mainMenu] itemArray] firstObject] submenu]; [appMenu addItem:[NSMenuItem separatorItem]]; diff --git a/sources/Settings/iTermAdvancedSettingsModel.h b/sources/Settings/iTermAdvancedSettingsModel.h index ac8092704a..9ae6e4ad34 100644 --- a/sources/Settings/iTermAdvancedSettingsModel.h +++ b/sources/Settings/iTermAdvancedSettingsModel.h @@ -463,6 +463,7 @@ extern NSString *const iTermAdvancedSettingsDidChange; + (BOOL)startDebugLoggingAutomatically; + (double)statusBarHeight; + (BOOL)statusBarIcon; ++ (BOOL)showMenuBarItem; + (BOOL)stealKeyFocus; + (BOOL)storeStateInSqlite; + (NSString *)successSound; diff --git a/sources/Settings/iTermAdvancedSettingsModel.m b/sources/Settings/iTermAdvancedSettingsModel.m index 97e9cbf5e5..97eacc71a3 100644 --- a/sources/Settings/iTermAdvancedSettingsModel.m +++ b/sources/Settings/iTermAdvancedSettingsModel.m @@ -474,6 +474,7 @@ + (BOOL)settingIsDeprecated:(NSString *)name { DEFINE_BOOL(hideStuckTooltips, YES, SECTION_GENERAL @"Hide stuck tooltips.\nWhen you hide iTerm2 using a hotkey while a tooltip is fading out it gets stuck because of an OS bug. Work around it with a nasty hack by enabling this feature.") DEFINE_BOOL(openFileOverridesSendText, YES, SECTION_GENERAL @"Should opening a script with iTerm2 disable the default profile's “Send Text at Start” setting?\nIf you use “open iTerm2 file.command” or drag a script onto iTerm2's icon and this setting is enabled then the script will be executed in lieu of the profile's “Send Text at Start” setting. If this setting is off then both will be executed."); DEFINE_BOOL(statusBarIcon, YES, SECTION_GENERAL @"Add status bar icon when excluded from dock?\nWhen you turn on “Exclude from Dock and ⌘-Tab Application Switcher” a status bar icon is added to the menu bar so you can switch the setting back off. Disable this to remove the status bar icon. Doing so makes it very hard to get to Settings. You must restart iTerm2 after changing this setting."); +DEFINE_BOOL(showMenuBarItem, NO, SECTION_GENERAL @"Show iTerm2 icon in the menu bar?\nWhen enabled, a status bar icon appears in the system menu bar regardless of whether iTerm2 is excluded from the dock. The icon shows a count badge when AI chat agents are working."); DEFINE_FLOAT(statusBarHeight, 21, SECTION_GENERAL @"Height of the status bar in points.\nThis will also affect the height of per-pane title bars becuase the status bar may be embedded in it. You must restart iTerm2 after changing this setting for it to take effect."); DEFINE_BOOL(wrapFocus, YES, SECTION_GENERAL @"Should split pane navigation by direction wrap around?"); DEFINE_BOOL(openUntitledFile, YES, SECTION_GENERAL @"Open a new window when you click the dock icon and no windows are already open, and also on app launch when no other windows are open?");