Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions iTerm2.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -6739,6 +6740,7 @@
A61220722E0FA74700F48E64 /* distraction-removal-helpers.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "distraction-removal-helpers.js"; sourceTree = "<group>"; };
A61220732E0FA74700F48E64 /* remove-element-at-point.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "remove-element-at-point.js"; sourceTree = "<group>"; };
A61220E22E10489000F48E64 /* iTermFocusFollowsMouse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iTermFocusFollowsMouse.swift; sourceTree = "<group>"; };
A6A1B2C22E50000000F48E64 /* AIMenuBarStatusController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIMenuBarStatusController.swift; sourceTree = "<group>"; };
A61220EA2E106F9800F48E64 /* adblock-css-injection.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = "adblock-css-injection.js"; sourceTree = "<group>"; };
A612E5C82DACB2890014F159 /* FileDescriptorMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDescriptorMonitor.swift; sourceTree = "<group>"; };
A612E5CA2DACB5E50014F159 /* IOBuffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBuffer.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
8 changes: 8 additions & 0 deletions sources/AITerm/ChatListModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,4 +310,12 @@ class TypingStatusModel {
let pc = PersonChat(participant: participant, chatID: chatID)
return typing.contains(pc)
}

func chatIDs(forParticipant participant: Participant) -> Set<String> {
var result = Set<String>()
for pc in typing where pc.participant == participant {
result.insert(pc.chatID)
}
return result
}
}
162 changes: 162 additions & 0 deletions sources/AppKit/AIMenuBarStatusController.swift
Original file line number Diff line number Diff line change
@@ -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<String>()
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
}
}
}
15 changes: 1 addition & 14 deletions sources/AppKit/iTermApplication.m
Original file line number Diff line number Diff line change
Expand Up @@ -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<iTermApplicationDelegate>)[self delegate] statusBarMenu];
}
} else if (_statusBarItem != nil) {
[[NSStatusBar systemStatusBar] removeStatusItem:_statusBarItem];
self.statusBarItem = nil;
}
[[iTermAIMenuBarStatusController sharedInstance] refresh];
}

- (NSArray<NSWindow *> *)orderedWindowsPlusVisibleHotkeyPanels {
Expand Down
1 change: 1 addition & 0 deletions sources/AppKit/iTermApplicationDelegate.m
Original file line number Diff line number Diff line change
Expand Up @@ -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]];
Expand Down
1 change: 1 addition & 0 deletions sources/Settings/iTermAdvancedSettingsModel.h
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ extern NSString *const iTermAdvancedSettingsDidChange;
+ (BOOL)startDebugLoggingAutomatically;
+ (double)statusBarHeight;
+ (BOOL)statusBarIcon;
+ (BOOL)showMenuBarItem;
+ (BOOL)stealKeyFocus;
+ (BOOL)storeStateInSqlite;
+ (NSString *)successSound;
Expand Down
1 change: 1 addition & 0 deletions sources/Settings/iTermAdvancedSettingsModel.m
Original file line number Diff line number Diff line change
Expand Up @@ -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?");
Expand Down
Loading