From 4778afa7b1b34b40a1cc384368bfdb556da35ad1 Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:48:08 +0000 Subject: [PATCH 01/11] feat: redesign GitHub star invitation as native settings card - Remove promotional star prompt from menu bar popover to keep it focused on monitoring - Add subtle, inline GitHubStarInvitationCard in General settings with SF Symbol star - Implement local eligibility criteria (5 days active, 3 distinct sessions, provider availability, dismissible) - Update default settings window height to 710pt to cleanly fit all sections without clipping - Keep permanent Star on GitHub link in About settings tab - Add unit tests and visual verification checks --- Loom.xcodeproj/project.pbxproj | 4 + Loom/App/DockController.swift | 3 +- Loom/App/LoomApp.swift | 1 + Loom/App/StatusItemController.swift | 16 +- Loom/Core/Models/LoomSettings.swift | 88 ++++++++++ Loom/Core/Store/SessionStore.swift | 10 +- Loom/UI/MenuBar/MenuBarRootView.swift | 157 +++++++++++------- .../Settings/GitHubStarInvitationCard.swift | 80 +++++++++ Loom/UI/Settings/SettingsView.swift | 30 +++- README.md | 2 + .../SessionProductivityTests.swift | 3 + Tests/LoomCoreTests/SettingsTests.swift | 92 ++++++++++ Tests/SettingsProbe.swift | 60 +++++++ 13 files changed, 473 insertions(+), 73 deletions(-) create mode 100644 Loom/UI/Settings/GitHubStarInvitationCard.swift diff --git a/Loom.xcodeproj/project.pbxproj b/Loom.xcodeproj/project.pbxproj index 376cd3d..191d5d2 100644 --- a/Loom.xcodeproj/project.pbxproj +++ b/Loom.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ A00000000000000000000077 /* AgentUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0000000000000000000002C /* AgentUsage.swift */; }; A00000000000000000000080 /* LoomApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000020 /* LoomApp.swift */; }; A00000000000000000000081 /* MenuBarRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000021 /* MenuBarRootView.swift */; }; + D00000000000000000000021 /* GitHubStarInvitationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000020 /* GitHubStarInvitationCard.swift */; }; A00000000000000000000082 /* ProviderSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000022 /* ProviderSectionView.swift */; }; A00000000000000000000083 /* SessionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000023 /* SessionRowView.swift */; }; A00000000000000000000084 /* LoomIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0000000000000000000002F /* LoomIcon.swift */; }; @@ -165,6 +166,7 @@ /* Begin PBXFileReference section */ A00000000000000000000020 /* LoomApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoomApp.swift; sourceTree = ""; }; A00000000000000000000021 /* MenuBarRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRootView.swift; sourceTree = ""; }; + D00000000000000000000020 /* GitHubStarInvitationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubStarInvitationCard.swift; sourceTree = ""; }; A00000000000000000000022 /* ProviderSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderSectionView.swift; sourceTree = ""; }; A00000000000000000000023 /* SessionRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionRowView.swift; sourceTree = ""; }; A00000000000000000000024 /* AgentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentState.swift; sourceTree = ""; }; @@ -529,6 +531,7 @@ A000000000000000000000FB /* Settings */ = { isa = PBXGroup; children = ( + D00000000000000000000020 /* GitHubStarInvitationCard.swift */, D00000000000000000000010 /* NotificationsSettingsTab.swift */, B00000000000000000000003 /* Loom/UI/Settings/StorageSettingsTab.swift */, A000000000000000000000F5 /* SettingsView.swift */, @@ -830,6 +833,7 @@ C00000000000000000000003 /* Loom/UI/Settings/StorageSettingsTab.swift in Sources */, A00000000000000000000080 /* LoomApp.swift in Sources */, A00000000000000000000081 /* MenuBarRootView.swift in Sources */, + D00000000000000000000021 /* GitHubStarInvitationCard.swift in Sources */, A00000000000000000000082 /* ProviderSectionView.swift in Sources */, A00000000000000000000083 /* SessionRowView.swift in Sources */, A00000000000000000000084 /* LoomIcon.swift in Sources */, diff --git a/Loom/App/DockController.swift b/Loom/App/DockController.swift index fb4d0bf..e703e47 100644 --- a/Loom/App/DockController.swift +++ b/Loom/App/DockController.swift @@ -108,8 +108,9 @@ public final class DockController: NSObject, NSApplicationDelegate { initialProviderID: providerID ) let minContentSize = NSSize(width: 640, height: 460) + let defaultContentSize = NSSize(width: 660, height: 710) let window = NSWindow( - contentRect: NSRect(origin: .zero, size: NSSize(width: 660, height: 560)), + contentRect: NSRect(origin: .zero, size: defaultContentSize), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false diff --git a/Loom/App/LoomApp.swift b/Loom/App/LoomApp.swift index 3dd4be6..fc0eede 100644 --- a/Loom/App/LoomApp.swift +++ b/Loom/App/LoomApp.swift @@ -158,6 +158,7 @@ struct LoomApp: App { fixture: fixture, enabledProviderIDs: initialSettings.enabledProviderIDs ) + initialStore.settings = initialSettings initialStore.startPeriodicRefresh(interval: TimeInterval(initialSettings.refreshIntervalSeconds)) initialSettings.onRefreshIntervalChanged = { [weak initialStore] interval in initialStore?.startPeriodicRefresh(interval: TimeInterval(interval)) diff --git a/Loom/App/StatusItemController.swift b/Loom/App/StatusItemController.swift index 92a1d87..62eab72 100644 --- a/Loom/App/StatusItemController.swift +++ b/Loom/App/StatusItemController.swift @@ -17,17 +17,20 @@ public struct MenuBarStatusResult: Equatable, Sendable { public let text: String? public let hasAttention: Bool public let activeCount: Int + public let attentionCount: Int public let lowestQuotaPercent: Int? public init( text: String? = nil, hasAttention: Bool = false, activeCount: Int = 0, + attentionCount: Int = 0, lowestQuotaPercent: Int? = nil ) { self.text = text self.hasAttention = hasAttention self.activeCount = activeCount + self.attentionCount = attentionCount self.lowestQuotaPercent = lowestQuotaPercent } } @@ -50,9 +53,9 @@ public enum MenuBarStatusEvaluator { }.count // 2. Restrained attention indicator for confirmed sessions needing input or failed - let hasAttention = allSessions.contains { session in + let attentionCount = allSessions.filter { session in session.state == .waiting || session.state == .failed - } + }.count // 3. Lowest quota percentage: // Must be non-stale, non-errored, and supported. @@ -82,14 +85,17 @@ public enum MenuBarStatusEvaluator { text = nil case .activeSessionCount: text = activeCount > 0 ? "\(activeCount)" : nil + case .needsAttentionCount: + text = attentionCount > 0 ? "\(attentionCount)" : nil case .lowestQuota: text = lowestQuotaPercent.map { "\($0)%" } } return MenuBarStatusResult( text: text, - hasAttention: hasAttention, + hasAttention: attentionCount > 0, activeCount: activeCount, + attentionCount: attentionCount, lowestQuotaPercent: lowestQuotaPercent ) } @@ -139,7 +145,7 @@ public final class StatusItemController { parts.append(text) } if status.hasAttention { - parts.append("Confirmed session requires attention") + parts.append("\(status.attentionCount) \(status.attentionCount == 1 ? "session needs" : "sessions need") attention") } return parts.joined(separator: ", ") } @@ -187,7 +193,7 @@ public struct MenuBarStatusLabel: View { parts.append(text) } if result.hasAttention { - parts.append("Session needs attention") + parts.append("\(result.attentionCount) \(result.attentionCount == 1 ? "session needs" : "sessions need") attention") } return parts.joined(separator: ", ") } diff --git a/Loom/Core/Models/LoomSettings.swift b/Loom/Core/Models/LoomSettings.swift index 0c1ad85..59cb5c2 100644 --- a/Loom/Core/Models/LoomSettings.swift +++ b/Loom/Core/Models/LoomSettings.swift @@ -52,6 +52,7 @@ public enum HideFinishedInterval: String, CaseIterable, Identifiable, Sendable { public enum MenuBarStatusMode: String, CaseIterable, Identifiable, Codable, Sendable { case iconOnly = "iconOnly" case activeSessionCount = "activeSessionCount" + case needsAttentionCount = "needsAttentionCount" case lowestQuota = "lowestQuota" public var id: String { rawValue } @@ -60,6 +61,7 @@ public enum MenuBarStatusMode: String, CaseIterable, Identifiable, Codable, Send switch self { case .iconOnly: return "Icon Only" case .activeSessionCount: return "Active Sessions" + case .needsAttentionCount: return "Needs Attention" case .lowestQuota: return "Lowest Quota" } } @@ -170,6 +172,11 @@ public final class LoomSettings { public nonisolated static let defaultNotifySessionFinished: Bool = false public nonisolated static let defaultNotifySessionFailed: Bool = true public nonisolated static let defaultMenuBarStatusMode: MenuBarStatusMode = .iconOnly + public nonisolated static let hasDismissedGitHubStarPromptKey = "com.loom.hasDismissedGitHubStarPrompt" + public nonisolated static let defaultHasDismissedGitHubStarPrompt: Bool = false + public nonisolated static let firstLaunchDateKey = "com.loom.firstLaunchDate" + public nonisolated static let distinctObservedSessionIDsKey = "com.loom.distinctObservedSessionIDs" + public nonisolated static let gitHubRepoURL = URL(string: "https://github.com/chaiwithcode/Loom")! private let defaults: UserDefaults @@ -191,6 +198,7 @@ public final class LoomSettings { public var onNotifySessionFailedChanged: (@MainActor (Bool) -> Void)? public var onMinimumWorkingDurationChanged: (@MainActor (MinimumWorkingDuration) -> Void)? public var onMenuBarStatusModeChanged: (@MainActor (MenuBarStatusMode) -> Void)? + public var onHasDismissedGitHubStarPromptChanged: (@MainActor (Bool) -> Void)? @ObservationIgnored private var isNormalizingRefreshInterval = false @ObservationIgnored private var isNormalizingDefaultProvider = false @@ -289,6 +297,27 @@ public final class LoomSettings { } } + public var hasDismissedGitHubStarPrompt: Bool { + didSet { + defaults.set(hasDismissedGitHubStarPrompt, forKey: Self.hasDismissedGitHubStarPromptKey) + if hasDismissedGitHubStarPrompt != oldValue { + onHasDismissedGitHubStarPromptChanged?(hasDismissedGitHubStarPrompt) + } + } + } + + public var firstLaunchDate: Date { + didSet { + defaults.set(firstLaunchDate, forKey: Self.firstLaunchDateKey) + } + } + + public var distinctObservedSessionIDs: Set { + didSet { + defaults.set(Array(distinctObservedSessionIDs).sorted(), forKey: Self.distinctObservedSessionIDsKey) + } + } + public var notificationsEnabled: Bool { didSet { defaults.set(notificationsEnabled, forKey: Self.notificationsEnabledKey) @@ -487,6 +516,29 @@ public final class LoomSettings { self.showVersionInMenuBar = Self.defaultShowVersionInMenuBar } + // hasDismissedGitHubStarPrompt: validate boolean type, default false + if let num = defaults.object(forKey: Self.hasDismissedGitHubStarPromptKey) as? NSNumber { + self.hasDismissedGitHubStarPrompt = num.boolValue + } else { + self.hasDismissedGitHubStarPrompt = Self.defaultHasDismissedGitHubStarPrompt + } + + // firstLaunchDate: retrieve stored date or record current date + if let storedDate = defaults.object(forKey: Self.firstLaunchDateKey) as? Date { + self.firstLaunchDate = storedDate + } else { + let now = Date() + defaults.set(now, forKey: Self.firstLaunchDateKey) + self.firstLaunchDate = now + } + + // distinctObservedSessionIDs: retrieve stored unique session identifiers + if let storedSessions = defaults.stringArray(forKey: Self.distinctObservedSessionIDsKey) { + self.distinctObservedSessionIDs = Set(storedSessions) + } else { + self.distinctObservedSessionIDs = [] + } + // notificationsEnabled: validate boolean type, default true; preserve a stored opt-out if let num = defaults.object(forKey: Self.notificationsEnabledKey) as? NSNumber { self.notificationsEnabled = num.boolValue @@ -590,6 +642,37 @@ public final class LoomSettings { notifySessionFailed = Self.defaultNotifySessionFailed minimumWorkingDuration = Self.defaultMinimumWorkingDuration menuBarStatusMode = Self.defaultMenuBarStatusMode + hasDismissedGitHubStarPrompt = Self.defaultHasDismissedGitHubStarPrompt + } + + public func recordDiscoveredSessions(_ sessionIDs: [String]) { + let nonEmpty = sessionIDs.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard !nonEmpty.isEmpty else { return } + let currentCount = distinctObservedSessionIDs.count + var updated = distinctObservedSessionIDs + updated.formUnion(nonEmpty) + if updated.count != currentCount { + distinctObservedSessionIDs = updated + } + } + + public func isEligibleForGitHubStarPrompt(hasAvailableProviders: Bool = true, now: Date = Date()) -> Bool { + #if DEBUG + if ProcessInfo.processInfo.arguments.contains("--force-github-star-card") || + ProcessInfo.processInfo.environment["LOOM_FORCE_GITHUB_STAR_CARD"] == "1" { + return true + } + #endif + guard !hasDismissedGitHubStarPrompt else { return false } + guard hasAvailableProviders else { return false } + guard distinctObservedSessionIDs.count >= 3 else { return false } + let fiveDaysInSeconds: TimeInterval = 5 * 86400 + guard now.timeIntervalSince(firstLaunchDate) >= fiveDaysInSeconds else { return false } + return true + } + + public func dismissGitHubStarPrompt() { + hasDismissedGitHubStarPrompt = true } public func persistToSharedAppGroup(appGroup: SharedAppGroup = .shared) { @@ -682,6 +765,7 @@ extension LoomSettings: @preconcurrency Codable { case notifySessionFailed case minimumWorkingDuration case menuBarStatusMode + case hasDismissedGitHubStarPrompt } public convenience init(from decoder: Decoder) throws { @@ -717,6 +801,9 @@ extension LoomSettings: @preconcurrency Codable { if let ver = try container.decodeIfPresent(Bool.self, forKey: .showVersionInMenuBar) { self.showVersionInMenuBar = ver } + if let dismissedStar = try container.decodeIfPresent(Bool.self, forKey: .hasDismissedGitHubStarPrompt) { + self.hasDismissedGitHubStarPrompt = dismissedStar + } // Notification preferences with backward-compatible defaults: self.notificationsEnabled = try container.decodeIfPresent(Bool.self, forKey: .notificationsEnabled) ?? Self.defaultNotificationsEnabled @@ -740,6 +827,7 @@ extension LoomSettings: @preconcurrency Codable { try container.encode(hideFinishedAfter.rawValue, forKey: .hideFinishedAfter) try container.encode(hideProjectPaths, forKey: .hideProjectPaths) try container.encode(showVersionInMenuBar, forKey: .showVersionInMenuBar) + try container.encode(hasDismissedGitHubStarPrompt, forKey: .hasDismissedGitHubStarPrompt) try container.encode(notificationsEnabled, forKey: .notificationsEnabled) try container.encode(notifyQuota15Percent, forKey: .notifyQuota15Percent) try container.encode(notifyQuota5Percent, forKey: .notifyQuota5Percent) diff --git a/Loom/Core/Store/SessionStore.swift b/Loom/Core/Store/SessionStore.swift index 82682f5..3e1ea9f 100644 --- a/Loom/Core/Store/SessionStore.swift +++ b/Loom/Core/Store/SessionStore.swift @@ -243,6 +243,9 @@ public final class SessionStore { /// Optional callback hook called when session state transitions or quota thresholds occur. public var onMonitoringEvent: (@Sendable (MonitoringEvent) -> Void)? + /// Reference to settings for recording discovered monitoring sessions. + public weak var settings: LoomSettings? + public var enabledSnapshots: [ProviderSnapshot] { providerSnapshots.filter(\.isEnabled) } @@ -700,9 +703,14 @@ public final class SessionStore { guard !Task.isCancelled else { return } + let current = self.allSessions + if !current.isEmpty { + self.settings?.recordDiscoveredSessions(current.map(\.id)) + } + // Evaluate state transitions and route to onMonitoringEvent hook let events = self.transitionDetector.detectTransitions( - currentSessions: self.allSessions, + currentSessions: current, failedProviders: failedProviders, cancelledProviders: cancelledProviders ) diff --git a/Loom/UI/MenuBar/MenuBarRootView.swift b/Loom/UI/MenuBar/MenuBarRootView.swift index 518c893..f343341 100644 --- a/Loom/UI/MenuBar/MenuBarRootView.swift +++ b/Loom/UI/MenuBar/MenuBarRootView.swift @@ -126,69 +126,7 @@ public struct MenuBarRootView: View { ForEach(MenuTab.allCases, id: \.self) { tab in ScrollView { VStack(alignment: .leading, spacing: 14) { - if tab == .storage { - storageTabContent - } else if store.providerSnapshots.isEmpty { - Text("No providers registered") - .font(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 24) - } else if store.enabledSnapshots.isEmpty { - VStack(spacing: 12) { - Image(systemName: "pause.circle") - .font(.system(size: 28)) - .foregroundStyle(.secondary) - Text("All Providers Disabled") - .font(.headline) - Text("Monitoring is disabled for all providers. Enable providers in Settings to monitor sessions and usage.") - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) - Button("Open Settings…") { - DockController.shared.showSettings(store: store) - } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 32) - } else if tab == .agents { - if let selectedSnapshot = store.enabledSnapshots.first(where: { $0.id == resolvedSelectedProviderID }) { - ProviderSectionView( - snapshot: selectedSnapshot, - showsSessions: true, - showsUsage: false, - expandedSessionIds: expandedSessionIds, - settings: settings, - sessions: matchingSessions(for: selectedSnapshot), - searchText: searchText, - selectedFilter: selectedFilter, - onClearFilters: { - searchText = "" - selectedFilter = .all - }, - onSessionExpansionChange: { id, expanded in - if expanded { expandedSessionIds.insert(id) } - else { expandedSessionIds.remove(id) } - } - ) - } - } else if tab == .usage { - ForEach(store.enabledSnapshots) { snapshot in - ProviderSectionView( - snapshot: snapshot, - showsSessions: false, - showsUsage: true, - expandedSessionIds: expandedSessionIds, - settings: settings - ) - if snapshot.id != store.enabledSnapshots.last?.id { - Divider().padding(.vertical, 4) - } - } - } + tabScrollContent(for: tab) } .padding(.horizontal, 16) .padding(.bottom, 16) @@ -344,6 +282,99 @@ public struct MenuBarRootView: View { } } + @ViewBuilder + private func tabScrollContent(for tab: MenuTab) -> some View { + if tab == .storage { + storageTabContent + } else if store.providerSnapshots.isEmpty { + noProvidersRegisteredView + } else if store.enabledSnapshots.isEmpty { + allProvidersDisabledView + } else if tab == .agents { + agentsTabContent + } else if tab == .usage { + usageTabContent + } + } + + @ViewBuilder + private var noProvidersRegisteredView: some View { + Text("No providers registered") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 24) + } + + @ViewBuilder + private var allProvidersDisabledView: some View { + VStack(spacing: 12) { + Image(systemName: "pause.circle") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text("All Providers Disabled") + .font(.headline) + Text("Monitoring is disabled for all providers. Enable providers in Settings to monitor sessions and usage.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Button("Open Settings…") { + DockController.shared.showSettings(store: store) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 32) + } + + @ViewBuilder + private var agentsTabContent: some View { + if let selectedSnapshot = store.enabledSnapshots.first(where: { $0.id == resolvedSelectedProviderID }) { + ProviderSectionView( + snapshot: selectedSnapshot, + showsSessions: true, + showsUsage: false, + expandedSessionIds: expandedSessionIds, + settings: settings, + sessions: matchingSessions(for: selectedSnapshot), + searchText: searchText, + selectedFilter: selectedFilter, + onClearFilters: { + searchText = "" + selectedFilter = .all + }, + onSessionExpansionChange: { id, expanded in + if expanded { expandedSessionIds.insert(id) } + else { expandedSessionIds.remove(id) } + } + ) + } + Text("Unknown means Loom cannot determine the session’s state.") + .font(.caption) + .foregroundStyle(.secondary) + } + + @ViewBuilder + private var usageTabContent: some View { + ForEach(store.enabledSnapshots) { snapshot in + ProviderSectionView( + snapshot: snapshot, + showsSessions: false, + showsUsage: true, + expandedSessionIds: expandedSessionIds, + settings: settings + ) + if snapshot.id != store.enabledSnapshots.last?.id { + Divider().padding(.vertical, 4) + } + } + Text("Claude Code quota is unavailable through its local interface.") + .font(.caption) + .foregroundStyle(.secondary) + } + @ViewBuilder private var storageTabContent: some View { VStack(alignment: .leading, spacing: 14) { diff --git a/Loom/UI/Settings/GitHubStarInvitationCard.swift b/Loom/UI/Settings/GitHubStarInvitationCard.swift new file mode 100644 index 0000000..594e75c --- /dev/null +++ b/Loom/UI/Settings/GitHubStarInvitationCard.swift @@ -0,0 +1,80 @@ +// +// GitHubStarInvitationCard.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import SwiftUI +import AppKit + +public struct GitHubStarInvitationCard: View { + @Environment(\.colorSchemeContrast) private var contrast + public let onDismiss: () -> Void + public let onStar: () -> Void + + public init( + onDismiss: @escaping () -> Void, + onStar: @escaping () -> Void + ) { + self.onDismiss = onDismiss + self.onStar = onStar + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "star.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.yellow) + .accessibilityHidden(true) + + Text("Enjoying Loom?") + .font(.headline) + .foregroundStyle(.primary) + + Spacer(minLength: 8) + } + + Text("If Loom makes managing your coding agents easier, consider giving it a star on GitHub. It helps others discover the project.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 10) { + Spacer(minLength: 0) + + Button("Not now", action: onDismiss) + .buttonStyle(.bordered) + .controlSize(.regular) + .accessibilityIdentifier("button-star-invitation-dismiss") + .accessibilityLabel("Not now, dismiss star invitation") + + Button(action: onStar) { + HStack(spacing: 5) { + Image(systemName: "star") + .font(.system(size: 11, weight: .semibold)) + Text("Star on GitHub") + } + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .accessibilityIdentifier("button-star-invitation-star") + .accessibilityLabel("Star Loom on GitHub") + } + .padding(.top, 2) + } + .padding(13) + .frame(maxWidth: .infinity, alignment: .leading) + .loomGlassSurface(in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay { + if contrast == .increased { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(Color.primary.opacity(0.6), lineWidth: 1) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("card-github-star-invitation") + } +} diff --git a/Loom/UI/Settings/SettingsView.swift b/Loom/UI/Settings/SettingsView.swift index 829f1e7..9f4fce6 100644 --- a/Loom/UI/Settings/SettingsView.swift +++ b/Loom/UI/Settings/SettingsView.swift @@ -94,7 +94,7 @@ public struct SettingsView: View { Group { switch selectedTab { case .general: - GeneralSettingsTab(settings: settings) + GeneralSettingsTab(settings: settings, store: store) case .providers: ProvidersSettingsTab(settings: settings, store: store, focusedProviderID: initialProviderID) case .sessions: @@ -124,7 +124,7 @@ public struct SettingsView: View { SettingsWindowBackdrop() } } - .frame(minWidth: 640, idealWidth: 660, minHeight: 460, idealHeight: 560) + .frame(minWidth: 640, idealWidth: 660, minHeight: 460, idealHeight: 710) } } @@ -152,6 +152,11 @@ public struct SettingsFooterView: View { private struct GeneralSettingsTab: View { @Bindable var settings: LoomSettings + let store: SessionStore? + + private var hasAvailableProviders: Bool { + store?.providerSnapshots.contains(where: { $0.availability.isAvailable }) ?? false + } var body: some View { VStack(spacing: 0) { @@ -205,6 +210,23 @@ private struct GeneralSettingsTab: View { } } + if settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: hasAvailableProviders) { + GitHubStarInvitationCard( + onDismiss: { + withAnimation(.easeInOut(duration: 0.2)) { + settings.dismissGitHubStarPrompt() + } + }, + onStar: { + NSWorkspace.shared.open(LoomSettings.gitHubRepoURL) + withAnimation(.easeInOut(duration: 0.2)) { + settings.dismissGitHubStarPrompt() + } + } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + SoftwareUpdatesSettingsSection() } @@ -482,7 +504,9 @@ private struct AboutSettingsTab: View { SettingsSection("Open Source") { HStack(spacing: 16) { - Link("Source Code", destination: URL(string: "https://github.com/chaiwithcode/Loom")!) + Link("Source Code", destination: LoomSettings.gitHubRepoURL) + Spacer(minLength: 0) + Link("Star on GitHub", destination: LoomSettings.gitHubRepoURL) Spacer(minLength: 0) Link("Release Notes", destination: URL(string: "https://github.com/chaiwithcode/Loom/releases")!) Spacer(minLength: 0) diff --git a/README.md b/README.md index 15ebe48..e38adfa 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ The Sessions view brings agent activity into one place. Waiting and failed sessi Search by repository, branch, or host app, then filter by attention, working, or recent activity. Available actions include focusing the host app, copying process or session details, and revealing a workspace in Finder. +In Settings, the menu bar can show the number of detected waiting or failed sessions. Unknown means Loom does not have enough evidence to determine a session's state. + Loom supports session discovery for **OpenAI Codex**, **Google Antigravity**, and **Anthropic Claude Code**. Discovery and workspace details depend on the evidence each provider exposes locally; Loom reports diagnostics rather than guaranteeing a session for every repository or process. *Loom is an independent open-source project and is not affiliated with or endorsed by OpenAI, Anthropic, or Google.* diff --git a/Tests/LoomCoreTests/SessionProductivityTests.swift b/Tests/LoomCoreTests/SessionProductivityTests.swift index d7e0ea9..9f03921 100644 --- a/Tests/LoomCoreTests/SessionProductivityTests.swift +++ b/Tests/LoomCoreTests/SessionProductivityTests.swift @@ -140,6 +140,9 @@ public enum TestMenuBarStatusEvaluator { text = nil case .activeSessionCount: text = activeCount > 0 ? "\(activeCount)" : nil + case .needsAttentionCount: + let count = allSessions.filter { $0.state == .waiting || $0.state == .failed }.count + text = count > 0 ? "\(count)" : nil case .lowestQuota: text = lowestQuotaPercent.map { "\($0)%" } } diff --git a/Tests/LoomCoreTests/SettingsTests.swift b/Tests/LoomCoreTests/SettingsTests.swift index 52ec813..a876e67 100644 --- a/Tests/LoomCoreTests/SettingsTests.swift +++ b/Tests/LoomCoreTests/SettingsTests.swift @@ -37,6 +37,7 @@ final class SettingsTests: XCTestCase { XCTAssertEqual(settings.hideFinishedAfter, .never, "Default hideFinishedAfter must be .never") XCTAssertFalse(settings.hideProjectPaths, "Default hideProjectPaths must be false") XCTAssertFalse(settings.showVersionInMenuBar, "Default showVersionInMenuBar must be false") + XCTAssertFalse(settings.hasDismissedGitHubStarPrompt, "Default hasDismissedGitHubStarPrompt must be false") // Notification preferences defaults XCTAssertTrue(settings.notificationsEnabled, "Default notificationsEnabled must be true") @@ -146,6 +147,10 @@ final class SettingsTests: XCTestCase { XCTAssertEqual(settings.menuBarStatusMode.displayName, "Active Sessions") XCTAssertEqual(settings.menuBarStatusMode.id, "activeSessionCount") + settings.menuBarStatusMode = .needsAttentionCount + XCTAssertEqual(LoomSettings(defaults: testDefaults).menuBarStatusMode, .needsAttentionCount) + XCTAssertEqual(settings.menuBarStatusMode.displayName, "Needs Attention") + settings.menuBarStatusMode = .lowestQuota XCTAssertEqual(settings.menuBarStatusMode, .lowestQuota) XCTAssertEqual(settings.menuBarStatusMode.displayName, "Lowest Quota") @@ -706,4 +711,91 @@ final class SettingsTests: XCTestCase { XCTAssertEqual(LoomSettings.safeProjectName(for: sessionWithCleanName, hidePaths: true), "MobileApp") XCTAssertEqual(LoomSettings.safeProjectName(for: sessionNilName, hidePaths: true), "BackendAPI") } + + @MainActor + func testGitHubStarPromptPersistenceAndCallbacks() throws { + let settings = LoomSettings(defaults: testDefaults) + XCTAssertFalse(settings.hasDismissedGitHubStarPrompt) + + var notifiedDismissed: Bool? + settings.onHasDismissedGitHubStarPromptChanged = { notifiedDismissed = $0 } + + settings.hasDismissedGitHubStarPrompt = true + XCTAssertTrue(settings.hasDismissedGitHubStarPrompt) + XCTAssertEqual(notifiedDismissed, true) + XCTAssertTrue(testDefaults.bool(forKey: LoomSettings.hasDismissedGitHubStarPromptKey)) + + // Verify restoration from defaults + let reloaded = LoomSettings(defaults: testDefaults) + XCTAssertTrue(reloaded.hasDismissedGitHubStarPrompt) + + // Verify Codable round-trip + let encoder = JSONEncoder() + let data = try encoder.encode(settings) + let decoded = try JSONDecoder().decode(LoomSettings.self, from: data) + XCTAssertTrue(decoded.hasDismissedGitHubStarPrompt) + + // Verify resetToDefaults resets it back to false + settings.resetToDefaults() + XCTAssertFalse(settings.hasDismissedGitHubStarPrompt) + } + + @MainActor + func testGitHubStarPromptEligibilityConditions() { + let settings = LoomSettings(defaults: testDefaults) + let now = Date() + + // 1. Initial launch state: 0 days, 0 sessions -> not eligible + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: now)) + + // 2. 6 days elapsed but 0 sessions -> not eligible + settings.firstLaunchDate = now.addingTimeInterval(-6 * 86400) + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: now)) + + // 3. 6 days elapsed, 2 distinct sessions -> not eligible + settings.recordDiscoveredSessions(["sess-1", "sess-2"]) + XCTAssertEqual(settings.distinctObservedSessionIDs.count, 2) + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: now)) + + // 4. Duplicate sessions do not increment count (polling refresh safety) + settings.recordDiscoveredSessions(["sess-1", "sess-2"]) + XCTAssertEqual(settings.distinctObservedSessionIDs.count, 2) + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: now)) + + // 5. 6 days elapsed, 3 distinct sessions, but no providers available -> not eligible + settings.recordDiscoveredSessions(["sess-3"]) + XCTAssertEqual(settings.distinctObservedSessionIDs.count, 3) + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: false, now: now)) + + // 6. 3 sessions, providers available, but only 4 days elapsed -> not eligible + let fourDaysLater = settings.firstLaunchDate.addingTimeInterval(4 * 86400) + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: fourDaysLater)) + + // 7. >= 5 days elapsed, >= 3 distinct sessions, providers available -> eligible! + let fiveDaysLater = settings.firstLaunchDate.addingTimeInterval(5 * 86400) + XCTAssertTrue(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: fiveDaysLater)) + + // 8. Dismissal permanently suppresses prompt + settings.dismissGitHubStarPrompt() + XCTAssertTrue(settings.hasDismissedGitHubStarPrompt) + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: fiveDaysLater)) + + // 9. Reloader verifies persistence of dismissal and sessions across instances + let reloaded = LoomSettings(defaults: testDefaults) + XCTAssertTrue(reloaded.hasDismissedGitHubStarPrompt) + XCTAssertEqual(reloaded.distinctObservedSessionIDs.count, 3) + XCTAssertFalse(reloaded.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: fiveDaysLater)) + } + + @MainActor + func testPriorDismissalStateRemainsSuppressed() { + // Prior dismissal key in standard UserDefaults suppresses prompts immediately + testDefaults.set(true, forKey: LoomSettings.hasDismissedGitHubStarPromptKey) + let settings = LoomSettings(defaults: testDefaults) + settings.firstLaunchDate = Date().addingTimeInterval(-10 * 86400) + settings.recordDiscoveredSessions(["s1", "s2", "s3", "s4"]) + + XCTAssertTrue(settings.hasDismissedGitHubStarPrompt) + XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: Date())) + } } diff --git a/Tests/SettingsProbe.swift b/Tests/SettingsProbe.swift index 960efd5..af9df8a 100644 --- a/Tests/SettingsProbe.swift +++ b/Tests/SettingsProbe.swift @@ -279,6 +279,22 @@ struct OCRResult { assert(MenuBarStatusEvaluator.evaluate(mode: .iconOnly, snapshots: [statusSnapshot]).text == nil) assert(MenuBarStatusEvaluator.evaluate(mode: .activeSessionCount, snapshots: [statusSnapshot]).text == "1") assert(MenuBarStatusEvaluator.evaluate(mode: .lowestQuota, snapshots: [statusSnapshot]).text == "12%") + var attentionSnapshot = statusSnapshot + attentionSnapshot.sessions = [ + AgentSession(id: "waiting", provider: .codex, state: .waiting), + AgentSession(id: "failed", provider: .codex, state: .failed), + AgentSession(id: "unknown", provider: .codex, state: .unknown), + statusSession + ] + let attention = MenuBarStatusEvaluator.evaluate(mode: .needsAttentionCount, snapshots: [attentionSnapshot]) + assert(attention.text == "2" && attention.attentionCount == 2 && attention.hasAttention) + assert(MenuBarStatusEvaluator.evaluate(mode: .needsAttentionCount, snapshots: [statusSnapshot]).text == nil) + var disabledAttention = attentionSnapshot + disabledAttention.isEnabled = false + assert(MenuBarStatusEvaluator.evaluate(mode: .needsAttentionCount, snapshots: [disabledAttention]).text == nil) + var unavailableAttention = attentionSnapshot + unavailableAttention.availability = .error(reason: "Provider unavailable") + assert(MenuBarStatusEvaluator.evaluate(mode: .needsAttentionCount, snapshots: [unavailableAttention]).text == nil) var failedStatusSnapshot = statusSnapshot failedStatusSnapshot.availability = .error(reason: "Provider unavailable") assert(MenuBarStatusEvaluator.evaluate(mode: .lowestQuota, snapshots: [failedStatusSnapshot]).text == nil) @@ -1056,6 +1072,50 @@ struct OCRResult { print(" PASS: Seven navigation controls visible and hittable without overflow on titled window across widths, appearances, and deep links") fflush(stdout) + // ===================================================================== + // Test 14: GitHub Star invitation card visual verification (Dark & Light) + // ===================================================================== + print("Check 14: GitHub Star invitation card visual appearance & dismissal") + let starSettings = LoomSettings(defaults: testDefaults) + starSettings.firstLaunchDate = Date().addingTimeInterval(-6 * 86400) + starSettings.recordDiscoveredSessions(["sess-alpha", "sess-beta", "sess-gamma"]) + assert(starSettings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true), "Star invitation must be eligible with >= 5 days and >= 3 sessions") + + let starHost = NSHostingView(rootView: SettingsView(settings: starSettings, store: healthStore, initialTab: .general).frame(width: 660, height: 710)) + + // 14a. Dark mode capture & OCR assertion + starHost.appearance = NSAppearance(named: .darkAqua) + probeWindow.contentView = starHost + await settle(starHost) + let starDarkRep = saveOpaque(starHost, "settings-general-star-card-dark") + let starDarkResults = try recognizeText(in: starDarkRep) + let starDarkText = normalizeAndJoinReadingOrder(starDarkResults) + assert(containsToken("Enjoying Loom?", in: starDarkText), "Dark mode must render 'Enjoying Loom?' card title") + assert(containsToken("giving it a star on GitHub", in: starDarkText), "Dark mode must render invitation message") + assert(containsToken("Not now", in: starDarkText), "Dark mode must render 'Not now' dismiss button") + assert(containsToken("Star on GitHub", in: starDarkText), "Dark mode must render 'Star on GitHub' action button") + + // 14b. Light mode capture & OCR assertion + starHost.appearance = NSAppearance(named: .aqua) + await settle(starHost) + let starLightRep = saveOpaque(starHost, "settings-general-star-card-light") + let starLightResults = try recognizeText(in: starLightRep) + let starLightText = normalizeAndJoinReadingOrder(starLightResults) + assert(containsToken("Enjoying Loom?", in: starLightText), "Light mode must render 'Enjoying Loom?' card title") + assert(containsToken("giving it a star on GitHub", in: starLightText), "Light mode must render invitation message") + assert(containsToken("Not now", in: starLightText), "Light mode must render 'Not now' dismiss button") + assert(containsToken("Star on GitHub", in: starLightText), "Light mode must render 'Star on GitHub' action button") + + // 14c. Dismissal removes card from view + starSettings.dismissGitHubStarPrompt() + assert(!starSettings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true), "Dismissal must render prompt ineligible") + await settle(starHost) + let dismissedRep = saveOpaque(starHost, "settings-general-star-card-dismissed") + let dismissedText = normalizeAndJoinReadingOrder(try recognizeText(in: dismissedRep)) + assert(!containsToken("Enjoying Loom?", in: dismissedText), "Dismissed star card must not appear on General tab") + print(" PASS: GitHub Star invitation card visual appearance (light & dark mode) and dismissal verified") + fflush(stdout) + settingsWindow.close() print("==================================================") From 460f5479c9633087afacdfd741c0f36854aad9a5 Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:11:15 +0000 Subject: [PATCH 02/11] feat: implement multi-agent CLI & T3Code monitoring with AgentSessionsSectionView --- Loom.xcodeproj/project.pbxproj | 94 +++ .../AgentMonitoringCoordinator.swift | 138 +++++ Loom/Core/Discovery/BoundedJSONLReader.swift | 209 +++++++ .../Core/Discovery/JSONLProviderParsers.swift | 359 +++++++++++ Loom/Core/Discovery/ProcessTreeScanner.swift | 131 ++++ Loom/Core/Models/AgentSessionModels.swift | 96 +++ Loom/Core/Models/ProviderID.swift | 3 + .../Antigravity/AntigravityCLIDetector.swift | 575 ++++++++++++++++++ .../Providers/Claude/ClaudeCodeDetector.swift | 505 +++++++++++++++ Loom/Providers/Codex/CodexCLIDetector.swift | 484 +++++++++++++++ Loom/Providers/T3Code/T3CodeDetector.swift | 472 ++++++++++++++ Loom/Providers/T3Code/T3CodeProvider.swift | 106 ++++ .../Components/AgentSessionsSectionView.swift | 244 ++++++++ PLANS.md | 553 +++++++++++++++++ Package.swift | 8 +- .../AgentMonitoringCoordinatorTests.swift | 201 ++++++ .../AgentSessionsSectionViewTests.swift | 31 + .../AntigravityCLIDetectorTests.swift | 135 ++++ .../BoundedJSONLReaderTests.swift | 104 ++++ .../ClaudeCodeDetectorTests.swift | 157 +++++ .../LoomCoreTests/CodexCLIDetectorTests.swift | 175 ++++++ .../ProcessTreeScannerTests.swift | 67 ++ Tests/LoomCoreTests/T3CodeDetectorTests.swift | 289 +++++++++ 23 files changed, 5132 insertions(+), 4 deletions(-) create mode 100644 Loom/Core/Discovery/AgentMonitoringCoordinator.swift create mode 100644 Loom/Core/Discovery/BoundedJSONLReader.swift create mode 100644 Loom/Core/Discovery/JSONLProviderParsers.swift create mode 100644 Loom/Core/Discovery/ProcessTreeScanner.swift create mode 100644 Loom/Core/Models/AgentSessionModels.swift create mode 100644 Loom/Providers/Antigravity/AntigravityCLIDetector.swift create mode 100644 Loom/Providers/Claude/ClaudeCodeDetector.swift create mode 100644 Loom/Providers/Codex/CodexCLIDetector.swift create mode 100644 Loom/Providers/T3Code/T3CodeDetector.swift create mode 100644 Loom/Providers/T3Code/T3CodeProvider.swift create mode 100644 Loom/UI/Components/AgentSessionsSectionView.swift create mode 100644 PLANS.md create mode 100644 Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift create mode 100644 Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift create mode 100644 Tests/LoomCoreTests/AntigravityCLIDetectorTests.swift create mode 100644 Tests/LoomCoreTests/BoundedJSONLReaderTests.swift create mode 100644 Tests/LoomCoreTests/ClaudeCodeDetectorTests.swift create mode 100644 Tests/LoomCoreTests/CodexCLIDetectorTests.swift create mode 100644 Tests/LoomCoreTests/ProcessTreeScannerTests.swift create mode 100644 Tests/LoomCoreTests/T3CodeDetectorTests.swift diff --git a/Loom.xcodeproj/project.pbxproj b/Loom.xcodeproj/project.pbxproj index 191d5d2..b79e0b7 100644 --- a/Loom.xcodeproj/project.pbxproj +++ b/Loom.xcodeproj/project.pbxproj @@ -34,8 +34,16 @@ A000000000000000000000A5 /* CodexProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000C5 /* CodexProvider.swift */; }; A000000000000000000000A6 /* CodexProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000C6 /* CodexProviderTests.swift */; }; A000000000000000000000A7 /* CodexUsageParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000C7 /* CodexUsageParser.swift */; }; + D00000000000000000000044 /* CodexCLIDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000043 /* CodexCLIDetector.swift */; }; A000000000000000000000A8 /* CodexUsageFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000C8 /* CodexUsageFetcher.swift */; }; A000000000000000000000A9 /* UsageEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000C9 /* UsageEngineTests.swift */; }; + D00000000000000000000033 /* ProcessTreeScannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000032 /* ProcessTreeScannerTests.swift */; }; + D0000000000000000000003B /* BoundedJSONLReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000003A /* BoundedJSONLReaderTests.swift */; }; + D00000000000000000000042 /* T3CodeDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000041 /* T3CodeDetectorTests.swift */; }; + D00000000000000000000046 /* CodexCLIDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000045 /* CodexCLIDetectorTests.swift */; }; + D0000000000000000000004A /* ClaudeCodeDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000049 /* ClaudeCodeDetectorTests.swift */; }; + D0000000000000000000004E /* AntigravityCLIDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004D /* AntigravityCLIDetectorTests.swift */; }; + D00000000000000000000052 /* AgentMonitoringCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000051 /* AgentMonitoringCoordinatorTests.swift */; }; A000000000000000000000B1 /* AntigravityProcessInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000E1 /* AntigravityProcessInspector.swift */; }; A000000000000000000000B2 /* AntigravityParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000E2 /* AntigravityParser.swift */; }; A000000000000000000000B3 /* DefaultAntigravityProcessDiscoverer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000E3 /* DefaultAntigravityProcessDiscoverer.swift */; }; @@ -52,6 +60,12 @@ A00000000000000000000102 /* AgentProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000101 /* AgentProcess.swift */; }; A00000000000000000000104 /* ProviderDiscoveryDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000103 /* ProviderDiscoveryDiagnostics.swift */; }; A00000000000000000000106 /* ProcessTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000105 /* ProcessTree.swift */; }; + D00000000000000000000031 /* ProcessTreeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000030 /* ProcessTreeScanner.swift */; }; + D00000000000000000000035 /* AgentSessionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000034 /* AgentSessionModels.swift */; }; + D00000000000000000000037 /* BoundedJSONLReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000036 /* BoundedJSONLReader.swift */; }; + D00000000000000000000039 /* JSONLProviderParsers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000038 /* JSONLProviderParsers.swift */; }; + D0000000000000000000003D /* T3CodeDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000003C /* T3CodeDetector.swift */; }; + D0000000000000000000003F /* T3CodeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000003E /* T3CodeProvider.swift */; }; A00000000000000000000108 /* WorkspaceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000107 /* WorkspaceResolver.swift */; }; A0000000000000000000010A /* SessionCorrelationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000109 /* SessionCorrelationEngine.swift */; }; A0000000000000000000010C /* DiscoveryPipelineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0000000000000000000010B /* DiscoveryPipelineTests.swift */; }; @@ -63,6 +77,9 @@ A00000000000000000000118 /* SessionLifecycleReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000119 /* SessionLifecycleReconcilerTests.swift */; }; A00000000000000000000120 /* ClaudeProcessInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000121 /* ClaudeProcessInspector.swift */; }; A00000000000000000000122 /* ClaudeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000123 /* ClaudeParser.swift */; }; + D00000000000000000000048 /* ClaudeCodeDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000047 /* ClaudeCodeDetector.swift */; }; + D0000000000000000000004C /* AntigravityCLIDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004B /* AntigravityCLIDetector.swift */; }; + D00000000000000000000050 /* AgentMonitoringCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004F /* AgentMonitoringCoordinator.swift */; }; A00000000000000000000124 /* ClaudeTranscriptInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000125 /* ClaudeTranscriptInspector.swift */; }; A00000000000000000000126 /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000127 /* ClaudeProvider.swift */; }; A00000000000000000000128 /* ClaudeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000129 /* ClaudeProviderTests.swift */; }; @@ -105,6 +122,9 @@ D00000000000000000000101 /* LICENSE.lobehub in Resources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000100 /* LICENSE.lobehub */; }; A00000000000000000000206 /* LoomCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A00000000000000000000030 /* LoomCore.framework */; }; A00000000000000000000209 /* LoomRegressionTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000208 /* LoomRegressionTestSuite.swift */; }; + D00000000000000000000053 /* AgentSessionsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000054 /* AgentSessionsSectionView.swift */; }; + D00000000000000000000056 /* AgentSessionsSectionViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000057 /* AgentSessionsSectionViewTests.swift */; }; + D00000000000000000000058 /* AgentSessionsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000054 /* AgentSessionsSectionView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -191,8 +211,16 @@ A000000000000000000000C5 /* CodexProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexProvider.swift; sourceTree = ""; }; A000000000000000000000C6 /* CodexProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexProviderTests.swift; sourceTree = ""; }; A000000000000000000000C7 /* CodexUsageParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexUsageParser.swift; sourceTree = ""; }; + D00000000000000000000043 /* CodexCLIDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexCLIDetector.swift; sourceTree = ""; }; A000000000000000000000C8 /* CodexUsageFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexUsageFetcher.swift; sourceTree = ""; }; A000000000000000000000C9 /* UsageEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageEngineTests.swift; sourceTree = ""; }; + D00000000000000000000032 /* ProcessTreeScannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTreeScannerTests.swift; sourceTree = ""; }; + D0000000000000000000003A /* BoundedJSONLReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoundedJSONLReaderTests.swift; sourceTree = ""; }; + D00000000000000000000041 /* T3CodeDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T3CodeDetectorTests.swift; sourceTree = ""; }; + D00000000000000000000045 /* CodexCLIDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexCLIDetectorTests.swift; sourceTree = ""; }; + D00000000000000000000049 /* ClaudeCodeDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeDetectorTests.swift; sourceTree = ""; }; + D0000000000000000000004D /* AntigravityCLIDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityCLIDetectorTests.swift; sourceTree = ""; }; + D00000000000000000000051 /* AgentMonitoringCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitoringCoordinatorTests.swift; sourceTree = ""; }; A000000000000000000000E1 /* AntigravityProcessInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityProcessInspector.swift; sourceTree = ""; }; A000000000000000000000E2 /* AntigravityParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityParser.swift; sourceTree = ""; }; A000000000000000000000E3 /* DefaultAntigravityProcessDiscoverer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAntigravityProcessDiscoverer.swift; sourceTree = ""; }; @@ -209,6 +237,12 @@ A00000000000000000000101 /* AgentProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentProcess.swift; sourceTree = ""; }; A00000000000000000000103 /* ProviderDiscoveryDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderDiscoveryDiagnostics.swift; sourceTree = ""; }; A00000000000000000000105 /* ProcessTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTree.swift; sourceTree = ""; }; + D00000000000000000000030 /* ProcessTreeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTreeScanner.swift; sourceTree = ""; }; + D00000000000000000000034 /* AgentSessionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionModels.swift; sourceTree = ""; }; + D00000000000000000000036 /* BoundedJSONLReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoundedJSONLReader.swift; sourceTree = ""; }; + D00000000000000000000038 /* JSONLProviderParsers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONLProviderParsers.swift; sourceTree = ""; }; + D0000000000000000000003C /* T3CodeDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T3CodeDetector.swift; sourceTree = ""; }; + D0000000000000000000003E /* T3CodeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = T3CodeProvider.swift; sourceTree = ""; }; A00000000000000000000107 /* WorkspaceResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceResolver.swift; sourceTree = ""; }; A00000000000000000000109 /* SessionCorrelationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCorrelationEngine.swift; sourceTree = ""; }; A0000000000000000000010B /* DiscoveryPipelineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPipelineTests.swift; sourceTree = ""; }; @@ -220,6 +254,9 @@ A00000000000000000000119 /* SessionLifecycleReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionLifecycleReconcilerTests.swift; sourceTree = ""; }; A00000000000000000000121 /* ClaudeProcessInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProcessInspector.swift; sourceTree = ""; }; A00000000000000000000123 /* ClaudeParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeParser.swift; sourceTree = ""; }; + D00000000000000000000047 /* ClaudeCodeDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeDetector.swift; sourceTree = ""; }; + D0000000000000000000004B /* AntigravityCLIDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityCLIDetector.swift; sourceTree = ""; }; + D0000000000000000000004F /* AgentMonitoringCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitoringCoordinator.swift; sourceTree = ""; }; A00000000000000000000125 /* ClaudeTranscriptInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeTranscriptInspector.swift; sourceTree = ""; }; A00000000000000000000127 /* ClaudeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProvider.swift; sourceTree = ""; }; A00000000000000000000129 /* ClaudeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProviderTests.swift; sourceTree = ""; }; @@ -261,6 +298,8 @@ D00000000000000000000100 /* LICENSE.lobehub */ = {isa = PBXFileReference; lastKnownFileType = text; path = ProviderSources/LICENSE.lobehub; sourceTree = ""; }; A00000000000000000000201 /* LoomRegressionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoomRegressionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A00000000000000000000208 /* LoomRegressionTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoomRegressionTestSuite.swift; sourceTree = ""; }; + D00000000000000000000054 /* AgentSessionsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionsSectionView.swift; sourceTree = ""; }; + D00000000000000000000057 /* AgentSessionsSectionViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionsSectionViewTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -351,10 +390,19 @@ ); path = App; sourceTree = ""; + }; + D00000000000000000000055 /* Components */ = { + isa = PBXGroup; + children = ( + D00000000000000000000054 /* AgentSessionsSectionView.swift */, + ); + path = Components; + sourceTree = ""; }; A00000000000000000000012 /* UI */ = { isa = PBXGroup; children = ( + D00000000000000000000055 /* Components */, A00000000000000000000013 /* MenuBar */, A000000000000000000000FB /* Settings */, ); @@ -403,6 +451,7 @@ A0000000000000000000002C /* AgentUsage.swift */, A000000000000000000000F1 /* LoomSettings.swift */, A00000000000000000000101 /* AgentProcess.swift */, + D00000000000000000000034 /* AgentSessionModels.swift */, A00000000000000000000103 /* ProviderDiscoveryDiagnostics.swift */, A00000000000000000000131 /* AppVersion.swift */, A00000000000000000000143 /* LoomWidgetSnapshot.swift */, @@ -435,10 +484,20 @@ A0000000000000000000012A /* Claude */, A000000000000000000000D0 /* Codex */, A00000000000000000000019 /* Mock */, + D00000000000000000000040 /* T3Code */, ); path = Providers; sourceTree = ""; }; + D00000000000000000000040 /* T3Code */ = { + isa = PBXGroup; + children = ( + D0000000000000000000003C /* T3CodeDetector.swift */, + D0000000000000000000003E /* T3CodeProvider.swift */, + ); + path = T3Code; + sourceTree = ""; + }; A00000000000000000000019 /* Mock */ = { isa = PBXGroup; children = ( @@ -459,11 +518,19 @@ A0000000000000000000001B /* LoomCoreTests */ = { isa = PBXGroup; children = ( + D00000000000000000000057 /* AgentSessionsSectionViewTests.swift */, B00000000000000000000005 /* Tests/LoomCoreTests/StorageAndRefreshTests.swift */, A0000000000000000000002B /* SessionStoreTests.swift */, A000000000000000000000C6 /* CodexProviderTests.swift */, A000000000000000000000E6 /* AntigravityProviderTests.swift */, A000000000000000000000C9 /* UsageEngineTests.swift */, + D00000000000000000000032 /* ProcessTreeScannerTests.swift */, + D0000000000000000000003A /* BoundedJSONLReaderTests.swift */, + D00000000000000000000041 /* T3CodeDetectorTests.swift */, + D00000000000000000000045 /* CodexCLIDetectorTests.swift */, + D00000000000000000000049 /* ClaudeCodeDetectorTests.swift */, + D0000000000000000000004D /* AntigravityCLIDetectorTests.swift */, + D00000000000000000000051 /* AgentMonitoringCoordinatorTests.swift */, A000000000000000000000F7 /* SettingsTests.swift */, A000000000000000000000F9 /* MenuLayoutTests.swift */, A0000000000000000000010B /* DiscoveryPipelineTests.swift */, @@ -507,6 +574,7 @@ A000000000000000000000C4 /* DefaultCodexProcessDiscoverer.swift */, A000000000000000000000C5 /* CodexProvider.swift */, A000000000000000000000C7 /* CodexUsageParser.swift */, + D00000000000000000000043 /* CodexCLIDetector.swift */, A000000000000000000000C8 /* CodexUsageFetcher.swift */, A00000000000000000000111 /* CodexStateStoreInspector.swift */, A00000000000000000000113 /* CodexWorkspaceEvidenceProvider.swift */, @@ -522,6 +590,7 @@ A000000000000000000000E3 /* DefaultAntigravityProcessDiscoverer.swift */, A000000000000000000000E4 /* DefaultAntigravityStoreInspector.swift */, A000000000000000000000E5 /* AntigravityProvider.swift */, + D0000000000000000000004B /* AntigravityCLIDetector.swift */, A000000000000000000000E7 /* AntigravityUsageParser.swift */, A000000000000000000000E8 /* AntigravityUsageFetcher.swift */, ); @@ -543,9 +612,13 @@ isa = PBXGroup; children = ( A00000000000000000000105 /* ProcessTree.swift */, + D00000000000000000000030 /* ProcessTreeScanner.swift */, + D00000000000000000000036 /* BoundedJSONLReader.swift */, + D00000000000000000000038 /* JSONLProviderParsers.swift */, A00000000000000000000107 /* WorkspaceResolver.swift */, A00000000000000000000109 /* SessionCorrelationEngine.swift */, A00000000000000000000117 /* SessionLifecycleReconciler.swift */, + D0000000000000000000004F /* AgentMonitoringCoordinator.swift */, ); path = Discovery; sourceTree = ""; @@ -555,6 +628,7 @@ children = ( A00000000000000000000121 /* ClaudeProcessInspector.swift */, A00000000000000000000123 /* ClaudeParser.swift */, + D00000000000000000000047 /* ClaudeCodeDetector.swift */, A00000000000000000000125 /* ClaudeTranscriptInspector.swift */, A00000000000000000000127 /* ClaudeProvider.swift */, ); @@ -795,6 +869,7 @@ A000000000000000000000B5 /* AntigravityProvider.swift in Sources */, A00000000000000000000077 /* AgentUsage.swift in Sources */, A000000000000000000000A7 /* CodexUsageParser.swift in Sources */, + D00000000000000000000044 /* CodexCLIDetector.swift in Sources */, A000000000000000000000A8 /* CodexUsageFetcher.swift in Sources */, A000000000000000000000B7 /* AntigravityUsageParser.swift in Sources */, A000000000000000000000B8 /* AntigravityUsageFetcher.swift in Sources */, @@ -802,6 +877,12 @@ A00000000000000000000102 /* AgentProcess.swift in Sources */, A00000000000000000000104 /* ProviderDiscoveryDiagnostics.swift in Sources */, A00000000000000000000106 /* ProcessTree.swift in Sources */, + D00000000000000000000031 /* ProcessTreeScanner.swift in Sources */, + D00000000000000000000035 /* AgentSessionModels.swift in Sources */, + D00000000000000000000037 /* BoundedJSONLReader.swift in Sources */, + D00000000000000000000039 /* JSONLProviderParsers.swift in Sources */, + D0000000000000000000003D /* T3CodeDetector.swift in Sources */, + D0000000000000000000003F /* T3CodeProvider.swift in Sources */, A00000000000000000000108 /* WorkspaceResolver.swift in Sources */, A0000000000000000000010A /* SessionCorrelationEngine.swift in Sources */, A00000000000000000000110 /* CodexStateStoreInspector.swift in Sources */, @@ -809,6 +890,9 @@ A00000000000000000000116 /* SessionLifecycleReconciler.swift in Sources */, A00000000000000000000120 /* ClaudeProcessInspector.swift in Sources */, A00000000000000000000122 /* ClaudeParser.swift in Sources */, + D00000000000000000000048 /* ClaudeCodeDetector.swift in Sources */, + D0000000000000000000004C /* AntigravityCLIDetector.swift in Sources */, + D00000000000000000000050 /* AgentMonitoringCoordinator.swift in Sources */, A00000000000000000000124 /* ClaudeTranscriptInspector.swift in Sources */, A00000000000000000000126 /* ClaudeProvider.swift in Sources */, A00000000000000000000132 /* AppVersion.swift in Sources */, @@ -823,6 +907,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D00000000000000000000053 /* AgentSessionsSectionView.swift in Sources */, D00000000000000000000019 /* DiagnosticsDisclosureView.swift in Sources */, D00000000000000000000017 /* SessionFilterBar.swift in Sources */, D00000000000000000000015 /* LoomGlassStyles.swift in Sources */, @@ -850,11 +935,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D00000000000000000000056 /* AgentSessionsSectionViewTests.swift in Sources */, + D00000000000000000000058 /* AgentSessionsSectionView.swift in Sources */, C00000000000000000000005 /* Tests/LoomCoreTests/StorageAndRefreshTests.swift in Sources */, A00000000000000000000088 /* SessionStoreTests.swift in Sources */, A000000000000000000000A6 /* CodexProviderTests.swift in Sources */, A000000000000000000000B6 /* AntigravityProviderTests.swift in Sources */, A000000000000000000000A9 /* UsageEngineTests.swift in Sources */, + D00000000000000000000033 /* ProcessTreeScannerTests.swift in Sources */, + D0000000000000000000003B /* BoundedJSONLReaderTests.swift in Sources */, + D00000000000000000000042 /* T3CodeDetectorTests.swift in Sources */, + D00000000000000000000046 /* CodexCLIDetectorTests.swift in Sources */, + D0000000000000000000004A /* ClaudeCodeDetectorTests.swift in Sources */, + D0000000000000000000004E /* AntigravityCLIDetectorTests.swift in Sources */, + D00000000000000000000052 /* AgentMonitoringCoordinatorTests.swift in Sources */, A000000000000000000000F8 /* SettingsTests.swift in Sources */, A000000000000000000000FA /* MenuLayoutTests.swift in Sources */, A0000000000000000000010C /* DiscoveryPipelineTests.swift in Sources */, diff --git a/Loom/Core/Discovery/AgentMonitoringCoordinator.swift b/Loom/Core/Discovery/AgentMonitoringCoordinator.swift new file mode 100644 index 0000000..9d426cd --- /dev/null +++ b/Loom/Core/Discovery/AgentMonitoringCoordinator.swift @@ -0,0 +1,138 @@ +// +// AgentMonitoringCoordinator.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation + +@MainActor +public final class AgentMonitoringCoordinator: ObservableObject { + @Published public private(set) var sessions: [AgentSessionStatus] = [] + + private let scanner: ProcessTreeScanner + private let t3Detector: T3CodeDetector + private let codexDetector: CodexCLIDetector + private let claudeDetector: ClaudeCodeDetector + private let agyDetector: AntigravityCLIDetector + + private let watcherQueue = DispatchQueue(label: "com.chaiwithcode.loom.fs-watcher", qos: .utility) + private var fileWatchers: [DispatchSourceFileSystemObject] = [] + private var debounceWorkItem: DispatchWorkItem? + private var pollingTask: Task? + + public init( + scanner: ProcessTreeScanner = ProcessTreeScanner(), + t3Detector: T3CodeDetector = T3CodeDetector(), + codexDetector: CodexCLIDetector = CodexCLIDetector(), + claudeDetector: ClaudeCodeDetector = ClaudeCodeDetector(), + agyDetector: AntigravityCLIDetector = AntigravityCLIDetector() + ) { + self.scanner = scanner + self.t3Detector = t3Detector + self.codexDetector = codexDetector + self.claudeDetector = claudeDetector + self.agyDetector = agyDetector + } + + public func startMonitoring() { + stopMonitoring() + setupDirectoryWatchers() + + pollingTask = Task { [weak self] in + while !Task.isCancelled { + await self?.refreshAll() + try? await Task.sleep(for: .seconds(3)) + } + } + } + + public func stopMonitoring() { + pollingTask?.cancel() + pollingTask = nil + debounceWorkItem?.cancel() + for watcher in fileWatchers { + watcher.cancel() + } + fileWatchers.removeAll() + } + + public func refreshAll() async { + let table = await Task.detached(priority: .utility) { [scanner] in + scanner.scanProcessTable() + }.value + + // 1. Run T3Code first to obtain rootT3PIDs for child CLI deduplication + let t3Result = await t3Detector.detect(processTable: table) + let rootT3PIDs = t3Result.rootT3PIDs + + // 2. Run CLI detectors in parallel with rootT3PIDs + async let codexSessions = Task.detached(priority: .utility) { [codexDetector] in + codexDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) + }.value + + async let claudeSessions = Task.detached(priority: .utility) { [claudeDetector] in + claudeDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) + }.value + + async let agySessions = Task.detached(priority: .utility) { [agyDetector] in + agyDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) + }.value + + let combined = t3Result.sessions + (await codexSessions) + (await claudeSessions) + (await agySessions) + self.sessions = combined.sorted { lhs, rhs in + if (lhs.state != .inactive) != (rhs.state != .inactive) { + return lhs.state != .inactive + } + return lhs.lastUpdated > rhs.lastUpdated + } + } + + // MARK: - 500ms Debounced DispatchSource Watchers + + private func setupDirectoryWatchers() { + let watchedDirs: [URL] = [ + T3CodeDetector.resolveT3HomeDirectory().appendingPathComponent("userdata"), + CodexCLIDetector.resolveCodexHomeDirectory().appendingPathComponent("sessions"), + ClaudeCodeDetector.resolveClaudeProjectsDirectory(), + AntigravityCLIDetector.resolveAntigravityCLIDirectory().appendingPathComponent("brain") + ] + + for url in watchedDirs { + let fd = Darwin.open(url.path, O_EVTONLY) + guard fd >= 0 else { continue } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .rename], + queue: watcherQueue + ) + + source.setEventHandler { [weak self] in + self?.scheduleDebouncedRefresh() + } + + source.setCancelHandler { + Darwin.close(fd) + } + + source.resume() + fileWatchers.append(source) + } + } + + private func scheduleDebouncedRefresh() { + debounceWorkItem?.cancel() + let item = DispatchWorkItem { [weak self] in + guard let self else { return } + Task { @MainActor in + await self.refreshAll() + } + } + debounceWorkItem = item + watcherQueue.asyncAfter(deadline: .now() + .milliseconds(500), execute: item) + } +} diff --git a/Loom/Core/Discovery/BoundedJSONLReader.swift b/Loom/Core/Discovery/BoundedJSONLReader.swift new file mode 100644 index 0000000..47c02a4 --- /dev/null +++ b/Loom/Core/Discovery/BoundedJSONLReader.swift @@ -0,0 +1,209 @@ +// +// BoundedJSONLReader.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation + +/// Result of a bounded head + tail read on an append-only JSONL transcript. +public struct JSONLSnapshot: Sendable { + /// Complete JSON lines from the start of the file (typically the first 4 KB). + public let headLines: [Data] + /// Complete JSON lines from the end of the file (typically the last 64 KB). + public let tailLines: [Data] + /// Total file size in bytes at the time of reading. + public let fileSize: UInt64 + /// Last modification timestamp from the filesystem. + public let modifiedAt: Date + + public init( + headLines: [Data], + tailLines: [Data], + fileSize: UInt64, + modifiedAt: Date + ) { + self.headLines = headLines + self.tailLines = tailLines + self.fileSize = fileSize + self.modifiedAt = modifiedAt + } +} + +public struct BoundedJSONLReader: Sendable { + public let headByteLimit: Int + public let tailByteLimit: Int + + public init(headByteLimit: Int = 4 * 1024, tailByteLimit: Int = 64 * 1024) { + self.headByteLimit = headByteLimit + self.tailByteLimit = tailByteLimit + } + + /// Reads the head (first 4 KB) and tail (last 64 KB) of a JSONL file without loading the entire file into memory. + public func readSnapshot(at url: URL) -> JSONLSnapshot? { + let path = url.path + + // 1. Open via POSIX with O_RDONLY | O_NONBLOCK | O_CLOEXEC so we never block CLI writers + let fd = Darwin.open(path, O_RDONLY | O_NONBLOCK | O_CLOEXEC) + guard fd >= 0 else { return nil } + + // fileHandle takes ownership and closes fd when close() is called + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) + defer { + try? handle.close() + } + + // 2. Stat the file descriptor directly for size and mtime + var statBuf = Darwin.stat() + guard fstat(fd, &statBuf) == 0 else { return nil } + + let fileSize = UInt64(max(0, statBuf.st_size)) + let modifiedAt = Date( + timeIntervalSince1970: TimeInterval(statBuf.st_mtimespec.tv_sec) + + TimeInterval(statBuf.st_mtimespec.tv_nsec) / 1_000_000_000 + ) + + guard fileSize > 0 else { + return JSONLSnapshot(headLines: [], tailLines: [], fileSize: 0, modifiedAt: modifiedAt) + } + + do { + // 3. If the entire file fits within tailByteLimit, read it in a single pass + if fileSize <= UInt64(tailByteLimit) { + try handle.seek(toOffset: 0) + let allData = try handle.readToEnd() ?? Data() + let lines = Self.extractCompleteLines( + from: allData, + dropLeadingPartialLine: false, + dropTrailingIncompleteLine: true + ) + return JSONLSnapshot( + headLines: Array(lines.prefix(5)), + tailLines: lines, + fileSize: fileSize, + modifiedAt: modifiedAt + ) + } + + // 4. Large File Path: Read Head (First 4 KB) for session metadata + try handle.seek(toOffset: 0) + let headData = try handle.read(upToCount: headByteLimit) ?? Data() + let headLines = Self.extractCompleteLines( + from: headData, + dropLeadingPartialLine: false, + dropTrailingIncompleteLine: true + ) + + // 5. Seek to Tail (Last 64 KB) + let tailOffset = fileSize - UInt64(tailByteLimit) + try handle.seek(toOffset: tailOffset) + let tailData = try handle.read(upToCount: tailByteLimit) ?? Data() + + // Because tailOffset > 0, the first bytes up to the first '\n' are almost certainly + // a truncated suffix of an earlier JSON line. Always drop the leading fragment. + let tailLines = Self.extractCompleteLines( + from: tailData, + dropLeadingPartialLine: true, + dropTrailingIncompleteLine: false + ) + + return JSONLSnapshot( + headLines: headLines, + tailLines: tailLines, + fileSize: fileSize, + modifiedAt: modifiedAt + ) + } catch { + return nil + } + } + + // MARK: - Byte-Level Line Splitting + + /// Splits raw UTF-8 JSONL data on `0x0A` (`\n`), safely trimming partial boundary lines. + public static func extractCompleteLines( + from data: Data, + dropLeadingPartialLine: Bool, + dropTrailingIncompleteLine: Bool + ) -> [Data] { + guard !data.isEmpty else { return [] } + + let newline: UInt8 = 0x0A // '\n' + let carriageReturn: UInt8 = 0x0D // '\r' + + var workingSlice = data[...] + + // 1. If we sought into the middle of a file, discard everything up to and including the first '\n' + if dropLeadingPartialLine { + guard let firstNewlineIdx = workingSlice.firstIndex(of: newline) else { + // Single giant line exceeding 64 KB; cannot safely parse + return [] + } + let nextStart = workingSlice.index(after: firstNewlineIdx) + guard nextStart < workingSlice.endIndex else { return [] } + workingSlice = workingSlice[nextStart...] + } + + // 2. If we read a fixed chunk from the start of a file (or if an agent is mid-write without a trailing '\n'), + // discard bytes after the last '\n' if requested or if the line doesn't end with '}' + if dropTrailingIncompleteLine, workingSlice.last != newline { + if let lastNewlineIdx = workingSlice.lastIndex(of: newline) { + workingSlice = workingSlice[...lastNewlineIdx] + } else { + return [] + } + } + + // 3. Split into non-empty lines and strip optional '\r' + var result: [Data] = [] + result.reserveCapacity(64) + + var lineStart = workingSlice.startIndex + var idx = workingSlice.startIndex + + while idx < workingSlice.endIndex { + if workingSlice[idx] == newline { + var lineEnd = idx + if lineEnd > lineStart && workingSlice[workingSlice.index(before: lineEnd)] == carriageReturn { + lineEnd = workingSlice.index(before: lineEnd) + } + if lineEnd > lineStart { + let lineData = Data(workingSlice[lineStart.. Bool { + guard data.count >= 2 else { return false } + + let openBrace: UInt8 = 0x7B // '{' + let closeBrace: UInt8 = 0x7D // '}' + + guard let firstNonWS = data.first(where: { $0 > 0x20 }), + let lastNonWS = data.last(where: { $0 > 0x20 }) else { + return false + } + return firstNonWS == openBrace && lastNonWS == closeBrace + } +} diff --git a/Loom/Core/Discovery/JSONLProviderParsers.swift b/Loom/Core/Discovery/JSONLProviderParsers.swift new file mode 100644 index 0000000..61ce0b7 --- /dev/null +++ b/Loom/Core/Discovery/JSONLProviderParsers.swift @@ -0,0 +1,359 @@ +// +// JSONLProviderParsers.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Foundation + +public enum JSONLProviderParsers { + + // MARK: - 1. Codex CLI Rollout Parser (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl) + + private struct CodexRolloutEnvelope: Decodable { + let timestamp: String? + let type: String? + let payload: CodexPayload? + } + + private struct CodexPayload: Decodable { + // session_meta / turn_context fields + let id: String? + let cwd: String? + let model: String? + let approval_mode: String? + + // event_msg fields + let type: String? + let info: CodexEventInfo? + } + + private struct CodexEventInfo: Decodable { + let total_token_usage: CodexTokenUsage? + } + + private struct CodexTokenUsage: Decodable { + let input_tokens: Int? + let cached_input_tokens: Int? + let output_tokens: Int? + let reasoning_output_tokens: Int? + } + + public struct CodexParsedTranscript: Sendable { + public var sessionID: String? + public var cwd: String? + public var model: String? + public var approvalMode: String? + public var latestTokenUsage: TokenUsageSnapshot? + public var lastEventType: String? + + public init( + sessionID: String? = nil, + cwd: String? = nil, + model: String? = nil, + approvalMode: String? = nil, + latestTokenUsage: TokenUsageSnapshot? = nil, + lastEventType: String? = nil + ) { + self.sessionID = sessionID + self.cwd = cwd + self.model = model + self.approvalMode = approvalMode + self.latestTokenUsage = latestTokenUsage + self.lastEventType = lastEventType + } + } + + public static func parseCodex(snapshot: JSONLSnapshot) -> CodexParsedTranscript { + let decoder = JSONDecoder() + var parsed = CodexParsedTranscript() + + // 1. Parse head lines for `session_meta` + for line in snapshot.headLines { + guard let env = try? decoder.decode(CodexRolloutEnvelope.self, from: line) else { continue } + if env.type == "session_meta" || env.type == "turn_context" { + if let id = env.payload?.id { parsed.sessionID = id } + if let cwd = env.payload?.cwd { parsed.cwd = cwd } + if let model = env.payload?.model { parsed.model = model } + if let mode = env.payload?.approval_mode { parsed.approvalMode = mode } + } + } + + // 2. Parse tail lines for latest context and cumulative `total_token_usage` + for line in snapshot.tailLines { + guard let env = try? decoder.decode(CodexRolloutEnvelope.self, from: line) else { continue } + parsed.lastEventType = env.type + + if env.type == "turn_context" { + if let cwd = env.payload?.cwd { parsed.cwd = cwd } + if let model = env.payload?.model { parsed.model = model } + if let mode = env.payload?.approval_mode { parsed.approvalMode = mode } + } else if env.type == "event_msg", + env.payload?.type == "token_count", + let usage = env.payload?.info?.total_token_usage { + // CRITICAL: Codex `total_token_usage` is already cumulative for the session. + // Overwrite with the latest snapshot rather than adding! + parsed.latestTokenUsage = TokenUsageSnapshot( + inputTokens: usage.input_tokens ?? 0, + cachedInputTokens: usage.cached_input_tokens ?? 0, + outputTokens: usage.output_tokens ?? 0, + reasoningTokens: usage.reasoning_output_tokens ?? 0 + ) + } + } + + return parsed + } + + // MARK: - 2. Claude Code Transcript Parser (~/.claude/projects/*/*.jsonl) + + private struct ClaudeTranscriptLine: Decodable { + let sessionId: String? + let cwd: String? + let gitBranch: String? + let type: String? + let timestamp: String? + let message: ClaudeMessage? + } + + private struct ClaudeMessage: Decodable { + let id: String? + let model: String? + let stop_reason: String? + let usage: ClaudeUsage? + } + + private struct ClaudeUsage: Decodable { + let input_tokens: Int? + let cache_creation_input_tokens: Int? + let cache_read_input_tokens: Int? + let output_tokens: Int? + } + + public struct ClaudeParsedTranscript: Sendable { + public var sessionID: String? + public var cwd: String? + public var gitBranch: String? + public var model: String? + public var lastRecordType: String? + public var lastStopReason: String? + public var deduplicatedUsage: TokenUsageSnapshot + + public init( + sessionID: String? = nil, + cwd: String? = nil, + gitBranch: String? = nil, + model: String? = nil, + lastRecordType: String? = nil, + lastStopReason: String? = nil, + deduplicatedUsage: TokenUsageSnapshot + ) { + self.sessionID = sessionID + self.cwd = cwd + self.gitBranch = gitBranch + self.model = model + self.lastRecordType = lastRecordType + self.lastStopReason = lastStopReason + self.deduplicatedUsage = deduplicatedUsage + } + } + + public static func parseClaude(snapshot: JSONLSnapshot) -> ClaudeParsedTranscript { + let decoder = JSONDecoder() + var sessionID: String? + var cwd: String? + var gitBranch: String? + var model: String? + var lastRecordType: String? + var lastStopReason: String? + + // Deduplicate usage by message.id (preserving latest entry per message.id) + var usageByMessageID: [String: ClaudeUsage] = [:] + + let allLines = snapshot.headLines + snapshot.tailLines + for line in allLines { + guard let record = try? decoder.decode(ClaudeTranscriptLine.self, from: line) else { continue } + + if let sid = record.sessionId { sessionID = sid } + if let dir = record.cwd { cwd = dir } + if let branch = record.gitBranch { gitBranch = branch } + if let recType = record.type { lastRecordType = recType } + + if let msg = record.message { + if let mdl = msg.model { model = mdl } + lastStopReason = msg.stop_reason + + if let msgID = msg.id, let usage = msg.usage { + usageByMessageID[msgID] = usage + } + } + } + + var totalInput = 0 + var totalCached = 0 + var totalOutput = 0 + + for usage in usageByMessageID.values { + totalInput += (usage.input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) + totalCached += (usage.cache_read_input_tokens ?? 0) + totalOutput += (usage.output_tokens ?? 0) + } + + return ClaudeParsedTranscript( + sessionID: sessionID, + cwd: cwd, + gitBranch: gitBranch, + model: model, + lastRecordType: lastRecordType, + lastStopReason: lastStopReason, + deduplicatedUsage: TokenUsageSnapshot( + inputTokens: totalInput, + cachedInputTokens: totalCached, + outputTokens: totalOutput, + reasoningTokens: 0 + ) + ) + } + + // MARK: - 3. Antigravity CLI Transcript Parser (~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript.jsonl) + + private struct AntigravityTranscriptStep: Decodable { + let step_index: Int? + let source: String? + let type: String? + let status: String? + let created_at: String? + let content: String? + let model: String? + let input_tokens: Int? + let cache_read_tokens: Int? + let output_tokens: Int? + let tool_calls: [AntigravityToolCallRef]? + } + + private struct AntigravityToolCallRef: Decodable { + let name: String? + } + + public struct AntigravityParsedTranscript: Sendable { + public var lastStepType: String? + public var lastStepStatus: String? + public var lastToolNames: [String] + public var model: String? + public var lastCreatedAt: Date? + public var tokenUsage: TokenUsageSnapshot + + public init( + lastStepType: String? = nil, + lastStepStatus: String? = nil, + lastToolNames: [String] = [], + model: String? = nil, + lastCreatedAt: Date? = nil, + tokenUsage: TokenUsageSnapshot = TokenUsageSnapshot(inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningTokens: 0) + ) { + self.lastStepType = lastStepType + self.lastStepStatus = lastStepStatus + self.lastToolNames = lastToolNames + self.model = model + self.lastCreatedAt = lastCreatedAt + self.tokenUsage = tokenUsage + } + } + + public static func parseAntigravity(snapshot: JSONLSnapshot) -> AntigravityParsedTranscript { + let decoder = JSONDecoder() + var lastStepType: String? + var lastStepStatus: String? + var lastToolNames: [String] = [] + var model: String? + var lastCreatedAt: Date? + + var latestInputTokens = 0 + var latestCachedTokens = 0 + var totalOutputTokens = 0 + + var seenStepIndices = Set() + + let isoFractional = ISO8601DateFormatter() + isoFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoStandard = ISO8601DateFormatter() + isoStandard.formatOptions = [.withInternetDateTime] + + let allLines = snapshot.fileSize <= 65536 ? snapshot.tailLines : (snapshot.headLines + snapshot.tailLines) + for line in allLines { + guard let step = try? decoder.decode(AntigravityTranscriptStep.self, from: line) else { continue } + + if let idx = step.step_index { + if seenStepIndices.contains(idx) { continue } + seenStepIndices.insert(idx) + } + + if let stType = step.type { lastStepType = stType } + if let stStatus = step.status { lastStepStatus = stStatus } + + if let calls = step.tool_calls { + lastToolNames = calls.compactMap { $0.name } + } else if step.type == "PLANNER_RESPONSE" { + lastToolNames = [] + } + + if let stepModel = step.model, !stepModel.isEmpty { + model = stepModel + } else if let content = step.content { + if let extracted = extractAntigravityModel(from: content) { + model = extracted + } + } + + if let createdAtStr = step.created_at { + if let date = isoFractional.date(from: createdAtStr) ?? isoStandard.date(from: createdAtStr) { + lastCreatedAt = date + } + } + + if let input = step.input_tokens { + latestInputTokens = input + } + if let cached = step.cache_read_tokens { + latestCachedTokens = cached + } + if let output = step.output_tokens { + totalOutputTokens += output + } + } + + return AntigravityParsedTranscript( + lastStepType: lastStepType, + lastStepStatus: lastStepStatus, + lastToolNames: lastToolNames, + model: model, + lastCreatedAt: lastCreatedAt, + tokenUsage: TokenUsageSnapshot( + inputTokens: latestInputTokens, + cachedInputTokens: latestCachedTokens, + outputTokens: totalOutputTokens, + reasoningTokens: 0 + ) + ) + } + + private static func extractAntigravityModel(from content: String) -> String? { + if let range = content.range(of: "Model Selection` from [^`]+ to ([^.\n`]+)", options: .regularExpression) { + let matched = String(content[range]) + if let toRange = matched.range(of: " to ") { + let modelPart = String(matched[toRange.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + if !modelPart.isEmpty { return modelPart } + } + } + if let range = content.range(of: "as (gemini-[a-zA-Z0-9.-]+)", options: .regularExpression) { + let matched = String(content[range]) + if let asRange = matched.range(of: "as ") { + let modelPart = String(matched[asRange.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) + if !modelPart.isEmpty { return modelPart } + } + } + return nil + } +} diff --git a/Loom/Core/Discovery/ProcessTreeScanner.swift b/Loom/Core/Discovery/ProcessTreeScanner.swift new file mode 100644 index 0000000..cae59a5 --- /dev/null +++ b/Loom/Core/Discovery/ProcessTreeScanner.swift @@ -0,0 +1,131 @@ +// +// ProcessTreeScanner.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation + +public struct MonitoredProcess: Sendable, Equatable { + public let pid: pid_t + public let ppid: pid_t + public let name: String + public let executablePath: String + public let cwd: String? + + public init( + pid: pid_t, + ppid: pid_t, + name: String, + executablePath: String, + cwd: String? + ) { + self.pid = pid + self.ppid = ppid + self.name = name + self.executablePath = executablePath + self.cwd = cwd + } +} + +public final class ProcessTreeScanner: Sendable { + public init() {} + + public func scanProcessTable() -> [pid_t: MonitoredProcess] { + let count = proc_listallpids(nil, 0) + guard count > 0 else { return [:] } + + var pids = [pid_t](repeating: 0, count: Int(count) * 2) + let actualBytes = proc_listallpids(&pids, Int32(pids.count * MemoryLayout.stride)) + let pidCount = Int(actualBytes) / MemoryLayout.stride + + var table: [pid_t: MonitoredProcess] = [:] + table.reserveCapacity(pidCount) + + for i in 0.. 0 else { continue } + + // 1. Get BSD Info (PPID & Process Name) + var bsdInfo = proc_bsdinfo() + let bsdSize = proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + &bsdInfo, + Int32(MemoryLayout.stride) + ) + guard bsdSize == MemoryLayout.stride else { continue } + + let ppid = pid_t(bsdInfo.pbi_ppid) + let name = withUnsafePointer(to: &bsdInfo.pbi_name) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN)) { + String(cString: $0) + } + } + + // 2. Get Executable Path + var pathBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + let pathLen = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count)) + let execPath = pathLen > 0 ? String(cString: pathBuffer) : "" + + // 3. Only resolve CWD for candidate processes to save syscalls + let isCandidate = Self.isCandidateProcess(name: name, execPath: execPath) + let cwd = isCandidate ? Self.resolveCWD(for: pid) : nil + + table[pid] = MonitoredProcess( + pid: pid, + ppid: ppid, + name: name, + executablePath: execPath, + cwd: cwd + ) + } + return table + } + + static func isCandidateProcess(name: String, execPath: String) -> Bool { + let lowerName = name.lowercased() + let lowerPath = execPath.lowercased() + let targets = ["t3", "t3code", "codex", "claude", "agy", "agy_acp_server", "antigravity", "node", "bun"] + return targets.contains { lowerName.contains($0) || lowerPath.contains($0) } + } + + public static func resolveCWD(for pid: pid_t) -> String? { + var vnodeInfo = proc_vnodepathinfo() + let size = proc_pidinfo( + pid, + PROC_PIDVNODEPATHINFO, + 0, + &vnodeInfo, + Int32(MemoryLayout.stride) + ) + guard size == MemoryLayout.stride else { return nil } + return withUnsafePointer(to: &vnodeInfo.pvi_cdir.vip_path) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { + let str = String(cString: $0) + return str.isEmpty ? nil : str + } + } + } + + /// Walks up the PPID chain (max depth 8) to check if `pid` is a descendant of `ancestorPIDs`. + public static func findAncestor( + of pid: pid_t, + in table: [pid_t: MonitoredProcess], + matching ancestorPIDs: Set + ) -> pid_t? { + var currentPID = pid + for _ in 0..<8 { + guard let proc = table[currentPID], proc.ppid > 1 else { return nil } + if ancestorPIDs.contains(proc.ppid) { + return proc.ppid + } + currentPID = proc.ppid + } + return nil + } +} diff --git a/Loom/Core/Models/AgentSessionModels.swift b/Loom/Core/Models/AgentSessionModels.swift new file mode 100644 index 0000000..3619a45 --- /dev/null +++ b/Loom/Core/Models/AgentSessionModels.swift @@ -0,0 +1,96 @@ +// +// AgentSessionModels.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation + +public enum AgentProviderKind: String, Codable, Sendable, CaseIterable { + case t3Code = "t3code" + case codex = "codex" + case claudeCode = "claude_code" + case antigravity = "antigravity" +} + +public enum AgentSurface: String, Codable, Sendable { + case cli = "CLI" + case gui = "GUI" + case t3Orchestrated = "T3Code" +} + +public enum AgentRunState: String, Codable, Sendable { + case working // Actively streaming or running a tool + case awaitingApproval // Blocked on user permission / question + case idle // Process alive, waiting for next user prompt + case inactive // Process exited, recent historical session +} + +public struct TokenUsageSnapshot: Sendable, Equatable, Codable { + public var inputTokens: Int + public var cachedInputTokens: Int + public var outputTokens: Int + public var reasoningTokens: Int + + public var totalTokens: Int { + inputTokens + cachedInputTokens + outputTokens + reasoningTokens + } + + public init( + inputTokens: Int = 0, + cachedInputTokens: Int = 0, + outputTokens: Int = 0, + reasoningTokens: Int = 0 + ) { + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.reasoningTokens = reasoningTokens + } +} + +public struct AgentSessionStatus: Identifiable, Sendable, Equatable { + public let id: String // Session UUID or "pid-" + public let provider: AgentProviderKind + public let surface: AgentSurface + public let pid: pid_t? + public let parentT3PID: pid_t? // Non-nil if spawned by T3Code + public let projectName: String // Last path component of cwd + public let workspacePath: String? // Full cwd + public let gitBranch: String? + public let modelName: String? + public let state: AgentRunState + public let tokenUsage: TokenUsageSnapshot? + public let lastUpdated: Date + + public init( + id: String, + provider: AgentProviderKind, + surface: AgentSurface, + pid: pid_t? = nil, + parentT3PID: pid_t? = nil, + projectName: String, + workspacePath: String? = nil, + gitBranch: String? = nil, + modelName: String? = nil, + state: AgentRunState, + tokenUsage: TokenUsageSnapshot? = nil, + lastUpdated: Date = Date() + ) { + self.id = id + self.provider = provider + self.surface = surface + self.pid = pid + self.parentT3PID = parentT3PID + self.projectName = projectName + self.workspacePath = workspacePath + self.gitBranch = gitBranch + self.modelName = modelName + self.state = state + self.tokenUsage = tokenUsage + self.lastUpdated = lastUpdated + } +} diff --git a/Loom/Core/Models/ProviderID.swift b/Loom/Core/Models/ProviderID.swift index 7fbdc53..1d79ffe 100644 --- a/Loom/Core/Models/ProviderID.swift +++ b/Loom/Core/Models/ProviderID.swift @@ -25,6 +25,7 @@ public struct ProviderID: Hashable, Equatable, Sendable, RawRepresentable, Ident public static let codex = ProviderID("codex") public static let claude = ProviderID("claude") public static let antigravity = ProviderID("antigravity") + public static let t3Code = ProviderID("t3code") public var displayName: String { switch self { @@ -34,6 +35,8 @@ public struct ProviderID: Hashable, Equatable, Sendable, RawRepresentable, Ident return "Claude Code" case .antigravity: return "Antigravity" + case .t3Code: + return "T3 Code" default: return rawValue.capitalized } diff --git a/Loom/Providers/Antigravity/AntigravityCLIDetector.swift b/Loom/Providers/Antigravity/AntigravityCLIDetector.swift new file mode 100644 index 0000000..975e146 --- /dev/null +++ b/Loom/Providers/Antigravity/AntigravityCLIDetector.swift @@ -0,0 +1,575 @@ +// +// AntigravityCLIDetector.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +import os +import SQLite3 + +public struct AntigravityLiveProcess: Sendable, Equatable { + public let pid: pid_t + public let ppid: pid_t + public let name: String + public let executablePath: String + public let cwd: String? + public let parentT3PID: pid_t? + public let surface: AgentSurface + + public init( + pid: pid_t, + ppid: pid_t, + name: String, + executablePath: String, + cwd: String?, + parentT3PID: pid_t? = nil, + surface: AgentSurface = .cli + ) { + self.pid = pid + self.ppid = ppid + self.name = name + self.executablePath = executablePath + self.cwd = cwd + self.parentT3PID = parentT3PID + self.surface = surface + } +} + +public struct AntigravityCLISessionRecord: Sendable, Equatable { + public let sessionID: String + public let pid: pid_t? + public let parentT3PID: pid_t? + public let surface: AgentSurface + public let cwd: String? + public let title: String? + public let model: String? + public let state: AgentRunState + public let tokenUsage: TokenUsageSnapshot + public let fileURL: URL + public let lastModifiedAt: Date + + public init( + sessionID: String, + pid: pid_t? = nil, + parentT3PID: pid_t? = nil, + surface: AgentSurface = .cli, + cwd: String? = nil, + title: String? = nil, + model: String? = nil, + state: AgentRunState, + tokenUsage: TokenUsageSnapshot, + fileURL: URL, + lastModifiedAt: Date = Date() + ) { + self.sessionID = sessionID + self.pid = pid + self.parentT3PID = parentT3PID + self.surface = surface + self.cwd = cwd + self.title = title + self.model = model + self.state = state + self.tokenUsage = tokenUsage + self.fileURL = fileURL + self.lastModifiedAt = lastModifiedAt + } +} + +public final class AntigravityCLIDetector: Sendable { + private let reader: BoundedJSONLReader + private let customBaseDirectory: URL? + public var baseDirectory: URL { + customBaseDirectory ?? Self.resolveAntigravityCLIDirectory() + } + public var jsonlReader: BoundedJSONLReader { reader } + + /// Thread-safe cache mapping Antigravity conversation UUID -> resolved CWD from live `agy` process. + private let cwdCache = OSAllocatedUnfairLock(initialState: [String: String]()) + + public init( + baseDirectory: URL? = nil, + reader: BoundedJSONLReader = BoundedJSONLReader(), + jsonlReader: BoundedJSONLReader? = nil + ) { + self.customBaseDirectory = baseDirectory + self.reader = jsonlReader ?? reader + } + + public func recordCWD(_ cwd: String, for sessionID: String) { + cwdCache.withLock { $0[sessionID] = cwd } + } + + public func cachedCWD(for sessionID: String) -> String? { + cwdCache.withLock { $0[sessionID] } + } + + public struct AntigravityStepLine: Decodable, Sendable { + public let step_index: Int? + public let type: String? + public let created_at: String? + public let model: String? + + public init(step_index: Int? = nil, type: String? = nil, created_at: String? = nil, model: String? = nil) { + self.step_index = step_index + self.type = type + self.created_at = created_at + self.model = model + } + } + + public struct AntigravitySettingsFile: Decodable, Sendable { + public let model: String? + public let defaultModel: String? + + public init(model: String? = nil, defaultModel: String? = nil) { + self.model = model + self.defaultModel = defaultModel + } + } + + // MARK: - Main detect() Entrypoint (NEW_PLANS Section 4) + + public func detect( + processTable: [pid_t: MonitoredProcess], + t3RootPIDs: Set + ) -> [AgentSessionStatus] { + // 1. Match live `agy` or `antigravity` CLI processes (excluding GUI .app bundles) + let liveAgyProcs = processTable.values.filter { proc in + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + guard !lowerPath.contains(".app/contents/") else { return false } + return lowerName == "agy" + || lowerName == "antigravity" + || lowerPath.hasSuffix("/agy") + || lowerPath.contains("antigravity-cli") + } + + let configuredModel = readConfiguredModel() + let recentTranscripts = findRecentBrainTranscripts(maxConversations: 8) + + var results: [AgentSessionStatus] = [] + var matchedUUIDs = Set() + + // 2. Pair running `agy` processes with the most recently modified transcript + for (index, proc) in liveAgyProcs.enumerated() { + let parentT3 = ProcessTreeScanner.findAncestor(of: proc.pid, in: processTable, matching: t3RootPIDs) + let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + + let transcriptEntry = index < recentTranscripts.count ? recentTranscripts[index] : nil + let uuid = transcriptEntry?.uuid ?? "agy-pid-\(proc.pid)" + + if let entry = transcriptEntry { + matchedUUIDs.insert(entry.uuid) + if let liveCWD = proc.cwd { + cwdCache.withLock { $0[entry.uuid] = liveCWD } + } + } + + let resolvedCWD = proc.cwd ?? cwdCache.withLock { $0[uuid] } + let projectName = resolvedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "agy" + + var state: AgentRunState = .idle + var lastUpdated = Date() + var detectedModel = configuredModel + + if let entry = transcriptEntry, let snapshot = reader.readSnapshot(at: entry.url) { + lastUpdated = snapshot.modifiedAt + let parsedStep = parseTailStep(from: snapshot) + if let stepModel = parsedStep?.model { + detectedModel = stepModel + } + state = inferState( + proc: proc, + processTable: processTable, + snapshot: snapshot, + lastStepType: parsedStep?.type + ) + } + + results.append( + AgentSessionStatus( + id: uuid, + provider: .antigravity, + surface: surface, + pid: proc.pid, + parentT3PID: parentT3, + projectName: projectName, + workspacePath: resolvedCWD, + gitBranch: nil, + modelName: detectedModel, + state: state, + tokenUsage: nil, + lastUpdated: lastUpdated + ) + ) + } + + // 3. Surface recent inactive CLI brain sessions from today if cached CWD exists + for entry in recentTranscripts where !matchedUUIDs.contains(entry.uuid) { + guard Calendar.current.isDateInToday(entry.mtime) else { continue } + let cachedCWD = cwdCache.withLock { $0[entry.uuid] } + let projectName = cachedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "agy-session" + + results.append( + AgentSessionStatus( + id: entry.uuid, + provider: .antigravity, + surface: .cli, + pid: nil, + parentT3PID: nil, + projectName: projectName, + workspacePath: cachedCWD, + gitBranch: nil, + modelName: configuredModel, + state: .inactive, + tokenUsage: nil, + lastUpdated: entry.mtime + ) + ) + } + + return results + } + + // MARK: - Helpers + + public static func resolveAntigravityCLIDirectory() -> URL { + if let customHome = ProcessInfo.processInfo.environment["ANTIGRAVITY_HOME"], !customHome.isEmpty { + return URL(fileURLWithPath: (customHome as NSString).expandingTildeInPath, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".gemini/antigravity-cli", isDirectory: true) + } + + public func readConfiguredModel() -> String? { + let settingsURL = baseDirectory.appendingPathComponent("settings.json") + guard let data = try? Data(contentsOf: settingsURL), + let settings = try? JSONDecoder().decode(AntigravitySettingsFile.self, from: data) else { + return "gemini-3.8-flash" + } + return settings.model ?? settings.defaultModel ?? "gemini-3.8-flash" + } + + public func findRecentBrainTranscripts(maxConversations: Int = 8) -> [(uuid: String, url: URL, mtime: Date)] { + let brainRoot = baseDirectory.appendingPathComponent("brain", isDirectory: true) + let fm = FileManager.default + guard let convDirs = try? fm.contentsOfDirectory( + at: brainRoot, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var transcripts: [(uuid: String, url: URL, mtime: Date)] = [] + + for dir in convDirs { + let uuid = dir.lastPathComponent + let transcriptURL = dir.appendingPathComponent(".system_generated/logs/transcript.jsonl") + guard fm.fileExists(atPath: transcriptURL.path), + let mtime = try? transcriptURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate else { + continue + } + transcripts.append((uuid, transcriptURL, mtime)) + } + + transcripts.sort { $0.mtime > $1.mtime } + return Array(transcripts.prefix(maxConversations)) + } + + public func parseTailStep(from snapshot: JSONLSnapshot) -> AntigravityStepLine? { + let decoder = JSONDecoder() + for line in snapshot.tailLines.reversed() { + if let step = try? decoder.decode(AntigravityStepLine.self, from: line) { + return step + } + } + return nil + } + + public func inferState( + proc: MonitoredProcess, + processTable: [pid_t: MonitoredProcess], + snapshot: JSONLSnapshot, + lastStepType: String? + ) -> AgentRunState { + let hasActiveChild = processTable.values.contains { $0.ppid == proc.pid } + if hasActiveChild { + return .working + } + + let age = Date().timeIntervalSince(snapshot.modifiedAt) + if age < 4.0 { + return .working + } + + switch lastStepType { + case "USER_INPUT", "RUN_COMMAND", "VIEW_FILE", "GREP_SEARCH", "LIST_DIRECTORY", "CODE_ACTION": + return age < 15.0 ? .working : .awaitingApproval + default: + return .idle + } + } + + // MARK: - Compatibility Methods (for Existing Tests) + + public func findLiveAntigravityProcesses( + table: [pid_t: MonitoredProcess], + t3AncestorPIDs: Set = [] + ) -> [AntigravityLiveProcess] { + var processes: [AntigravityLiveProcess] = [] + + for (pid, proc) in table { + guard pid > 1 else { continue } + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + + let isAgy = lowerName == "agy" || + lowerPath.hasSuffix("/agy") || + lowerName.hasPrefix("agy_") || + lowerPath.contains("/antigravity-cli/") + + guard isAgy else { continue } + + let parentT3 = ProcessTreeScanner.findAncestor(of: pid, in: table, matching: t3AncestorPIDs) + let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + let cwd = proc.cwd ?? ProcessTreeScanner.resolveCWD(for: pid) + + processes.append( + AntigravityLiveProcess( + pid: pid, + ppid: proc.ppid, + name: proc.name, + executablePath: proc.executablePath, + cwd: cwd, + parentT3PID: parentT3, + surface: surface + ) + ) + } + + return processes + } + + public func findRecentTranscriptURLs() -> [URL] { + let brainDir = baseDirectory.appendingPathComponent("brain") + guard FileManager.default.fileExists(atPath: brainDir.path) else { return [] } + + let fileManager = FileManager.default + guard let subdirs = try? fileManager.contentsOfDirectory( + at: brainDir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var candidateURLs: [URL] = [] + for dir in subdirs { + let transcriptURL = dir + .appendingPathComponent(".system_generated") + .appendingPathComponent("logs") + .appendingPathComponent("transcript.jsonl") + + if fileManager.fileExists(atPath: transcriptURL.path) { + candidateURLs.append(transcriptURL) + } + } + + // Sort descending by modification date + return candidateURLs.sorted { u1, u2 in + let date1 = (try? u1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + let date2 = (try? u2.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + return date1 > date2 + } + } + + public func detectSessions( + table: [pid_t: MonitoredProcess], + t3AncestorPIDs: Set = [], + now: Date = Date() + ) -> [AntigravityCLISessionRecord] { + let liveProcesses = findLiveAntigravityProcesses(table: table, t3AncestorPIDs: t3AncestorPIDs) + let transcriptURLs = findRecentTranscriptURLs() + + // Read conversation_summaries.db if present to seed CWD and titles + let dbSummaries = readConversationSummaries() + + var sessions: [AntigravityCLISessionRecord] = [] + var claimedPIDs = Set() + + for url in transcriptURLs { + guard let snapshot = reader.readSnapshot(at: url) else { continue } + let parsed = JSONLProviderParsers.parseAntigravity(snapshot: snapshot) + + // The conversation UUID is 3 path components up from transcript.jsonl + // (.../brain//.system_generated/logs/transcript.jsonl) + let sessionID = url + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .lastPathComponent + + let summary = dbSummaries[sessionID] + + // Resolve CWD: check memory cache -> check DB summary -> check single live proc + var resolvedCWD = cwdCache.withLock { $0[sessionID] } ?? summary?.cwd + if resolvedCWD == nil, liveProcesses.count == 1, let singleCWD = liveProcesses.first?.cwd { + resolvedCWD = singleCWD + cwdCache.withLock { $0[sessionID] = singleCWD } + } + + // Correlate with running process via resolved CWD + let matchingProcess = liveProcesses.first { proc in + !claimedPIDs.contains(proc.pid) && + proc.cwd != nil && + proc.cwd == resolvedCWD + } + + let pid: pid_t? + let parentT3: pid_t? + let surface: AgentSurface + let state: AgentRunState + + if let proc = matchingProcess { + claimedPIDs.insert(proc.pid) + pid = proc.pid + parentT3 = proc.parentT3PID + surface = proc.surface + + if let procCWD = proc.cwd { + resolvedCWD = procCWD + cwdCache.withLock { $0[sessionID] = procCWD } + } + + // Determine state + if parsed.lastStepType == "USER_INPUT" { + state = .working + } else if parsed.lastStepType == "PLANNER_RESPONSE" { + if parsed.lastToolNames.contains("ask_question") { + state = .awaitingApproval + } else if parsed.lastStepStatus == "DONE" && parsed.lastToolNames.isEmpty { + let stepDate = parsed.lastCreatedAt ?? snapshot.modifiedAt + if now.timeIntervalSince(stepDate) > 2.0 { + state = .idle + } else { + state = .working + } + } else { + state = .working + } + } else if parsed.lastStepType != nil && parsed.lastStepType != "USER_SETTINGS_CHANGE" { + state = .working + } else { + state = .idle + } + } else { + pid = nil + parentT3 = nil + surface = .cli + state = .inactive + } + + sessions.append( + AntigravityCLISessionRecord( + sessionID: sessionID, + pid: pid, + parentT3PID: parentT3, + surface: surface, + cwd: resolvedCWD, + title: summary?.title, + model: parsed.model, + state: state, + tokenUsage: parsed.tokenUsage, + fileURL: url, + lastModifiedAt: snapshot.modifiedAt + ) + ) + } + + return sessions + } + + public func calculateDailyTokenUsage( + calendar: Calendar = .current, + now: Date = Date() + ) -> TokenUsageSnapshot { + let transcriptURLs = findRecentTranscriptURLs() + var totalInput = 0 + var totalCached = 0 + var totalOutput = 0 + + for url in transcriptURLs { + guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), + let modDate = values.contentModificationDate, + calendar.isDate(modDate, inSameDayAs: now) else { + continue + } + + guard let snapshot = reader.readSnapshot(at: url) else { continue } + let parsed = JSONLProviderParsers.parseAntigravity(snapshot: snapshot) + + totalInput += parsed.tokenUsage.inputTokens + totalCached += parsed.tokenUsage.cachedInputTokens + totalOutput += parsed.tokenUsage.outputTokens + } + + return TokenUsageSnapshot( + inputTokens: totalInput, + cachedInputTokens: totalCached, + outputTokens: totalOutput, + reasoningTokens: 0 + ) + } + + private struct ConversationSummaryEntry { + let title: String + let cwd: String? + } + + private func readConversationSummaries() -> [String: ConversationSummaryEntry] { + let dbURL = baseDirectory.appendingPathComponent("conversation_summaries.db") + guard FileManager.default.fileExists(atPath: dbURL.path) else { return [:] } + + var db: OpaquePointer? + let uri = "file://\(dbURL.standardizedFileURL.path)?mode=ro&immutable=1" + let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_URI + + guard sqlite3_open_v2(uri, &db, flags, nil) == SQLITE_OK, let validDB = db else { + if let db = db { sqlite3_close(db) } + return [:] + } + defer { sqlite3_close(validDB) } + + let query = "SELECT conversation_id, title, workspace_uris FROM conversation_summaries" + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(validDB, query, -1, &stmt, nil) == SQLITE_OK, let validStmt = stmt else { + return [:] + } + defer { sqlite3_finalize(validStmt) } + + var results: [String: ConversationSummaryEntry] = [:] + + while sqlite3_step(validStmt) == SQLITE_ROW { + guard let idCol = sqlite3_column_text(validStmt, 0) else { continue } + let id = String(cString: idCol) + let title = sqlite3_column_text(validStmt, 1).map { String(cString: $0) } ?? "" + let urisJSON = sqlite3_column_text(validStmt, 2).map { String(cString: $0) } ?? "" + + var parsedCWD: String? = nil + if let data = urisJSON.data(using: .utf8), + let arr = try? JSONSerialization.jsonObject(with: data) as? [String], + let firstURI = arr.first, + let url = URL(string: firstURI), + url.scheme == "file" { + parsedCWD = url.path + } + + results[id] = ConversationSummaryEntry(title: title, cwd: parsedCWD) + } + + return results + } +} diff --git a/Loom/Providers/Claude/ClaudeCodeDetector.swift b/Loom/Providers/Claude/ClaudeCodeDetector.swift new file mode 100644 index 0000000..03b47fe --- /dev/null +++ b/Loom/Providers/Claude/ClaudeCodeDetector.swift @@ -0,0 +1,505 @@ +// +// ClaudeCodeDetector.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation + +public struct ClaudeLiveProcess: Sendable, Equatable { + public let pid: pid_t + public let ppid: pid_t + public let name: String + public let executablePath: String + public let cwd: String? + public let parentT3PID: pid_t? + public let surface: AgentSurface + public let childPIDs: [pid_t] + + public init( + pid: pid_t, + ppid: pid_t, + name: String, + executablePath: String, + cwd: String?, + parentT3PID: pid_t? = nil, + surface: AgentSurface = .cli, + childPIDs: [pid_t] = [] + ) { + self.pid = pid + self.ppid = ppid + self.name = name + self.executablePath = executablePath + self.cwd = cwd + self.parentT3PID = parentT3PID + self.surface = surface + self.childPIDs = childPIDs + } +} + +public struct ClaudeCLISessionRecord: Sendable, Equatable { + public let sessionID: String + public let pid: pid_t? + public let parentT3PID: pid_t? + public let surface: AgentSurface + public let cwd: String? + public let gitBranch: String? + public let model: String? + public let state: AgentRunState + public let tokenUsage: TokenUsageSnapshot + public let fileURL: URL + public let lastModifiedAt: Date + + public init( + sessionID: String, + pid: pid_t? = nil, + parentT3PID: pid_t? = nil, + surface: AgentSurface = .cli, + cwd: String? = nil, + gitBranch: String? = nil, + model: String? = nil, + state: AgentRunState, + tokenUsage: TokenUsageSnapshot, + fileURL: URL, + lastModifiedAt: Date = Date() + ) { + self.sessionID = sessionID + self.pid = pid + self.parentT3PID = parentT3PID + self.surface = surface + self.cwd = cwd + self.gitBranch = gitBranch + self.model = model + self.state = state + self.tokenUsage = tokenUsage + self.fileURL = fileURL + self.lastModifiedAt = lastModifiedAt + } +} + +public final class ClaudeCodeDetector: Sendable { + private let reader: BoundedJSONLReader + private let customBaseDirectory: URL? + public var baseDirectory: URL { + customBaseDirectory ?? Self.resolveClaudeHomeDirectory() + } + public var jsonlReader: BoundedJSONLReader { reader } + + public init( + baseDirectory: URL? = nil, + reader: BoundedJSONLReader = BoundedJSONLReader() + ) { + self.customBaseDirectory = baseDirectory + self.reader = reader + } + + public convenience init(reader: BoundedJSONLReader) { + self.init(baseDirectory: nil, reader: reader) + } + + public convenience init(baseDirectory: URL?, jsonlReader: BoundedJSONLReader) { + self.init(baseDirectory: baseDirectory, reader: jsonlReader) + } + + // MARK: - Main detect() Entrypoint (NEW_PLANS Section 3) + + public func detect( + processTable: [pid_t: MonitoredProcess], + t3RootPIDs: Set + ) -> [AgentSessionStatus] { + // 1. Find running `claude` CLI processes + let liveClaudeProcs = processTable.values.filter { proc in + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + return lowerName == "claude" + || lowerPath.hasSuffix("/claude") + || lowerPath.contains("@anthropic-ai/claude-code") + } + + // 2. Discover recent ~/.claude/projects/*/*.jsonl transcripts + let recentTranscripts = findRecentClaudeTranscripts(maxFiles: 15) + var parsedFiles: [(url: URL, snapshot: JSONLSnapshot, parsed: JSONLProviderParsers.ClaudeParsedTranscript)] = [] + + for fileURL in recentTranscripts { + guard let snapshot = reader.readSnapshot(at: fileURL) else { continue } + let parsed = JSONLProviderParsers.parseClaude(snapshot: snapshot) + parsedFiles.append((fileURL, snapshot, parsed)) + } + + var matchedURLs = Set() + var results: [AgentSessionStatus] = [] + + // 3. Bind live `claude` processes to their active transcript + for proc in liveClaudeProcs { + let parentT3 = ProcessTreeScanner.findAncestor(of: proc.pid, in: processTable, matching: t3RootPIDs) + let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + + let matchingFile = parsedFiles.first { candidate in + guard !matchedURLs.contains(candidate.url) else { return false } + guard let procCWD = proc.cwd, let fileCWD = candidate.parsed.cwd else { return false } + return Self.normalizePath(procCWD) == Self.normalizePath(fileCWD) + } + + if let match = matchingFile { + matchedURLs.insert(match.url) + } + + let resolvedCWD = proc.cwd ?? matchingFile?.parsed.cwd + let projectName = resolvedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "claude" + let hasActiveChild = processTable.values.contains { $0.ppid == proc.pid } + + let state = inferClaudeState( + hasActiveChild: hasActiveChild, + snapshot: matchingFile?.snapshot, + parsed: matchingFile?.parsed + ) + + let sessionID = matchingFile?.parsed.sessionID ?? "claude-pid-\(proc.pid)" + + results.append( + AgentSessionStatus( + id: sessionID, + provider: .claudeCode, + surface: surface, + pid: proc.pid, + parentT3PID: parentT3, + projectName: projectName, + workspacePath: resolvedCWD, + gitBranch: matchingFile?.parsed.gitBranch, + modelName: matchingFile?.parsed.model, + state: state, + tokenUsage: matchingFile?.parsed.deduplicatedUsage, + lastUpdated: matchingFile?.snapshot.modifiedAt ?? Date() + ) + ) + } + + // 4. Append today's completed/inactive sessions for accurate daily quota tracking + for item in parsedFiles where !matchedURLs.contains(item.url) { + guard Calendar.current.isDateInToday(item.snapshot.modifiedAt) else { continue } + let cwd = item.parsed.cwd + let projectName = cwd.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "claude" + let sessionID = item.parsed.sessionID ?? item.url.deletingPathExtension().lastPathComponent + + results.append( + AgentSessionStatus( + id: sessionID, + provider: .claudeCode, + surface: .cli, + pid: nil, + parentT3PID: nil, + projectName: projectName, + workspacePath: cwd, + gitBranch: item.parsed.gitBranch, + modelName: item.parsed.model, + state: .inactive, + tokenUsage: item.parsed.deduplicatedUsage, + lastUpdated: item.snapshot.modifiedAt + ) + ) + } + + return results + } + + // MARK: - Helpers + + public static func resolveClaudeHomeDirectory() -> URL { + if let envPath = ProcessInfo.processInfo.environment["CLAUDE_HOME"], !envPath.isEmpty { + return URL(fileURLWithPath: (envPath as NSString).expandingTildeInPath, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".claude", isDirectory: true) + } + + public static func resolveClaudeProjectsDirectory() -> URL { + resolveClaudeHomeDirectory().appendingPathComponent("projects", isDirectory: true) + } + + public func findRecentClaudeTranscripts(maxFiles: Int = 15) -> [URL] { + let projectsRoot = baseDirectory.appendingPathComponent("projects", isDirectory: true) + let fm = FileManager.default + guard let projectDirs = try? fm.contentsOfDirectory( + at: projectsRoot, + includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var jsonlFiles: [(url: URL, mtime: Date)] = [] + let cutoff = Date().addingTimeInterval(-48 * 3600) // Last 48 hours + + for dir in projectDirs { + guard let files = try? fm.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for file in files where file.pathExtension == "jsonl" { + let mtime = (try? file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date() + guard mtime >= cutoff else { continue } + jsonlFiles.append((file, mtime)) + } + } + + jsonlFiles.sort { $0.mtime > $1.mtime } + return Array(jsonlFiles.prefix(maxFiles).map(\.url)) + } + + public func inferClaudeState( + hasActiveChild: Bool, + snapshot: JSONLSnapshot?, + parsed: JSONLProviderParsers.ClaudeParsedTranscript? + ) -> AgentRunState { + if hasActiveChild { + return .working + } + guard let snapshot = snapshot, let parsed = parsed else { + return .idle + } + + let age = Date().timeIntervalSince(snapshot.modifiedAt) + + if parsed.lastRecordType == "assistant" && parsed.lastStopReason == "tool_use" { + // If Claude requested a tool_use more than 2 seconds ago and no child process is running, + // it is waiting on the user's permission prompt in the terminal. + return age > 2.0 ? .awaitingApproval : .working + } + + if age < 3.0 { + return .working + } + + return .idle + } + + private static func normalizePath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path + } + + // MARK: - Compatibility Methods (for Existing Tests) + + public func findLiveClaudeProcesses( + table: [pid_t: MonitoredProcess], + t3AncestorPIDs: Set = [] + ) -> [ClaudeLiveProcess] { + var processes: [ClaudeLiveProcess] = [] + + // Build a quick map of parent to direct children + var childrenByPPID: [pid_t: [pid_t]] = [:] + for (pid, proc) in table { + if proc.ppid > 1 { + childrenByPPID[proc.ppid, default: []].append(pid) + } + } + + for (pid, proc) in table { + guard pid > 1 else { continue } + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + + let isClaude = lowerName == "claude" || + lowerPath.hasSuffix("/claude") || + lowerPath.contains("/@anthropic-ai/claude-code") + + guard isClaude else { continue } + + let parentT3 = ProcessTreeScanner.findAncestor(of: pid, in: table, matching: t3AncestorPIDs) + let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + let cwd = proc.cwd ?? ProcessTreeScanner.resolveCWD(for: pid) + let childPIDs = childrenByPPID[pid] ?? [] + + processes.append( + ClaudeLiveProcess( + pid: pid, + ppid: proc.ppid, + name: proc.name, + executablePath: proc.executablePath, + cwd: cwd, + parentT3PID: parentT3, + surface: surface, + childPIDs: childPIDs + ) + ) + } + + return processes + } + + public func findRecentTranscriptURLs() -> [URL] { + let projectsDir = baseDirectory.appendingPathComponent("projects") + guard FileManager.default.fileExists(atPath: projectsDir.path) else { return [] } + + var transcriptURLs: [URL] = [] + let fileManager = FileManager.default + + guard let projectFolders = try? fileManager.contentsOfDirectory( + at: projectsDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + for folder in projectFolders { + guard let files = try? fileManager.contentsOfDirectory( + at: folder, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + continue + } + + for file in files { + if file.pathExtension == "jsonl" { + transcriptURLs.append(file) + } + } + } + + // Sort descending by modification date + return transcriptURLs.sorted { u1, u2 in + let date1 = (try? u1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + let date2 = (try? u2.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + return date1 > date2 + } + } + + public func detectSessions( + table: [pid_t: MonitoredProcess], + t3AncestorPIDs: Set = [], + now: Date = Date() + ) -> [ClaudeCLISessionRecord] { + let liveProcesses = findLiveClaudeProcesses(table: table, t3AncestorPIDs: t3AncestorPIDs) + let transcriptURLs = findRecentTranscriptURLs() + + var sessions: [ClaudeCLISessionRecord] = [] + var claimedPIDs = Set() + + for url in transcriptURLs { + guard let snapshot = reader.readSnapshot(at: url) else { continue } + let parsed = JSONLProviderParsers.parseClaude(snapshot: snapshot) + + let sessionID = parsed.sessionID ?? url.deletingPathExtension().lastPathComponent + + // Correlate with running claude process via CWD + let matchingProcess = liveProcesses.first { proc in + !claimedPIDs.contains(proc.pid) && + proc.cwd != nil && + proc.cwd == parsed.cwd + } + + let pid: pid_t? + let parentT3: pid_t? + let surface: AgentSurface + let state: AgentRunState + + if let proc = matchingProcess { + claimedPIDs.insert(proc.pid) + pid = proc.pid + parentT3 = proc.parentT3PID + surface = proc.surface + + // State determination from tail event and child process activity + if parsed.lastStopReason == "tool_use" { + if !proc.childPIDs.isEmpty { + state = .working + } else if now.timeIntervalSince(snapshot.modifiedAt) > 2.0 { + state = .awaitingApproval + } else { + state = .working + } + } else if parsed.lastStopReason == "end_turn" { + state = .idle + } else if parsed.lastRecordType == "user" { + state = .working + } else { + state = .idle + } + } else { + pid = nil + parentT3 = nil + surface = .cli + state = .inactive + } + + sessions.append( + ClaudeCLISessionRecord( + sessionID: sessionID, + pid: pid, + parentT3PID: parentT3, + surface: surface, + cwd: parsed.cwd, + gitBranch: parsed.gitBranch, + model: parsed.model, + state: state, + tokenUsage: parsed.deduplicatedUsage, + fileURL: url, + lastModifiedAt: snapshot.modifiedAt + ) + ) + } + + // Add any active claude processes that haven't written a transcript file yet + for proc in liveProcesses where !claimedPIDs.contains(proc.pid) { + let sessionID = "pid-\(proc.pid)" + sessions.append( + ClaudeCLISessionRecord( + sessionID: sessionID, + pid: proc.pid, + parentT3PID: proc.parentT3PID, + surface: proc.surface, + cwd: proc.cwd, + gitBranch: nil, + model: nil, + state: .working, + tokenUsage: TokenUsageSnapshot(), + fileURL: URL(fileURLWithPath: proc.executablePath), + lastModifiedAt: Date() + ) + ) + } + + return sessions + } + + public func computeDailyTokenUsage( + calendar: Calendar = Calendar.current, + now: Date = Date() + ) -> TokenUsageSnapshot { + let transcriptURLs = findRecentTranscriptURLs() + + var totalInput = 0 + var totalCached = 0 + var totalOutput = 0 + + for url in transcriptURLs { + let modDate = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + guard calendar.isDate(modDate, inSameDayAs: now) else { continue } + + guard let snapshot = reader.readSnapshot(at: url) else { continue } + let parsed = JSONLProviderParsers.parseClaude(snapshot: snapshot) + + totalInput += parsed.deduplicatedUsage.inputTokens + totalCached += parsed.deduplicatedUsage.cachedInputTokens + totalOutput += parsed.deduplicatedUsage.outputTokens + } + + return TokenUsageSnapshot( + inputTokens: totalInput, + cachedInputTokens: totalCached, + outputTokens: totalOutput, + reasoningTokens: 0 + ) + } + + public func calculateDailyTokenUsage( + calendar: Calendar = Calendar.current, + now: Date = Date() + ) -> TokenUsageSnapshot { + computeDailyTokenUsage(calendar: calendar, now: now) + } +} diff --git a/Loom/Providers/Codex/CodexCLIDetector.swift b/Loom/Providers/Codex/CodexCLIDetector.swift new file mode 100644 index 0000000..d7ddfb2 --- /dev/null +++ b/Loom/Providers/Codex/CodexCLIDetector.swift @@ -0,0 +1,484 @@ +// +// CodexCLIDetector.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation + +public struct CodexLiveProcess: Sendable, Equatable { + public let pid: pid_t + public let ppid: pid_t + public let name: String + public let executablePath: String + public let cwd: String? + public let parentT3PID: pid_t? + public let surface: AgentSurface + + public init( + pid: pid_t, + ppid: pid_t, + name: String, + executablePath: String, + cwd: String?, + parentT3PID: pid_t? = nil, + surface: AgentSurface = .cli + ) { + self.pid = pid + self.ppid = ppid + self.name = name + self.executablePath = executablePath + self.cwd = cwd + self.parentT3PID = parentT3PID + self.surface = surface + } +} + +public struct CodexCLISessionRecord: Sendable, Equatable { + public let sessionID: String + public let pid: pid_t? + public let parentT3PID: pid_t? + public let surface: AgentSurface + public let cwd: String? + public let model: String? + public let approvalMode: String? + public let state: AgentRunState + public let tokenUsage: TokenUsageSnapshot? + public let fileURL: URL + public let lastModifiedAt: Date + + public init( + sessionID: String, + pid: pid_t? = nil, + parentT3PID: pid_t? = nil, + surface: AgentSurface = .cli, + cwd: String? = nil, + model: String? = nil, + approvalMode: String? = nil, + state: AgentRunState, + tokenUsage: TokenUsageSnapshot? = nil, + fileURL: URL, + lastModifiedAt: Date = Date() + ) { + self.sessionID = sessionID + self.pid = pid + self.parentT3PID = parentT3PID + self.surface = surface + self.cwd = cwd + self.model = model + self.approvalMode = approvalMode + self.state = state + self.tokenUsage = tokenUsage + self.fileURL = fileURL + self.lastModifiedAt = lastModifiedAt + } +} + +public final class CodexCLIDetector: Sendable { + private let reader: BoundedJSONLReader + private let customBaseDirectory: URL? + public var baseDirectory: URL { + customBaseDirectory ?? Self.resolveCodexHomeDirectory() + } + public var jsonlReader: BoundedJSONLReader { reader } + + public init( + baseDirectory: URL? = nil, + reader: BoundedJSONLReader = BoundedJSONLReader() + ) { + self.customBaseDirectory = baseDirectory + self.reader = reader + } + + public convenience init(reader: BoundedJSONLReader) { + self.init(baseDirectory: nil, reader: reader) + } + + public convenience init(baseDirectory: URL?, jsonlReader: BoundedJSONLReader) { + self.init(baseDirectory: baseDirectory, reader: jsonlReader) + } + + // MARK: - Main detect() Entrypoint (NEW_PLANS Section 2) + + public func detect( + processTable: [pid_t: MonitoredProcess], + t3RootPIDs: Set + ) -> [AgentSessionStatus] { + // 1. Identify running `codex` CLI processes + let liveCodexProcs = processTable.values.filter { proc in + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + return lowerName == "codex" + || lowerPath.hasSuffix("/codex") + || lowerPath.contains("@openai/codex") + } + + // 2. Scan recent rollout-*.jsonl files from ~/.codex/sessions/YYYY/MM/DD/ + let recentRolloutFiles = findRecentRolloutFiles(maxFiles: 12) + var parsedRollouts: [(url: URL, snapshot: JSONLSnapshot, parsed: JSONLProviderParsers.CodexParsedTranscript)] = [] + + for fileURL in recentRolloutFiles { + guard let snapshot = reader.readSnapshot(at: fileURL) else { continue } + let parsed = JSONLProviderParsers.parseCodex(snapshot: snapshot) + parsedRollouts.append((fileURL, snapshot, parsed)) + } + + var matchedRolloutURLs = Set() + var results: [AgentSessionStatus] = [] + + // 3. Match live processes to their active rollout transcript by CWD + for proc in liveCodexProcs { + let parentT3 = ProcessTreeScanner.findAncestor(of: proc.pid, in: processTable, matching: t3RootPIDs) + let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + + let matchingRollout = parsedRollouts.first { candidate in + guard !matchedRolloutURLs.contains(candidate.url) else { return false } + guard let procCWD = proc.cwd, let fileCWD = candidate.parsed.cwd else { return false } + return Self.normalizePath(procCWD) == Self.normalizePath(fileCWD) + } + + if let match = matchingRollout { + matchedRolloutURLs.insert(match.url) + } + + let resolvedCWD = proc.cwd ?? matchingRollout?.parsed.cwd + let projectName = resolvedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "codex" + let hasActiveChild = processTable.values.contains { $0.ppid == proc.pid } + + let state = inferState( + proc: proc, + hasActiveChild: hasActiveChild, + snapshot: matchingRollout?.snapshot, + parsed: matchingRollout?.parsed + ) + + let sessionID = matchingRollout?.parsed.sessionID ?? "codex-pid-\(proc.pid)" + + results.append( + AgentSessionStatus( + id: sessionID, + provider: .codex, + surface: surface, + pid: proc.pid, + parentT3PID: parentT3, + projectName: projectName, + workspacePath: resolvedCWD, + gitBranch: nil, + modelName: matchingRollout?.parsed.model, + state: state, + tokenUsage: matchingRollout?.parsed.latestTokenUsage, + lastUpdated: matchingRollout?.snapshot.modifiedAt ?? Date() + ) + ) + } + + // 4. Include recent inactive sessions from today (so daily token totals remain visible after exit) + for item in parsedRollouts where !matchedRolloutURLs.contains(item.url) { + guard Calendar.current.isDateInToday(item.snapshot.modifiedAt) else { continue } + let cwd = item.parsed.cwd + let projectName = cwd.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "codex" + let sessionID = item.parsed.sessionID ?? item.url.deletingPathExtension().lastPathComponent + + results.append( + AgentSessionStatus( + id: sessionID, + provider: .codex, + surface: .cli, + pid: nil, + parentT3PID: nil, + projectName: projectName, + workspacePath: cwd, + gitBranch: nil, + modelName: item.parsed.model, + state: .inactive, + tokenUsage: item.parsed.latestTokenUsage, + lastUpdated: item.snapshot.modifiedAt + ) + ) + } + + return results + } + + // MARK: - Helpers + + public static func resolveCodexHomeDirectory() -> URL { + if let customHome = ProcessInfo.processInfo.environment["CODEX_HOME"], !customHome.isEmpty { + return URL(fileURLWithPath: (customHome as NSString).expandingTildeInPath, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex", isDirectory: true) + } + + public func findRecentRolloutFiles(maxFiles: Int = 12) -> [URL] { + let sessionsRoot = baseDirectory.appendingPathComponent("sessions", isDirectory: true) + let fm = FileManager.default + guard fm.fileExists(atPath: sessionsRoot.path) else { return [] } + + let cal = Calendar.current + let now = Date() + let candidateDates = [now, cal.date(byAdding: .day, value: -1, to: now)].compactMap { $0 } + + var filesWithDates: [(url: URL, mtime: Date)] = [] + + for date in candidateDates { + let comps = cal.dateComponents([.year, .month, .day], from: date) + guard let y = comps.year, let m = comps.month, let d = comps.day else { continue } + let dayPath = String(format: "%04d/%02d/%02d", y, m, d) + let dayDir = sessionsRoot.appendingPathComponent(dayPath, isDirectory: true) + + guard let items = try? fm.contentsOfDirectory( + at: dayDir, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { continue } + + for url in items where url.lastPathComponent.hasPrefix("rollout-") && url.pathExtension == "jsonl" { + let mtime = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast + filesWithDates.append((url, mtime)) + } + } + + filesWithDates.sort { $0.mtime > $1.mtime } + return Array(filesWithDates.prefix(maxFiles).map(\.url)) + } + + public func inferState( + proc: MonitoredProcess, + hasActiveChild: Bool, + snapshot: JSONLSnapshot?, + parsed: JSONLProviderParsers.CodexParsedTranscript? + ) -> AgentRunState { + if hasActiveChild { + return .working + } + if let snapshot = snapshot { + let age = Date().timeIntervalSince(snapshot.modifiedAt) + if age < 4.0 { + return .working + } + if parsed?.approvalMode == "suggest" && age >= 4.0 && age < 300.0 && parsed?.lastEventType == "event_msg" { + return .awaitingApproval + } + } + return .idle + } + + private static func normalizePath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path + } + + // MARK: - Compatibility Methods (for Existing Tests) + + public func findLiveCodexProcesses( + table: [pid_t: MonitoredProcess], + t3AncestorPIDs: Set = [] + ) -> [CodexLiveProcess] { + var processes: [CodexLiveProcess] = [] + + for (pid, proc) in table { + guard pid > 1 else { continue } + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + + let isCodex = lowerName == "codex" || + lowerPath.hasSuffix("/codex") || + lowerPath.contains("/.codex/") || + lowerPath.contains("@openai/codex") + + guard isCodex else { continue } + + let parentT3 = ProcessTreeScanner.findAncestor(of: pid, in: table, matching: t3AncestorPIDs) + let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli + let cwd = proc.cwd ?? ProcessTreeScanner.resolveCWD(for: pid) + + processes.append( + CodexLiveProcess( + pid: pid, + ppid: proc.ppid, + name: proc.name, + executablePath: proc.executablePath, + cwd: cwd, + parentT3PID: parentT3, + surface: surface + ) + ) + } + + return processes + } + + public func findRecentRolloutFileURLs( + calendar: Calendar = Calendar.current, + now: Date = Date(), + daysBack: Int = 1 + ) -> [URL] { + let sessionsDir = baseDirectory.appendingPathComponent("sessions") + guard FileManager.default.fileExists(atPath: sessionsDir.path) else { return [] } + + var rolloutURLs: [URL] = [] + let fileManager = FileManager.default + + for dayOffset in 0...daysBack { + guard let targetDate = calendar.date(byAdding: .day, value: -dayOffset, to: now) else { continue } + let year = String(format: "%04d", calendar.component(.year, from: targetDate)) + let month = String(format: "%02d", calendar.component(.month, from: targetDate)) + let day = String(format: "%02d", calendar.component(.day, from: targetDate)) + + let dayDir = sessionsDir + .appendingPathComponent(year) + .appendingPathComponent(month) + .appendingPathComponent(day) + + guard let enumerator = fileManager.enumerator( + at: dayDir, + includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey], + options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants] + ) else { + continue + } + + for case let fileURL as URL in enumerator { + if fileURL.lastPathComponent.hasPrefix("rollout-") && fileURL.pathExtension == "jsonl" { + rolloutURLs.append(fileURL) + } + } + } + + // Sort by modification date descending + return rolloutURLs.sorted { u1, u2 in + let date1 = (try? u1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + let date2 = (try? u2.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date.distantPast + return date1 > date2 + } + } + + public func detectSessions( + table: [pid_t: MonitoredProcess], + t3AncestorPIDs: Set = [] + ) -> [CodexCLISessionRecord] { + let liveProcesses = findLiveCodexProcesses(table: table, t3AncestorPIDs: t3AncestorPIDs) + let rolloutURLs = findRecentRolloutFileURLs() + + var sessions: [CodexCLISessionRecord] = [] + var claimedPIDs = Set() + + for url in rolloutURLs { + guard let snapshot = reader.readSnapshot(at: url) else { continue } + let parsed = JSONLProviderParsers.parseCodex(snapshot: snapshot) + + let sessionID = parsed.sessionID ?? url.deletingPathExtension().lastPathComponent + + // Try to correlate with a live process by matching CWD + let matchingProcess = liveProcesses.first { proc in + !claimedPIDs.contains(proc.pid) && + proc.cwd != nil && + proc.cwd == parsed.cwd + } + + let pid: pid_t? + let parentT3: pid_t? + let surface: AgentSurface + let state: AgentRunState + + if let proc = matchingProcess { + claimedPIDs.insert(proc.pid) + pid = proc.pid + parentT3 = proc.parentT3PID + surface = proc.surface + + // Check runtime state from latest events + if parsed.approvalMode == "suggest" && parsed.lastEventType == "turn_context" { + state = .awaitingApproval + } else if parsed.lastEventType == "event_msg" { + state = .working + } else { + state = .idle + } + } else { + pid = nil + parentT3 = nil + surface = .cli + state = .inactive + } + + sessions.append( + CodexCLISessionRecord( + sessionID: sessionID, + pid: pid, + parentT3PID: parentT3, + surface: surface, + cwd: parsed.cwd, + model: parsed.model, + approvalMode: parsed.approvalMode, + state: state, + tokenUsage: parsed.latestTokenUsage, + fileURL: url, + lastModifiedAt: snapshot.modifiedAt + ) + ) + } + + // Add any remaining live codex processes without an existing rollout file + for proc in liveProcesses where !claimedPIDs.contains(proc.pid) { + let sessionID = "pid-\(proc.pid)" + sessions.append( + CodexCLISessionRecord( + sessionID: sessionID, + pid: proc.pid, + parentT3PID: proc.parentT3PID, + surface: proc.surface, + cwd: proc.cwd, + model: nil, + approvalMode: nil, + state: .working, + tokenUsage: nil, + fileURL: URL(fileURLWithPath: proc.executablePath), + lastModifiedAt: Date() + ) + ) + } + + return sessions + } + + public func computeDailyTokenUsage( + calendar: Calendar = Calendar.current, + now: Date = Date() + ) -> TokenUsageSnapshot { + let todayRollouts = findRecentRolloutFileURLs(calendar: calendar, now: now, daysBack: 0) + + var totalInput = 0 + var totalCached = 0 + var totalOutput = 0 + var totalReasoning = 0 + + for url in todayRollouts { + guard let snapshot = reader.readSnapshot(at: url) else { continue } + let parsed = JSONLProviderParsers.parseCodex(snapshot: snapshot) + + if let usage = parsed.latestTokenUsage { + totalInput += usage.inputTokens + totalCached += usage.cachedInputTokens + totalOutput += usage.outputTokens + totalReasoning += usage.reasoningTokens + } + } + + return TokenUsageSnapshot( + inputTokens: totalInput, + cachedInputTokens: totalCached, + outputTokens: totalOutput, + reasoningTokens: totalReasoning + ) + } + + public func calculateDailyTokenUsage( + calendar: Calendar = Calendar.current, + now: Date = Date() + ) -> TokenUsageSnapshot { + computeDailyTokenUsage(calendar: calendar, now: now) + } +} diff --git a/Loom/Providers/T3Code/T3CodeDetector.swift b/Loom/Providers/T3Code/T3CodeDetector.swift new file mode 100644 index 0000000..13f5908 --- /dev/null +++ b/Loom/Providers/T3Code/T3CodeDetector.swift @@ -0,0 +1,472 @@ +// +// T3CodeDetector.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +import SQLite3 + +public struct T3ServerRuntime: Codable, Sendable { + public let pid: pid_t + public let port: Int? + public let origin: String? + public let devUrl: String? + public let serverVersion: String? + public let startedAt: String? + + public init( + pid: pid_t, + port: Int? = nil, + origin: String? = nil, + devUrl: String? = nil, + serverVersion: String? = nil, + startedAt: String? = nil + ) { + self.pid = pid + self.port = port + self.origin = origin + self.devUrl = devUrl + self.serverVersion = serverVersion + self.startedAt = startedAt + } +} + +public struct T3ActiveThreadRecord: Sendable, Equatable { + public let threadID: String + public let projectID: String + public let title: String + public let provider: String + public let projectTitle: String? + public let workspaceRoot: String? + public let runStatus: String? + public let pendingRequestsCount: Int + public let updatedAt: Date? + + public var state: AgentRunState { + if pendingRequestsCount > 0 { + return .awaitingApproval + } + if let status = runStatus?.lowercased() { + if ["running", "starting", "preparing"].contains(status) { + return .working + } + if ["needs-attention", "waiting"].contains(status) { + return .awaitingApproval + } + } + return .idle + } +} + +public final class T3CodeDetector: Sendable { + public let baseDirectory: URL + private let urlSession: URLSession + private let reader: BoundedJSONLReader + + public init( + baseDirectory: URL? = nil, + urlSession: URLSession? = nil, + reader: BoundedJSONLReader = BoundedJSONLReader() + ) { + if let baseDirectory = baseDirectory { + self.baseDirectory = baseDirectory + } else if let envPath = ProcessInfo.processInfo.environment["T3CODE_HOME"], !envPath.isEmpty { + self.baseDirectory = URL(fileURLWithPath: envPath) + } else { + self.baseDirectory = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".t3") + } + + self.reader = reader + + if let session = urlSession { + self.urlSession = session + } else { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 1.5 + config.timeoutIntervalForResource = 1.5 + config.waitsForConnectivity = false + self.urlSession = URLSession(configuration: config) + } + } + + // MARK: - Server Runtime Resolution + + public func resolveServerRuntime(processTable: [pid_t: MonitoredProcess]? = nil) -> T3ServerRuntime? { + let candidates = [ + baseDirectory.appendingPathComponent("userdata/server-runtime.json"), + baseDirectory.appendingPathComponent("dev/userdata/server-runtime.json") + ] + + let decoder = JSONDecoder() + for path in candidates { + guard let data = try? Data(contentsOf: path), + let runtime = try? decoder.decode(T3ServerRuntime.self, from: data) else { + continue + } + + // CRITICAL: Stale PID Verification + if let table = processTable { + guard let proc = table[runtime.pid], proc.pid == runtime.pid else { + continue + } + } else { + // Posix probe: kill with signal 0 checks process existence without signaling + guard kill(runtime.pid, 0) == 0 else { + continue + } + } + + return runtime + } + return nil + } + + // MARK: - Loopback Environment Probe + + public func probeEnvironment(origin: String) async -> Bool { + guard let url = URL(string: "\(origin)/.well-known/t3/environment") else { return false } + do { + let (_, response) = try await urlSession.data(from: url) + if let httpResponse = response as? HTTPURLResponse { + return httpResponse.statusCode == 200 + } + } catch { + return false + } + return false + } + + // MARK: - Read-Only SQLite Threads & Runs Inspection + + public func readActiveThreads() -> [T3ActiveThreadRecord] { + let sqliteURL = baseDirectory.appendingPathComponent("userdata/statev2.sqlite") + guard FileManager.default.fileExists(atPath: sqliteURL.path) else { return [] } + + var db: OpaquePointer? + let openFlags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI + let uri = "file:\(sqliteURL.path)?mode=ro" + let status = sqlite3_open_v2(uri, &db, openFlags, nil) + guard status == SQLITE_OK, let validDB = db else { + if let db = db { sqlite3_close(db) } + return [] + } + defer { sqlite3_close(validDB) } + + sqlite3_busy_timeout(validDB, 250) + + let query = """ + SELECT + t.thread_id, + t.project_id, + t.title, + t.default_provider, + p.title AS project_title, + p.workspace_root, + r.status AS run_status, + (SELECT COUNT(*) FROM orchestration_v2_projection_runtime_requests req WHERE req.thread_id = t.thread_id AND req.status = 'pending') AS pending_requests_count, + t.updated_at + FROM orchestration_v2_projection_threads t + LEFT JOIN projection_projects p ON t.project_id = p.project_id + LEFT JOIN ( + SELECT thread_id, status FROM orchestration_v2_projection_runs r1 + WHERE ordinal = (SELECT MAX(ordinal) FROM orchestration_v2_projection_runs r2 WHERE r2.thread_id = r1.thread_id) + ) r ON t.thread_id = r.thread_id + WHERE t.deleted_at IS NULL AND t.archived_at IS NULL + ORDER BY t.updated_at DESC; + """ + + var statement: OpaquePointer? + guard sqlite3_prepare_v2(validDB, query, -1, &statement, nil) == SQLITE_OK, + let stmt = statement else { + return [] + } + defer { sqlite3_finalize(stmt) } + + var records: [T3ActiveThreadRecord] = [] + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + while sqlite3_step(stmt) == SQLITE_ROW { + let threadID = String(cString: sqlite3_column_text(stmt, 0)) + let projectID = String(cString: sqlite3_column_text(stmt, 1)) + let title = String(cString: sqlite3_column_text(stmt, 2)) + let provider = String(cString: sqlite3_column_text(stmt, 3)) + let projectTitle = sqlite3_column_text(stmt, 4).map { String(cString: $0) } + let workspaceRoot = sqlite3_column_text(stmt, 5).map { String(cString: $0) } + let runStatus = sqlite3_column_text(stmt, 6).map { String(cString: $0) } + let pendingCount = Int(sqlite3_column_int(stmt, 7)) + let updatedAtString = sqlite3_column_text(stmt, 8).map { String(cString: $0) } + let updatedAt = updatedAtString.flatMap { + isoFormatter.date(from: $0) ?? ISO8601DateFormatter().date(from: $0) + } + + records.append( + T3ActiveThreadRecord( + threadID: threadID, + projectID: projectID, + title: title, + provider: provider, + projectTitle: projectTitle, + workspaceRoot: workspaceRoot, + runStatus: runStatus, + pendingRequestsCount: pendingCount, + updatedAt: updatedAt + ) + ) + } + + return records + } + + // MARK: - Child Process Deduplication Helper + + public func collectT3AncestorPIDs( + table: [pid_t: MonitoredProcess], + serverPID: pid_t? + ) -> Set { + var t3PIDs = Set() + if let sPID = serverPID, sPID > 1 { + t3PIDs.insert(sPID) + } + + for (pid, proc) in table { + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + if lowerName.contains("t3") || lowerName.contains("t3 code") || + lowerPath.contains("t3 code") || lowerPath.contains("/t3code") { + t3PIDs.insert(pid) + } + } + return t3PIDs + } + + // MARK: - Orchestrator detect() Entrypoint + + public struct DetectionResult: Sendable { + public let sessions: [AgentSessionStatus] + /// All root PIDs owned by T3Code (server daemon + macOS wrapper apps) used to deduplicate child CLIs. + public let rootT3PIDs: Set + + public init(sessions: [AgentSessionStatus], rootT3PIDs: Set) { + self.sessions = sessions + self.rootT3PIDs = rootT3PIDs + } + } + + public static func resolveT3HomeDirectory() -> URL { + if let customHome = ProcessInfo.processInfo.environment["T3CODE_HOME"], !customHome.isEmpty { + return URL(fileURLWithPath: (customHome as NSString).expandingTildeInPath, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".t3", isDirectory: true) + } + + public func findRunningT3AppPIDs(in processTable: [pid_t: MonitoredProcess]) -> Set { + var pids = Set() + for (pid, proc) in processTable { + let lowerName = proc.name.lowercased() + let lowerPath = proc.executablePath.lowercased() + if lowerName == "t3code" || lowerName == "t3" || lowerPath.contains("t3 code") || lowerPath.contains("/t3code") { + pids.insert(pid) + } + } + return pids + } + + public func detect(processTable: [pid_t: MonitoredProcess]) async -> DetectionResult { + var rootPIDs = findRunningT3AppPIDs(in: processTable) + + // 1. Check server-runtime.json (prod and dev paths) + let activeRuntime = resolveServerRuntime(processTable: processTable) + if let runtime = activeRuntime { + rootPIDs.insert(runtime.pid) + } + + // If neither the server PID nor any T3Code process is alive, return empty + guard !rootPIDs.isEmpty else { + return DetectionResult(sessions: [], rootT3PIDs: []) + } + + let primaryPID = activeRuntime?.pid ?? rootPIDs.first + + // 2. Try querying loopback /api/orchestration/shell if runtime origin is valid + if let runtime = activeRuntime, + let origin = runtime.origin, + let shellURL = URL(string: origin)?.appendingPathComponent("api/orchestration/shell"), + let shellSessions = await fetchOrchestrationSessions(from: shellURL, daemonPID: runtime.pid) { + if !shellSessions.isEmpty { + return DetectionResult(sessions: shellSessions, rootT3PIDs: rootPIDs) + } + } + + // 2b. Fallback to SQLite statev2 database + let sqliteThreads = readActiveThreads() + if !sqliteThreads.isEmpty { + var sessions: [AgentSessionStatus] = [] + for thread in sqliteThreads { + let projectName = thread.workspaceRoot.map { URL(fileURLWithPath: $0).lastPathComponent } + ?? thread.projectTitle + ?? thread.title + sessions.append( + AgentSessionStatus( + id: "t3-\(thread.threadID)", + provider: .t3Code, + surface: .gui, + pid: primaryPID, + parentT3PID: nil, + projectName: projectName, + workspacePath: thread.workspaceRoot, + gitBranch: nil, + modelName: nil, + state: thread.state, + tokenUsage: nil, + lastUpdated: thread.updatedAt ?? Date() + ) + ) + } + return DetectionResult(sessions: sessions, rootT3PIDs: rootPIDs) + } + + // 3. Fallback: Parse ~/.t3/userdata/logs/server.trace.ndjson or process CWD + let fallbackSession = buildFallbackSession( + primaryPID: primaryPID, + processTable: processTable + ) + return DetectionResult(sessions: [fallbackSession], rootT3PIDs: rootPIDs) + } + + private struct OrchestrationShellResponse: Decodable, Sendable { + let projects: [T3Project]? + let threads: [T3Thread]? + } + + private struct T3Project: Decodable, Sendable { + let id: String + let title: String? + let workspaceRoot: String? + } + + private struct T3Thread: Decodable, Sendable { + let id: String + let projectId: String? + let title: String? + let model: String? + let status: String? + let runStatus: String? + let updatedAt: String? + } + + private struct TraceSpanLine: Decodable, Sendable { + let timestamp: String? + let name: String? + let message: String? + let threadId: String? + let model: String? + } + + private func fetchOrchestrationSessions(from url: URL, daemonPID: pid_t) async -> [AgentSessionStatus]? { + do { + let (data, response) = try await urlSession.data(from: url) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + return nil + } + let shell = try JSONDecoder().decode(OrchestrationShellResponse.self, from: data) + let projectsByID = Dictionary( + uniqueKeysWithValues: (shell.projects ?? []).map { ($0.id, $0) } + ) + + var results: [AgentSessionStatus] = [] + let isoFormatter = ISO8601DateFormatter() + + for thread in shell.threads ?? [] { + let status = thread.status ?? "active" + if status == "settled" || status == "snoozed" { continue } + + let project = thread.projectId.flatMap { projectsByID[$0] } + let workspacePath = project?.workspaceRoot + let projectName = workspacePath.map { URL(fileURLWithPath: $0).lastPathComponent } + ?? project?.title + ?? thread.title + ?? "T3Code Workspace" + + let runState: AgentRunState + switch thread.runStatus { + case "running": + runState = .working + case "needs-attention": + runState = .awaitingApproval + default: + runState = .idle + } + + let updatedDate = thread.updatedAt.flatMap { isoFormatter.date(from: $0) } ?? Date() + + results.append( + AgentSessionStatus( + id: "t3-\(thread.id)", + provider: .t3Code, + surface: .gui, + pid: daemonPID, + parentT3PID: nil, + projectName: projectName, + workspacePath: workspacePath, + gitBranch: nil, + modelName: thread.model, + state: runState, + tokenUsage: nil, + lastUpdated: updatedDate + ) + ) + } + return results + } catch { + return nil + } + } + + private func buildFallbackSession( + primaryPID: pid_t?, + processTable: [pid_t: MonitoredProcess] + ) -> AgentSessionStatus { + let traceURL = baseDirectory.appendingPathComponent("userdata/logs/server.trace.ndjson") + var state: AgentRunState = .idle + var detectedModel: String? + var lastUpdated = Date() + + if let snapshot = reader.readSnapshot(at: traceURL) { + lastUpdated = snapshot.modifiedAt + let decoder = JSONDecoder() + if let lastLine = snapshot.tailLines.last, + let trace = try? decoder.decode(TraceSpanLine.self, from: lastLine) { + let combined = ((trace.name ?? "") + " " + (trace.message ?? "")).lowercased() + if combined.contains("waiting for approval") || combined.contains("needs-attention") { + state = .awaitingApproval + } else if combined.contains("starting provider turn") || combined.contains("streaming") { + state = .working + } + detectedModel = trace.model + } + } + + let cwd = primaryPID.flatMap { processTable[$0]?.cwd } + let projectName = cwd.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "T3Code" + + return AgentSessionStatus( + id: "t3-daemon-\(primaryPID ?? 0)", + provider: .t3Code, + surface: .gui, + pid: primaryPID, + parentT3PID: nil, + projectName: projectName, + workspacePath: cwd, + gitBranch: nil, + modelName: detectedModel, + state: state, + tokenUsage: nil, + lastUpdated: lastUpdated + ) + } +} diff --git a/Loom/Providers/T3Code/T3CodeProvider.swift b/Loom/Providers/T3Code/T3CodeProvider.swift new file mode 100644 index 0000000..9ec01d3 --- /dev/null +++ b/Loom/Providers/T3Code/T3CodeProvider.swift @@ -0,0 +1,106 @@ +// +// T3CodeProvider.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation + +public final class T3CodeProvider: AgentProvider, Sendable { + public let id: ProviderID = .t3Code + public let displayName: String = "T3 Code" + + private let detector: T3CodeDetector + private let processScanner: ProcessTreeScanner + + public init( + detector: T3CodeDetector = T3CodeDetector(), + processScanner: ProcessTreeScanner = ProcessTreeScanner() + ) { + self.detector = detector + self.processScanner = processScanner + } + + public func availability() async -> ProviderAvailability { + let table = processScanner.scanProcessTable() + guard let runtime = detector.resolveServerRuntime(processTable: table) else { + return .unavailable(reason: "T3 Code server not running") + } + + if let origin = runtime.origin { + let isReachable = await detector.probeEnvironment(origin: origin) + if isReachable { + return .available + } + } + + // If loopback probe timed out but PID is alive and state database exists + return .available + } + + public func discoverSessions() async throws -> [AgentSession] { + let table = processScanner.scanProcessTable() + guard let runtime = detector.resolveServerRuntime(processTable: table) else { + return [] + } + + let records = detector.readActiveThreads() + var sessions: [AgentSession] = [] + + for record in records { + let agentState: AgentState + switch record.state { + case .working: + agentState = .working + case .awaitingApproval: + agentState = .waiting + case .idle: + agentState = .idle + case .inactive: + agentState = .finished + } + + let projectURL = record.workspaceRoot.map { URL(fileURLWithPath: $0) } + let projectName = record.projectTitle + ?? projectURL?.lastPathComponent + ?? "T3 Project" + + let session = AgentSession( + id: record.threadID, + provider: .t3Code, + projectName: projectName, + projectPath: projectURL, + state: agentState, + startedAt: record.updatedAt, + lastActivityAt: record.updatedAt, + branch: nil, + summary: record.title, + pid: Int(runtime.pid), + providerSessionID: record.threadID, + associatedProcesses: [], + hostApp: .terminal, + diagnostics: nil, + discoveryEvidence: [ + "T3Code runtime PID \(runtime.pid)", + "Provider: \(record.provider)", + "Run status: \(record.runStatus ?? "unknown")", + "Pending requests: \(record.pendingRequestsCount)" + ] + ) + sessions.append(session) + } + + return sessions + } + + public func fetchUsage() async throws -> ProviderUsageSnapshot? { + return nil + } + + public func lastDiagnostics() async -> ProviderDiscoveryDiagnostics? { + return nil + } +} diff --git a/Loom/UI/Components/AgentSessionsSectionView.swift b/Loom/UI/Components/AgentSessionsSectionView.swift new file mode 100644 index 0000000..d5af85a --- /dev/null +++ b/Loom/UI/Components/AgentSessionsSectionView.swift @@ -0,0 +1,244 @@ +// +// AgentSessionsSectionView.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import SwiftUI +#if !SWIFT_PACKAGE +import LoomCore +#endif + +public struct AgentSessionsSectionView: View { + @ObservedObject var coordinator: AgentMonitoringCoordinator + + public init(coordinator: AgentMonitoringCoordinator) { + self.coordinator = coordinator + } + + /// Top-level sessions (excludes CLI processes spawned as children of T3Code) + private var topLevelSessions: [AgentSessionStatus] { + coordinator.sessions.filter { $0.surface != .t3Orchestrated } + } + + /// Child CLI sessions spawned by a given T3Code daemon/app PID + private func childSessions(for t3Session: AgentSessionStatus) -> [AgentSessionStatus] { + guard t3Session.provider == .t3Code else { return [] } + return coordinator.sessions.filter { child in + guard child.surface == .t3Orchestrated else { return false } + if let t3PID = t3Session.pid, let parentPID = child.parentT3PID, t3PID == parentPID { + return true + } + if let ws1 = t3Session.workspacePath, let ws2 = child.workspacePath, ws1 == ws2 { + return true + } + return false + } + } + + public var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("ACTIVE AGENTS & CLIs") + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + Spacer() + if coordinator.sessions.contains(where: { $0.state == .awaitingApproval }) { + Label("Needs Approval", systemImage: "exclamationmark.bubble.fill") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.orange) + } + } + .padding(.horizontal, 4) + + if topLevelSessions.isEmpty { + HStack(spacing: 8) { + Image(systemName: "terminal") + .foregroundStyle(.tertiary) + Text("No active CLI or T3Code sessions") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } else { + ForEach(topLevelSessions) { session in + AgentSessionCardView( + session: session, + orchestratedChildren: childSessions(for: session) + ) + } + } + } + } + + public static func formatTokens(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000) + } else if count >= 1_000 { + return String(format: "%.1fK", Double(count) / 1_000) + } + return "\(count)" + } +} + +private struct AgentSessionCardView: View { + let session: AgentSessionStatus + let orchestratedChildren: [AgentSessionStatus] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(statusColor(for: session.state)) + .frame(width: 7, height: 7) + + Text(session.projectName) + .font(.system(size: 13, weight: .semibold)) + .lineLimit(1) + + if let branch = session.gitBranch, !branch.isEmpty { + Text(branch) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary.opacity(0.6), in: Capsule()) + } + + Spacer() + + ProviderBadge(provider: session.provider, surface: session.surface) + } + + HStack(spacing: 8) { + Text(statusLabel(for: session.state)) + .font(.system(size: 11, weight: session.state == .awaitingApproval ? .semibold : .regular)) + .foregroundStyle(session.state == .awaitingApproval ? .orange : .secondary) + + if let model = session.modelName, !model.isEmpty { + Text("• \(model)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + + Spacer() + + if let usage = session.tokenUsage, usage.totalTokens > 0 { + Text("\(Self.formatTokens(usage.totalTokens)) tok") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + // Nested child CLI rows when T3Code is driving Codex / Claude / agy + if !orchestratedChildren.isEmpty { + Divider() + .opacity(0.4) + .padding(.vertical, 2) + + ForEach(orchestratedChildren) { child in + HStack(spacing: 6) { + Image(systemName: "arrow.turn.down.right") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + + Text(providerDisplayName(child.provider)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + + if let model = child.modelName { + Text(model) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + + Spacer() + + if let usage = child.tokenUsage, usage.totalTokens > 0 { + Text("\(Self.formatTokens(usage.totalTokens)) tok") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Circle() + .fill(statusColor(for: child.state)) + .frame(width: 6, height: 6) + } + .padding(.leading, 6) + } + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(.quaternary.opacity(session.state == .inactive ? 0.25 : 0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder( + session.state == .awaitingApproval ? Color.orange.opacity(0.6) : Color.primary.opacity(0.07), + lineWidth: 1 + ) + ) + .opacity(session.state == .inactive ? 0.65 : 1.0) + } + + private func statusColor(for state: AgentRunState) -> Color { + switch state { + case .working: return .green + case .awaitingApproval: return .orange + case .idle: return .blue + case .inactive: return .gray.opacity(0.5) + } + } + + private func statusLabel(for state: AgentRunState) -> String { + switch state { + case .working: return "Working" + case .awaitingApproval: return "Waiting for Approval" + case .idle: return "Idle" + case .inactive: return "Recent" + } + } + + private func providerDisplayName(_ provider: AgentProviderKind) -> String { + switch provider { + case .t3Code: return "T3Code" + case .codex: return "Codex CLI" + case .claudeCode: return "Claude Code" + case .antigravity: return "Antigravity CLI" + } + } + + private static func formatTokens(_ count: Int) -> String { + AgentSessionsSectionView.formatTokens(count) + } +} + +private struct ProviderBadge: View { + let provider: AgentProviderKind + let surface: AgentSurface + + var body: some View { + Text(label) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.08), in: Capsule()) + .foregroundStyle(.secondary) + } + + private var label: String { + switch provider { + case .t3Code: return "T3Code" + case .codex: return "codex" + case .claudeCode: return "claude" + case .antigravity: return surface == .cli ? "agy" : "Antigravity" + } + } +} diff --git a/PLANS.md b/PLANS.md new file mode 100644 index 0000000..8720935 --- /dev/null +++ b/PLANS.md @@ -0,0 +1,553 @@ +# PLANS.md — Loom Multi-Agent & T3Code Expansion Specification + +## 0. STRICT AGENT GUARDRAILS (READ FIRST) +1. **Zero Shell Subprocesses for Process Scanning:** NEVER use `Process()` / `NSTask` to run `ps`, `lsof`, `pgrep`, or `top` on a timer. All process discovery, Parent PID (`ppid`) lookup, executable path resolution, and working directory (`cwd`) extraction MUST use native Darwin `` C APIs (`proc_listallpids`, `proc_pidinfo`, `proc_pidpath`). +2. **Read-Only & Privacy-First:** Loom is strictly a read-only monitor. NEVER write to, lock, or mutate any file inside `~/.t3`, `~/.codex`, `~/.claude`, or `~/.gemini`. Open all SQLite databases (if any) with `SQLITE_OPEN_READONLY` and read JSONL files using non-blocking file descriptors (`O_RDONLY | O_NONBLOCK`). +3. **Bounded Tail Reads:** Agent JSONL transcripts can exceed 100 MB. NEVER load an entire `.jsonl` file into memory with `String(contentsOf:)` or `Data(contentsOf:)` on the main thread. Always read the first 4 KB (for session metadata) and seek (`lseek` / `FileHandle.seek(toOffset:)`) to the last 64 KB to parse recent state and cumulative token usage. +4. **Execution Order:** Implement strictly in order: **Phase 0 (Process Scanner) -> Phase 1 (T3Code) -> Phase 2 (Codex CLI) -> Phase 3 (Claude Code) -> Phase 4 (Antigravity CLI) -> Phase 5 (UI Aggregation)**. Do not skip ahead. + +--- + +## Phase 0: Core `libproc` Process Tree & CWD Scanner + +### Objective +Build a zero-allocation Darwin process scanner in Swift that snapshots running processes, resolves their `ppid` (Parent PID), full binary path, and current working directory (`cwd`), and builds a parent-to-child map so CLI agents spawned by T3Code are grouped under T3Code rather than duplicated as standalone sessions. + +### Exact Implementation (`ProcessTreeScanner.swift`) +```swift +import Foundation +import Darwin + +public struct MonitoredProcess: Sendable, Equatable { + public let pid: pid_t + public let ppid: pid_t + public let name: String + public let executablePath: String + public let cwd: String? +} + +public final class ProcessTreeScanner: Sendable { + public init() {} + + public func scanProcessTable() -> [pid_t: MonitoredProcess] { + let count = proc_listallpids(nil, 0) + guard count > 0 else { return [:] } + + var pids = [pid_t](repeating: 0, count: Int(count) * 2) + let actualBytes = proc_listallpids(&pids, Int32(pids.count * MemoryLayout.stride)) + let pidCount = Int(actualBytes) / MemoryLayout.stride + + var table: [pid_t: MonitoredProcess] = [:] + table.reserveCapacity(pidCount) + + for i in 0.. 0 else { continue } + + // 1. Get BSD Info (PPID & Process Name) + var bsdInfo = proc_bsdinfo() + let bsdSize = proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + &bsdInfo, + Int32(MemoryLayout.stride) + ) + guard bsdSize == MemoryLayout.stride else { continue } + + let ppid = pid_t(bsdInfo.pbi_ppid) + let name = withUnsafePointer(to: &bsdInfo.pbi_name) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN)) { + String(cString: $0) + } + } + + // 2. Get Executable Path + var pathBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN)) + let pathLen = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count)) + let execPath = pathLen > 0 ? String(cString: pathBuffer) : "" + + // 3. Only resolve CWD for candidate processes to save syscalls + let isCandidate = Self.isCandidateProcess(name: name, execPath: execPath) + let cwd = isCandidate ? Self.resolveCWD(for: pid) : nil + + table[pid] = MonitoredProcess( + pid: pid, + ppid: ppid, + name: name, + executablePath: execPath, + cwd: cwd + ) + } + return table + } + + private static func isCandidateProcess(name: String, execPath: String) -> Bool { + let lowerName = name.lowercased() + let lowerPath = execPath.lowercased() + let targets = ["t3", "t3code", "codex", "claude", "agy", "antigravity", "node", "bun"] + return targets.contains { lowerName.contains($0) || lowerPath.contains($0) } + } + + public static func resolveCWD(for pid: pid_t) -> String? { + var vnodeInfo = proc_vnodepathinfo() + let size = proc_pidinfo( + pid, + PROC_PIDVNODEPATHINFO, + 0, + &vnodeInfo, + Int32(MemoryLayout.stride) + ) + guard size == MemoryLayout.stride else { return nil } + return withUnsafePointer(to: &vnodeInfo.pvi_cdir.vip_path) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { + let str = String(cString: $0) + return str.isEmpty ? nil : str + } + } + } + + /// Walks up the PPID chain (max depth 8) to check if `pid` is a descendant of `ancestorPIDs`. + public static func findAncestor( + of pid: pid_t, + in table: [pid_t: MonitoredProcess], + matching ancestorPIDs: Set + ) -> pid_t? { + var currentPID = pid + for _ in 0..<8 { + guard let proc = table[currentPID], proc.ppid > 1 else { return nil } + if ancestorPIDs.contains(proc.ppid) { + return proc.ppid + } + currentPID = proc.ppid + } + return nil + } +} + + +Phase 1: T3Code Detector (Implement First) + +How T3Code Works on macOS + +T3Code (pingdotgg/t3code) runs a local orchestration server (default port 3773) managed either by the desktop app (T3 Code (Alpha) or T3 Code (Nightly)) or standalone via npx t3 / t3code. It spawns underlying CLI agents (codex, claude, opencode) as child processes and manages git worktrees under ~/.t3/worktrees//. + +Exact File Paths & Discovery Mechanics + +1. Base Directory Resolution: + ⚬ Check ProcessInfo.processInfo.environment["T3CODE_HOME"], fallback to ~/.t3. +2. Runtime State File (server-runtime.json): + ⚬ Primary path: ~/.t3/userdata/server-runtime.json + ⚬ Dev fallback path: ~/.t3/dev/userdata/server-runtime.json + ⚬ Schema of server-runtime.json: + { + "pid": 48291, + "port": 3773, + "origin": "[http://127.0.0.1:3773](http://127.0.0.1:3773)", + "devUrl": null + } + + ⚬ Stale PID Verification (CRITICAL): After a crash, server-runtime.json can hold a stale PID. Always verify that table[pid] exists in ProcessTreeScanner before trusting server-runtime.json. +3. Loopback HTTP Endpoints (Read-Only): + ⚬ Readiness / Protocol Check: GET /.well-known/t3/environment (timeout: 1.5s). + ⚬ Active Projects & Threads: GET /api/orchestration/shell + ⚬ Returns active projects (id, title, workspaceRoot) and threads (id, projectId, title, model, status, runStatus, updatedAt). + ⚬ Thread Status Mapping: + ⚬ runStatus == "running" -> .working + ⚬ runStatus == "needs-attention" (or pendingRequests non-empty) -> .awaitingApproval (Waiting for user approval/answer) + ⚬ status == "active" and runStatus == "idle" -> .idle + ⚬ status == "settled" or "snoozed" -> .inactive +4. Local NDJSON Trace Fallback (server.trace.ndjson): + ⚬ Path: ~/.t3/userdata/logs/server.trace.ndjson + ⚬ If /api/orchestration/shell requires an auth ticket in v2, tail the last 64 KB of server.trace.ndjson for active spans ("starting provider turn", "waiting for approval response"). +5. Child CLI Deduplication: + ⚬ Collect the T3Code server pid (from server-runtime.json) and any running macOS app PIDs whose bundle/app name contains "T3 Code". + ⚬ Pass this Set into ProcessTreeScanner.findAncestor(of:in:matching:). Any codex or claude CLI process whose ancestor is T3Code MUST be marked managedBy = .t3Code(parentPID) so it attaches to the T3Code session card instead of creating a duplicate standalone CLI row. + +Phase 2: Codex CLI Detector (@openai/codex) + +How Codex CLI Works on macOS + +OpenAI's codex CLI stores all state under $CODEX_HOME (defaults to ~/.codex). In addition to Loom's existing app-server stdio check, standalone terminal sessions write structured append-only JSONL rollout files. + +Exact File Paths & Schemas + +1. Process Detection: + ⚬ Match processes in ProcessTreeScanner where name == "codex" or executablePath.hasSuffix("/codex"). + ⚬ Extract cwd directly via ProcessTreeScanner.resolveCWD(for: pid). +2. Session Transcripts Directory: + ⚬ Path pattern: ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl + ⚬ Also check ~/.codex/config.toml for custom sqlite_home or configuration overrides. +3. Exact JSONL Record Schema (RolloutLine): + Every line in rollout-*.jsonl is a JSON object with 3 top-level keys: timestamp (ISO-8601 String), type (or kind in older builds), and payload. + ⚬ Record 1 (type == "session_meta"): Located on Line 1 of the file. + { + "timestamp": "2026-10-31T14:20:00.000Z", + "type": "session_meta", + "payload": { + "id": "abc12345-...", + "cwd": "/Users/username/Developer/Loom", + "model": "o3", + "approval_mode": "suggest" + } + } + + ⚬ Record 2 (type == "turn_context"): Updates current cwd, model, or sandbox policy mid-session. + ⚬ Record 3 (type == "event_msg"): Carries token usage and runtime state events. + ⚬ Token Usage (payload.type == "token_count"): + { + "timestamp": "2026-10-31T14:22:10.000Z", + "type": "event_msg", + "payload": { + "type": "token_count", + "info": { + "total_token_usage": { + "input_tokens": 45210, + "cached_input_tokens": 38100, + "output_tokens": 1840, + "reasoning_output_tokens": 620, + "total_tokens": 47050 + } + } + } + } + + ⚬ CRITICAL RULE FOR CODEX TOKENS: total_token_usage is cumulative within a single session file. Do NOT sum total_token_usage across multiple lines in the same file! Take the maximum / final total_token_usage from each session file, and sum those maxima across files modified today to compute daily usage. + +Phase 3: Claude Code CLI Detector (@anthropic-ai/claude-code) + +How Claude Code Works on macOS + +Claude Code (claude) writes one append-only JSONL file per session under ~/.claude/projects//.jsonl. + +Exact File Paths & Schemas + +1. Process Detection: + ⚬ Match processes where name == "claude" or executablePath.contains("/claude"). + ⚬ Resolve live cwd from ProcessTreeScanner. +2. Session Transcripts Directory: + ⚬ Root path: ~/.claude/projects/ + ⚬ Subfolders encode the project path by replacing / with - (e.g., /Users/dev/Loom -> -Users-dev-Loom). Do NOT rely on decoding the folder name—always read the explicit cwd field inside the JSONL line! +3. Exact JSONL Record Schema: + Each line in ~/.claude/projects/*/*.jsonl contains top-level metadata alongside a message object: + { + "parentUuid": "...", + "isSidechain": false, + "userType": "external", + "cwd": "/Users/username/Developer/Loom", + "sessionId": "7f9c2b10-...", + "version": "1.0.58", + "gitBranch": "main", + "type": "assistant", + "timestamp": "2026-10-31T18:10:04.192Z", + "message": { + "id": "msg_019abc...", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6-20260514", + "stop_reason": "tool_use", + "content": [ + { "type": "tool_use", "id": "toolu_01...", "name": "Bash", "input": { "command": "swift test" } } + ], + "usage": { + "input_tokens": 340, + "cache_creation_input_tokens": 1420, + "cache_read_input_tokens": 28450, + "output_tokens": 512 + } + } + } + +4. CRITICAL RULES FOR CLAUDE CODE PARSING: + ⚬ Message ID Deduplication: Streaming or intermediate updates can write multiple JSONL lines with the exact same message.id. You MUST deduplicate by message.id (keeping only the last entry for a given message.id) before summing usage.input_tokens, usage.output_tokens, usage.cache_creation_input_tokens, and usage.cache_read_input_tokens. + ⚬ State Detection from Tail Line: + ⚬ If the last record has type == "assistant" and message.stop_reason == "tool_use": + ⚬ Check if a child process of claude is currently running in ProcessTreeScanner. If no child process is running and Date().timeIntervalSince(lastTimestamp) > 2.0, Claude is waiting for user tool permission -> .awaitingApproval. + ⚬ If a child process is running -> .working. + ⚬ If the last record has type == "assistant" and message.stop_reason == "end_turn" -> .idle (waiting for user prompt). + +Phase 4: Antigravity CLI (agy) Detector + +How Antigravity CLI Works on macOS + +Antigravity CLI installs its binary as agy (usually in ~/.local/bin/agy) and stores its state in ~/.gemini/antigravity-cli/—completely separate from the IDE's Markdown artifact directory (~/.gemini/antigravity/brain/). + +Exact File Paths & Schemas + +1. Process Detection: + ⚬ Match processes where name == "agy" or name == "antigravity" or executablePath.hasSuffix("/agy"). + ⚬ CRITICAL CWD RULE: The Antigravity CLI transcript does not write the project working directory (cwd) anywhere inside transcript.jsonl! You MUST obtain the project cwd from ProcessTreeScanner.resolveCWD(for: pid) while the agy process is alive, and cache the mapping [conversationUUID: cwd] in memory. +2. Configuration & Settings Path: + ⚬ ~/.gemini/antigravity-cli/settings.json + ⚬ ~/.gemini/antigravity-cli/mcp_config.json +3. Session Transcript Path: + ⚬ ~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript.jsonl +4. Exact JSONL Record Schema (transcript.jsonl): + Each line is a step object with step_index (Int), type (String), created_at (ISO-8601 String), and truncated_fields (optional Array): + ⚬ type == "USER_INPUT": User prompt (note: content wraps the prompt in XML-like envelope tags; strip tags if displaying preview). + ⚬ type == "PLANNER_RESPONSE": Model response containing content, thinking, and tool_calls[] (name, args). + ⚬ type == "RUN_COMMAND" | "VIEW_FILE" | "LIST_DIRECTORY" | "GREP_SEARCH" | "CODE_ACTION": Tool execution outputs. + ⚬ type == "USER_SETTINGS_CHANGE": Contains active model selection changes. +5. Quota Unification with Existing Antigravity Monitor: + ⚬ Reuse Loom's existing loopback HTTPS quota service for Antigravity, and attach active agy CLI sessions to the unified Antigravity provider card with a surface: .cli badge (agy). + +Phase 5: Unified Domain Model & File-Watcher Pipeline + +1. Unified Swift Structs (AgentSessionModels.swift) + +import Foundation + +public enum AgentProviderKind: String, Codable, Sendable, CaseIterable { + case t3Code = "t3code" + case codex = "codex" + case claudeCode = "claude_code" + case antigravity = "antigravity" +} + +public enum AgentSurface: String, Codable, Sendable { + case cli = "CLI" + case gui = "GUI" + case t3Orchestrated = "T3Code" +} + +public enum AgentRunState: String, Codable, Sendable { + case working // Actively streaming or running a tool + case awaitingApproval // Blocked on user permission / question + case idle // Process alive, waiting for next user prompt + case inactive // Process exited, recent historical session +} + +public struct TokenUsageSnapshot: Sendable, Equatable, Codable { + public var inputTokens: Int + public var cachedInputTokens: Int + public var outputTokens: Int + public var reasoningTokens: Int + + public var totalTokens: Int { + inputTokens + cachedInputTokens + outputTokens + reasoningTokens + } +} + +public struct AgentSessionStatus: Identifiable, Sendable, Equatable { + public let id: String // Session UUID or "pid-" + public let provider: AgentProviderKind + public let surface: AgentSurface + public let pid: pid_t? + public let parentT3PID: pid_t? // Non-nil if spawned by T3Code + public let projectName: String // Last path component of cwd + public let workspacePath: String? // Full cwd + public let gitBranch: String? + public let modelName: String? + public let state: AgentRunState + public let tokenUsage: TokenUsageSnapshot? + public let lastUpdated: Date +} + + +2. Coordinator Polling & File Watching Cadence + +⚬ Process Table Tick: Run ProcessTreeScanner().scanProcessTable() every 3.0 seconds on a background serial DispatchQueue(label: "com.chaiwithcode.loom.process-scanner", qos: .utility). +⚬ Directory Watchers: Attach DispatchSource.makeFileSystemObjectSource(fileDescriptor:eventMask: .write) on: + ⚬ ~/.t3/userdata/ + ⚬ ~/.codex/sessions/ (current YYYY/MM/DD leaf directory) + ⚬ ~/.claude/projects/ + ⚬ ~/.gemini/antigravity-cli/brain/ +⚬ Debounce: Coalesce file system events with a 500ms debounce before re-reading the tail 64 KB of modified .jsonl files. + +Verification Checklist for Antigravity + +⚬ [ ] swift build compiles with zero warnings under Swift 6 strict concurrency (Sendable compliance on all models and detectors). +⚬ [ ] No Process() / NSTask calls exist in any detector for ps, lsof, or pgrep. +⚬ [ ] Launching codex or claude inside T3Code sets surface = .t3Orchestrated and parentT3PID != nil, preventing duplicate standalone rows in the menu bar popover. +⚬ [ ] Codex token calculation reads the latest total_token_usage per session file without summing cumulative snapshots within the same file. +⚬ [ ] Claude Code token calculation deduplicates records by message.id before summing usage fields. +⚬ [ ] Antigravity CLI (agy) resolves its cwd via proc_pidinfo(PROC_PIDVNODEPATHINFO) and maps it to the active brain transcript UUID. + + +--- + +### Updated Roadmap Tracker + + +```json +{ + "categories": [ + "M0: Core Architecture", + "M1: T3Code (Priority 1)", + "M2: Codex CLI", + "M3: Claude Code", + "M4: Antigravity CLI", + "M5: UI & WidgetKit" + ], + "items": [ + { + "id": "m0-1", + "label": "Build libproc ProcessTreeScanner with PPID resolution", + "sublines": [ + "Use proc_listallpids, PROC_PIDTBSDINFO, and PROC_PIDVNODEPATHINFO in ProcessTreeScanner.swift" + ], + "category": "M0: Core Architecture", + "checked": true + }, + { + "id": "m0-2", + "label": "Define AgentSessionModels & 64KB Tail JSONL Reader", + "sublines": [ + "Implement non-blocking FileHandle tail reader and 500ms debounced DispatchSource watchers" + ], + "category": "M0: Core Architecture", + "checked": true + }, + { + "id": "m1-1", + "label": "Implement T3CodeDetector (~/.t3/userdata/server-runtime.json)", + "sublines": [ + "Validate PID against ProcessTreeScanner and query GET /api/orchestration/shell on loopback" + ], + "category": "M1: T3Code (Priority 1)", + "checked": true + }, + { + "id": "m1-2", + "label": "Deduplicate T3Code child CLI processes via PPID chain walk", + "sublines": [ + "Tag child codex/claude PIDs with surface = .t3Orchestrated and parentT3PID" + ], + "category": "M1: T3Code (Priority 1)", + "checked": true + }, + { + "id": "m2-1", + "label": "Implement CodexCLIDetector (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl)", + "sublines": [ + "Parse session_meta for cwd/model and take max cumulative total_token_usage from event_msg" + ], + "category": "M2: Codex CLI", + "checked": true + }, + { + "id": "m3-1", + "label": "Implement ClaudeCodeDetector (~/.claude/projects/*/*.jsonl)", + "sublines": [ + "Deduplicate lines by message.id, sum usage tokens, and detect stop_reason == tool_use approval waits" + ], + "category": "M3: Claude Code", + "checked": true + }, + { + "id": "m4-1", + "label": "Implement AntigravityCLIDetector (~/.gemini/antigravity-cli/brain/)", + "sublines": [ + "Match agy binary, bind live libproc CWD to transcript.jsonl UUID, and merge with loopback quota" + ], + "category": "M4: Antigravity CLI", + "checked": true + }, + { + "id": "m5-1", + "label": "Update Menu Bar Popover & WidgetKit views", + "sublines": [ + "Render T3Code parent cards, CLI surface badges, and awaitingApproval indicators" + ], + "category": "M5: UI & WidgetKit", + "checked": true + } + ] +} + + + + + +--- + +### Updated Roadmap Tracker + + +```json +{ + "categories": [ + "M0: Core Architecture", + "M1: T3Code (Priority 1)", + "M2: Codex CLI", + "M3: Claude Code", + "M4: Antigravity CLI", + "M5: UI & WidgetKit" + ], + "items": [ + { + "id": "m0-1", + "label": "Build libproc ProcessTreeScanner with PPID resolution", + "sublines": [ + "Use proc_listallpids, PROC_PIDTBSDINFO, and PROC_PIDVNODEPATHINFO in ProcessTreeScanner.swift" + ], + "category": "M0: Core Architecture", + "checked": false + }, + { + "id": "m0-2", + "label": "Define AgentSessionModels & 64KB Tail JSONL Reader", + "sublines": [ + "Implement non-blocking FileHandle tail reader and 500ms debounced DispatchSource watchers" + ], + "category": "M0: Core Architecture", + "checked": false + }, + { + "id": "m1-1", + "label": "Implement T3CodeDetector (~/.t3/userdata/server-runtime.json)", + "sublines": [ + "Validate PID against ProcessTreeScanner and query GET /api/orchestration/shell on loopback" + ], + "category": "M1: T3Code (Priority 1)", + "checked": false + }, + { + "id": "m1-2", + "label": "Deduplicate T3Code child CLI processes via PPID chain walk", + "sublines": [ + "Tag child codex/claude PIDs with surface = .t3Orchestrated and parentT3PID" + ], + "category": "M1: T3Code (Priority 1)", + "checked": false + }, + { + "id": "m2-1", + "label": "Implement CodexCLIDetector (~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl)", + "sublines": [ + "Parse session_meta for cwd/model and take max cumulative total_token_usage from event_msg" + ], + "category": "M2: Codex CLI", + "checked": false + }, + { + "id": "m3-1", + "label": "Implement ClaudeCodeDetector (~/.claude/projects/*/*.jsonl)", + "sublines": [ + "Deduplicate lines by message.id, sum usage tokens, and detect stop_reason == tool_use approval waits" + ], + "category": "M3: Claude Code", + "checked": false + }, + { + "id": "m4-1", + "label": "Implement AntigravityCLIDetector (~/.gemini/antigravity-cli/brain/)", + "sublines": [ + "Match agy binary, bind live libproc CWD to transcript.jsonl UUID, and merge with loopback quota" + ], + "category": "M4: Antigravity CLI", + "checked": false + }, + { + "id": "m5-1", + "label": "Update Menu Bar Popover & WidgetKit views", + "sublines": [ + "Render T3Code parent cards, CLI surface badges, and awaitingApproval indicators" + ], + "category": "M5: UI & WidgetKit", + "checked": false + } + ] +} + + diff --git a/Package.swift b/Package.swift index 44dbd6b..3fd1bbe 100644 --- a/Package.swift +++ b/Package.swift @@ -24,8 +24,8 @@ let package = Package( name: "LoomCore", dependencies: [], path: "Loom", - exclude: ["App", "UI", "Update"], - sources: ["Core", "Providers"] + exclude: ["App", "UI/MenuBar", "UI/SessionDetail", "UI/Settings", "Update"], + sources: ["Core", "Providers", "UI/Components"] ), .executableTarget( name: "Loom", @@ -34,8 +34,8 @@ let package = Package( .product(name: "Sparkle", package: "Sparkle") ], path: "Loom", - exclude: ["Core", "Providers"], - sources: ["App", "UI", "Update"] + exclude: ["Core", "Providers", "UI/Components"], + sources: ["App", "UI/MenuBar", "UI/SessionDetail", "UI/Settings", "Update"] ), .testTarget( name: "LoomCoreTests", diff --git a/Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift b/Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift new file mode 100644 index 0000000..7029a27 --- /dev/null +++ b/Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift @@ -0,0 +1,201 @@ +// +// AgentMonitoringCoordinatorTests.swift +// LoomCoreTests +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +@testable import LoomCore +import XCTest + +final class AgentMonitoringCoordinatorTests: XCTestCase { + var temporaryDirectory: URL! + + override func setUp() { + super.setUp() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentMonitoringCoordinatorTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let temporaryDirectory = temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + super.tearDown() + } + + @MainActor + func testCoordinatorInitialStateAndLifecycle() async { + let coordinator = AgentMonitoringCoordinator() + XCTAssertTrue(coordinator.sessions.isEmpty, "Initial sessions array should be empty") + + coordinator.startMonitoring() + // Wait briefly for first potential loop or background check + try? await Task.sleep(nanoseconds: 50_000_000) + + coordinator.stopMonitoring() + // Calling stopMonitoring again should be idempotent and not crash + coordinator.stopMonitoring() + } + + @MainActor + func testCoordinatorRefreshAllProducesSortedSessions() async { + let t3Dir = temporaryDirectory.appendingPathComponent(".t3") + let codexDir = temporaryDirectory.appendingPathComponent(".codex") + let claudeDir = temporaryDirectory.appendingPathComponent(".claude") + let agyDir = temporaryDirectory.appendingPathComponent(".gemini/antigravity-cli") + + try? FileManager.default.createDirectory(at: t3Dir, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: agyDir, withIntermediateDirectories: true) + + let t3Detector = T3CodeDetector(baseDirectory: t3Dir) + let codexDetector = CodexCLIDetector(baseDirectory: codexDir) + let claudeDetector = ClaudeCodeDetector(baseDirectory: claudeDir) + let agyDetector = AntigravityCLIDetector(baseDirectory: agyDir) + + let coordinator = AgentMonitoringCoordinator( + t3Detector: t3Detector, + codexDetector: codexDetector, + claudeDetector: claudeDetector, + agyDetector: agyDetector + ) + + await coordinator.refreshAll() + // When there are no live agent processes, sessions may be empty or contain today's inactive sessions + for session in coordinator.sessions { + XCTAssertNotNil(session.id) + XCTAssertNotNil(session.provider) + } + } + + @MainActor + func testSessionSortingActiveBeforeInactiveAndDateDescending() async { + let now = Date() + let activeRecent = AgentSessionStatus( + id: "active-recent", + provider: .codex, + surface: .cli, + projectName: "proj-1", + state: .working, + lastUpdated: now + ) + let activeOlder = AgentSessionStatus( + id: "active-older", + provider: .claudeCode, + surface: .cli, + projectName: "proj-2", + state: .awaitingApproval, + lastUpdated: now.addingTimeInterval(-100) + ) + let inactiveRecent = AgentSessionStatus( + id: "inactive-recent", + provider: .antigravity, + surface: .cli, + projectName: "proj-3", + state: .inactive, + lastUpdated: now + ) + let inactiveOlder = AgentSessionStatus( + id: "inactive-older", + provider: .t3Code, + surface: .gui, + projectName: "proj-4", + state: .inactive, + lastUpdated: now.addingTimeInterval(-200) + ) + + let input = [inactiveRecent, activeOlder, inactiveOlder, activeRecent] + + let sorted = input.sorted { lhs, rhs in + if (lhs.state != .inactive) != (rhs.state != .inactive) { + return lhs.state != .inactive + } + return lhs.lastUpdated > rhs.lastUpdated + } + + XCTAssertEqual(sorted.map(\.id), ["active-recent", "active-older", "inactive-recent", "inactive-older"]) + } + + @MainActor + func testAntigravityCLIDetectorDetectSessionIntegration() throws { + let brainDir = temporaryDirectory.appendingPathComponent("brain") + let convID = "conv-active-test" + let logsDir = brainDir.appendingPathComponent(convID).appendingPathComponent(".system_generated").appendingPathComponent("logs") + try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) + let transcriptURL = logsDir.appendingPathComponent("transcript.jsonl") + + let lines = [ + "{\"step_index\": 0, \"type\": \"USER_INPUT\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:00Z\", \"content\": \"hello\"}", + "{\"step_index\": 1, \"type\": \"PLANNER_RESPONSE\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:05Z\", \"model\": \"gemini-ultra\"}" + ] + try (lines.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: transcriptURL) + + let agyPID: pid_t = 8888 + let table: [pid_t: MonitoredProcess] = [ + agyPID: MonitoredProcess( + pid: agyPID, + ppid: 1, + name: "agy", + executablePath: "/Users/dev/.local/bin/agy", + cwd: "/Users/dev/workspace/app" + ) + ] + + let detector = AntigravityCLIDetector(baseDirectory: temporaryDirectory) + let sessions = detector.detect(processTable: table, t3RootPIDs: []) + + XCTAssertFalse(sessions.isEmpty) + let session = try XCTUnwrap(sessions.first(where: { $0.pid == agyPID })) + XCTAssertEqual(session.provider, .antigravity) + XCTAssertEqual(session.surface, .cli) + XCTAssertEqual(session.workspacePath, "/Users/dev/workspace/app") + XCTAssertEqual(session.projectName, "app") + XCTAssertEqual(session.modelName, "gemini-ultra") + } + + @MainActor + func testAntigravityCLIDetectorChildOfT3CodeSetsSurface() throws { + let brainDir = temporaryDirectory.appendingPathComponent("brain") + let convID = "conv-t3-child" + let logsDir = brainDir.appendingPathComponent(convID).appendingPathComponent(".system_generated").appendingPathComponent("logs") + try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) + let transcriptURL = logsDir.appendingPathComponent("transcript.jsonl") + + let lines = [ + "{\"step_index\": 0, \"type\": \"USER_INPUT\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:00Z\", \"content\": \"orchestrated run\"}" + ] + try (lines.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: transcriptURL) + + let t3RootPID: pid_t = 1200 + let agyPID: pid_t = 1205 + let table: [pid_t: MonitoredProcess] = [ + t3RootPID: MonitoredProcess( + pid: t3RootPID, + ppid: 1, + name: "t3code", + executablePath: "/usr/local/bin/t3code", + cwd: "/Users/dev/t3-project" + ), + agyPID: MonitoredProcess( + pid: agyPID, + ppid: t3RootPID, + name: "agy", + executablePath: "/Users/dev/.local/bin/agy", + cwd: "/Users/dev/t3-project" + ) + ] + + let detector = AntigravityCLIDetector(baseDirectory: temporaryDirectory) + let sessions = detector.detect(processTable: table, t3RootPIDs: [t3RootPID]) + + let session = try XCTUnwrap(sessions.first(where: { $0.pid == agyPID })) + XCTAssertEqual(session.surface, .t3Orchestrated) + XCTAssertEqual(session.parentT3PID, t3RootPID) + } +} diff --git a/Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift b/Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift new file mode 100644 index 0000000..0f4e87b --- /dev/null +++ b/Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift @@ -0,0 +1,31 @@ +// +// AgentSessionsSectionViewTests.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import XCTest +import SwiftUI +@testable import LoomCore + +@MainActor +final class AgentSessionsSectionViewTests: XCTestCase { + + func testAgentSessionsSectionViewInstantiation() { + let coordinator = AgentMonitoringCoordinator() + let view = AgentSessionsSectionView(coordinator: coordinator) + XCTAssertNotNil(view) + XCTAssertNotNil(view.body) + } + + func testTokenFormatting() { + XCTAssertEqual(AgentSessionsSectionView.formatTokens(0), "0") + XCTAssertEqual(AgentSessionsSectionView.formatTokens(999), "999") + XCTAssertEqual(AgentSessionsSectionView.formatTokens(1_000), "1.0K") + XCTAssertEqual(AgentSessionsSectionView.formatTokens(47_100), "47.1K") + XCTAssertEqual(AgentSessionsSectionView.formatTokens(1_200_000), "1.2M") + XCTAssertEqual(AgentSessionsSectionView.formatTokens(2_500_000), "2.5M") + } +} diff --git a/Tests/LoomCoreTests/AntigravityCLIDetectorTests.swift b/Tests/LoomCoreTests/AntigravityCLIDetectorTests.swift new file mode 100644 index 0000000..ee20bee --- /dev/null +++ b/Tests/LoomCoreTests/AntigravityCLIDetectorTests.swift @@ -0,0 +1,135 @@ +// +// AntigravityCLIDetectorTests.swift +// LoomCoreTests +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +@testable import LoomCore +import XCTest + +final class AntigravityCLIDetectorTests: XCTestCase { + var temporaryDirectory: URL! + + override func setUp() { + super.setUp() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("AntigravityCLIDetectorTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let temporaryDirectory = temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + super.tearDown() + } + + func testFindLiveAntigravityProcessesAndT3Deduplication() { + let detector = AntigravityCLIDetector(baseDirectory: temporaryDirectory) + + let t3PID: pid_t = 1000 + let standaloneAgyPID: pid_t = 2000 + let t3ChildAgyPID: pid_t = 3000 + + let table: [pid_t: MonitoredProcess] = [ + t3PID: MonitoredProcess(pid: t3PID, ppid: 1, name: "t3code", executablePath: "/usr/local/bin/t3code", cwd: "/tmp"), + standaloneAgyPID: MonitoredProcess(pid: standaloneAgyPID, ppid: 1, name: "agy", executablePath: "/Users/dev/.local/bin/agy", cwd: "/Users/dev/standalone-project"), + t3ChildAgyPID: MonitoredProcess(pid: t3ChildAgyPID, ppid: t3PID, name: "agy_acp_server.par", executablePath: "/Users/dev/.t3/tools/agy_acp_server.par", cwd: "/Users/dev/t3-project") + ] + + let t3AncestorPIDs: Set = [t3PID] + let processes = detector.findLiveAntigravityProcesses(table: table, t3AncestorPIDs: t3AncestorPIDs) + + XCTAssertEqual(processes.count, 2) + + let standalone = try? XCTUnwrap(processes.first(where: { $0.pid == standaloneAgyPID })) + XCTAssertEqual(standalone?.surface, .cli) + XCTAssertNil(standalone?.parentT3PID) + XCTAssertEqual(standalone?.cwd, "/Users/dev/standalone-project") + + let child = try? XCTUnwrap(processes.first(where: { $0.pid == t3ChildAgyPID })) + XCTAssertEqual(child?.surface, .t3Orchestrated) + XCTAssertEqual(child?.parentT3PID, t3PID) + XCTAssertEqual(child?.cwd, "/Users/dev/t3-project") + } + + func testStateDetectionAwaitingApprovalVsWorkingVsIdle() throws { + let brainDir = temporaryDirectory.appendingPathComponent("brain") + + // 1. Session A: asking question (ask_question tool call) -> .awaitingApproval + let convID1 = "conv-ask-question" + let logsDir1 = brainDir.appendingPathComponent(convID1).appendingPathComponent(".system_generated").appendingPathComponent("logs") + try FileManager.default.createDirectory(at: logsDir1, withIntermediateDirectories: true) + let fileURL1 = logsDir1.appendingPathComponent("transcript.jsonl") + + let lines1 = [ + "{\"step_index\": 0, \"type\": \"USER_INPUT\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:00Z\", \"content\": \"Refactor project\"}", + "{\"step_index\": 1, \"type\": \"PLANNER_RESPONSE\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:05Z\", \"input_tokens\": 500, \"output_tokens\": 100, \"tool_calls\": [{\"name\": \"ask_question\", \"args\": {\"question\": \"Which branch?\"}}]}" + ] + try (lines1.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: fileURL1) + + // 2. Session B: completed response without tool calls -> .idle + let convID2 = "conv-idle" + let logsDir2 = brainDir.appendingPathComponent(convID2).appendingPathComponent(".system_generated").appendingPathComponent("logs") + try FileManager.default.createDirectory(at: logsDir2, withIntermediateDirectories: true) + let fileURL2 = logsDir2.appendingPathComponent("transcript.jsonl") + + let lines2 = [ + "{\"step_index\": 0, \"type\": \"USER_INPUT\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T19:00:00Z\", \"content\": \"List files\"}", + "{\"step_index\": 1, \"type\": \"PLANNER_RESPONSE\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T19:00:05Z\", \"input_tokens\": 600, \"output_tokens\": 150, \"content\": \"Done!\"}" + ] + try (lines2.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: fileURL2) + + let now = Date() + let oldModDate = now.addingTimeInterval(-10.0) + try FileManager.default.setAttributes([.modificationDate: oldModDate], ofItemAtPath: fileURL1.path) + try FileManager.default.setAttributes([.modificationDate: oldModDate], ofItemAtPath: fileURL2.path) + + let agyPID1: pid_t = 5001 + let agyPID2: pid_t = 5002 + let table: [pid_t: MonitoredProcess] = [ + agyPID1: MonitoredProcess(pid: agyPID1, ppid: 1, name: "agy", executablePath: "/usr/local/bin/agy", cwd: "/Users/dev/project1"), + agyPID2: MonitoredProcess(pid: agyPID2, ppid: 1, name: "agy", executablePath: "/usr/local/bin/agy", cwd: "/Users/dev/project2") + ] + + let detector = AntigravityCLIDetector(baseDirectory: temporaryDirectory) + + // Pre-seed CWD mapping for sessions (in live execution, discovered via proc_pidvnodepathinfo or DB) + detector.recordCWD("/Users/dev/project1", for: convID1) + detector.recordCWD("/Users/dev/project2", for: convID2) + + let sessions = detector.detectSessions(table: table, now: now) + + let s1 = try XCTUnwrap(sessions.first(where: { $0.sessionID == convID1 })) + XCTAssertEqual(s1.state, .awaitingApproval, "ask_question tool call should set state to .awaitingApproval") + + let s2 = try XCTUnwrap(sessions.first(where: { $0.sessionID == convID2 })) + XCTAssertEqual(s2.state, .idle, "Completed PLANNER_RESPONSE with no tool calls and past 2s should be .idle") + } + + func testDailyTokenUsageComputation() throws { + let brainDir = temporaryDirectory.appendingPathComponent("brain") + let convID = "conv-daily-tokens" + let logsDir = brainDir.appendingPathComponent(convID).appendingPathComponent(".system_generated").appendingPathComponent("logs") + try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) + let fileURL = logsDir.appendingPathComponent("transcript.jsonl") + + let lines = [ + "{\"step_index\": 0, \"type\": \"USER_INPUT\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T10:00:00Z\", \"content\": \"hello\"}", + "{\"step_index\": 1, \"type\": \"PLANNER_RESPONSE\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T10:00:05Z\", \"input_tokens\": 1000, \"cache_read_tokens\": 500, \"output_tokens\": 250}" + ] + try (lines.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: fileURL) + + let detector = AntigravityCLIDetector(baseDirectory: temporaryDirectory) + let usage = detector.calculateDailyTokenUsage(now: Date()) + + XCTAssertEqual(usage.inputTokens, 1000) + XCTAssertEqual(usage.cachedInputTokens, 500) + XCTAssertEqual(usage.outputTokens, 250) + XCTAssertEqual(usage.totalTokens, 1750) + } +} diff --git a/Tests/LoomCoreTests/BoundedJSONLReaderTests.swift b/Tests/LoomCoreTests/BoundedJSONLReaderTests.swift new file mode 100644 index 0000000..ebef9f4 --- /dev/null +++ b/Tests/LoomCoreTests/BoundedJSONLReaderTests.swift @@ -0,0 +1,104 @@ +// +// BoundedJSONLReaderTests.swift +// LoomCoreTests +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Foundation +@testable import LoomCore +import XCTest + +final class BoundedJSONLReaderTests: XCTestCase { + var temporaryDirectory: URL! + + override func setUp() { + super.setUp() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("BoundedJSONLReaderTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let temporaryDirectory = temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + super.tearDown() + } + + func testSmallFileSinglePassRead() throws { + let fileURL = temporaryDirectory.appendingPathComponent("small.jsonl") + let lines = [ + "{\"index\": 1, \"text\": \"hello\"}", + "{\"index\": 2, \"text\": \"world\"}" + ] + let content = lines.joined(separator: "\n") + "\n" + try content.data(using: .utf8)?.write(to: fileURL) + + let reader = BoundedJSONLReader() + let snapshot = try XCTUnwrap(reader.readSnapshot(at: fileURL)) + + XCTAssertEqual(snapshot.headLines.count, 2) + XCTAssertEqual(snapshot.tailLines.count, 2) + XCTAssertEqual(snapshot.fileSize, UInt64(content.utf8.count)) + } + + func testLargeFileHeadAndTailSlicing() throws { + let fileURL = temporaryDirectory.appendingPathComponent("large.jsonl") + var fileData = Data() + + // Generate lines exceeding 70 KB + let firstLine = "{\"type\": \"session_meta\", \"payload\": {\"id\": \"meta-1\", \"model\": \"test\"}}\n" + fileData.append(Data(firstLine.utf8)) + + let paddingLine = "{\"type\": \"step\", \"content\": \"\(String(repeating: "x", count: 1000))\"}\n" + for _ in 0..<75 { + fileData.append(Data(paddingLine.utf8)) + } + + let lastLine = "{\"type\": \"event_msg\", \"payload\": {\"type\": \"token_count\", \"info\": {\"total_token_usage\": {\"input_tokens\": 500, \"output_tokens\": 100}}}}\n" + fileData.append(Data(lastLine.utf8)) + + try fileData.write(to: fileURL) + + let reader = BoundedJSONLReader(headByteLimit: 4 * 1024, tailByteLimit: 16 * 1024) + let snapshot = try XCTUnwrap(reader.readSnapshot(at: fileURL)) + + XCTAssertTrue(snapshot.fileSize > 16 * 1024) + XCTAssertFalse(snapshot.headLines.isEmpty) + XCTAssertFalse(snapshot.tailLines.isEmpty) + + // Parse with Codex parser to ensure head and tail combine correctly + let codex = JSONLProviderParsers.parseCodex(snapshot: snapshot) + XCTAssertEqual(codex.sessionID, "meta-1") + XCTAssertEqual(codex.model, "test") + XCTAssertEqual(codex.latestTokenUsage?.inputTokens, 500) + XCTAssertEqual(codex.latestTokenUsage?.outputTokens, 100) + } + + func testClaudeTranscriptDeduplication() throws { + let fileURL = temporaryDirectory.appendingPathComponent("claude.jsonl") + let lines = [ + "{\"sessionId\": \"claude-1\", \"cwd\": \"/projects/app\", \"type\": \"assistant\", \"message\": {\"id\": \"msg-1\", \"model\": \"sonnet\", \"stop_reason\": \"tool_use\", \"usage\": {\"input_tokens\": 10, \"output_tokens\": 5}}}", + // Duplicate message id with updated token count + "{\"sessionId\": \"claude-1\", \"cwd\": \"/projects/app\", \"type\": \"assistant\", \"message\": {\"id\": \"msg-1\", \"model\": \"sonnet\", \"stop_reason\": \"end_turn\", \"usage\": {\"input_tokens\": 20, \"output_tokens\": 15}}}", + // Distinct message id + "{\"sessionId\": \"claude-1\", \"cwd\": \"/projects/app\", \"type\": \"assistant\", \"message\": {\"id\": \"msg-2\", \"model\": \"sonnet\", \"stop_reason\": \"end_turn\", \"usage\": {\"input_tokens\": 30, \"output_tokens\": 10}}}" + ] + let content = lines.joined(separator: "\n") + "\n" + try content.data(using: .utf8)?.write(to: fileURL) + + let reader = BoundedJSONLReader() + let snapshot = try XCTUnwrap(reader.readSnapshot(at: fileURL)) + + let claude = JSONLProviderParsers.parseClaude(snapshot: snapshot) + XCTAssertEqual(claude.sessionID, "claude-1") + XCTAssertEqual(claude.cwd, "/projects/app") + XCTAssertEqual(claude.lastStopReason, "end_turn") + // Deduplicated tokens: msg-1 has 20 in, 15 out. msg-2 has 30 in, 10 out. + // Total input: 20 + 30 = 50. Total output: 15 + 10 = 25. + XCTAssertEqual(claude.deduplicatedUsage.inputTokens, 50) + XCTAssertEqual(claude.deduplicatedUsage.outputTokens, 25) + } +} diff --git a/Tests/LoomCoreTests/ClaudeCodeDetectorTests.swift b/Tests/LoomCoreTests/ClaudeCodeDetectorTests.swift new file mode 100644 index 0000000..0e58365 --- /dev/null +++ b/Tests/LoomCoreTests/ClaudeCodeDetectorTests.swift @@ -0,0 +1,157 @@ +// +// ClaudeCodeDetectorTests.swift +// LoomCoreTests +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +@testable import LoomCore +import XCTest + +final class ClaudeCodeDetectorTests: XCTestCase { + var temporaryDirectory: URL! + + override func setUp() { + super.setUp() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("ClaudeCodeDetectorTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let temporaryDirectory = temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + super.tearDown() + } + + func testFindLiveClaudeProcessesAndT3Deduplication() { + let detector = ClaudeCodeDetector(baseDirectory: temporaryDirectory) + + let t3PID: pid_t = 1000 + let standaloneClaudePID: pid_t = 2000 + let t3ChildClaudePID: pid_t = 3000 + let bashChildPID: pid_t = 3001 + + let table: [pid_t: MonitoredProcess] = [ + t3PID: MonitoredProcess(pid: t3PID, ppid: 1, name: "t3code", executablePath: "/usr/local/bin/t3code", cwd: "/tmp"), + standaloneClaudePID: MonitoredProcess(pid: standaloneClaudePID, ppid: 1, name: "claude", executablePath: "/usr/local/bin/claude", cwd: "/Users/dev/standalone"), + t3ChildClaudePID: MonitoredProcess(pid: t3ChildClaudePID, ppid: t3PID, name: "claude", executablePath: "/usr/local/bin/claude", cwd: "/Users/dev/t3project"), + bashChildPID: MonitoredProcess(pid: bashChildPID, ppid: t3ChildClaudePID, name: "bash", executablePath: "/bin/bash", cwd: "/Users/dev/t3project") + ] + + let t3AncestorPIDs: Set = [t3PID] + let processes = detector.findLiveClaudeProcesses(table: table, t3AncestorPIDs: t3AncestorPIDs) + + XCTAssertEqual(processes.count, 2) + + let standalone = try? XCTUnwrap(processes.first(where: { $0.pid == standaloneClaudePID })) + XCTAssertEqual(standalone?.surface, .cli) + XCTAssertNil(standalone?.parentT3PID) + XCTAssertTrue(standalone?.childPIDs.isEmpty ?? false) + + let child = try? XCTUnwrap(processes.first(where: { $0.pid == t3ChildClaudePID })) + XCTAssertEqual(child?.surface, .t3Orchestrated) + XCTAssertEqual(child?.parentT3PID, t3PID) + XCTAssertEqual(child?.childPIDs, [bashChildPID]) + } + + func testStateDetectionAwaitingApprovalVsWorking() throws { + let projectsDir = temporaryDirectory.appendingPathComponent("projects").appendingPathComponent("-Users-dev-ProjectApp") + try FileManager.default.createDirectory(at: projectsDir, withIntermediateDirectories: true) + let fileURL = projectsDir.appendingPathComponent("session-claude-1.jsonl") + + // Tool use stop reason + let lines = [ + "{\"sessionId\": \"claude-tool-wait\", \"cwd\": \"/Users/dev/ProjectApp\", \"type\": \"assistant\", \"timestamp\": \"2026-10-31T18:00:00Z\", \"message\": {\"id\": \"msg-1\", \"model\": \"claude-sonnet\", \"stop_reason\": \"tool_use\", \"usage\": {\"input_tokens\": 100, \"output_tokens\": 50}}}" + ] + let content = lines.joined(separator: "\n") + "\n" + try content.data(using: .utf8)?.write(to: fileURL) + + // Case 1: Live process has NO running child processes, modified 10 seconds ago -> .awaitingApproval + let now = Date() + let oldModDate = now.addingTimeInterval(-10.0) + try FileManager.default.setAttributes([.modificationDate: oldModDate], ofItemAtPath: fileURL.path) + + let claudePID: pid_t = 4500 + let tableNoChild: [pid_t: MonitoredProcess] = [ + claudePID: MonitoredProcess(pid: claudePID, ppid: 1, name: "claude", executablePath: "/usr/local/bin/claude", cwd: "/Users/dev/ProjectApp") + ] + + let detector = ClaudeCodeDetector(baseDirectory: temporaryDirectory) + let sessionsWaiting = detector.detectSessions(table: tableNoChild, now: now) + + let s1 = try XCTUnwrap(sessionsWaiting.first(where: { $0.sessionID == "claude-tool-wait" })) + XCTAssertEqual(s1.state, .awaitingApproval, "Claude should be awaitingApproval when stopped for tool_use with no active child processes") + + // Case 2: Live process HAS a running child subprocess -> .working + let childSubprocPID: pid_t = 4501 + let tableWithChild: [pid_t: MonitoredProcess] = [ + claudePID: MonitoredProcess(pid: claudePID, ppid: 1, name: "claude", executablePath: "/usr/local/bin/claude", cwd: "/Users/dev/ProjectApp"), + childSubprocPID: MonitoredProcess(pid: childSubprocPID, ppid: claudePID, name: "swift", executablePath: "/usr/bin/swift", cwd: "/Users/dev/ProjectApp") + ] + + let sessionsWorking = detector.detectSessions(table: tableWithChild, now: now) + let s2 = try XCTUnwrap(sessionsWorking.first(where: { $0.sessionID == "claude-tool-wait" })) + XCTAssertEqual(s2.state, .working, "Claude should be working when a child process (e.g. tool execution) is actively running") + } + + func testDailyTokenUsageDeduplicatesMessageIDs() throws { + let projectsDir = temporaryDirectory.appendingPathComponent("projects").appendingPathComponent("-Users-dev-ProjectApp") + try FileManager.default.createDirectory(at: projectsDir, withIntermediateDirectories: true) + let fileURL = projectsDir.appendingPathComponent("session-stream.jsonl") + + // 3 lines for the same message id (streaming updates with increasing token counts) + let lines = [ + "{\"sessionId\": \"s1\", \"cwd\": \"/Users/dev/ProjectApp\", \"type\": \"assistant\", \"message\": {\"id\": \"msg-1\", \"model\": \"sonnet\", \"usage\": {\"input_tokens\": 100, \"cache_read_input_tokens\": 500, \"output_tokens\": 10}}}", + "{\"sessionId\": \"s1\", \"cwd\": \"/Users/dev/ProjectApp\", \"type\": \"assistant\", \"message\": {\"id\": \"msg-1\", \"model\": \"sonnet\", \"usage\": {\"input_tokens\": 100, \"cache_read_input_tokens\": 500, \"output_tokens\": 50}}}", + "{\"sessionId\": \"s1\", \"cwd\": \"/Users/dev/ProjectApp\", \"type\": \"assistant\", \"message\": {\"id\": \"msg-1\", \"model\": \"sonnet\", \"usage\": {\"input_tokens\": 100, \"cache_read_input_tokens\": 500, \"output_tokens\": 120}}}" + ] + let content = lines.joined(separator: "\n") + "\n" + try content.data(using: .utf8)?.write(to: fileURL) + + let detector = ClaudeCodeDetector(baseDirectory: temporaryDirectory) + let now = Date() + let usage = detector.computeDailyTokenUsage(now: now) + + // Must deduplicate by msg-1, taking the final values (100 in, 500 cached, 120 out) + // NOT 3x values (300 in, 1500 cached, 180 out)! + XCTAssertEqual(usage.inputTokens, 100) + XCTAssertEqual(usage.cachedInputTokens, 500) + XCTAssertEqual(usage.outputTokens, 120) + } + + func testDetectMethodWithOrchestrationAndInactive() throws { + let projectsDir = temporaryDirectory.appendingPathComponent("projects").appendingPathComponent("-Users-dev-T3ClaudeApp") + try FileManager.default.createDirectory(at: projectsDir, withIntermediateDirectories: true) + let fileURL = projectsDir.appendingPathComponent("session-active.jsonl") + + let lines = [ + "{\"sessionId\": \"claude-active-1\", \"cwd\": \"/Users/dev/T3ClaudeApp\", \"gitBranch\": \"feature/login\", \"type\": \"assistant\", \"message\": {\"id\": \"msg-1\", \"model\": \"claude-3-7-sonnet\", \"usage\": {\"input_tokens\": 300, \"cache_read_input_tokens\": 100, \"output_tokens\": 80}}}" + ] + try (lines.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: fileURL) + + let t3PID: pid_t = 1000 + let claudePID: pid_t = 2000 + let table: [pid_t: MonitoredProcess] = [ + t3PID: MonitoredProcess(pid: t3PID, ppid: 1, name: "t3code", executablePath: "/usr/local/bin/t3code", cwd: "/tmp"), + claudePID: MonitoredProcess(pid: claudePID, ppid: t3PID, name: "claude", executablePath: "/usr/local/bin/claude", cwd: "/Users/dev/T3ClaudeApp") + ] + + let detector = ClaudeCodeDetector(baseDirectory: temporaryDirectory) + let results = detector.detect(processTable: table, t3RootPIDs: [t3PID]) + + XCTAssertFalse(results.isEmpty) + let active = try XCTUnwrap(results.first(where: { $0.pid == claudePID })) + XCTAssertEqual(active.surface, .t3Orchestrated) + XCTAssertEqual(active.parentT3PID, t3PID) + XCTAssertEqual(active.workspacePath, "/Users/dev/T3ClaudeApp") + XCTAssertEqual(active.projectName, "T3ClaudeApp") + XCTAssertEqual(active.gitBranch, "feature/login") + XCTAssertEqual(active.modelName, "claude-3-7-sonnet") + XCTAssertEqual(active.tokenUsage?.inputTokens, 300) + } +} diff --git a/Tests/LoomCoreTests/CodexCLIDetectorTests.swift b/Tests/LoomCoreTests/CodexCLIDetectorTests.swift new file mode 100644 index 0000000..2c85723 --- /dev/null +++ b/Tests/LoomCoreTests/CodexCLIDetectorTests.swift @@ -0,0 +1,175 @@ +// +// CodexCLIDetectorTests.swift +// LoomCoreTests +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +@testable import LoomCore +import XCTest + +final class CodexCLIDetectorTests: XCTestCase { + var temporaryDirectory: URL! + + override func setUp() { + super.setUp() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexCLIDetectorTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let temporaryDirectory = temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + super.tearDown() + } + + func testFindLiveCodexProcessesAndT3Deduplication() { + let detector = CodexCLIDetector(baseDirectory: temporaryDirectory) + + let t3PID: pid_t = 500 + let standaloneCodexPID: pid_t = 600 + let t3ChildCodexPID: pid_t = 700 + let otherPID: pid_t = 800 + + let table: [pid_t: MonitoredProcess] = [ + t3PID: MonitoredProcess(pid: t3PID, ppid: 1, name: "t3code", executablePath: "/usr/local/bin/t3code", cwd: "/tmp"), + standaloneCodexPID: MonitoredProcess(pid: standaloneCodexPID, ppid: 1, name: "codex", executablePath: "/usr/local/bin/codex", cwd: "/Users/dev/standalone"), + t3ChildCodexPID: MonitoredProcess(pid: t3ChildCodexPID, ppid: t3PID, name: "codex", executablePath: "/usr/local/bin/codex", cwd: "/Users/dev/t3project"), + otherPID: MonitoredProcess(pid: otherPID, ppid: 1, name: "zsh", executablePath: "/bin/zsh", cwd: "/tmp") + ] + + let t3AncestorPIDs: Set = [t3PID] + let processes = detector.findLiveCodexProcesses(table: table, t3AncestorPIDs: t3AncestorPIDs) + + XCTAssertEqual(processes.count, 2) + + let standalone = try? XCTUnwrap(processes.first(where: { $0.pid == standaloneCodexPID })) + XCTAssertEqual(standalone?.surface, .cli) + XCTAssertNil(standalone?.parentT3PID) + + let child = try? XCTUnwrap(processes.first(where: { $0.pid == t3ChildCodexPID })) + XCTAssertEqual(child?.surface, .t3Orchestrated) + XCTAssertEqual(child?.parentT3PID, t3PID) + } + + func testDetectSessionsAndCumulativeDailyTokenCalculation() throws { + let calendar = Calendar.current + let now = Date() + let year = String(format: "%04d", calendar.component(.year, from: now)) + let month = String(format: "%02d", calendar.component(.month, from: now)) + let day = String(format: "%02d", calendar.component(.day, from: now)) + + let dayDir = temporaryDirectory + .appendingPathComponent("sessions") + .appendingPathComponent(year) + .appendingPathComponent(month) + .appendingPathComponent(day) + try FileManager.default.createDirectory(at: dayDir, withIntermediateDirectories: true) + + // Session 1: Has multiple token_count lines (cumulative). + // Line 1: meta + // Line 2: turn 1 (total 100) + // Line 3: turn 2 (total 350) + let session1URL = dayDir.appendingPathComponent("rollout-2026-10-31T10-00-00-session-1.jsonl") + let session1Content = """ + {"timestamp": "2026-10-31T10:00:00Z", "type": "session_meta", "payload": {"id": "session-1", "cwd": "/Users/dev/ProjectA", "model": "o3"}} + {"timestamp": "2026-10-31T10:01:00Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 80, "cached_input_tokens": 10, "output_tokens": 10, "reasoning_output_tokens": 0, "total_tokens": 100}}}} + {"timestamp": "2026-10-31T10:02:00Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 250, "cached_input_tokens": 50, "output_tokens": 50, "reasoning_output_tokens": 10, "total_tokens": 350}}}} + + """ + try session1Content.data(using: .utf8)?.write(to: session1URL) + + // Session 2: Another session with final cumulative tokens: 200 + let session2URL = dayDir.appendingPathComponent("rollout-2026-10-31T11-00-00-session-2.jsonl") + let session2Content = """ + {"timestamp": "2026-10-31T11:00:00Z", "type": "session_meta", "payload": {"id": "session-2", "cwd": "/Users/dev/ProjectB", "model": "o3-mini"}} + {"timestamp": "2026-10-31T11:01:00Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 150, "cached_input_tokens": 20, "output_tokens": 30, "reasoning_output_tokens": 5, "total_tokens": 200}}}} + + """ + try session2Content.data(using: .utf8)?.write(to: session2URL) + + let detector = CodexCLIDetector(baseDirectory: temporaryDirectory) + + // Process running on ProjectA + let runningPID: pid_t = 12345 + let table: [pid_t: MonitoredProcess] = [ + runningPID: MonitoredProcess(pid: runningPID, ppid: 1, name: "codex", executablePath: "/usr/local/bin/codex", cwd: "/Users/dev/ProjectA") + ] + + let sessions = detector.detectSessions(table: table) + XCTAssertEqual(sessions.count, 2) + + let s1 = try XCTUnwrap(sessions.first(where: { $0.sessionID == "session-1" })) + XCTAssertEqual(s1.pid, runningPID) + XCTAssertEqual(s1.state, .working) + XCTAssertEqual(s1.cwd, "/Users/dev/ProjectA") + XCTAssertEqual(s1.model, "o3") + XCTAssertEqual(s1.tokenUsage?.inputTokens, 250) + XCTAssertEqual(s1.tokenUsage?.outputTokens, 50) + + let s2 = try XCTUnwrap(sessions.first(where: { $0.sessionID == "session-2" })) + XCTAssertNil(s2.pid) + XCTAssertEqual(s2.state, .inactive) + XCTAssertEqual(s2.tokenUsage?.inputTokens, 150) + + // Daily token sum must take session1 max (250 in, 50 cached, 50 out, 10 reasoning) + session2 max (150 in, 20 cached, 30 out, 5 reasoning) + // NOT summing session1's intermediate line! + // Expected Input: 250 + 150 = 400 + // Expected Cached: 50 + 20 = 70 + // Expected Output: 50 + 30 = 80 + // Expected Reasoning: 10 + 5 = 15 + let dailyTokens = detector.computeDailyTokenUsage(calendar: calendar, now: now) + XCTAssertEqual(dailyTokens.inputTokens, 400) + XCTAssertEqual(dailyTokens.cachedInputTokens, 70) + XCTAssertEqual(dailyTokens.outputTokens, 80) + XCTAssertEqual(dailyTokens.reasoningTokens, 15) + XCTAssertEqual(dailyTokens.totalTokens, 400 + 70 + 80 + 15) + } + + func testDetectMethodWithOrchestrationAndInactive() throws { + let calendar = Calendar.current + let now = Date() + let year = String(format: "%04d", calendar.component(.year, from: now)) + let month = String(format: "%02d", calendar.component(.month, from: now)) + let day = String(format: "%02d", calendar.component(.day, from: now)) + + let dayDir = temporaryDirectory + .appendingPathComponent("sessions") + .appendingPathComponent(year) + .appendingPathComponent(month) + .appendingPathComponent(day) + try FileManager.default.createDirectory(at: dayDir, withIntermediateDirectories: true) + + let sessionURL = dayDir.appendingPathComponent("rollout-2026-10-31T12-00-00-session-active.jsonl") + let sessionContent = """ + {"timestamp": "2026-10-31T12:00:00Z", "type": "session_meta", "payload": {"id": "session-active", "cwd": "/Users/dev/t3project", "model": "o3"}} + {"timestamp": "2026-10-31T12:01:00Z", "type": "event_msg", "payload": {"type": "token_count", "info": {"total_token_usage": {"input_tokens": 100, "cached_input_tokens": 10, "output_tokens": 20, "reasoning_output_tokens": 5, "total_tokens": 135}}}} + + """ + try sessionContent.data(using: .utf8)?.write(to: sessionURL) + + let t3PID: pid_t = 500 + let codexPID: pid_t = 700 + let table: [pid_t: MonitoredProcess] = [ + t3PID: MonitoredProcess(pid: t3PID, ppid: 1, name: "t3code", executablePath: "/usr/local/bin/t3code", cwd: "/tmp"), + codexPID: MonitoredProcess(pid: codexPID, ppid: t3PID, name: "codex", executablePath: "/usr/local/bin/codex", cwd: "/Users/dev/t3project") + ] + + let detector = CodexCLIDetector(baseDirectory: temporaryDirectory) + let results = detector.detect(processTable: table, t3RootPIDs: [t3PID]) + + XCTAssertFalse(results.isEmpty) + let active = try XCTUnwrap(results.first(where: { $0.pid == codexPID })) + XCTAssertEqual(active.surface, .t3Orchestrated) + XCTAssertEqual(active.parentT3PID, t3PID) + XCTAssertEqual(active.workspacePath, "/Users/dev/t3project") + XCTAssertEqual(active.projectName, "t3project") + XCTAssertEqual(active.modelName, "o3") + XCTAssertEqual(active.tokenUsage?.inputTokens, 100) + } +} diff --git a/Tests/LoomCoreTests/ProcessTreeScannerTests.swift b/Tests/LoomCoreTests/ProcessTreeScannerTests.swift new file mode 100644 index 0000000..bf9128e --- /dev/null +++ b/Tests/LoomCoreTests/ProcessTreeScannerTests.swift @@ -0,0 +1,67 @@ +// +// ProcessTreeScannerTests.swift +// LoomCoreTests +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +@testable import LoomCore +import XCTest + +final class ProcessTreeScannerTests: XCTestCase { + func testScanProcessTableFindsCurrentProcess() { + let scanner = ProcessTreeScanner() + let table = scanner.scanProcessTable() + let myPID = getpid() + + XCTAssertFalse(table.isEmpty, "Process table should not be empty") + guard let myProc = table[myPID] else { + XCTFail("Process table should contain the running test process (PID \(myPID))") + return + } + + XCTAssertEqual(myProc.pid, myPID) + XCTAssertEqual(myProc.ppid, getppid()) + XCTAssertFalse(myProc.name.isEmpty, "Process name should not be empty") + XCTAssertFalse(myProc.executablePath.isEmpty, "Executable path should not be empty") + } + + func testResolveCWDForCurrentProcess() { + let myPID = getpid() + let cwd = ProcessTreeScanner.resolveCWD(for: myPID) + XCTAssertNotNil(cwd, "Current process CWD should be resolvable") + if let cwd = cwd { + XCTAssertTrue(FileManager.default.fileExists(atPath: cwd), "Resolved CWD path should exist") + } + } + + func testFindAncestorWalkingPPIDChain() { + let rootPID: pid_t = 100 + let midPID: pid_t = 200 + let leafPID: pid_t = 300 + let otherPID: pid_t = 400 + + let table: [pid_t: MonitoredProcess] = [ + rootPID: MonitoredProcess(pid: rootPID, ppid: 1, name: "t3code", executablePath: "/usr/local/bin/t3code", cwd: "/tmp"), + midPID: MonitoredProcess(pid: midPID, ppid: rootPID, name: "sh", executablePath: "/bin/sh", cwd: "/tmp"), + leafPID: MonitoredProcess(pid: leafPID, ppid: midPID, name: "codex", executablePath: "/usr/local/bin/codex", cwd: "/tmp"), + otherPID: MonitoredProcess(pid: otherPID, ppid: 1, name: "safari", executablePath: "/Applications/Safari.app", cwd: nil) + ] + + let ancestor = ProcessTreeScanner.findAncestor(of: leafPID, in: table, matching: [rootPID]) + XCTAssertEqual(ancestor, rootPID, "Should find ancestor rootPID via midPID") + + let noAncestor = ProcessTreeScanner.findAncestor(of: otherPID, in: table, matching: [rootPID]) + XCTAssertNil(noAncestor, "Unrelated process should not match ancestor") + } + + func testIsCandidateProcessMatchesAgyAcpServer() { + XCTAssertTrue(ProcessTreeScanner.isCandidateProcess(name: "agy_acp_server.par", execPath: "/opt/bin/agy_acp_server.par")) + XCTAssertTrue(ProcessTreeScanner.isCandidateProcess(name: "python", execPath: "/usr/local/bin/agy_acp_server")) + XCTAssertTrue(ProcessTreeScanner.isCandidateProcess(name: "agy", execPath: "/usr/local/bin/agy")) + XCTAssertFalse(ProcessTreeScanner.isCandidateProcess(name: "finder", execPath: "/System/Library/CoreServices/Finder.app")) + } +} diff --git a/Tests/LoomCoreTests/T3CodeDetectorTests.swift b/Tests/LoomCoreTests/T3CodeDetectorTests.swift new file mode 100644 index 0000000..d27b699 --- /dev/null +++ b/Tests/LoomCoreTests/T3CodeDetectorTests.swift @@ -0,0 +1,289 @@ +// +// T3CodeDetectorTests.swift +// LoomCoreTests +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Darwin +import Foundation +@testable import LoomCore +import SQLite3 +import XCTest + +final class T3CodeDetectorTests: XCTestCase { + var temporaryDirectory: URL! + + override func setUp() { + super.setUp() + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("T3CodeDetectorTests-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDown() { + if let temporaryDirectory = temporaryDirectory { + try? FileManager.default.removeItem(at: temporaryDirectory) + } + super.tearDown() + } + + func testResolveServerRuntimeRejectsStalePID() throws { + let userdata = temporaryDirectory.appendingPathComponent("userdata") + try FileManager.default.createDirectory(at: userdata, withIntermediateDirectories: true) + let runtimeURL = userdata.appendingPathComponent("server-runtime.json") + + // Use an intentionally stale/dead PID + let stalePID: pid_t = 999999 + let json = "{\"version\": 1, \"pid\": \(stalePID), \"port\": 3773, \"origin\": \"http://127.0.0.1:3773\"}" + try json.data(using: .utf8)?.write(to: runtimeURL) + + let detector = T3CodeDetector(baseDirectory: temporaryDirectory) + // With an empty process table, stale PID must be rejected + let runtime = detector.resolveServerRuntime(processTable: [:]) + XCTAssertNil(runtime, "Stale PID should be rejected when not in process table") + } + + func testResolveServerRuntimeAcceptsValidPID() throws { + let userdata = temporaryDirectory.appendingPathComponent("userdata") + try FileManager.default.createDirectory(at: userdata, withIntermediateDirectories: true) + let runtimeURL = userdata.appendingPathComponent("server-runtime.json") + + let myPID = getpid() + let json = "{\"version\": 1, \"pid\": \(myPID), \"port\": 3773, \"origin\": \"http://127.0.0.1:3773\"}" + try json.data(using: .utf8)?.write(to: runtimeURL) + + let detector = T3CodeDetector(baseDirectory: temporaryDirectory) + let table: [pid_t: MonitoredProcess] = [ + myPID: MonitoredProcess(pid: myPID, ppid: getppid(), name: "test", executablePath: "/bin/test", cwd: "/tmp") + ] + let runtime = detector.resolveServerRuntime(processTable: table) + XCTAssertNotNil(runtime, "Valid running PID in table should be accepted") + XCTAssertEqual(runtime?.pid, myPID) + XCTAssertEqual(runtime?.port, 3773) + } + + func testReadActiveThreadsFromMockSQLite() throws { + let userdata = temporaryDirectory.appendingPathComponent("userdata") + try FileManager.default.createDirectory(at: userdata, withIntermediateDirectories: true) + let dbURL = userdata.appendingPathComponent("statev2.sqlite") + + var db: OpaquePointer? + XCTAssertEqual(sqlite3_open(dbURL.path, &db), SQLITE_OK) + defer { sqlite3_close(db) } + + let schema = """ + CREATE TABLE projection_projects ( + project_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + workspace_root TEXT NOT NULL, + scripts_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + deleted_at TEXT + ); + + CREATE TABLE orchestration_v2_projection_threads ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + default_provider TEXT NOT NULL, + runtime_mode TEXT NOT NULL DEFAULT 'standard', + interaction_mode TEXT NOT NULL DEFAULT 'chat', + active_provider_thread_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE TABLE orchestration_v2_projection_runs ( + run_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + provider TEXT NOT NULL, + provider_thread_id TEXT, + status TEXT NOT NULL, + requested_at TEXT NOT NULL, + completed_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE TABLE orchestration_v2_projection_runtime_requests ( + runtime_request_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + node_id TEXT NOT NULL DEFAULT '', + provider_turn_id TEXT, + kind TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + """ + XCTAssertEqual(sqlite3_exec(db, schema, nil, nil, nil), SQLITE_OK) + + let insertData = """ + INSERT INTO projection_projects (project_id, title, workspace_root) + VALUES ('proj-1', 'Loom Test', '/Users/dev/Loom'); + + -- Thread 1: Working (run is running) + INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, title, default_provider, created_at, updated_at) + VALUES ('thread-1', 'proj-1', 'Build Feature', 'codex', '2026-10-31T20:00:00Z', '2026-10-31T20:05:00Z'); + + INSERT INTO orchestration_v2_projection_runs (run_id, thread_id, ordinal, provider, status, requested_at) + VALUES ('run-1', 'thread-1', 1, 'codex', 'running', '2026-10-31T20:05:00Z'); + + -- Thread 2: Awaiting Approval (has pending request) + INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, title, default_provider, created_at, updated_at) + VALUES ('thread-2', 'proj-1', 'Review PR', 'claude', '2026-10-31T20:10:00Z', '2026-10-31T20:15:00Z'); + + INSERT INTO orchestration_v2_projection_runtime_requests (runtime_request_id, thread_id, kind, status, created_at) + VALUES ('req-1', 'thread-2', 'user_approval', 'pending', '2026-10-31T20:15:00Z'); + + -- Thread 3: Idle (run completed) + INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, title, default_provider, created_at, updated_at) + VALUES ('thread-3', 'proj-1', 'Fix Bug', 'antigravity', '2026-10-31T20:20:00Z', '2026-10-31T20:25:00Z'); + + INSERT INTO orchestration_v2_projection_runs (run_id, thread_id, ordinal, provider, status, requested_at, completed_at) + VALUES ('run-2', 'thread-3', 1, 'antigravity', 'completed', '2026-10-31T20:20:00Z', '2026-10-31T20:25:00Z'); + """ + XCTAssertEqual(sqlite3_exec(db, insertData, nil, nil, nil), SQLITE_OK) + + let detector = T3CodeDetector(baseDirectory: temporaryDirectory) + let threads = detector.readActiveThreads() + + XCTAssertEqual(threads.count, 3) + + // Find thread 1 (working) + let t1 = try XCTUnwrap(threads.first(where: { $0.threadID == "thread-1" })) + XCTAssertEqual(t1.state, .working) + XCTAssertEqual(t1.projectTitle, "Loom Test") + XCTAssertEqual(t1.workspaceRoot, "/Users/dev/Loom") + + // Find thread 2 (awaitingApproval) + let t2 = try XCTUnwrap(threads.first(where: { $0.threadID == "thread-2" })) + XCTAssertEqual(t2.state, .awaitingApproval) + XCTAssertEqual(t2.pendingRequestsCount, 1) + + // Find thread 3 (idle) + let t3 = try XCTUnwrap(threads.first(where: { $0.threadID == "thread-3" })) + XCTAssertEqual(t3.state, .idle) + } + + func testCollectT3AncestorPIDsAndDeduplication() { + let detector = T3CodeDetector(baseDirectory: temporaryDirectory) + let serverPID: pid_t = 1000 + let desktopAppPID: pid_t = 1050 + let childCLITerminalPID: pid_t = 2000 + let otherAppPID: pid_t = 3000 + + let table: [pid_t: MonitoredProcess] = [ + serverPID: MonitoredProcess(pid: serverPID, ppid: 1, name: "t3code", executablePath: "/usr/local/bin/t3code", cwd: "/tmp"), + desktopAppPID: MonitoredProcess(pid: desktopAppPID, ppid: 1, name: "T3 Code", executablePath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code", cwd: "/tmp"), + childCLITerminalPID: MonitoredProcess(pid: childCLITerminalPID, ppid: serverPID, name: "codex", executablePath: "/usr/local/bin/codex", cwd: "/tmp"), + otherAppPID: MonitoredProcess(pid: otherAppPID, ppid: 1, name: "xcode", executablePath: "/Applications/Xcode.app", cwd: nil) + ] + + let t3PIDs = detector.collectT3AncestorPIDs(table: table, serverPID: serverPID) + XCTAssertTrue(t3PIDs.contains(serverPID)) + XCTAssertTrue(t3PIDs.contains(desktopAppPID)) + XCTAssertFalse(t3PIDs.contains(childCLITerminalPID)) + XCTAssertFalse(t3PIDs.contains(otherAppPID)) + + // Child process should resolve to T3Code ancestor + let ancestor = ProcessTreeScanner.findAncestor(of: childCLITerminalPID, in: table, matching: t3PIDs) + XCTAssertEqual(ancestor, serverPID, "codex spawned by t3code server should be identified as child of T3Code") + } + + func testT3CodeProviderDiscoverSessions() async throws { + let userdata = temporaryDirectory.appendingPathComponent("userdata") + try FileManager.default.createDirectory(at: userdata, withIntermediateDirectories: true) + let runtimeURL = userdata.appendingPathComponent("server-runtime.json") + + let myPID = getpid() + let json = "{\"version\": 1, \"pid\": \(myPID), \"port\": 3773, \"origin\": \"http://127.0.0.1:3773\"}" + try json.data(using: .utf8)?.write(to: runtimeURL) + + let dbURL = userdata.appendingPathComponent("statev2.sqlite") + var db: OpaquePointer? + XCTAssertEqual(sqlite3_open(dbURL.path, &db), SQLITE_OK) + defer { sqlite3_close(db) } + + let schema = """ + CREATE TABLE projection_projects ( + project_id TEXT PRIMARY KEY, + title TEXT NOT NULL, + workspace_root TEXT NOT NULL, + scripts_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '', + deleted_at TEXT + ); + + CREATE TABLE orchestration_v2_projection_threads ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + default_provider TEXT NOT NULL, + runtime_mode TEXT NOT NULL DEFAULT 'standard', + interaction_mode TEXT NOT NULL DEFAULT 'chat', + active_provider_thread_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE TABLE orchestration_v2_projection_runs ( + run_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + provider TEXT NOT NULL, + provider_thread_id TEXT, + status TEXT NOT NULL, + requested_at TEXT NOT NULL, + completed_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + + CREATE TABLE orchestration_v2_projection_runtime_requests ( + runtime_request_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + node_id TEXT NOT NULL DEFAULT '', + provider_turn_id TEXT, + kind TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' + ); + + INSERT INTO projection_projects (project_id, title, workspace_root) + VALUES ('proj-loom', 'Loom Desktop', '/Users/deepak/Desktop/Projects/OSS/Loom'); + + INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, title, default_provider, created_at, updated_at) + VALUES ('t3-thread-live', 'proj-loom', 'Active Task', 'acpRegistry_google_antigravity', '2026-10-31T20:00:00Z', '2026-10-31T20:05:00Z'); + + INSERT INTO orchestration_v2_projection_runs (run_id, thread_id, ordinal, provider, status, requested_at) + VALUES ('run-live', 't3-thread-live', 1, 'acpRegistry_google_antigravity', 'running', '2026-10-31T20:05:00Z'); + """ + XCTAssertEqual(sqlite3_exec(db, schema, nil, nil, nil), SQLITE_OK) + + let detector = T3CodeDetector(baseDirectory: temporaryDirectory) + let provider = T3CodeProvider(detector: detector) + + let sessions = try await provider.discoverSessions() + XCTAssertEqual(sessions.count, 1) + + let session = try XCTUnwrap(sessions.first) + XCTAssertEqual(session.id, "t3-thread-live") + XCTAssertEqual(session.provider, .t3Code) + XCTAssertEqual(session.projectName, "Loom Desktop") + XCTAssertEqual(session.state, .working) + XCTAssertEqual(session.pid, Int(myPID)) + } +} From 296e4abfc608b641d27046dbc099ddb3bd9e18e0 Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:51:17 +0000 Subject: [PATCH 03/11] feat(m5): integrate T3Code, Codex, Claude, and Antigravity CLI into native SessionStore, MenuBar, and WidgetKit --- Loom.xcodeproj/project.pbxproj | 4 + .../Discovery/SharedCLIDiscoveryCache.swift | 203 ++++++++++++++++++ Loom/Core/Models/LoomSettings.swift | 2 +- Loom/Core/Models/ProviderPresentation.swift | 7 + Loom/Core/Store/SessionStore+Widget.swift | 4 +- .../Antigravity/AntigravityProvider.swift | 45 +++- Loom/Providers/Claude/ClaudeProvider.swift | 44 ++++ Loom/Providers/Codex/CodexProvider.swift | 58 ++++- Loom/Providers/Mock/MockAgentProvider.swift | 2 +- Loom/Providers/T3Code/T3CodeDetector.swift | 7 +- Loom/Providers/T3Code/T3CodeProvider.swift | 176 +++++++++------ Loom/UI/MenuBar/MenuBarRootView.swift | 7 +- Loom/UI/MenuBar/ProviderIconView.swift | 1 + Tests/LoomCoreTests/SessionStoreTests.swift | 4 +- Tests/LoomCoreTests/T3CodeDetectorTests.swift | 7 +- 15 files changed, 477 insertions(+), 94 deletions(-) create mode 100644 Loom/Core/Discovery/SharedCLIDiscoveryCache.swift diff --git a/Loom.xcodeproj/project.pbxproj b/Loom.xcodeproj/project.pbxproj index b79e0b7..9b8dbc3 100644 --- a/Loom.xcodeproj/project.pbxproj +++ b/Loom.xcodeproj/project.pbxproj @@ -80,6 +80,7 @@ D00000000000000000000048 /* ClaudeCodeDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000047 /* ClaudeCodeDetector.swift */; }; D0000000000000000000004C /* AntigravityCLIDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004B /* AntigravityCLIDetector.swift */; }; D00000000000000000000050 /* AgentMonitoringCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004F /* AgentMonitoringCoordinator.swift */; }; + D0000000000000000000005A /* SharedCLIDiscoveryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000059 /* SharedCLIDiscoveryCache.swift */; }; A00000000000000000000124 /* ClaudeTranscriptInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000125 /* ClaudeTranscriptInspector.swift */; }; A00000000000000000000126 /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000127 /* ClaudeProvider.swift */; }; A00000000000000000000128 /* ClaudeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000129 /* ClaudeProviderTests.swift */; }; @@ -257,6 +258,7 @@ D00000000000000000000047 /* ClaudeCodeDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeDetector.swift; sourceTree = ""; }; D0000000000000000000004B /* AntigravityCLIDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityCLIDetector.swift; sourceTree = ""; }; D0000000000000000000004F /* AgentMonitoringCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitoringCoordinator.swift; sourceTree = ""; }; + D00000000000000000000059 /* SharedCLIDiscoveryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedCLIDiscoveryCache.swift; sourceTree = ""; }; A00000000000000000000125 /* ClaudeTranscriptInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeTranscriptInspector.swift; sourceTree = ""; }; A00000000000000000000127 /* ClaudeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProvider.swift; sourceTree = ""; }; A00000000000000000000129 /* ClaudeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProviderTests.swift; sourceTree = ""; }; @@ -619,6 +621,7 @@ A00000000000000000000109 /* SessionCorrelationEngine.swift */, A00000000000000000000117 /* SessionLifecycleReconciler.swift */, D0000000000000000000004F /* AgentMonitoringCoordinator.swift */, + D00000000000000000000059 /* SharedCLIDiscoveryCache.swift */, ); path = Discovery; sourceTree = ""; @@ -893,6 +896,7 @@ D00000000000000000000048 /* ClaudeCodeDetector.swift in Sources */, D0000000000000000000004C /* AntigravityCLIDetector.swift in Sources */, D00000000000000000000050 /* AgentMonitoringCoordinator.swift in Sources */, + D0000000000000000000005A /* SharedCLIDiscoveryCache.swift in Sources */, A00000000000000000000124 /* ClaudeTranscriptInspector.swift in Sources */, A00000000000000000000126 /* ClaudeProvider.swift in Sources */, A00000000000000000000132 /* AppVersion.swift in Sources */, diff --git a/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift b/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift new file mode 100644 index 0000000..53b8fc6 --- /dev/null +++ b/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift @@ -0,0 +1,203 @@ +// +// SharedCLIDiscoveryCache.swift +// Loom +// +// Copyright © 2026 Deepak Sharma (ChaiWithCode). +// Licensed under the MIT License. See LICENSE for details. +// + +import Foundation + +public actor SharedCLIDiscoveryCache { + public static let shared = SharedCLIDiscoveryCache() + + public struct Snapshot: Sendable { + public let timestamp: Date + public let processTable: [pid_t: MonitoredProcess] + public let t3Result: T3CodeDetector.DetectionResult + public let codexStatuses: [AgentSessionStatus] + public let claudeStatuses: [AgentSessionStatus] + public let antigravityStatuses: [AgentSessionStatus] + } + + private let scanner = ProcessTreeScanner() + private let t3Detector = T3CodeDetector() + private let codexDetector = CodexCLIDetector() + private let claudeDetector = ClaudeCodeDetector() + private let agyDetector = AntigravityCLIDetector() + + private var cachedSnapshot: Snapshot? + private var inFlightTask: Task? + private let cacheTTL: TimeInterval = 1.5 + + public init() {} + + public func snapshot(forceRefresh: Bool = false) async -> Snapshot { + if !forceRefresh, + let cached = cachedSnapshot, + Date().timeIntervalSince(cached.timestamp) < cacheTTL { + return cached + } + + if let existingTask = inFlightTask { + return await existingTask.value + } + + let task = Task { [scanner, t3Detector, codexDetector, claudeDetector, agyDetector] in + let table = scanner.scanProcessTable() + let t3Result = await t3Detector.detect(processTable: table) + let rootT3PIDs = t3Result.rootT3PIDs + + let codex = codexDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) + let claude = claudeDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) + let agy = agyDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) + + return Snapshot( + timestamp: Date(), + processTable: table, + t3Result: t3Result, + codexStatuses: codex, + claudeStatuses: claude, + antigravityStatuses: agy + ) + } + + inFlightTask = task + let result = await task.value + cachedSnapshot = result + inFlightTask = nil + return result + } + + // MARK: - Native AgentSession Conversion + + public static func mapState(_ state: AgentRunState) -> AgentState { + switch state { + case .working: return .working + case .awaitingApproval: return .waiting + case .idle: return .idle + case .inactive: return .finished + } + } + + public static func formatTokens(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM tok", Double(count) / 1_000_000) + } else if count >= 1_000 { + return String(format: "%.1fK tok", Double(count) / 1_000) + } + return "\(count) tok" + } + + public static func toAgentSession( + _ status: AgentSessionStatus, + providerID: ProviderID, + orchestratedChildren: [AgentSessionStatus] = [], + diagnostics: ProviderDiscoveryDiagnostics? = nil + ) -> AgentSession { + let mappedState = mapState(status.state) + let projectURL = status.workspacePath.map { URL(fileURLWithPath: $0) } + + var summaryParts: [String] = [] + if status.state == .awaitingApproval { + summaryParts.append("Waiting for approval") + } + if let model = status.modelName, !model.isEmpty { + summaryParts.append(model) + } + if let usage = status.tokenUsage, usage.totalTokens > 0 { + summaryParts.append(formatTokens(usage.totalTokens)) + } + if !orchestratedChildren.isEmpty { + let childLabels = orchestratedChildren.map { child in + switch child.provider { + case .codex: return "Codex CLI" + case .claudeCode: return "Claude Code" + case .antigravity: return "Antigravity CLI" + case .t3Code: return "T3Code" + } + } + summaryParts.insert("Driving \(childLabels.joined(separator: ", "))", at: 0) + } + + let summaryText = summaryParts.isEmpty ? status.projectName : summaryParts.joined(separator: " • ") + + let host: HostApp = { + switch status.surface { + case .t3Orchestrated: + return .custom("T3 Code") + case .gui: + return providerID == .t3Code ? .custom("T3 Code") : .antigravity + case .cli: + return .terminal + } + }() + + var processes: [AgentProcess] = [] + if let pid = status.pid { + processes.append( + AgentProcess( + pid: pid, + parentPID: status.parentT3PID, + executable: providerID.rawValue, + commandLine: providerID.rawValue, + workingDirectory: projectURL, + startedAt: status.lastUpdated, + provider: providerID, + hostApp: host, + sessionIDToken: status.id + ) + ) + } + + for child in orchestratedChildren { + if let cpid = child.pid { + processes.append( + AgentProcess( + pid: cpid, + parentPID: child.parentT3PID ?? status.pid, + executable: child.provider.rawValue, + commandLine: child.provider.rawValue, + workingDirectory: child.workspacePath.map { URL(fileURLWithPath: $0) }, + startedAt: child.lastUpdated, + provider: providerID, + hostApp: .custom("T3 Code"), + sessionIDToken: child.id + ) + ) + } + } + + var evidence: [String] = [ + "Surface: \(status.surface.rawValue)", + "State: \(mappedState.displayName)" + ] + if let model = status.modelName, !model.isEmpty { + evidence.append("Model: \(model)") + } + if let usage = status.tokenUsage, usage.totalTokens > 0 { + evidence.append("Tokens: \(formatTokens(usage.totalTokens)) (in: \(usage.inputTokens), cached: \(usage.cachedInputTokens), out: \(usage.outputTokens))") + } + if !orchestratedChildren.isEmpty { + evidence.append("Orchestrated child CLIs: \(orchestratedChildren.count)") + } + + return AgentSession( + id: status.id, + provider: providerID, + projectName: status.projectName, + projectPath: projectURL, + state: mappedState, + startedAt: status.lastUpdated, + lastActivityAt: status.lastUpdated, + branch: status.gitBranch, + summary: summaryText, + pid: status.pid.map { Int($0) }, + providerSessionID: status.id, + associatedProcesses: processes, + hostApp: host, + diagnostics: diagnostics, + discoveryEvidence: evidence + ) + } +} diff --git a/Loom/Core/Models/LoomSettings.swift b/Loom/Core/Models/LoomSettings.swift index 59cb5c2..1466faa 100644 --- a/Loom/Core/Models/LoomSettings.swift +++ b/Loom/Core/Models/LoomSettings.swift @@ -157,7 +157,7 @@ public final class LoomSettings { public nonisolated static let defaultShowInDock = false public nonisolated static let defaultRefreshIntervalSeconds = 60 public nonisolated static let supportedRefreshIntervals = [30, 60, 120, 300] - public nonisolated static let defaultProviderIDs: Set = [.codex, .claude, .antigravity] + public nonisolated static let defaultProviderIDs: Set = [.t3Code, .codex, .claude, .antigravity] public nonisolated static let defaultProviderID: ProviderID = .codex public nonisolated static let defaultIncludeRecentSessions = true public nonisolated static let defaultHideFinishedAfter: HideFinishedInterval = .never diff --git a/Loom/Core/Models/ProviderPresentation.swift b/Loom/Core/Models/ProviderPresentation.swift index 8ca1fe4..b6b290f 100644 --- a/Loom/Core/Models/ProviderPresentation.swift +++ b/Loom/Core/Models/ProviderPresentation.swift @@ -36,6 +36,13 @@ public struct ProviderPresentation: Sendable, Equatable, Identifiable { public static func presentation(for id: ProviderID) -> ProviderPresentation { let normalized = id.rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() switch normalized { + case "t3code", "t3-code", "t3": + return ProviderPresentation( + providerID: .t3Code, + displayName: "T3 Code", + iconAssetName: "provider-t3code", + iconRenderingMode: .template + ) case "codex": return ProviderPresentation( providerID: .codex, diff --git a/Loom/Core/Store/SessionStore+Widget.swift b/Loom/Core/Store/SessionStore+Widget.swift index e6e4b7f..ccfee19 100644 --- a/Loom/Core/Store/SessionStore+Widget.swift +++ b/Loom/Core/Store/SessionStore+Widget.swift @@ -73,8 +73,8 @@ extension LoomWidgetSnapshot { status: .unavailable, summary: "Monitoring disabled" ) - } else if snapshot.providerID == .claude { - // Claude Code local integration currently does not provide quota/usage metrics + } else if snapshot.providerID == .claude || snapshot.providerID == .t3Code { + // Claude Code and T3 Code do not provide account quota/usage metrics usageSummary = ProviderUsageWidgetSummary( status: .unsupported, summary: "Usage unsupported" diff --git a/Loom/Providers/Antigravity/AntigravityProvider.swift b/Loom/Providers/Antigravity/AntigravityProvider.swift index 0d60ad6..761810b 100644 --- a/Loom/Providers/Antigravity/AntigravityProvider.swift +++ b/Loom/Providers/Antigravity/AntigravityProvider.swift @@ -63,7 +63,12 @@ public final class AntigravityProvider: AgentProvider, Sendable { } public func fetchUsage() async throws -> ProviderUsageSnapshot? { - guard await processDiscoverer.isAntigravityRunning() else { + var isRunning = await processDiscoverer.isAntigravityRunning() + if !isRunning && processDiscoverer is DefaultAntigravityProcessDiscoverer { + let agyStatuses = await SharedCLIDiscoveryCache.shared.snapshot().antigravityStatuses + isRunning = !agyStatuses.filter { $0.state != .inactive }.isEmpty + } + guard isRunning else { throw AntigravityUsageError.processNotFound } return try await usageFetcher.fetchUsage() @@ -75,9 +80,15 @@ public final class AntigravityProvider: AgentProvider, Sendable { public func discoverSessions() async throws -> [AgentSession] { let startTime = Date() - - // 1. Process liveness gate: if Antigravity is not running, reconcile empty sessions with termination reason - guard await processDiscoverer.isAntigravityRunning() else { + let isDefault = gitResolver is DefaultGitRepositoryResolver && processDiscoverer is DefaultAntigravityProcessDiscoverer + let cliSnapshot = isDefault ? await SharedCLIDiscoveryCache.shared.snapshot() : nil + let standaloneCLISessions = cliSnapshot?.antigravityStatuses + .filter { $0.surface == .cli && $0.state != .inactive } + .map { SharedCLIDiscoveryCache.toAgentSession($0, providerID: .antigravity) } ?? [] + + // 1. Process liveness gate: if neither Antigravity nor standalone CLI is running, reconcile empty sessions + let isRunning = await processDiscoverer.isAntigravityRunning() || !standaloneCLISessions.isEmpty + guard isRunning else { let duration = Date().timeIntervalSince(startTime) let diag = ProviderDiscoveryDiagnostics( processesDetected: 0, @@ -205,7 +216,8 @@ public final class AntigravityProvider: AgentProvider, Sendable { } await storage.update(diagnostics, availability: .available) - return await storage.reconcile(newSessions: sessions, now: Date()) + let finalSessions = mergeWithStandaloneCLI(sessions: sessions, standalone: standaloneCLISessions) + return await storage.reconcile(newSessions: finalSessions, now: Date()) } // 4. Graceful degradation: if metadata failed or was empty, fall back to running processes @@ -335,7 +347,8 @@ public final class AntigravityProvider: AgentProvider, Sendable { ) } - return await storage.reconcile(newSessions: sessionsWithDiagnostics, now: Date()) + let finalSessions = mergeWithStandaloneCLI(sessions: sessionsWithDiagnostics, standalone: standaloneCLISessions) + return await storage.reconcile(newSessions: finalSessions, now: Date()) } // Clean idle when metadata is available and reports no active sessions @@ -350,6 +363,24 @@ public final class AntigravityProvider: AgentProvider, Sendable { ) await storage.update(diagnostics, availability: .available) - return await storage.reconcile(newSessions: [], now: Date()) + let finalSessions = mergeWithStandaloneCLI(sessions: [], standalone: standaloneCLISessions) + return await storage.reconcile(newSessions: finalSessions, now: Date()) + } + + private func mergeWithStandaloneCLI(sessions: [AgentSession], standalone: [AgentSession]) -> [AgentSession] { + var merged = sessions + for cli in standalone { + let cliPID = cli.pid + let cliSessionID = cli.providerSessionID + let alreadyPresent = merged.contains { existing in + if let cp = cliPID, let ep = existing.pid, cp == ep { return true } + if let cs = cliSessionID, let es = existing.providerSessionID, cs == es { return true } + return false + } + if !alreadyPresent { + merged.append(cli) + } + } + return merged } } diff --git a/Loom/Providers/Claude/ClaudeProvider.swift b/Loom/Providers/Claude/ClaudeProvider.swift index da82306..48bf4cf 100644 --- a/Loom/Providers/Claude/ClaudeProvider.swift +++ b/Loom/Providers/Claude/ClaudeProvider.swift @@ -238,6 +238,50 @@ public final class ClaudeProvider: AgentProvider, Sendable { sessions.append(session) } + if gitResolver is DefaultGitRepositoryResolver && processDiscoverer is DefaultClaudeProcessDiscoverer { + let cliSnapshot = await SharedCLIDiscoveryCache.shared.snapshot() + let t3ChildPIDs = Set(cliSnapshot.claudeStatuses.filter { $0.surface == .t3Orchestrated }.compactMap { $0.pid.map(Int.init) }) + sessions = sessions.filter { session in + guard let pid = session.pid else { return true } + return !t3ChildPIDs.contains(pid) + } + + let standaloneStatuses = cliSnapshot.claudeStatuses.filter { $0.surface == .cli } + for status in standaloneStatuses { + guard let usage = status.tokenUsage, usage.totalTokens > 0 else { continue } + let statusPID = status.pid.map(Int.init) + let statusNormPath = status.workspacePath.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + if let idx = sessions.firstIndex(where: { existing in + if let sp = statusPID, let ep = existing.pid, sp == ep { return true } + if let sn = statusNormPath, let en = existing.projectPath?.standardizedFileURL.path, sn == en { return true } + return false + }) { + let existing = sessions[idx] + let formattedTok = SharedCLIDiscoveryCache.formatTokens(usage.totalTokens) + let newSummary = existing.summary.map { "\($0) • \(formattedTok)" } ?? formattedTok + var newEvidence = existing.discoveryEvidence + newEvidence.append("Tokens: \(formattedTok) (in: \(usage.inputTokens), cached: \(usage.cachedInputTokens), out: \(usage.outputTokens))") + sessions[idx] = AgentSession( + id: existing.id, + provider: existing.provider, + projectName: existing.projectName, + projectPath: existing.projectPath, + state: existing.state, + startedAt: existing.startedAt, + lastActivityAt: existing.lastActivityAt, + branch: existing.branch, + summary: newSummary, + pid: existing.pid, + providerSessionID: existing.providerSessionID, + associatedProcesses: existing.associatedProcesses, + hostApp: existing.hostApp, + diagnostics: existing.diagnostics, + discoveryEvidence: newEvidence + ) + } + } + } + let duration = Date().timeIntervalSince(startTime) let diagnostics = ProviderDiscoveryDiagnostics( processesDetected: processes.count, diff --git a/Loom/Providers/Codex/CodexProvider.swift b/Loom/Providers/Codex/CodexProvider.swift index 85c50e9..f128cda 100644 --- a/Loom/Providers/Codex/CodexProvider.swift +++ b/Loom/Providers/Codex/CodexProvider.swift @@ -83,7 +83,7 @@ public final class CodexProvider: AgentProvider, Sendable { processTree = ProcessTree(nodes: []) } - let correlatedSessions: [AgentSession] + var correlatedSessions: [AgentSession] if !processes.isEmpty { correlatedSessions = await correlationEngine.correlate( processes: processes, @@ -94,6 +94,62 @@ public final class CodexProvider: AgentProvider, Sendable { correlatedSessions = [] } + if gitResolver is DefaultGitRepositoryResolver && processDiscoverer is DefaultCodexProcessDiscoverer { + let cliSnapshot = await SharedCLIDiscoveryCache.shared.snapshot() + let t3ChildPIDs = Set(cliSnapshot.codexStatuses.filter { $0.surface == .t3Orchestrated }.compactMap { $0.pid.map(Int.init) }) + correlatedSessions = correlatedSessions.filter { session in + guard let pid = session.pid else { return true } + return !t3ChildPIDs.contains(pid) + } + + let standaloneStatuses = cliSnapshot.codexStatuses.filter { $0.surface == .cli && $0.state != .inactive } + for status in standaloneStatuses { + let statusPID = status.pid.map(Int.init) + let statusNormPath = status.workspacePath.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + if let idx = correlatedSessions.firstIndex(where: { existing in + if let sp = statusPID, let ep = existing.pid, sp == ep { return true } + if let sn = statusNormPath, let en = existing.projectPath?.standardizedFileURL.path, sn == en { return true } + return false + }) { + let existing = correlatedSessions[idx] + var extraSummary: [String] = [] + if let model = status.modelName, !model.isEmpty { extraSummary.append(model) } + if let usage = status.tokenUsage, usage.totalTokens > 0 { extraSummary.append(SharedCLIDiscoveryCache.formatTokens(usage.totalTokens)) } + let newSummary: String + if !extraSummary.isEmpty { + newSummary = existing.summary.map { "\($0) • \(extraSummary.joined(separator: " • "))" } ?? extraSummary.joined(separator: " • ") + } else { + newSummary = existing.summary ?? existing.projectName ?? "Codex Session" + } + var newEvidence = existing.discoveryEvidence + if let usage = status.tokenUsage, usage.totalTokens > 0 { + newEvidence.append("Tokens: \(SharedCLIDiscoveryCache.formatTokens(usage.totalTokens))") + } + correlatedSessions[idx] = AgentSession( + id: existing.id, + provider: existing.provider, + projectName: existing.projectName, + projectPath: existing.projectPath, + state: existing.state, + startedAt: existing.startedAt, + lastActivityAt: existing.lastActivityAt, + branch: existing.branch, + summary: newSummary, + pid: existing.pid, + providerSessionID: existing.providerSessionID, + associatedProcesses: existing.associatedProcesses, + hostApp: existing.hostApp, + diagnostics: existing.diagnostics, + discoveryEvidence: newEvidence + ) + } else { + correlatedSessions.append( + SharedCLIDiscoveryCache.toAgentSession(status, providerID: .codex) + ) + } + } + } + let duration = Date().timeIntervalSince(startTime) let diagnostics = ProviderDiscoveryDiagnostics( processesDetected: processes.count, diff --git a/Loom/Providers/Mock/MockAgentProvider.swift b/Loom/Providers/Mock/MockAgentProvider.swift index ad12e5e..3ae5e4b 100644 --- a/Loom/Providers/Mock/MockAgentProvider.swift +++ b/Loom/Providers/Mock/MockAgentProvider.swift @@ -1026,7 +1026,7 @@ public enum DemoFixture: Sendable, Equatable { public func makeProviders() -> [any AgentProvider] { switch self { case .standard: - return [CodexProvider(), ClaudeProvider(), AntigravityProvider()] + return [T3CodeProvider(), CodexProvider(), ClaudeProvider(), AntigravityProvider()] case .mock: return MockAgentProvider.createDefaultProviders() case .empty: diff --git a/Loom/Providers/T3Code/T3CodeDetector.swift b/Loom/Providers/T3Code/T3CodeDetector.swift index 13f5908..bea0db6 100644 --- a/Loom/Providers/T3Code/T3CodeDetector.swift +++ b/Loom/Providers/T3Code/T3CodeDetector.swift @@ -307,12 +307,13 @@ public final class T3CodeDetector: Sendable { if !sqliteThreads.isEmpty { var sessions: [AgentSessionStatus] = [] for thread in sqliteThreads { - let projectName = thread.workspaceRoot.map { URL(fileURLWithPath: $0).lastPathComponent } - ?? thread.projectTitle + let projectName = thread.projectTitle + ?? thread.workspaceRoot.map { URL(fileURLWithPath: $0).lastPathComponent } ?? thread.title + let threadID = thread.threadID.hasPrefix("t3-") ? thread.threadID : "t3-\(thread.threadID)" sessions.append( AgentSessionStatus( - id: "t3-\(thread.threadID)", + id: threadID, provider: .t3Code, surface: .gui, pid: primaryPID, diff --git a/Loom/Providers/T3Code/T3CodeProvider.swift b/Loom/Providers/T3Code/T3CodeProvider.swift index 9ec01d3..0fe1354 100644 --- a/Loom/Providers/T3Code/T3CodeProvider.swift +++ b/Loom/Providers/T3Code/T3CodeProvider.swift @@ -6,101 +6,135 @@ // Licensed under the MIT License. See LICENSE for details. // -import Darwin import Foundation public final class T3CodeProvider: AgentProvider, Sendable { public let id: ProviderID = .t3Code public let displayName: String = "T3 Code" - private let detector: T3CodeDetector - private let processScanner: ProcessTreeScanner + private actor DiagnosticsStorage { + var diagnostics: ProviderDiscoveryDiagnostics? + private let reconciler = SessionLifecycleReconciler() - public init( - detector: T3CodeDetector = T3CodeDetector(), - processScanner: ProcessTreeScanner = ProcessTreeScanner() - ) { - self.detector = detector - self.processScanner = processScanner - } + func updateDiagnostics(_ diag: ProviderDiscoveryDiagnostics?) { + diagnostics = diag + } - public func availability() async -> ProviderAvailability { - let table = processScanner.scanProcessTable() - guard let runtime = detector.resolveServerRuntime(processTable: table) else { - return .unavailable(reason: "T3 Code server not running") + func getDiagnostics() -> ProviderDiscoveryDiagnostics? { + diagnostics } - if let origin = runtime.origin { - let isReachable = await detector.probeEnvironment(origin: origin) - if isReachable { - return .available - } + func reconcileSessions(newSessions: [AgentSession], now: Date) async -> [AgentSession] { + await reconciler.reconcile(newSessions: newSessions, now: now, providerTerminationReason: "T3 Code stopped") } + } + + private let storage = DiagnosticsStorage() + private let customDetector: T3CodeDetector? + + public init(detector: T3CodeDetector? = nil) { + self.customDetector = detector + } - // If loopback probe timed out but PID is alive and state database exists + public func availability() async -> ProviderAvailability { return .available } + public func fetchUsage() async throws -> ProviderUsageSnapshot? { + return nil + } + + public func lastDiagnostics() async -> ProviderDiscoveryDiagnostics? { + await storage.getDiagnostics() + } + public func discoverSessions() async throws -> [AgentSession] { - let table = processScanner.scanProcessTable() - guard let runtime = detector.resolveServerRuntime(processTable: table) else { - return [] + let startTime = Date() + let t3Result: T3CodeDetector.DetectionResult + let allOrchestratedChildren: [AgentSessionStatus] + + if let customDetector { + let scanner = ProcessTreeScanner() + let table = scanner.scanProcessTable() + t3Result = await customDetector.detect(processTable: table) + allOrchestratedChildren = [] + } else { + let cache = await SharedCLIDiscoveryCache.shared.snapshot() + t3Result = cache.t3Result + allOrchestratedChildren = (cache.codexStatuses + cache.claudeStatuses + cache.antigravityStatuses) + .filter { $0.surface == .t3Orchestrated && $0.state != .inactive } } - let records = detector.readActiveThreads() - var sessions: [AgentSession] = [] - - for record in records { - let agentState: AgentState - switch record.state { - case .working: - agentState = .working - case .awaitingApproval: - agentState = .waiting - case .idle: - agentState = .idle - case .inactive: - agentState = .finished + let activeT3Sessions = t3Result.sessions.filter { $0.state != .inactive } + let duration = Date().timeIntervalSince(startTime) + + let diagnostics = ProviderDiscoveryDiagnostics( + processesDetected: t3Result.rootT3PIDs.count + allOrchestratedChildren.count, + logicalSessions: activeT3Sessions.count, + workspacesResolved: activeT3Sessions.filter { $0.workspacePath != nil }.count, + metadataStatus: .available, + metadataError: nil, + discoveryDuration: duration + ) + await storage.updateDiagnostics(diagnostics) + + let mappedSessions: [AgentSession] = activeT3Sessions.map { t3Session in + let matchingChildren = allOrchestratedChildren.filter { child in + if let ws1 = t3Session.workspacePath, let ws2 = child.workspacePath, + URL(fileURLWithPath: ws1).standardizedFileURL.path == URL(fileURLWithPath: ws2).standardizedFileURL.path { + return true + } + if let t3PID = t3Session.pid, let parentPID = child.parentT3PID, t3PID == parentPID { + return true + } + return activeT3Sessions.count == 1 } - let projectURL = record.workspaceRoot.map { URL(fileURLWithPath: $0) } - let projectName = record.projectTitle - ?? projectURL?.lastPathComponent - ?? "T3 Project" + // Elevate T3Code session state if any orchestrated child CLI is awaiting approval or working + let effectiveState: AgentRunState = { + if t3Session.state == .awaitingApproval || matchingChildren.contains(where: { $0.state == .awaitingApproval }) { + return .awaitingApproval + } + if t3Session.state == .working || matchingChildren.contains(where: { $0.state == .working }) { + return .working + } + return t3Session.state + }() - let session = AgentSession( - id: record.threadID, + let aggregatedTokens: TokenUsageSnapshot? = { + let usages = matchingChildren.compactMap(\.tokenUsage) + guard !usages.isEmpty else { return t3Session.tokenUsage } + return TokenUsageSnapshot( + inputTokens: usages.reduce(0) { $0 + $1.inputTokens }, + cachedInputTokens: usages.reduce(0) { $0 + $1.cachedInputTokens }, + outputTokens: usages.reduce(0) { $0 + $1.outputTokens }, + reasoningTokens: usages.reduce(0) { $0 + $1.reasoningTokens } + ) + }() + + let effectiveStatus = AgentSessionStatus( + id: t3Session.id, provider: .t3Code, - projectName: projectName, - projectPath: projectURL, - state: agentState, - startedAt: record.updatedAt, - lastActivityAt: record.updatedAt, - branch: nil, - summary: record.title, - pid: Int(runtime.pid), - providerSessionID: record.threadID, - associatedProcesses: [], - hostApp: .terminal, - diagnostics: nil, - discoveryEvidence: [ - "T3Code runtime PID \(runtime.pid)", - "Provider: \(record.provider)", - "Run status: \(record.runStatus ?? "unknown")", - "Pending requests: \(record.pendingRequestsCount)" - ] + surface: .gui, + pid: t3Session.pid, + parentT3PID: nil, + projectName: t3Session.projectName, + workspacePath: t3Session.workspacePath, + gitBranch: t3Session.gitBranch ?? matchingChildren.compactMap(\.gitBranch).first, + modelName: t3Session.modelName ?? matchingChildren.compactMap(\.modelName).first, + state: effectiveState, + tokenUsage: aggregatedTokens, + lastUpdated: max(t3Session.lastUpdated, matchingChildren.map(\.lastUpdated).max() ?? t3Session.lastUpdated) ) - sessions.append(session) - } - - return sessions - } - public func fetchUsage() async throws -> ProviderUsageSnapshot? { - return nil - } + return SharedCLIDiscoveryCache.toAgentSession( + effectiveStatus, + providerID: .t3Code, + orchestratedChildren: matchingChildren, + diagnostics: diagnostics + ) + } - public func lastDiagnostics() async -> ProviderDiscoveryDiagnostics? { - return nil + return await storage.reconcileSessions(newSessions: mappedSessions, now: Date()) } } diff --git a/Loom/UI/MenuBar/MenuBarRootView.swift b/Loom/UI/MenuBar/MenuBarRootView.swift index f343341..a9ffaa1 100644 --- a/Loom/UI/MenuBar/MenuBarRootView.swift +++ b/Loom/UI/MenuBar/MenuBarRootView.swift @@ -358,7 +358,8 @@ public struct MenuBarRootView: View { @ViewBuilder private var usageTabContent: some View { - ForEach(store.enabledSnapshots) { snapshot in + let usageSnapshots = store.enabledSnapshots.filter { $0.providerID != .t3Code } + ForEach(usageSnapshots) { snapshot in ProviderSectionView( snapshot: snapshot, showsSessions: false, @@ -366,11 +367,11 @@ public struct MenuBarRootView: View { expandedSessionIds: expandedSessionIds, settings: settings ) - if snapshot.id != store.enabledSnapshots.last?.id { + if snapshot.id != usageSnapshots.last?.id { Divider().padding(.vertical, 4) } } - Text("Claude Code quota is unavailable through its local interface.") + Text("Claude Code and T3 Code quota is unavailable through local interfaces.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Loom/UI/MenuBar/ProviderIconView.swift b/Loom/UI/MenuBar/ProviderIconView.swift index 438cc19..bbfc9a4 100644 --- a/Loom/UI/MenuBar/ProviderIconView.swift +++ b/Loom/UI/MenuBar/ProviderIconView.swift @@ -74,6 +74,7 @@ public struct ProviderIconView: View { private func fallbackSymbol(for id: ProviderID) -> String { switch id { + case .t3Code: return "square.stack.3d.up.fill" case .codex: return "terminal" case .claude: return "bubble.left.and.text.bubble.right" case .antigravity: return "wand.and.stars" diff --git a/Tests/LoomCoreTests/SessionStoreTests.swift b/Tests/LoomCoreTests/SessionStoreTests.swift index a083759..4b7c022 100644 --- a/Tests/LoomCoreTests/SessionStoreTests.swift +++ b/Tests/LoomCoreTests/SessionStoreTests.swift @@ -514,8 +514,8 @@ final class SessionStoreTests: XCTestCase { @MainActor func testDemoFixturesAndStates() async throws { let standardProviders = DemoFixture.standard.makeProviders() - XCTAssertEqual(standardProviders.count, 3) - XCTAssertEqual(standardProviders.map(\.id), [.codex, .claude, .antigravity]) + XCTAssertEqual(standardProviders.count, 4) + XCTAssertEqual(standardProviders.map(\.id), [.t3Code, .codex, .claude, .antigravity]) // Verify DemoFixture metadata XCTAssertNil(DemoFixture.standard.badgeLabel) diff --git a/Tests/LoomCoreTests/T3CodeDetectorTests.swift b/Tests/LoomCoreTests/T3CodeDetectorTests.swift index d27b699..d693ec1 100644 --- a/Tests/LoomCoreTests/T3CodeDetectorTests.swift +++ b/Tests/LoomCoreTests/T3CodeDetectorTests.swift @@ -204,7 +204,7 @@ final class T3CodeDetectorTests: XCTestCase { let runtimeURL = userdata.appendingPathComponent("server-runtime.json") let myPID = getpid() - let json = "{\"version\": 1, \"pid\": \(myPID), \"port\": 3773, \"origin\": \"http://127.0.0.1:3773\"}" + let json = "{\"version\": 1, \"pid\": \(myPID), \"port\": 65530, \"origin\": \"http://127.0.0.1:65530\"}" try json.data(using: .utf8)?.write(to: runtimeURL) let dbURL = userdata.appendingPathComponent("statev2.sqlite") @@ -212,6 +212,7 @@ final class T3CodeDetectorTests: XCTestCase { XCTAssertEqual(sqlite3_open(dbURL.path, &db), SQLITE_OK) defer { sqlite3_close(db) } + let nowISO = ISO8601DateFormatter().string(from: Date()) let schema = """ CREATE TABLE projection_projects ( project_id TEXT PRIMARY KEY, @@ -266,10 +267,10 @@ final class T3CodeDetectorTests: XCTestCase { VALUES ('proj-loom', 'Loom Desktop', '/Users/deepak/Desktop/Projects/OSS/Loom'); INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, title, default_provider, created_at, updated_at) - VALUES ('t3-thread-live', 'proj-loom', 'Active Task', 'acpRegistry_google_antigravity', '2026-10-31T20:00:00Z', '2026-10-31T20:05:00Z'); + VALUES ('t3-thread-live', 'proj-loom', 'Active Task', 'acpRegistry_google_antigravity', '\(nowISO)', '\(nowISO)'); INSERT INTO orchestration_v2_projection_runs (run_id, thread_id, ordinal, provider, status, requested_at) - VALUES ('run-live', 't3-thread-live', 1, 'acpRegistry_google_antigravity', 'running', '2026-10-31T20:05:00Z'); + VALUES ('run-live', 't3-thread-live', 1, 'acpRegistry_google_antigravity', 'running', '\(nowISO)'); """ XCTAssertEqual(sqlite3_exec(db, schema, nil, nil, nil), SQLITE_OK) From cadba0e0c0569a36c84fcc82c00c60d0e688a51c Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:56:50 +0000 Subject: [PATCH 04/11] chore: remove unused AgentMonitoringCoordinator and AgentSessionsSectionView --- Loom.xcodeproj/project.pbxproj | 26 -- .../AgentMonitoringCoordinator.swift | 138 ---------- .../Components/AgentSessionsSectionView.swift | 244 ------------------ .../AgentMonitoringCoordinatorTests.swift | 201 --------------- .../AgentSessionsSectionViewTests.swift | 31 --- 5 files changed, 640 deletions(-) delete mode 100644 Loom/Core/Discovery/AgentMonitoringCoordinator.swift delete mode 100644 Loom/UI/Components/AgentSessionsSectionView.swift delete mode 100644 Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift delete mode 100644 Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift diff --git a/Loom.xcodeproj/project.pbxproj b/Loom.xcodeproj/project.pbxproj index 9b8dbc3..2cbbe96 100644 --- a/Loom.xcodeproj/project.pbxproj +++ b/Loom.xcodeproj/project.pbxproj @@ -43,7 +43,6 @@ D00000000000000000000046 /* CodexCLIDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000045 /* CodexCLIDetectorTests.swift */; }; D0000000000000000000004A /* ClaudeCodeDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000049 /* ClaudeCodeDetectorTests.swift */; }; D0000000000000000000004E /* AntigravityCLIDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004D /* AntigravityCLIDetectorTests.swift */; }; - D00000000000000000000052 /* AgentMonitoringCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000051 /* AgentMonitoringCoordinatorTests.swift */; }; A000000000000000000000B1 /* AntigravityProcessInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000E1 /* AntigravityProcessInspector.swift */; }; A000000000000000000000B2 /* AntigravityParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000E2 /* AntigravityParser.swift */; }; A000000000000000000000B3 /* DefaultAntigravityProcessDiscoverer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A000000000000000000000E3 /* DefaultAntigravityProcessDiscoverer.swift */; }; @@ -79,7 +78,6 @@ A00000000000000000000122 /* ClaudeParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000123 /* ClaudeParser.swift */; }; D00000000000000000000048 /* ClaudeCodeDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000047 /* ClaudeCodeDetector.swift */; }; D0000000000000000000004C /* AntigravityCLIDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004B /* AntigravityCLIDetector.swift */; }; - D00000000000000000000050 /* AgentMonitoringCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0000000000000000000004F /* AgentMonitoringCoordinator.swift */; }; D0000000000000000000005A /* SharedCLIDiscoveryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000059 /* SharedCLIDiscoveryCache.swift */; }; A00000000000000000000124 /* ClaudeTranscriptInspector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000125 /* ClaudeTranscriptInspector.swift */; }; A00000000000000000000126 /* ClaudeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000127 /* ClaudeProvider.swift */; }; @@ -123,9 +121,6 @@ D00000000000000000000101 /* LICENSE.lobehub in Resources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000100 /* LICENSE.lobehub */; }; A00000000000000000000206 /* LoomCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A00000000000000000000030 /* LoomCore.framework */; }; A00000000000000000000209 /* LoomRegressionTestSuite.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000208 /* LoomRegressionTestSuite.swift */; }; - D00000000000000000000053 /* AgentSessionsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000054 /* AgentSessionsSectionView.swift */; }; - D00000000000000000000056 /* AgentSessionsSectionViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000057 /* AgentSessionsSectionViewTests.swift */; }; - D00000000000000000000058 /* AgentSessionsSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000054 /* AgentSessionsSectionView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -221,7 +216,6 @@ D00000000000000000000045 /* CodexCLIDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexCLIDetectorTests.swift; sourceTree = ""; }; D00000000000000000000049 /* ClaudeCodeDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeDetectorTests.swift; sourceTree = ""; }; D0000000000000000000004D /* AntigravityCLIDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityCLIDetectorTests.swift; sourceTree = ""; }; - D00000000000000000000051 /* AgentMonitoringCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitoringCoordinatorTests.swift; sourceTree = ""; }; A000000000000000000000E1 /* AntigravityProcessInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityProcessInspector.swift; sourceTree = ""; }; A000000000000000000000E2 /* AntigravityParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityParser.swift; sourceTree = ""; }; A000000000000000000000E3 /* DefaultAntigravityProcessDiscoverer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAntigravityProcessDiscoverer.swift; sourceTree = ""; }; @@ -257,7 +251,6 @@ A00000000000000000000123 /* ClaudeParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeParser.swift; sourceTree = ""; }; D00000000000000000000047 /* ClaudeCodeDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeDetector.swift; sourceTree = ""; }; D0000000000000000000004B /* AntigravityCLIDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AntigravityCLIDetector.swift; sourceTree = ""; }; - D0000000000000000000004F /* AgentMonitoringCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentMonitoringCoordinator.swift; sourceTree = ""; }; D00000000000000000000059 /* SharedCLIDiscoveryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedCLIDiscoveryCache.swift; sourceTree = ""; }; A00000000000000000000125 /* ClaudeTranscriptInspector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeTranscriptInspector.swift; sourceTree = ""; }; A00000000000000000000127 /* ClaudeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeProvider.swift; sourceTree = ""; }; @@ -300,8 +293,6 @@ D00000000000000000000100 /* LICENSE.lobehub */ = {isa = PBXFileReference; lastKnownFileType = text; path = ProviderSources/LICENSE.lobehub; sourceTree = ""; }; A00000000000000000000201 /* LoomRegressionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LoomRegressionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A00000000000000000000208 /* LoomRegressionTestSuite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoomRegressionTestSuite.swift; sourceTree = ""; }; - D00000000000000000000054 /* AgentSessionsSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionsSectionView.swift; sourceTree = ""; }; - D00000000000000000000057 /* AgentSessionsSectionViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentSessionsSectionViewTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -392,19 +383,10 @@ ); path = App; sourceTree = ""; - }; - D00000000000000000000055 /* Components */ = { - isa = PBXGroup; - children = ( - D00000000000000000000054 /* AgentSessionsSectionView.swift */, - ); - path = Components; - sourceTree = ""; }; A00000000000000000000012 /* UI */ = { isa = PBXGroup; children = ( - D00000000000000000000055 /* Components */, A00000000000000000000013 /* MenuBar */, A000000000000000000000FB /* Settings */, ); @@ -520,7 +502,6 @@ A0000000000000000000001B /* LoomCoreTests */ = { isa = PBXGroup; children = ( - D00000000000000000000057 /* AgentSessionsSectionViewTests.swift */, B00000000000000000000005 /* Tests/LoomCoreTests/StorageAndRefreshTests.swift */, A0000000000000000000002B /* SessionStoreTests.swift */, A000000000000000000000C6 /* CodexProviderTests.swift */, @@ -532,7 +513,6 @@ D00000000000000000000045 /* CodexCLIDetectorTests.swift */, D00000000000000000000049 /* ClaudeCodeDetectorTests.swift */, D0000000000000000000004D /* AntigravityCLIDetectorTests.swift */, - D00000000000000000000051 /* AgentMonitoringCoordinatorTests.swift */, A000000000000000000000F7 /* SettingsTests.swift */, A000000000000000000000F9 /* MenuLayoutTests.swift */, A0000000000000000000010B /* DiscoveryPipelineTests.swift */, @@ -620,7 +600,6 @@ A00000000000000000000107 /* WorkspaceResolver.swift */, A00000000000000000000109 /* SessionCorrelationEngine.swift */, A00000000000000000000117 /* SessionLifecycleReconciler.swift */, - D0000000000000000000004F /* AgentMonitoringCoordinator.swift */, D00000000000000000000059 /* SharedCLIDiscoveryCache.swift */, ); path = Discovery; @@ -895,7 +874,6 @@ A00000000000000000000122 /* ClaudeParser.swift in Sources */, D00000000000000000000048 /* ClaudeCodeDetector.swift in Sources */, D0000000000000000000004C /* AntigravityCLIDetector.swift in Sources */, - D00000000000000000000050 /* AgentMonitoringCoordinator.swift in Sources */, D0000000000000000000005A /* SharedCLIDiscoveryCache.swift in Sources */, A00000000000000000000124 /* ClaudeTranscriptInspector.swift in Sources */, A00000000000000000000126 /* ClaudeProvider.swift in Sources */, @@ -911,7 +889,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D00000000000000000000053 /* AgentSessionsSectionView.swift in Sources */, D00000000000000000000019 /* DiagnosticsDisclosureView.swift in Sources */, D00000000000000000000017 /* SessionFilterBar.swift in Sources */, D00000000000000000000015 /* LoomGlassStyles.swift in Sources */, @@ -939,8 +916,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D00000000000000000000056 /* AgentSessionsSectionViewTests.swift in Sources */, - D00000000000000000000058 /* AgentSessionsSectionView.swift in Sources */, C00000000000000000000005 /* Tests/LoomCoreTests/StorageAndRefreshTests.swift in Sources */, A00000000000000000000088 /* SessionStoreTests.swift in Sources */, A000000000000000000000A6 /* CodexProviderTests.swift in Sources */, @@ -952,7 +927,6 @@ D00000000000000000000046 /* CodexCLIDetectorTests.swift in Sources */, D0000000000000000000004A /* ClaudeCodeDetectorTests.swift in Sources */, D0000000000000000000004E /* AntigravityCLIDetectorTests.swift in Sources */, - D00000000000000000000052 /* AgentMonitoringCoordinatorTests.swift in Sources */, A000000000000000000000F8 /* SettingsTests.swift in Sources */, A000000000000000000000FA /* MenuLayoutTests.swift in Sources */, A0000000000000000000010C /* DiscoveryPipelineTests.swift in Sources */, diff --git a/Loom/Core/Discovery/AgentMonitoringCoordinator.swift b/Loom/Core/Discovery/AgentMonitoringCoordinator.swift deleted file mode 100644 index 9d426cd..0000000 --- a/Loom/Core/Discovery/AgentMonitoringCoordinator.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// AgentMonitoringCoordinator.swift -// Loom -// -// Copyright © 2026 Deepak Sharma (ChaiWithCode). -// Licensed under the MIT License. See LICENSE for details. -// - -import Darwin -import Foundation - -@MainActor -public final class AgentMonitoringCoordinator: ObservableObject { - @Published public private(set) var sessions: [AgentSessionStatus] = [] - - private let scanner: ProcessTreeScanner - private let t3Detector: T3CodeDetector - private let codexDetector: CodexCLIDetector - private let claudeDetector: ClaudeCodeDetector - private let agyDetector: AntigravityCLIDetector - - private let watcherQueue = DispatchQueue(label: "com.chaiwithcode.loom.fs-watcher", qos: .utility) - private var fileWatchers: [DispatchSourceFileSystemObject] = [] - private var debounceWorkItem: DispatchWorkItem? - private var pollingTask: Task? - - public init( - scanner: ProcessTreeScanner = ProcessTreeScanner(), - t3Detector: T3CodeDetector = T3CodeDetector(), - codexDetector: CodexCLIDetector = CodexCLIDetector(), - claudeDetector: ClaudeCodeDetector = ClaudeCodeDetector(), - agyDetector: AntigravityCLIDetector = AntigravityCLIDetector() - ) { - self.scanner = scanner - self.t3Detector = t3Detector - self.codexDetector = codexDetector - self.claudeDetector = claudeDetector - self.agyDetector = agyDetector - } - - public func startMonitoring() { - stopMonitoring() - setupDirectoryWatchers() - - pollingTask = Task { [weak self] in - while !Task.isCancelled { - await self?.refreshAll() - try? await Task.sleep(for: .seconds(3)) - } - } - } - - public func stopMonitoring() { - pollingTask?.cancel() - pollingTask = nil - debounceWorkItem?.cancel() - for watcher in fileWatchers { - watcher.cancel() - } - fileWatchers.removeAll() - } - - public func refreshAll() async { - let table = await Task.detached(priority: .utility) { [scanner] in - scanner.scanProcessTable() - }.value - - // 1. Run T3Code first to obtain rootT3PIDs for child CLI deduplication - let t3Result = await t3Detector.detect(processTable: table) - let rootT3PIDs = t3Result.rootT3PIDs - - // 2. Run CLI detectors in parallel with rootT3PIDs - async let codexSessions = Task.detached(priority: .utility) { [codexDetector] in - codexDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) - }.value - - async let claudeSessions = Task.detached(priority: .utility) { [claudeDetector] in - claudeDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) - }.value - - async let agySessions = Task.detached(priority: .utility) { [agyDetector] in - agyDetector.detect(processTable: table, t3RootPIDs: rootT3PIDs) - }.value - - let combined = t3Result.sessions + (await codexSessions) + (await claudeSessions) + (await agySessions) - self.sessions = combined.sorted { lhs, rhs in - if (lhs.state != .inactive) != (rhs.state != .inactive) { - return lhs.state != .inactive - } - return lhs.lastUpdated > rhs.lastUpdated - } - } - - // MARK: - 500ms Debounced DispatchSource Watchers - - private func setupDirectoryWatchers() { - let watchedDirs: [URL] = [ - T3CodeDetector.resolveT3HomeDirectory().appendingPathComponent("userdata"), - CodexCLIDetector.resolveCodexHomeDirectory().appendingPathComponent("sessions"), - ClaudeCodeDetector.resolveClaudeProjectsDirectory(), - AntigravityCLIDetector.resolveAntigravityCLIDirectory().appendingPathComponent("brain") - ] - - for url in watchedDirs { - let fd = Darwin.open(url.path, O_EVTONLY) - guard fd >= 0 else { continue } - - let source = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: fd, - eventMask: [.write, .extend, .rename], - queue: watcherQueue - ) - - source.setEventHandler { [weak self] in - self?.scheduleDebouncedRefresh() - } - - source.setCancelHandler { - Darwin.close(fd) - } - - source.resume() - fileWatchers.append(source) - } - } - - private func scheduleDebouncedRefresh() { - debounceWorkItem?.cancel() - let item = DispatchWorkItem { [weak self] in - guard let self else { return } - Task { @MainActor in - await self.refreshAll() - } - } - debounceWorkItem = item - watcherQueue.asyncAfter(deadline: .now() + .milliseconds(500), execute: item) - } -} diff --git a/Loom/UI/Components/AgentSessionsSectionView.swift b/Loom/UI/Components/AgentSessionsSectionView.swift deleted file mode 100644 index d5af85a..0000000 --- a/Loom/UI/Components/AgentSessionsSectionView.swift +++ /dev/null @@ -1,244 +0,0 @@ -// -// AgentSessionsSectionView.swift -// Loom -// -// Copyright © 2026 Deepak Sharma (ChaiWithCode). -// Licensed under the MIT License. See LICENSE for details. -// - -import SwiftUI -#if !SWIFT_PACKAGE -import LoomCore -#endif - -public struct AgentSessionsSectionView: View { - @ObservedObject var coordinator: AgentMonitoringCoordinator - - public init(coordinator: AgentMonitoringCoordinator) { - self.coordinator = coordinator - } - - /// Top-level sessions (excludes CLI processes spawned as children of T3Code) - private var topLevelSessions: [AgentSessionStatus] { - coordinator.sessions.filter { $0.surface != .t3Orchestrated } - } - - /// Child CLI sessions spawned by a given T3Code daemon/app PID - private func childSessions(for t3Session: AgentSessionStatus) -> [AgentSessionStatus] { - guard t3Session.provider == .t3Code else { return [] } - return coordinator.sessions.filter { child in - guard child.surface == .t3Orchestrated else { return false } - if let t3PID = t3Session.pid, let parentPID = child.parentT3PID, t3PID == parentPID { - return true - } - if let ws1 = t3Session.workspacePath, let ws2 = child.workspacePath, ws1 == ws2 { - return true - } - return false - } - } - - public var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("ACTIVE AGENTS & CLIs") - .font(.system(size: 11, weight: .semibold, design: .rounded)) - .foregroundStyle(.secondary) - Spacer() - if coordinator.sessions.contains(where: { $0.state == .awaitingApproval }) { - Label("Needs Approval", systemImage: "exclamationmark.bubble.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.orange) - } - } - .padding(.horizontal, 4) - - if topLevelSessions.isEmpty { - HStack(spacing: 8) { - Image(systemName: "terminal") - .foregroundStyle(.tertiary) - Text("No active CLI or T3Code sessions") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(10) - .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } else { - ForEach(topLevelSessions) { session in - AgentSessionCardView( - session: session, - orchestratedChildren: childSessions(for: session) - ) - } - } - } - } - - public static func formatTokens(_ count: Int) -> String { - if count >= 1_000_000 { - return String(format: "%.1fM", Double(count) / 1_000_000) - } else if count >= 1_000 { - return String(format: "%.1fK", Double(count) / 1_000) - } - return "\(count)" - } -} - -private struct AgentSessionCardView: View { - let session: AgentSessionStatus - let orchestratedChildren: [AgentSessionStatus] - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Circle() - .fill(statusColor(for: session.state)) - .frame(width: 7, height: 7) - - Text(session.projectName) - .font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - - if let branch = session.gitBranch, !branch.isEmpty { - Text(branch) - .font(.system(size: 10, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(.quaternary.opacity(0.6), in: Capsule()) - } - - Spacer() - - ProviderBadge(provider: session.provider, surface: session.surface) - } - - HStack(spacing: 8) { - Text(statusLabel(for: session.state)) - .font(.system(size: 11, weight: session.state == .awaitingApproval ? .semibold : .regular)) - .foregroundStyle(session.state == .awaitingApproval ? .orange : .secondary) - - if let model = session.modelName, !model.isEmpty { - Text("• \(model)") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - - Spacer() - - if let usage = session.tokenUsage, usage.totalTokens > 0 { - Text("\(Self.formatTokens(usage.totalTokens)) tok") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - - // Nested child CLI rows when T3Code is driving Codex / Claude / agy - if !orchestratedChildren.isEmpty { - Divider() - .opacity(0.4) - .padding(.vertical, 2) - - ForEach(orchestratedChildren) { child in - HStack(spacing: 6) { - Image(systemName: "arrow.turn.down.right") - .font(.system(size: 10)) - .foregroundStyle(.tertiary) - - Text(providerDisplayName(child.provider)) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - - if let model = child.modelName { - Text(model) - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.tertiary) - } - - Spacer() - - if let usage = child.tokenUsage, usage.totalTokens > 0 { - Text("\(Self.formatTokens(usage.totalTokens)) tok") - .font(.system(size: 10, design: .monospaced)) - .foregroundStyle(.secondary) - } - - Circle() - .fill(statusColor(for: child.state)) - .frame(width: 6, height: 6) - } - .padding(.leading, 6) - } - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(.quaternary.opacity(session.state == .inactive ? 0.25 : 0.5)) - ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .strokeBorder( - session.state == .awaitingApproval ? Color.orange.opacity(0.6) : Color.primary.opacity(0.07), - lineWidth: 1 - ) - ) - .opacity(session.state == .inactive ? 0.65 : 1.0) - } - - private func statusColor(for state: AgentRunState) -> Color { - switch state { - case .working: return .green - case .awaitingApproval: return .orange - case .idle: return .blue - case .inactive: return .gray.opacity(0.5) - } - } - - private func statusLabel(for state: AgentRunState) -> String { - switch state { - case .working: return "Working" - case .awaitingApproval: return "Waiting for Approval" - case .idle: return "Idle" - case .inactive: return "Recent" - } - } - - private func providerDisplayName(_ provider: AgentProviderKind) -> String { - switch provider { - case .t3Code: return "T3Code" - case .codex: return "Codex CLI" - case .claudeCode: return "Claude Code" - case .antigravity: return "Antigravity CLI" - } - } - - private static func formatTokens(_ count: Int) -> String { - AgentSessionsSectionView.formatTokens(count) - } -} - -private struct ProviderBadge: View { - let provider: AgentProviderKind - let surface: AgentSurface - - var body: some View { - Text(label) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.primary.opacity(0.08), in: Capsule()) - .foregroundStyle(.secondary) - } - - private var label: String { - switch provider { - case .t3Code: return "T3Code" - case .codex: return "codex" - case .claudeCode: return "claude" - case .antigravity: return surface == .cli ? "agy" : "Antigravity" - } - } -} diff --git a/Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift b/Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift deleted file mode 100644 index 7029a27..0000000 --- a/Tests/LoomCoreTests/AgentMonitoringCoordinatorTests.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// AgentMonitoringCoordinatorTests.swift -// LoomCoreTests -// -// Copyright © 2026 Deepak Sharma (ChaiWithCode). -// Licensed under the MIT License. See LICENSE for details. -// - -import Darwin -import Foundation -@testable import LoomCore -import XCTest - -final class AgentMonitoringCoordinatorTests: XCTestCase { - var temporaryDirectory: URL! - - override func setUp() { - super.setUp() - temporaryDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("AgentMonitoringCoordinatorTests-\(UUID().uuidString)") - try? FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) - } - - override func tearDown() { - if let temporaryDirectory = temporaryDirectory { - try? FileManager.default.removeItem(at: temporaryDirectory) - } - super.tearDown() - } - - @MainActor - func testCoordinatorInitialStateAndLifecycle() async { - let coordinator = AgentMonitoringCoordinator() - XCTAssertTrue(coordinator.sessions.isEmpty, "Initial sessions array should be empty") - - coordinator.startMonitoring() - // Wait briefly for first potential loop or background check - try? await Task.sleep(nanoseconds: 50_000_000) - - coordinator.stopMonitoring() - // Calling stopMonitoring again should be idempotent and not crash - coordinator.stopMonitoring() - } - - @MainActor - func testCoordinatorRefreshAllProducesSortedSessions() async { - let t3Dir = temporaryDirectory.appendingPathComponent(".t3") - let codexDir = temporaryDirectory.appendingPathComponent(".codex") - let claudeDir = temporaryDirectory.appendingPathComponent(".claude") - let agyDir = temporaryDirectory.appendingPathComponent(".gemini/antigravity-cli") - - try? FileManager.default.createDirectory(at: t3Dir, withIntermediateDirectories: true) - try? FileManager.default.createDirectory(at: codexDir, withIntermediateDirectories: true) - try? FileManager.default.createDirectory(at: claudeDir, withIntermediateDirectories: true) - try? FileManager.default.createDirectory(at: agyDir, withIntermediateDirectories: true) - - let t3Detector = T3CodeDetector(baseDirectory: t3Dir) - let codexDetector = CodexCLIDetector(baseDirectory: codexDir) - let claudeDetector = ClaudeCodeDetector(baseDirectory: claudeDir) - let agyDetector = AntigravityCLIDetector(baseDirectory: agyDir) - - let coordinator = AgentMonitoringCoordinator( - t3Detector: t3Detector, - codexDetector: codexDetector, - claudeDetector: claudeDetector, - agyDetector: agyDetector - ) - - await coordinator.refreshAll() - // When there are no live agent processes, sessions may be empty or contain today's inactive sessions - for session in coordinator.sessions { - XCTAssertNotNil(session.id) - XCTAssertNotNil(session.provider) - } - } - - @MainActor - func testSessionSortingActiveBeforeInactiveAndDateDescending() async { - let now = Date() - let activeRecent = AgentSessionStatus( - id: "active-recent", - provider: .codex, - surface: .cli, - projectName: "proj-1", - state: .working, - lastUpdated: now - ) - let activeOlder = AgentSessionStatus( - id: "active-older", - provider: .claudeCode, - surface: .cli, - projectName: "proj-2", - state: .awaitingApproval, - lastUpdated: now.addingTimeInterval(-100) - ) - let inactiveRecent = AgentSessionStatus( - id: "inactive-recent", - provider: .antigravity, - surface: .cli, - projectName: "proj-3", - state: .inactive, - lastUpdated: now - ) - let inactiveOlder = AgentSessionStatus( - id: "inactive-older", - provider: .t3Code, - surface: .gui, - projectName: "proj-4", - state: .inactive, - lastUpdated: now.addingTimeInterval(-200) - ) - - let input = [inactiveRecent, activeOlder, inactiveOlder, activeRecent] - - let sorted = input.sorted { lhs, rhs in - if (lhs.state != .inactive) != (rhs.state != .inactive) { - return lhs.state != .inactive - } - return lhs.lastUpdated > rhs.lastUpdated - } - - XCTAssertEqual(sorted.map(\.id), ["active-recent", "active-older", "inactive-recent", "inactive-older"]) - } - - @MainActor - func testAntigravityCLIDetectorDetectSessionIntegration() throws { - let brainDir = temporaryDirectory.appendingPathComponent("brain") - let convID = "conv-active-test" - let logsDir = brainDir.appendingPathComponent(convID).appendingPathComponent(".system_generated").appendingPathComponent("logs") - try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) - let transcriptURL = logsDir.appendingPathComponent("transcript.jsonl") - - let lines = [ - "{\"step_index\": 0, \"type\": \"USER_INPUT\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:00Z\", \"content\": \"hello\"}", - "{\"step_index\": 1, \"type\": \"PLANNER_RESPONSE\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:05Z\", \"model\": \"gemini-ultra\"}" - ] - try (lines.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: transcriptURL) - - let agyPID: pid_t = 8888 - let table: [pid_t: MonitoredProcess] = [ - agyPID: MonitoredProcess( - pid: agyPID, - ppid: 1, - name: "agy", - executablePath: "/Users/dev/.local/bin/agy", - cwd: "/Users/dev/workspace/app" - ) - ] - - let detector = AntigravityCLIDetector(baseDirectory: temporaryDirectory) - let sessions = detector.detect(processTable: table, t3RootPIDs: []) - - XCTAssertFalse(sessions.isEmpty) - let session = try XCTUnwrap(sessions.first(where: { $0.pid == agyPID })) - XCTAssertEqual(session.provider, .antigravity) - XCTAssertEqual(session.surface, .cli) - XCTAssertEqual(session.workspacePath, "/Users/dev/workspace/app") - XCTAssertEqual(session.projectName, "app") - XCTAssertEqual(session.modelName, "gemini-ultra") - } - - @MainActor - func testAntigravityCLIDetectorChildOfT3CodeSetsSurface() throws { - let brainDir = temporaryDirectory.appendingPathComponent("brain") - let convID = "conv-t3-child" - let logsDir = brainDir.appendingPathComponent(convID).appendingPathComponent(".system_generated").appendingPathComponent("logs") - try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) - let transcriptURL = logsDir.appendingPathComponent("transcript.jsonl") - - let lines = [ - "{\"step_index\": 0, \"type\": \"USER_INPUT\", \"status\": \"DONE\", \"created_at\": \"2026-10-31T20:00:00Z\", \"content\": \"orchestrated run\"}" - ] - try (lines.joined(separator: "\n") + "\n").data(using: .utf8)?.write(to: transcriptURL) - - let t3RootPID: pid_t = 1200 - let agyPID: pid_t = 1205 - let table: [pid_t: MonitoredProcess] = [ - t3RootPID: MonitoredProcess( - pid: t3RootPID, - ppid: 1, - name: "t3code", - executablePath: "/usr/local/bin/t3code", - cwd: "/Users/dev/t3-project" - ), - agyPID: MonitoredProcess( - pid: agyPID, - ppid: t3RootPID, - name: "agy", - executablePath: "/Users/dev/.local/bin/agy", - cwd: "/Users/dev/t3-project" - ) - ] - - let detector = AntigravityCLIDetector(baseDirectory: temporaryDirectory) - let sessions = detector.detect(processTable: table, t3RootPIDs: [t3RootPID]) - - let session = try XCTUnwrap(sessions.first(where: { $0.pid == agyPID })) - XCTAssertEqual(session.surface, .t3Orchestrated) - XCTAssertEqual(session.parentT3PID, t3RootPID) - } -} diff --git a/Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift b/Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift deleted file mode 100644 index 0f4e87b..0000000 --- a/Tests/LoomCoreTests/AgentSessionsSectionViewTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// AgentSessionsSectionViewTests.swift -// Loom -// -// Copyright © 2026 Deepak Sharma (ChaiWithCode). -// Licensed under the MIT License. See LICENSE for details. -// - -import XCTest -import SwiftUI -@testable import LoomCore - -@MainActor -final class AgentSessionsSectionViewTests: XCTestCase { - - func testAgentSessionsSectionViewInstantiation() { - let coordinator = AgentMonitoringCoordinator() - let view = AgentSessionsSectionView(coordinator: coordinator) - XCTAssertNotNil(view) - XCTAssertNotNil(view.body) - } - - func testTokenFormatting() { - XCTAssertEqual(AgentSessionsSectionView.formatTokens(0), "0") - XCTAssertEqual(AgentSessionsSectionView.formatTokens(999), "999") - XCTAssertEqual(AgentSessionsSectionView.formatTokens(1_000), "1.0K") - XCTAssertEqual(AgentSessionsSectionView.formatTokens(47_100), "47.1K") - XCTAssertEqual(AgentSessionsSectionView.formatTokens(1_200_000), "1.2M") - XCTAssertEqual(AgentSessionsSectionView.formatTokens(2_500_000), "2.5M") - } -} From b10ce9ff4bc8ee058edef3ab35b82835288292ce Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:37:30 +0000 Subject: [PATCH 05/11] feat: add T3 Code icon asset and auto-enable migration in UserDefaults --- Loom.xcodeproj/project.pbxproj | 4 +++ Loom/Core/Models/LoomSettings.swift | 13 ++++++++-- Loom/Core/Models/ProviderPresentation.swift | 2 +- Loom/UI/Settings/SettingsView.swift | 4 +-- Package.swift | 4 +-- .../provider-t3code.imageset/Contents.json | 23 ++++++++++++++++++ .../provider-t3code.imageset/icon_32x32.png | Bin 0 -> 2046 bytes .../icon_32x32@2x.png | Bin 0 -> 3815 bytes .../icon_32x32@3x.png | Bin 0 -> 6394 bytes Resources/provider-t3code.png | Bin 0 -> 32056 bytes .../ProviderPresentationTests.swift | 17 ++++++++++++- Tests/LoomCoreTests/SettingsTests.swift | 19 +++++++++++++++ 12 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 Resources/Assets.xcassets/provider-t3code.imageset/Contents.json create mode 100644 Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32.png create mode 100644 Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32@2x.png create mode 100644 Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32@3x.png create mode 100644 Resources/provider-t3code.png diff --git a/Loom.xcodeproj/project.pbxproj b/Loom.xcodeproj/project.pbxproj index 2cbbe96..ca3f260 100644 --- a/Loom.xcodeproj/project.pbxproj +++ b/Loom.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ A00000000000000000000192 /* provider-codex.png in Resources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000191 /* provider-codex.png */; }; A00000000000000000000194 /* provider-claude.pdf in Resources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000193 /* provider-claude.pdf */; }; A00000000000000000000196 /* provider-antigravity.png in Resources */ = {isa = PBXBuildFile; fileRef = A00000000000000000000195 /* provider-antigravity.png */; }; + D00000000000000000000198 /* provider-t3code.png in Resources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000197 /* provider-t3code.png */; }; D00000000000000000000003 /* NotificationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000002 /* NotificationCoordinator.swift */; }; D00000000000000000000005 /* StatusItemController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000004 /* StatusItemController.swift */; }; D00000000000000000000007 /* QuotaThresholdEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00000000000000000000006 /* QuotaThresholdEvaluator.swift */; }; @@ -278,6 +279,7 @@ A00000000000000000000191 /* provider-codex.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = provider-codex.png; sourceTree = ""; }; A00000000000000000000193 /* provider-claude.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = provider-claude.pdf; sourceTree = ""; }; A00000000000000000000195 /* provider-antigravity.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = provider-antigravity.png; sourceTree = ""; }; + D00000000000000000000197 /* provider-t3code.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = provider-t3code.png; sourceTree = ""; }; D00000000000000000000002 /* NotificationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCoordinator.swift; sourceTree = ""; }; D00000000000000000000004 /* StatusItemController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusItemController.swift; sourceTree = ""; }; D00000000000000000000006 /* QuotaThresholdEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaThresholdEvaluator.swift; sourceTree = ""; }; @@ -543,6 +545,7 @@ A00000000000000000000191 /* provider-codex.png */, A00000000000000000000193 /* provider-claude.pdf */, A00000000000000000000195 /* provider-antigravity.png */, + D00000000000000000000197 /* provider-t3code.png */, ); path = Resources; sourceTree = ""; @@ -814,6 +817,7 @@ A00000000000000000000192 /* provider-codex.png in Resources */, A00000000000000000000194 /* provider-claude.pdf in Resources */, A00000000000000000000196 /* provider-antigravity.png in Resources */, + D00000000000000000000198 /* provider-t3code.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loom/Core/Models/LoomSettings.swift b/Loom/Core/Models/LoomSettings.swift index 1466faa..9611a0e 100644 --- a/Loom/Core/Models/LoomSettings.swift +++ b/Loom/Core/Models/LoomSettings.swift @@ -157,7 +157,9 @@ public final class LoomSettings { public nonisolated static let defaultShowInDock = false public nonisolated static let defaultRefreshIntervalSeconds = 60 public nonisolated static let supportedRefreshIntervals = [30, 60, 120, 300] - public nonisolated static let defaultProviderIDs: Set = [.t3Code, .codex, .claude, .antigravity] + public nonisolated static let didMigrateT3CodeProviderKey = "com.loom.didMigrateT3CodeProvider_v1" + public nonisolated static let orderedProviderIDs: [ProviderID] = [.t3Code, .codex, .claude, .antigravity] + public nonisolated static let defaultProviderIDs: Set = Set(orderedProviderIDs) public nonisolated static let defaultProviderID: ProviderID = .codex public nonisolated static let defaultIncludeRecentSessions = true public nonisolated static let defaultHideFinishedAfter: HideFinishedInterval = .never @@ -468,10 +470,17 @@ public final class LoomSettings { if validIDs.isEmpty { self.enabledProviderIDs = Self.defaultProviderIDs } else { - self.enabledProviderIDs = validIDs + var migrated = validIDs + if !defaults.bool(forKey: Self.didMigrateT3CodeProviderKey) { + migrated.insert(.t3Code) + defaults.set(true, forKey: Self.didMigrateT3CodeProviderKey) + defaults.set(Array(migrated.map(\.rawValue)).sorted(), forKey: Self.enabledProviderIDsKey) + } + self.enabledProviderIDs = migrated } } } else { + defaults.set(true, forKey: Self.didMigrateT3CodeProviderKey) self.enabledProviderIDs = Self.defaultProviderIDs } diff --git a/Loom/Core/Models/ProviderPresentation.swift b/Loom/Core/Models/ProviderPresentation.swift index b6b290f..cfd1609 100644 --- a/Loom/Core/Models/ProviderPresentation.swift +++ b/Loom/Core/Models/ProviderPresentation.swift @@ -41,7 +41,7 @@ public struct ProviderPresentation: Sendable, Equatable, Identifiable { providerID: .t3Code, displayName: "T3 Code", iconAssetName: "provider-t3code", - iconRenderingMode: .template + iconRenderingMode: .original ) case "codex": return ProviderPresentation( diff --git a/Loom/UI/Settings/SettingsView.swift b/Loom/UI/Settings/SettingsView.swift index 9f4fce6..1cd615f 100644 --- a/Loom/UI/Settings/SettingsView.swift +++ b/Loom/UI/Settings/SettingsView.swift @@ -258,7 +258,7 @@ private struct ProvidersSettingsTab: View { let focusedProviderID: ProviderID? private var supportedProviders: [ProviderRowItem] { - let supported = LoomSettings.defaultProviderIDs.sorted(by: { $0.rawValue < $1.rawValue }) + let supported = LoomSettings.orderedProviderIDs return supported.map { id in let snapshot = store?.providerSnapshots.first(where: { $0.providerID == id }) let displayName = snapshot?.displayName ?? id.displayName @@ -273,7 +273,7 @@ private struct ProvidersSettingsTab: View { Text("Default Provider") Spacer(minLength: 16) Picker("Default Provider", selection: $settings.defaultProviderID) { - ForEach(LoomSettings.defaultProviderIDs.sorted(by: { $0.rawValue < $1.rawValue })) { id in + ForEach(LoomSettings.orderedProviderIDs) { id in Text(id.displayName).tag(id) } } diff --git a/Package.swift b/Package.swift index 3fd1bbe..afad8fb 100644 --- a/Package.swift +++ b/Package.swift @@ -25,7 +25,7 @@ let package = Package( dependencies: [], path: "Loom", exclude: ["App", "UI/MenuBar", "UI/SessionDetail", "UI/Settings", "Update"], - sources: ["Core", "Providers", "UI/Components"] + sources: ["Core", "Providers"] ), .executableTarget( name: "Loom", @@ -34,7 +34,7 @@ let package = Package( .product(name: "Sparkle", package: "Sparkle") ], path: "Loom", - exclude: ["Core", "Providers", "UI/Components"], + exclude: ["Core", "Providers"], sources: ["App", "UI/MenuBar", "UI/SessionDetail", "UI/Settings", "Update"] ), .testTarget( diff --git a/Resources/Assets.xcassets/provider-t3code.imageset/Contents.json b/Resources/Assets.xcassets/provider-t3code.imageset/Contents.json new file mode 100644 index 0000000..25c7634 --- /dev/null +++ b/Resources/Assets.xcassets/provider-t3code.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "icon_32x32.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "icon_32x32@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "icon_32x32@3x.png", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32.png b/Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b147a9650f832ca484357dc91d4f87dc300d8fff GIT binary patch literal 2046 zcmZ`)2~-nl67Fyxf{e10A%Heff)F7gB1cG2gm8#~a15d>kN}YZLc*N@Lb#DZZ~-|a zfFO4qU{M)vkVAI31rdd(C@80k96I>qcx*=<_3hj3_o}P9zOSnOs{ilqWQw)9j1)o& z000?F3sXBtg94EdgYH4$;qM_O;$=)W27tP2(mOO!0q9|2M+Sg!6aYlW1HcyKie3bO z5G(*JQvm><3jlEc{AODM1ensDEH9DCfCgks0I(!)Km@X2kRTud0O2ASAPi}kz*i*n z6$Oigzu5xfZ;9^jAli_(gHx~*`5fMZ<)=mSWVzF|IDY;D0zly4A=8f@OanQ73}z6X zLqvXJ;2~S!#vs8@lwe;X(uqs~O;~|+P*)49g+&@kfgnf-^t^<(Gd24X4m}Z(zXS*S z<1rXEo2|t@p~VXH!e|>97+|nC3=XFWF*Jk1n87rTCNt>B=OlmSF{KB21bX`id$X9J zATQ0G6%tHDA_axMUZ2-V=Xigs#0>h<7Std{fMB$>u$Zs8p-_Uri#K60Sb_HbG!MFw zHsKTTd)K!rpV<^|4xQm->g`8o20<$_(#GO+zC`^u^glx{{QuDJpf8~WjG*D~2Jm^h zpS;j`jid;euUlm#)wyC=1puN{OH*S9C&~KrWDDmD@$URL6kF)w zL{oTYXlLs*e(97dsCuY{$C7?3$Qt`oUav~pMY@Z+c8xDtjXu%$>U+-B6HoIVxJ&xx&G+^9BltBnQTjxp z@~@hRxU#ac*tE2?^*eWPfkPGzlZxf#kfHa0dB;q4b0l8fK| z_vGDBsfKkgF1KTQw8Z=ri@{i9nJEs5*-vckMqviG^Fs&ye0(-e`}EqdvePp&U&W{g zGQ5WKKfYam^{);lli6$FY4#YHmva;$@6aMYymc9>u(@f}!YEfY|9E(*`EOITqu{en ziib&V<)Jcz3Gq6u&=!|LT8_^V?^H@T20W#&)6j9` z>Y7h#Br~$d$Hy7mes|9vt)a23tK+}*-n)v=#~mzHHe`p2NSgWh z`tGq0@3$l?q$2K5Gi8L6in9|EL<3r${}C>3i+4dNDn6L;uY5#|*sN&f?&(b&r!FluTjl&7Dc=I_HWWTy&yb^3A4QM!aRnG7qb5iW)H z_eO^7&g=wpy0vO`d)H_6-2!(hS>Z?2_`twGNNR%W&m|tVhGIhjN~x_iotP-@+G)nq z)%C%FRK{a*;9?z=z z8uXbGMwNhP@%vX8vc;bFdjHO5H{ca^<07`Vwmhj+a9&zWxkDRRvNn?`c04ARcsK9H z(KL1WCQ=rwhc6F@Tb)0jBlWnet4lx7EFs%kxD*xryp2d7c?C3b_u7m2{6__+xULtS zoki(Rl-<&@GE!n@259dY8Ha4`}p54_42!|%>EqM9)*E7{@`pc|x zJR>5UmzS5Xs!j-Hg;X2sJK^K!}BlUg@5%9X+4VU7B`h&!)hOU7*;4vMCvq)34#lg}h%36Y5S z(pCvOG<4`su3F(ylqtQ@Ud!Z?%yD4Hy<19!g@toxY#QF7t2-^PqK3}XT%?;rlydz7|#Gj-DGsd%j1n)P zc=JQL!^@@~FMHHUSp6F~##QOCfE6Yr$Gs1(`~ zI=~zWz}4j%+Upp0_b&o-CK+K8pyES5oZU}qZC*g5(H)$^J#pqDl`CCHOcMMmElJj< JHD{=?{{bWSRe}Hj literal 0 HcmV?d00001 diff --git a/Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32@2x.png b/Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9eaf755da190b58e75904f22e2e8a100a867d622 GIT binary patch literal 3815 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 RmiP3Z5OdK8Ri*6|^&en>nkoPQ literal 0 HcmV?d00001 diff --git a/Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32@3x.png b/Resources/Assets.xcassets/provider-t3code.imageset/icon_32x32@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..102bb1872fcdbf3a8257a0fc965b5cb496bb0ddd GIT binary patch literal 6394 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

l3GSjzSKHZ% z1F~(T><#H4-|UU515%5kLN`Dg|QsZ_RQDnYRATOrPgpy7_PAe zTjSm4>KxACqi|gARd5A|cntGyHCdz|d-mwZ%z(SAW%Cz1D5^;~z*mUw55i=Wow4y| zDnO$Hj9`Zs&|^$wjBj*GaFPzC#OQF!lAQX(;7R=fw{eUr9a8#lrLpDZcxVnm)p?zX zM*2CBkNCe-#E!dT%a{wRt@p!z4Lr*Acp-bTN(E4q?nj7_Oa%;(g8^qM)|qu8bx_-- znYxA4ri(mmLTHW-YwmKKt%M#35ZDI!d6T^WR2-t%3IAuWXH+w~FNJ+`7!4qC># zug{_F%Z-w^NU?`r(fLw^1175-F7*_7mcm@O^%va67SkamGIRy$ML-5rZNR(B>KAR5 z#m_W+oGBBZY{FbKq)96w60897s^CQ(S?RAay+>r+ZB@Br8qwu72))9B60PUHAKX>d z?vj6^r2RUGMDap{M}DX4iyY@^STB#l?e$qD?Ctznzvw+s9LRFBjAgml5+KkkpCPsy z#>jE>fKrn7KZm~|3RJ7WjIvki%GrOE7CSznLqT(l` z6s5?O{Jva^ae(%9Y4U-&=sG_whu%LPXOZY!)=a}%7SHgHz8%JBm08-WNqaol(@3+@ z-(xal#E<{&Ei=c~B~8z4t=&*4t-lZlLO=H!&9C_>byQu5Wn?D=}8rBVB8?>##B#>uJs!ps#{Zh z(_8=aYXigb_x89plf=*eL@{-2F}HADH1l|vbA&NdA%=9l!p z9htkYL(rk>4G?;QwQ#BJ?-pOd^V+jmfYM6<7fT$i`BMyY(QuS6f29&&4?3m3tpGiZe~Bx9e+tAx~Pc51iwv8jhy&0 ze!c%;j6ustG+N*7TC^k$aA$tJ+B0yY@z@&x5lY_tl(bIpZoC(Dx`8_F+dxyP7A=_B{%UI8PJ1kaEK$;q+VML^qeu-^O4dPU7mQZY@x$skV$Erf~Tiv9G{Sj^SstOifS``R+_N+hGVnqM>n=|u!-&d=ltfq5)Ia)xk}O6(&Z4$ zjz@{U>0AI;(tR)>tko>?Wg3|W7A`}Ev&})`WwoeS69HH(+GjQ&DeGeH=Rx2iY9Fei zrD@exSfdW?>CAH}ya`GbJS&GmC)%LGPCNVkcw?6P2tGu+{J@Nuyt0ikfmTaR_zj4w z<>$A)2oiQnyfE7EW0r`@>J7UMyY;J~C;nw4%nA*U(b3FE5Ugl02Q~>}(q-M918vWo zE^)=D)Yj-{zV$4{DoLYFrXq`2Qdv^l#Mn{4=J*9?S=+TJ*-H3JpxW%`!IF~NflEY` za1C9BqcT;fEmNi~`s>?cL!q7(i~Odo l*;-3|H06sMZhgN4$ob#K^fRz~{P|6w@KRZ}T*f5ue*k=-=Z63Q literal 0 HcmV?d00001 diff --git a/Resources/provider-t3code.png b/Resources/provider-t3code.png new file mode 100644 index 0000000000000000000000000000000000000000..0276071a233de8ec83e417edefa5af3e038839e6 GIT binary patch literal 32056 zcmZ^J1yo$kvhEBPg1fr}2=30{gy0g~3BeiMVUQrfJrLYOa0nLM2@qU@ySooCFpvM7 z^X|Rxt@mcFS-W?4RoCv`wQE&gr7LJ30Px@f01nLn0FiV6fY>?f zmxkEOfUK3C;wKdqz}uHLDgYQ^3qX2l{bPgdMK^8ZnPxf7?e zad&qX;pFu4^5XE~bam7Arj zt+Tr=*oo$!dCe`r9`53FbpIIouiw9))5_cSzb!er{byM(3*`J~gp-?ti}Szceo+;QJvayGZLlHeBmm*D^G`fn@$mR7g*wsO#uwRN;|a(j6q32rVPzW-?b-$VaT zRsH|J>i-z}kE$5wKMVdJ8}RSl{a5eH_DZ0MasJngUB(o=r`X{-E_(WU%Qi&mEZkczw9?=%(}HIhD&P7 zX6kRqMoKEI^Ro)NuK*HZ;Zdd%^L8FNK0cnSt*!mT%gdXLruJui+>}R9(5tM<@jWlU zpg>b?Iy;9hls4of6;fFxUX;PPD>yd(C!VDvUH4=D+I`D9YmY_n!Bl*4?N6e4bk$4Y zI_7woFuI&O|_9hfHJGU^2I9xi7HQOJf^d` zC-aNP9305Hz3JoP>+UN%_twDWU)%u5tu>_a=K5e7J27dEd8bYBxv;;FoS;Lq{r+^J zWkJQ#a;txtZCKQDxEvZ%uD!cp;Uw>@Wfz8fTeN|_zM zg0AzxP?YuSj4|b)j?W}7HDmY=LAoWUk&SoRP@>0bfu(Z;P!RG87UWTKf_ZnneI;N( zI>*P!nr3_O4*yW)z=1)t1UW-2c13?7*re9MU(INf!t9hq%QS3*B0R;|*t+VEsb4QKWh+vs2+V|Mm;xMicB|p(9 z_WP4?)6iL=FgeC^CBmI1J zc{Y7QybVuB41^BxqXqTBKNMsUBdS6T{g;Svv^R2KHTb;xfq zc2VuvRJrE{SSWKB9!c*Xu(k(~^ym_p79#3;>N;z=$H?FqwZ@y1V>Z`>$h0K^kjz!@ zxFr`!uq1rkzG{A)oaMv%{;@pus7B0KKRuP&iG3yA>3H=C;2Ne9iCiJA$?mR~Mls)J zR413!zOQYkE^<1;!%k80DMx}c8OnXhoUow#1MTic50U*;K`yq~;D?=smh@qYd5u6E zp;vr-EJS((dsO95!0|q!!kNBN@knm@V%<)4Cpflg#dB##^VC`?!<4P~S%BrjiW&Ah znaTKIJe!pd54HX2z6dr6FiP)6g{p%{gEAH@KB>z1lL9mGaO5Yq!kjZVA5m%I5X?N= zTrDN(E&MxCXeUa*iI;Cqo?K_wr4Y}THS~UcNi>G>7-w|)X)0B27H)bd+RobFPy36E zc373u*rd5v*mvs`$;kNXj@pHPPvI6Q{^Cy5b%Dpw*JAo+zl{?=t<316jA>3-Ktg8- z(DzvQ3*P+qVyvvJ3|s8H3o(LDwS01Xso}@D-+b%pjS%LB>D!%v=j*Tod(~WF<1C@T z0wIsXlp81skRt`YEFAvegj7O$8?g!^3nnEEM(05oz4?Qhe)$y*JZz7K{o3d^J3-N3 zB##dorE;Il&}1naF{Gr&hKv`B=OsaAI99h8u%)=NJl zssU7jsfUjkZ{7?^uos?76+gmnzTbzwU8P>CKzMH`stcqFBY?MP!oDii=DO&h1wzkp z$#+-$>4J1_w9lEc)T)W}(1d(;fi;_8~5JTw5btV~qd%{LKCNJYF`@B2{o%i3p+ zH2jZlxG`2AGl8h|KzSq=Z7vCkTioJ%Leh{WU4$p!?A0XSW1dxee)#Ld1IX=lnbx={ zEbr7cF$f-^rpzU$xEHFR(w0idD{OJ`IfkHeL zOB}VR3595&Q6Lw>XODcC*lOk^!m6Fi(d?T(Pq|LjGZN~j{+^@z&44CX1?2c0w&>~s zJ+*3<;P58_{u*0JZwtwEbeTMN(;Vg~T`D1esQ5^Tq0VbQQ-Ql+Y=5&--+R-fP59c2 zCCcdbj-I(kUW7>Ec2F;@I~+PP_qnmagkZn4yZy4tGMzL;h#fVG8dLIsmyX2ms^s7R z;*YaN6cPP-y981ZVqvi2Gc7z5OnpJ zltG`0-U{R|2?Zv=RxW$O1>g@kh-}ypmG}1Ta6JlS*<4#G?!dji@GsQUDM7~JN~ajr z#Z_{wFc3rW^M{+$rMnZq8ljjT`&9T)V(+i>mN>iMv!at;Q#=2~OG7mG;x3-Qi1JAI zmShiVHfZYPxStz>x5!A_l26asl>D?#JszF1AXD% zlv6+87h8i4uht?LXNaaX&1SU#^k!O!HxI@!fL3%W1@JR zeWd=|X*%Dr*&XmW3aBX@HwKB$^!T~iHcXca-nQq5rpKEwCUg=9$OF%GA)G?s2<`bi zR|o$0&HD3OP2ja>;7P<6wCVCL|?Yajo&#j-2lh0buB(&Up{i+aO zHQz412R&Z?36^Sjnk_K&R?00-iU094_SOklH*zYy#$1#rnMG8?{GC7K_ix1#^xT=7 zb}~5-*Lwy9JHWL+ob2TAu@Ik0P2#k}?+o)jM6yF3K!$81-X(BXaYGA7fG!S|M~g$~ zH5T6$N@k&a?w4F*GLtuxmN)Z1=AFtWwiD^{L85n|&qSEGvM+|m^It=j+Yj##4oEML zT;yvZYtI(B;m;0-wkskfUGG|xJ(NqSR0`cHu)QrJXc3)fq0qi2@Jii|T0@Nlo)y^_ zpxGGb&NKbV8f5cd({KZ>A4mWxxlfOO;&yM6vP`(%Q^-Ak8WrB2gD|X?+F0d-AE2#qLp(jC$fJw2jTGADTHkxWe8}=)`O9mCy2^GfFr#5K z^rTK7v?q2qU@GBtyBxpj7mxku*g?Jbbh(rRp{>yCw2e69drYkfwB2K$=5{TE>nf8F z*|1(%HaaYb6kqY4>aigcIRaMi$Go1`U-DX0KR6qWzQg;al4JYT zO=JbQZa%xvJYSnHZ_B*3*NU&cwO<9hJuilshC9T`MyUo|5>cFtzDi%S&L0H2PX@Idk1o`A{)ra9 zhB=&BWu67sKui0(-cq)boDpOr%sL!65%#e9+jmumF`eF=wkgg@cCy_~^|GeMLO=C6 zZh8BzChaiTgC-X{Rf~l>oNtzVd7Kxd4QtF0^9BQ&azK8ky8(yy@%zP8ya3nMA@8|;4FnAS$)$X z8Q~5)5A`i3-f0qixOT-BeG+PKdN}4O2+`Jl=tD()ra3oA8$Mya)J3aUx$em>Vi&`$ z(6};uV0hp|AyUnK$$iw23vd(Qy{(=nzEYO_#&oX)0MEg3_23Cws;!6{D$v=rB>;;` z!shI%wEc;akd`nu30D&f;f$FtnndooqB+itb6Ih@NA#V}7d^}CgNZ@1=W-xbDMALY z3w3|<-KLQ)p z@0$GkqQGRoY;c@v{T%f9Jdni>*@U4<8Tl{s_V5{b7|E9rA=tU2 ze*^(Nf+@-K_gW7k@*GkCUXuu-qPmC_QSU1Szn0+j_F=$_v!jQMbdhJGG0+EbPkaZ+ zsE>+UkLQT5O(NG5tV{#GpT7?9I!k3z%cj_Cb{aeFwZ;q@ch5MIZhdjZvN5|xj`I}X z7kA`Y3Nl4kTRy5ZWU$XOP4he0NFA2%a(5z6-yRWC(X^j5~6vylm zUA;K9R#D4^{0OL08i(GiN01_r1`D@mIoJNO+O*fbtXh2d#}iHG!yAI+G`i%00x;_l z=X5+9N68Ddm3r&FdSAlDSpf6Z-|OoINMZFwTy6JW-)ojme+&Ti85^Zh^|cSRrf8knfMU8NNOJRM0)N6USCWwuix-oJxUFaPtW(Z zJ2N?=S8G-Hc8={&=f7>Zt<)6K8&C*Njg{_SuD_OD&ny>XJ^Mu#ba3DXVn(dN-HAsD zVgckH*%>PZdng5e>+t&NM%a7sb<3URp$Agf1Nu7|hcjsdIBnjFGu|6h?Tj1@5ZeR& z_L%CNiB7eB(F#+~E1a&dI>kQwTA;Ho=Xb=3{^v+Bh#h6oH>h&9eLqQwV_{Y{O&&21 zQ(`>V?P5IoXfTf`N2{E-t>b{#sC3~JaX;m&SjihvIsq*JHP8%| z&3|@gW~TnihP1QH3 z`bJa^d8SyWWtj{V0+8Z5+%=5oCAzE39>4KiA`~lx4#&N*?D-kPzvckGGf24a1LgI@ zLrRl8@E5_HQrxMR@uz2mORpIf+04#F zHkP_C5(3-vagukvB7ngNaV9JD;3(jF3_K$v!%#Ix%nAjtvnS$JHQ(~=+@5%qAGH%S zpP9OL*Awf`E>@~z4W%noi;$RI?37E%9&3PsiN}ch zngWwY&Hg{9UUF3kFQ75{?o&2tGovO$EPx}c!#TS{*b`IcW#ALi7~vu=$+Qlv=#oVp z_*Kb1`g7^juWeIBZ@Ew8=k^<4L{G@rekc5k|x%uY^y&K37fc_1A@V{$DOJJyjaYkQ#|RRZ8!}Uf{VzoBOm* zxZD$q3GcO$)L6tmPfuqf1!QyhT59``J#0;`g`xlHg{Ln(eboOkdEg|xURD?0WpKu6 z2qRDbCKsD7L5Zfr3eocpZ+T_EgN~t`scB}cW!CrbSE`(l8AJ-CwcNO`B`5efwibB(3}<|3?n)InZpz7Bg-h?8 zbjF4M#AQcLi$!Q~a$Gnxek9Cjuf2z82lkJSoBAxsaJGJ)%K%PfLi}cy&f?l7#Nnf( zVN{Rsc?okJUG#_7P=g3;rIWyw-I|^1Ef~hrte9_L)=F&bNsgFsON8G`ssXf#zkRLt z=li_ZPVOXXR|l8$H~Fu!Jjg$OYr3PcVdQ9f{qF2o7e`uN?3~;9VwfEq9nzWoN0nP? zZFin)_@RUIoS zaJIKi9V2=4jb&okh}YP{0rb%4UfC%|5D7ci2%0a$?>#~9kw8w*?R1j&1AHoimcaZS zwZDo!)W6Rq+AM4$TgUzSrjz)z2)v=9&AMgqkn+<8jXgnv^0z&1!xGm0@hh+-E+US? zl@rEp1ETM(NYVg*(1?bA2VYeD@uuDpryQKuOZ@@+Rjvk>xAm zDL)(hzAX0uziM*9#H=Cp`}FfjEi?GoLOf3#F)!4^QC2CKUkR8AS2*TJ8p=i3PY7zE z6u8!{j^E@ZjC2c6FTF8D`$?dOP4Mm!!Sg#m{?-dDJSbp;-8C=;@ID(44%z*-OMpLIlPbt+ip@(Z!X5D(Wu)JV2Cg;GverN4X_OqFSU=BXrV>B=@R70}- z)N^=lzT>t5nWN@zx|*y_reW&iD$Wk7tREd02t*5Ub@lYLcprSBKZa?2`V!a+`bjE$ z+O^uRi$UsQNBV2#))?wOM>?;N1)P@k{Gq+n941ePx?XbohviiqtLLnpn#wHfT77fu zaR|xe0tBWHo5az%&@T`zqlwJMXwRpa($_itR=Z~Y78VwEnrJ?+QE1+F5FruU$?1z)ivClLUm6se-3M4k(UOR_}{HTAoFsodan_9g}F7B-KSOhOH?u)m@|HB zDbGEKGY7SuEj3j`r$Enznw1PUZ$0YKp-GB?Bspd)QZuCpg$>s!(Ksi6y9)>BDpAm0 zaJQa&$G!gBfygoQrA9~38`TBrOy;g80p+Hdma3vc`mt>1U!PSZm-Kpj0o(GDBz^`W z2Pj_)wDM>*h>(E>O(M))wP$-{`=Oi`Uv59&$f1p;o`qkltiBmy?)q)uZ@z>Pw5d>q z{PdT2UFEPe4nmF|l&Or9(vuKFAN)6xgXG1fB1y?$24v+5H{6#tE0>x8`gnDopL?97 z4}mrx@n$}bn!9j1aqzcMKd=HLR0C_Oj)=DBzQJrXf@|81LP`NP>B(%$Dr2#K9DnA3 zm?hK@zL6}K#|75tnde~bfH5Ui6N|BXb-iPG0~&MX=Fs7F6+S}Is@=umrBOPHT{e`B zGfX^6xTE`3d~rb9gSDAvt|@aGvS-$*fE=Xlx9CT{wbj&FbGth}M+%M~+h%T?h>_Pt zvz!<45Bulair&HvR(F0LP@uFgRoGaiwmp&`Em2yMpBa`M#a zut&Pfv)3I9EaeddRZobUY}ZfI_!3S0W{y6HIn^KTN8uxrF)b8+IvK``Sv(Rj{BA5- zwE)k#euhZu*l;3trWM9n*$jLuNB9-N`rbBPfR7CIvY`qaS(d&KtXMwjRS}^6Jy=PZ zf=fc@m~>tuL22QUS{H7~nRw}$QTGy&-U*W--V9X#lyi`E%~tp&#)Gy_Hagn7nx-3# zk5R~b&Fecq3wE*c+&(OY08P%GDzj>9m4xve=uzo#^Zo`q5VvW$qq*nl#D1q2vFWg5 zIaxtv2DEA%No6?_XihDorng}v-P5V!+Y&r6(#wGaP`t-LRL@XLs6}D+Ym(GJ#l%Y5 zL+R#^A8pZ{ih}yOP^q5=WMdO4Z(FU*=(8{1&9k0qUqK09E1)bsW8ON~U|ah;P_&N{ zb|zPE$FyXspCQ}7r}j(!CTF9%I1rbBP%O!jH$j8bVzhl(7nH>DqiAB};B#&425v&0 z%!&w|U8+!)>CsIgwZNtdnP-&y2A7*UImNBkOyD`0#By8`4Q4;5m9{q0B>0c;p!oS; zZ?1Rv-k%Lsytqk&+D_0ngpcfmn2ckCKDc7}=(3WxRLJ&cnxWdrss~~qR3m0y?&+~b zIE3mfr`dV17IbVLJGumg*+=sCum~0LHoHtTRT|v$H;;$eA@B7K^?Zztvv@^_N~AYg zAO>M$k|(RWrGFQvIy8gS)GW0@>+&1SBVduDekExwVgYrh_~~XHW^Eqgv!L`kUa0TE zcP;b^3jJJ_&XKn=X!y>qqXszumh}f3#-80>ji?vyS(~wfc8H7}%f&{ZiV{@!L#JJr zWyS84GWY8{<6Fuejx}lqN6wIVBr3FER1uQ=^sKDo-!$%jjWN8()7@!8c&QQhs0}Iu zZG9M^);BYu0VDA$->r{bsO!x}*NywV?}nNdC7l2R(U>B6mafv^UN2m}t)}j_9T)DO z#GSURt#+k1jf@hU#wu#2%wt)?h0{SL=qBBbV`gEIn^%F;{#t>&fOWi7(1URq4?>^y z4W{&J{4PDi48CoaFN7I8wQwMj=Dq0;P_%M4L5-_c$xAU!D5OLB$;ENGrZzo2>s}m`F%( zjwwWys^%nVu&Vt$_^7o`oe=}39CdrHLN#iK_vzKTxLR_HgN#SP>4GxrC*{u~D`BGh z3V7Pd?#ra(rTte$FHDI(3p`&YB8n5T!`h!2c%`x+C@5OTX^12i`&9+#JGacBG@S4f zsr^_I!!csxs6n67xLLb-Q4HKH`LTzi63d(U$Lq5<6&4iL4OHTuqevnHa%+cbvctGvSaeneOwl9#%-Eu_Rk_wzHb zl$Et3c2jRBDBMg!_<1L|q|Y@T4s35=#4}gXd*``vSUZsQ?pM+C?YDEARjsGP^)YXj zdX?^kO@PnzVSx67G4<3ZdE1`_UhqQNpuGreYL4rb z*v;6^Tl@aC=aV%nVR;ga@1E@3p`z8c0G@KCBbskWsl&j;X6(9^hS4wt8dxH|Lewkj z

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 N3bLPMeoL7K|9^h}TYdll literal 0 HcmV?d00001 diff --git a/Tests/LoomCoreTests/ProviderPresentationTests.swift b/Tests/LoomCoreTests/ProviderPresentationTests.swift index 60b9489..5fdc7d6 100644 --- a/Tests/LoomCoreTests/ProviderPresentationTests.swift +++ b/Tests/LoomCoreTests/ProviderPresentationTests.swift @@ -11,6 +11,14 @@ import AppKit @testable import LoomCore final class ProviderPresentationTests: XCTestCase { + func testT3CodePresentation() { + let presentation = ProviderPresentation.presentation(for: .t3Code) + XCTAssertEqual(presentation.id, .t3Code) + XCTAssertEqual(presentation.displayName, "T3 Code") + XCTAssertEqual(presentation.iconAssetName, "provider-t3code") + XCTAssertEqual(presentation.iconRenderingMode, .original) + } + func testCodexPresentation() { let presentation = ProviderPresentation.presentation(for: .codex) XCTAssertEqual(presentation.id, .codex) @@ -46,11 +54,14 @@ final class ProviderPresentationTests: XCTestCase { func testProviderIDNormalizationAndAliases() { // Casing and whitespace + XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID(" T3Code ")).id, .t3Code) XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID(" Codex ")).id, .codex) XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID("CLAUDE")).id, .claude) XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID("AntiGravity")).id, .antigravity) // Aliases + XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID("t3-code")).id, .t3Code) + XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID("t3")).id, .t3Code) XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID("claude-code")).id, .claude) XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID("claudecode")).id, .claude) XCTAssertEqual(ProviderPresentation.presentation(for: ProviderID("gemini-antigravity")).id, .antigravity) @@ -58,7 +69,7 @@ final class ProviderPresentationTests: XCTestCase { } func testKnownProviderAssetsCanBeResolvedAndDecoded() { - let knownProviders: [ProviderID] = [.codex, .claude, .antigravity] + let knownProviders: [ProviderID] = [.t3Code, .codex, .claude, .antigravity] for providerID in knownProviders { let presentation = ProviderPresentation.presentation(for: providerID) @@ -96,6 +107,10 @@ final class ProviderPresentationTests: XCTestCase { // 5. Provider-specific assertions switch providerID { + case .t3Code: + XCTAssertEqual(presentation.iconRenderingMode, .original) + XCTAssertEqual(resolvedURL?.pathExtension, "png") + XCTAssertGreaterThanOrEqual(img.representations.first?.pixelsWide ?? 0, 256) case .codex: XCTAssertEqual(presentation.iconRenderingMode, .original) XCTAssertEqual(img.size.width, 104.0) diff --git a/Tests/LoomCoreTests/SettingsTests.swift b/Tests/LoomCoreTests/SettingsTests.swift index a876e67..cdce249 100644 --- a/Tests/LoomCoreTests/SettingsTests.swift +++ b/Tests/LoomCoreTests/SettingsTests.swift @@ -375,6 +375,25 @@ final class SettingsTests: XCTestCase { XCTAssertEqual(reloadedMixed.enabledProviderIDs, [.codex], "Mixed list must keep valid providers") } + @MainActor + func testT3CodeProviderMigrationAutoEnablesForExistingUserDefaults() { + // Simulate an existing installation that saved [.codex, .claude, .antigravity] before .t3Code existed + testDefaults.set(["codex", "claude", "antigravity"], forKey: LoomSettings.enabledProviderIDsKey) + testDefaults.set(false, forKey: LoomSettings.didMigrateT3CodeProviderKey) + + let settings = LoomSettings(defaults: testDefaults) + XCTAssertTrue(settings.enabledProviderIDs.contains(.t3Code), "Migration must auto-enable .t3Code for existing installations") + XCTAssertTrue(settings.enabledProviderIDs.contains(.codex)) + XCTAssertTrue(settings.enabledProviderIDs.contains(.claude)) + XCTAssertTrue(settings.enabledProviderIDs.contains(.antigravity)) + XCTAssertTrue(testDefaults.bool(forKey: LoomSettings.didMigrateT3CodeProviderKey)) + + // Subsequent launches should retain user modifications (e.g. user manually disables .t3Code) + settings.enabledProviderIDs = [.codex] + let reloaded = LoomSettings(defaults: testDefaults) + XCTAssertEqual(reloaded.enabledProviderIDs, [.codex], "After migration has run, user preference should be respected") + } + @MainActor func testDefaultProviderPersistenceAndSanitization() { let settings = LoomSettings(defaults: testDefaults) From 85feb06d423260c20ade96167a3b101d1dd44a6d Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:13:47 +0000 Subject: [PATCH 06/11] feat(menubar): add All provider pill, fix pill clipping, surface GitHub star banner, and add dev Makefile --- Loom/UI/MenuBar/MenuBarRootView.swift | 157 ++++++++++++++++++++++---- Makefile | 31 +++++ 2 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 Makefile diff --git a/Loom/UI/MenuBar/MenuBarRootView.swift b/Loom/UI/MenuBar/MenuBarRootView.swift index a9ffaa1..e90bf14 100644 --- a/Loom/UI/MenuBar/MenuBarRootView.swift +++ b/Loom/UI/MenuBar/MenuBarRootView.swift @@ -17,6 +17,7 @@ public struct MenuBarRootView: View { @State private var contentHeights: [MenuTab: CGFloat] = [.agents: 180, .usage: 180, .storage: 180] @State private var selectedTab: MenuTab @State private var selectedProviderID: ProviderID? + @State private var isAllProvidersSelected: Bool @State private var searchText = "" @State private var selectedFilter: SessionFilterCriterion = .all @State private var expandedSessionIds: Set @@ -40,11 +41,13 @@ public struct MenuBarRootView: View { self._selectedTab = State(initialValue: isUsageExpanded ? .usage : .agents) self._expandedSessionIds = State(initialValue: expandedSessionIds) self._selectedProviderID = State(initialValue: selectedProviderID) + self._isAllProvidersSelected = State(initialValue: selectedProviderID == nil) self._searchText = State(initialValue: initialSearchText) self._selectedFilter = State(initialValue: initialFilter) } private var resolvedSelectedProviderID: ProviderID? { + guard !isAllProvidersSelected else { return nil } let enabled = store.enabledSnapshots guard !enabled.isEmpty else { return nil } @@ -61,6 +64,22 @@ public struct MenuBarRootView: View { return enabled.first?.id } + private var allFilteredSessions: [AgentSession] { + store.enabledSnapshots.flatMap { filteredSessions(for: $0) } + } + + private var allFilterCounts: [SessionFilterCriterion: Int] { + let eligible = allFilteredSessions + return Dictionary(uniqueKeysWithValues: SessionFilterCriterion.allCases.map { criterion in + (criterion, SessionFilterEngine.filterSessions( + eligible, + query: searchText, + filter: criterion, + hideProjectPaths: settings?.hideProjectPaths ?? false + ).count) + }) + } + private func filteredSessions(for snapshot: ProviderSnapshot) -> [AgentSession] { if let settings { return snapshot.sessions.filter { settings.shouldInclude(session: $0) } @@ -110,7 +129,14 @@ public struct MenuBarRootView: View { if selectedTab == .agents && !store.enabledSnapshots.isEmpty { providerSelector .background(MenuHeightReader(part: "selector")) - if let snapshot = store.enabledSnapshots.first(where: { $0.id == resolvedSelectedProviderID }) { + if isAllProvidersSelected { + SessionFilterBar( + searchText: $searchText, + selectedFilter: $selectedFilter, + counts: allFilterCounts + ) + .background(MenuHeightReader(part: "filters")) + } else if let snapshot = store.enabledSnapshots.first(where: { $0.id == resolvedSelectedProviderID }) { SessionFilterBar( searchText: $searchText, selectedFilter: $selectedFilter, @@ -215,27 +241,21 @@ public struct MenuBarRootView: View { let snapshots = store.enabledSnapshots ViewThatFits(in: .horizontal) { HStack(spacing: 4) { + allProviderButton ForEach(snapshots) { snapshot in providerButton(for: snapshot) } } - .fixedSize(horizontal: true, vertical: false) - VStack(alignment: .leading, spacing: 3) { + ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 4) { - ForEach(Array(snapshots.prefix(2))) { snapshot in + allProviderButton + ForEach(snapshots) { snapshot in providerButton(for: snapshot) } } - if snapshots.count > 2 { - HStack(spacing: 4) { - ForEach(Array(snapshots.dropFirst(2))) { snapshot in - providerButton(for: snapshot) - } - } - } + .padding(.horizontal, 1) } - .fixedSize(horizontal: true, vertical: false) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) @@ -244,18 +264,46 @@ public struct MenuBarRootView: View { .accessibilityLabel("Provider selector") } + private var allProviderButton: some View { + let isSelected = isAllProvidersSelected + let sessionCount = allFilteredSessions.count + let needsAttention = store.enabledSnapshots.contains { snapshot in + snapshot.sessions.contains { $0.state == .waiting || $0.state == .failed } + } + + return ProviderFilterPill( + title: "All", + iconName: "square.grid.2x2", + providerID: nil, + sessionCount: sessionCount, + needsAttention: needsAttention, + isSelected: isSelected, + accessibilityId: "provider-tab-all" + ) { + withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { + isAllProvidersSelected = true + selectedProviderID = nil + } + } + } + private func providerButton(for snapshot: ProviderSnapshot) -> some View { - let isSelected = resolvedSelectedProviderID == snapshot.id + let isSelected = !isAllProvidersSelected && resolvedSelectedProviderID == snapshot.id let sessionCount = filteredSessions(for: snapshot).count let needsAttention = snapshot.sessions.contains { $0.state == .waiting || $0.state == .failed } + let displayName = snapshot.id == .claude ? "Claude" : snapshot.displayName return ProviderFilterPill( - snapshot: snapshot, + title: displayName, + iconName: nil, + providerID: snapshot.id, sessionCount: sessionCount, needsAttention: needsAttention, - isSelected: isSelected + isSelected: isSelected, + accessibilityId: "provider-tab-\(snapshot.id.rawValue)" ) { withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) { + isAllProvidersSelected = false selectedProviderID = snapshot.id } } @@ -274,7 +322,8 @@ public struct MenuBarRootView: View { private func scrollId(for tab: MenuTab) -> String { switch tab { case .agents: - return "scroll-agents-\(resolvedSelectedProviderID?.rawValue ?? "none")-\(selectedFilter.rawValue)-\(searchText.isEmpty ? "normal" : "search")" + let providerKey = isAllProvidersSelected ? "all" : (resolvedSelectedProviderID?.rawValue ?? "none") + return "scroll-agents-\(providerKey)-\(selectedFilter.rawValue)-\(searchText.isEmpty ? "normal" : "search")" case .usage: return "scroll-usage" case .storage: @@ -295,6 +344,23 @@ public struct MenuBarRootView: View { } else if tab == .usage { usageTabContent } + + if let settings, settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: !store.enabledSnapshots.isEmpty) { + GitHubStarInvitationCard( + onDismiss: { + withAnimation(.easeInOut(duration: 0.2)) { + settings.dismissGitHubStarPrompt() + } + }, + onStar: { + NSWorkspace.shared.open(LoomSettings.gitHubRepoURL) + withAnimation(.easeInOut(duration: 0.2)) { + settings.dismissGitHubStarPrompt() + } + } + ) + .padding(.top, 4) + } } @ViewBuilder @@ -331,7 +397,44 @@ public struct MenuBarRootView: View { @ViewBuilder private var agentsTabContent: some View { - if let selectedSnapshot = store.enabledSnapshots.first(where: { $0.id == resolvedSelectedProviderID }) { + if isAllProvidersSelected { + let matchingSnapshots = store.enabledSnapshots.compactMap { snapshot -> (ProviderSnapshot, [AgentSession])? in + let matches = matchingSessions(for: snapshot) + return matches.isEmpty ? nil : (snapshot, matches) + } + + if matchingSnapshots.isEmpty { + SessionFilterEmptyStateView( + query: searchText, + filter: selectedFilter, + onClearFilters: { + searchText = "" + selectedFilter = .all + } + ) + } else { + ForEach(matchingSnapshots, id: \.0.id) { snapshot, sessions in + ProviderSectionView( + snapshot: snapshot, + showsSessions: true, + showsUsage: false, + expandedSessionIds: expandedSessionIds, + settings: settings, + sessions: sessions, + searchText: searchText, + selectedFilter: selectedFilter, + onClearFilters: { + searchText = "" + selectedFilter = .all + }, + onSessionExpansionChange: { id, expanded in + if expanded { expandedSessionIds.insert(id) } + else { expandedSessionIds.remove(id) } + } + ) + } + } + } else if let selectedSnapshot = store.enabledSnapshots.first(where: { $0.id == resolvedSelectedProviderID }) { ProviderSectionView( snapshot: selectedSnapshot, showsSessions: true, @@ -534,19 +637,27 @@ private struct FloatingGlassSegmentedControl: View { } private struct ProviderFilterPill: View { - let snapshot: ProviderSnapshot + let title: String + var iconName: String? = nil + var providerID: ProviderID? = nil let sessionCount: Int let needsAttention: Bool let isSelected: Bool + let accessibilityId: String let action: () -> Void @State private var isHovered = false var body: some View { Button(action: action) { HStack(spacing: 4) { - ProviderIconView(providerID: snapshot.id, size: 12) + if let providerID { + ProviderIconView(providerID: providerID, size: 12) + } else if let iconName { + Image(systemName: iconName) + .font(.system(size: 11, weight: .medium)) + } - Text(snapshot.displayName) + Text(title) .font(.system(size: 11, weight: isSelected ? .semibold : .medium)) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) @@ -561,6 +672,8 @@ private struct ProviderFilterPill: View { Text("\(sessionCount)") .font(.system(size: 10, weight: isSelected ? .semibold : .medium, design: .rounded)) .foregroundStyle(isSelected ? Color.primary : Color.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } .foregroundStyle(isSelected ? Color.primary : Color.secondary) .padding(.horizontal, 6) @@ -570,10 +683,10 @@ private struct ProviderFilterPill: View { } .buttonStyle(.plain) .accessibilityElement(children: .combine) - .accessibilityLabel("\(snapshot.displayName), \(sessionCount) \(sessionCount == 1 ? "session" : "sessions")\(needsAttention ? ", needs attention" : "")") + .accessibilityLabel("\(title), \(sessionCount) \(sessionCount == 1 ? "session" : "sessions")\(needsAttention ? ", needs attention" : "")") .accessibilityAddTraits(isSelected ? [.isSelected] : []) .accessibilityValue(isSelected ? "Selected" : "") - .accessibilityIdentifier("provider-tab-\(snapshot.id.rawValue)") + .accessibilityIdentifier(accessibilityId) .onHover { isHovered = $0 } } } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfe95f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: all dev reset-defaults test clean + +all: dev + +dev: + @echo "Stopping running Loom processes..." + @killall Loom LoomWidget 2>/dev/null || true + @echo "Building Loom (Debug)..." + @xcodebuild -scheme Loom -configuration Debug -derivedDataPath .build/DerivedData build + @echo "Launching Loom.app..." + @open .build/DerivedData/Build/Products/Debug/Loom.app + +reset-defaults: + @echo "Clearing migration and provider settings..." + @killall Loom LoomWidget 2>/dev/null || true + @defaults delete com.chaiwithcode.loom com.loom.didMigrateT3CodeProvider_v1 2>/dev/null || true + @defaults delete com.chaiwithcode.loom enabledProviderIDs 2>/dev/null || true + @defaults delete com.loom com.loom.didMigrateT3CodeProvider_v1 2>/dev/null || true + @defaults delete com.loom enabledProviderIDs 2>/dev/null || true + @$(MAKE) dev + +test: + @echo "Running SPM test suite..." + @swift test + @echo "Building Xcode macOS scheme..." + @xcodebuild -scheme Loom -destination 'platform=macOS' build + +clean: + @echo "Cleaning DerivedData..." + @rm -rf .build/DerivedData + @swift package clean From 265ca671c0af1121839c275783565cc88a824cd3 Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:43:33 +0000 Subject: [PATCH 07/11] fix(menubar): 2-row provider layout, All Providers label, single selection, sanitize root cwd, and clear finished processes --- .../Discovery/SharedCLIDiscoveryCache.swift | 53 ++++++++++++------- Loom/Providers/Codex/CodexCLIDetector.swift | 39 ++++++++++++-- Loom/UI/MenuBar/MenuBarRootView.swift | 25 ++++----- 3 files changed, 79 insertions(+), 38 deletions(-) diff --git a/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift b/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift index 53b8fc6..db6cf42 100644 --- a/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift +++ b/Loom/Core/Discovery/SharedCLIDiscoveryCache.swift @@ -96,7 +96,15 @@ public actor SharedCLIDiscoveryCache { diagnostics: ProviderDiscoveryDiagnostics? = nil ) -> AgentSession { let mappedState = mapState(status.state) - let projectURL = status.workspacePath.map { URL(fileURLWithPath: $0) } + + let rawProjectName = status.projectName.trimmingCharacters(in: .whitespacesAndNewlines) + let projectName = (rawProjectName.isEmpty || rawProjectName == "/") ? "\(providerID.displayName) Session" : rawProjectName + + let safeWorkspacePath: String? = { + guard let path = status.workspacePath, !path.isEmpty, path != "/" else { return nil } + return path + }() + let projectURL = safeWorkspacePath.map { URL(fileURLWithPath: $0) } var summaryParts: [String] = [] if status.state == .awaitingApproval { @@ -120,7 +128,7 @@ public actor SharedCLIDiscoveryCache { summaryParts.insert("Driving \(childLabels.joined(separator: ", "))", at: 0) } - let summaryText = summaryParts.isEmpty ? status.projectName : summaryParts.joined(separator: " • ") + let summaryText = summaryParts.isEmpty ? projectName : summaryParts.joined(separator: " • ") let host: HostApp = { switch status.surface { @@ -134,7 +142,7 @@ public actor SharedCLIDiscoveryCache { }() var processes: [AgentProcess] = [] - if let pid = status.pid { + if mappedState != .finished, let pid = status.pid { processes.append( AgentProcess( pid: pid, @@ -150,21 +158,23 @@ public actor SharedCLIDiscoveryCache { ) } - for child in orchestratedChildren { - if let cpid = child.pid { - processes.append( - AgentProcess( - pid: cpid, - parentPID: child.parentT3PID ?? status.pid, - executable: child.provider.rawValue, - commandLine: child.provider.rawValue, - workingDirectory: child.workspacePath.map { URL(fileURLWithPath: $0) }, - startedAt: child.lastUpdated, - provider: providerID, - hostApp: .custom("T3 Code"), - sessionIDToken: child.id + if mappedState != .finished { + for child in orchestratedChildren { + if let cpid = child.pid { + processes.append( + AgentProcess( + pid: cpid, + parentPID: child.parentT3PID ?? status.pid, + executable: child.provider.rawValue, + commandLine: child.provider.rawValue, + workingDirectory: child.workspacePath.map { URL(fileURLWithPath: $0) }, + startedAt: child.lastUpdated, + provider: providerID, + hostApp: .custom("T3 Code"), + sessionIDToken: child.id + ) ) - ) + } } } @@ -182,19 +192,22 @@ public actor SharedCLIDiscoveryCache { evidence.append("Orchestrated child CLIs: \(orchestratedChildren.count)") } + let finalPID: Int? = mappedState == .finished ? nil : status.pid.map { Int($0) } + let finalProcesses: [AgentProcess] = mappedState == .finished ? [] : processes + return AgentSession( id: status.id, provider: providerID, - projectName: status.projectName, + projectName: projectName, projectPath: projectURL, state: mappedState, startedAt: status.lastUpdated, lastActivityAt: status.lastUpdated, branch: status.gitBranch, summary: summaryText, - pid: status.pid.map { Int($0) }, + pid: finalPID, providerSessionID: status.id, - associatedProcesses: processes, + associatedProcesses: finalProcesses, hostApp: host, diagnostics: diagnostics, discoveryEvidence: evidence diff --git a/Loom/Providers/Codex/CodexCLIDetector.swift b/Loom/Providers/Codex/CodexCLIDetector.swift index d7ddfb2..b5fc306 100644 --- a/Loom/Providers/Codex/CodexCLIDetector.swift +++ b/Loom/Providers/Codex/CodexCLIDetector.swift @@ -140,12 +140,34 @@ public final class CodexCLIDetector: Sendable { return Self.normalizePath(procCWD) == Self.normalizePath(fileCWD) } + // Filter out background codex helper processes whose cwd == "/" if they have no matching transcript + let isRootOrEmptyCWD = proc.cwd == nil || proc.cwd == "/" || proc.cwd?.isEmpty == true + if isRootOrEmptyCWD && matchingRollout == nil { + continue + } + if let match = matchingRollout { matchedRolloutURLs.insert(match.url) } - let resolvedCWD = proc.cwd ?? matchingRollout?.parsed.cwd - let projectName = resolvedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "codex" + let resolvedCWD: String? = { + if let fileCWD = matchingRollout?.parsed.cwd, fileCWD != "/" && !fileCWD.isEmpty { + return fileCWD + } + if let procCWD = proc.cwd, procCWD != "/" && !procCWD.isEmpty { + return procCWD + } + return nil + }() + + let projectName: String = { + if let path = resolvedCWD { + let rawName = URL(fileURLWithPath: path).lastPathComponent + return (rawName.isEmpty || rawName == "/") ? "Codex Session" : rawName + } + return "Codex Session" + }() + let hasActiveChild = processTable.values.contains { $0.ppid == proc.pid } let state = inferState( @@ -178,8 +200,17 @@ public final class CodexCLIDetector: Sendable { // 4. Include recent inactive sessions from today (so daily token totals remain visible after exit) for item in parsedRollouts where !matchedRolloutURLs.contains(item.url) { guard Calendar.current.isDateInToday(item.snapshot.modifiedAt) else { continue } - let cwd = item.parsed.cwd - let projectName = cwd.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "codex" + let cwd: String? = { + guard let path = item.parsed.cwd, path != "/" && !path.isEmpty else { return nil } + return path + }() + let projectName: String = { + if let path = cwd { + let rawName = URL(fileURLWithPath: path).lastPathComponent + return (rawName.isEmpty || rawName == "/") ? "Codex Session" : rawName + } + return "Codex Session" + }() let sessionID = item.parsed.sessionID ?? item.url.deletingPathExtension().lastPathComponent results.append( diff --git a/Loom/UI/MenuBar/MenuBarRootView.swift b/Loom/UI/MenuBar/MenuBarRootView.swift index e90bf14..0410414 100644 --- a/Loom/UI/MenuBar/MenuBarRootView.swift +++ b/Loom/UI/MenuBar/MenuBarRootView.swift @@ -239,27 +239,24 @@ public struct MenuBarRootView: View { @ViewBuilder private var providerSelector: some View { let snapshots = store.enabledSnapshots - ViewThatFits(in: .horizontal) { - HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { allProviderButton - ForEach(snapshots) { snapshot in + ForEach(Array(snapshots.prefix(2))) { snapshot in providerButton(for: snapshot) } } - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - allProviderButton - ForEach(snapshots) { snapshot in + if snapshots.count > 2 { + HStack(spacing: 6) { + ForEach(Array(snapshots.dropFirst(2))) { snapshot in providerButton(for: snapshot) } } - .padding(.horizontal, 1) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 16) - .padding(.vertical, 3) + .padding(.vertical, 4) .accessibilityElement(children: .contain) .accessibilityLabel("Provider selector") } @@ -272,7 +269,7 @@ public struct MenuBarRootView: View { } return ProviderFilterPill( - title: "All", + title: "All Providers", iconName: "square.grid.2x2", providerID: nil, sessionCount: sessionCount, @@ -288,13 +285,12 @@ public struct MenuBarRootView: View { } private func providerButton(for snapshot: ProviderSnapshot) -> some View { - let isSelected = !isAllProvidersSelected && resolvedSelectedProviderID == snapshot.id + let isSelected = !isAllProvidersSelected && (resolvedSelectedProviderID == snapshot.id) let sessionCount = filteredSessions(for: snapshot).count let needsAttention = snapshot.sessions.contains { $0.state == .waiting || $0.state == .failed } - let displayName = snapshot.id == .claude ? "Claude" : snapshot.displayName return ProviderFilterPill( - title: displayName, + title: snapshot.displayName, iconName: nil, providerID: snapshot.id, sessionCount: sessionCount, @@ -682,6 +678,7 @@ private struct ProviderFilterPill: View { .contentShape(Capsule()) } .buttonStyle(.plain) + .fixedSize(horizontal: true, vertical: false) .accessibilityElement(children: .combine) .accessibilityLabel("\(title), \(sessionCount) \(sessionCount == 1 ? "session" : "sessions")\(needsAttention ? ", needs attention" : "")") .accessibilityAddTraits(isSelected ? [.isSelected] : []) From 72a4e051b2965a5f7d7f565fa3c93595c413efa9 Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 09:55:10 +0000 Subject: [PATCH 08/11] fix(polish): graceful Antigravity/T3 quota notices and waiting badge for icon-only mode --- Loom/App/StatusItemController.swift | 10 +++- Loom/Core/Store/SessionStore.swift | 3 ++ .../Antigravity/AntigravityProvider.swift | 12 ++--- Loom/UI/MenuBar/ProviderSectionView.swift | 48 +++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/Loom/App/StatusItemController.swift b/Loom/App/StatusItemController.swift index 62eab72..be14cdc 100644 --- a/Loom/App/StatusItemController.swift +++ b/Loom/App/StatusItemController.swift @@ -131,7 +131,7 @@ public final class StatusItemController { let title = currentStatus.hasAttention ? "\(text) •" : text button.title = " \(title)" } else if currentStatus.hasAttention { - button.title = " •" + button.title = " \(currentStatus.attentionCount) •" } else { button.title = "" } @@ -143,6 +143,8 @@ public final class StatusItemController { var parts: [String] = ["Loom"] if let text = status.text { parts.append(text) + } else if status.hasAttention { + parts.append("\(status.attentionCount) waiting") } if status.hasAttention { parts.append("\(status.attentionCount) \(status.attentionCount == 1 ? "session needs" : "sessions need") attention") @@ -175,6 +177,10 @@ public struct MenuBarStatusLabel: View { if let text = result.text { Text(text) .font(.system(size: 11, weight: .semibold, design: .rounded)) + } else if result.hasAttention { + Text("\(result.attentionCount)") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.orange) } if result.hasAttention { @@ -191,6 +197,8 @@ public struct MenuBarStatusLabel: View { var parts: [String] = ["Loom"] if let text = result.text { parts.append(text) + } else if result.hasAttention { + parts.append("\(result.attentionCount) waiting") } if result.hasAttention { parts.append("\(result.attentionCount) \(result.attentionCount == 1 ? "session needs" : "sessions need") attention") diff --git a/Loom/Core/Store/SessionStore.swift b/Loom/Core/Store/SessionStore.swift index 3e1ea9f..a4b9334 100644 --- a/Loom/Core/Store/SessionStore.swift +++ b/Loom/Core/Store/SessionStore.swift @@ -51,6 +51,9 @@ public struct ProviderSnapshot: Identifiable, Hashable, Equatable, Sendable { public var hasWarning: Bool { guard isEnabled else { return false } + if providerID == .antigravity && usage == nil && (usageErrorMessage?.contains("language_server") == true || usageErrorMessage?.contains("not running") == true) { + return errorMessage != nil || !availability.isAvailable + } return errorMessage != nil || !availability.isAvailable || usageErrorMessage != nil || diff --git a/Loom/Providers/Antigravity/AntigravityProvider.swift b/Loom/Providers/Antigravity/AntigravityProvider.swift index 761810b..2a64d0d 100644 --- a/Loom/Providers/Antigravity/AntigravityProvider.swift +++ b/Loom/Providers/Antigravity/AntigravityProvider.swift @@ -63,12 +63,12 @@ public final class AntigravityProvider: AgentProvider, Sendable { } public func fetchUsage() async throws -> ProviderUsageSnapshot? { - var isRunning = await processDiscoverer.isAntigravityRunning() - if !isRunning && processDiscoverer is DefaultAntigravityProcessDiscoverer { - let agyStatuses = await SharedCLIDiscoveryCache.shared.snapshot().antigravityStatuses - isRunning = !agyStatuses.filter { $0.state != .inactive }.isEmpty - } - guard isRunning else { + let isGUIRunning = await processDiscoverer.isAntigravityRunning() + guard isGUIRunning else { + // When only CLI is running (or GUI app is closed), return nil gracefully to avoid spurious warning badge on Menu Bar + if processDiscoverer is DefaultAntigravityProcessDiscoverer { + return nil + } throw AntigravityUsageError.processNotFound } return try await usageFetcher.fetchUsage() diff --git a/Loom/UI/MenuBar/ProviderSectionView.swift b/Loom/UI/MenuBar/ProviderSectionView.swift index f514426..ff1e7e8 100644 --- a/Loom/UI/MenuBar/ProviderSectionView.swift +++ b/Loom/UI/MenuBar/ProviderSectionView.swift @@ -168,6 +168,10 @@ public struct ProviderSectionView: View { } } else if snapshot.id == .claude { claudeQuotaNotice + } else if snapshot.id == .antigravity && (snapshot.usageErrorMessage == nil || snapshot.usageErrorMessage?.contains("language_server") == true || snapshot.usageErrorMessage?.contains("processNotFound") == true) { + antigravityQuotaNotice + } else if snapshot.id == .t3Code { + t3CodeQuotaNotice } else if let error = snapshot.usageErrorMessage, !error.isEmpty { issueNotice(.usage(hasSavedData: false), details: error, color: .orange) } else if snapshot.availability.isAvailable && !snapshot.isRefreshing { @@ -343,6 +347,50 @@ public struct ProviderSectionView: View { .accessibilityLabel("Quota monitoring not exposed by CLI. Claude Code does not provide a local quota command or telemetry endpoint.") } + private var antigravityQuotaNotice: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + Text("Open Antigravity app to view quota") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + } + Text("Antigravity quota telemetry is exposed via the desktop app loopback server.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .loomCard(cornerRadius: 10) + .accessibilityElement(children: .combine) + .accessibilityLabel("Open Antigravity app to view quota. Antigravity quota telemetry is exposed via the desktop app loopback server.") + } + + private var t3CodeQuotaNotice: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Image(systemName: "info.circle") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + Text("Quota monitoring not exposed by T3 Code") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + } + Text("T3 Code does not provide a local quota command or telemetry endpoint.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .loomCard(cornerRadius: 10) + .accessibilityElement(children: .combine) + .accessibilityLabel("Quota monitoring not exposed by T3 Code. T3 Code does not provide a local quota command or telemetry endpoint.") + } + private func issueNotice(_ issue: ProviderIssue, details: String, color: Color) -> some View { VStack(alignment: .leading, spacing: 5) { Label(issue.title, systemImage: "exclamationmark.triangle") From c599abbd09ecb9d6cf34af684cb274baed40ec16 Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:04:04 +0000 Subject: [PATCH 09/11] feat(storage,ui): add T3 Code storage locations, native app launcher, and usage notice fix --- Loom/Core/Store/SessionStore.swift | 12 +- Loom/Providers/ProviderStorageLocations.swift | 8 + Loom/UI/MenuBar/MenuBarRootView.swift | 20 ++- Loom/UI/MenuBar/ProviderSectionView.swift | 169 +++++++++++++++++- .../StorageAndRefreshTests.swift | 8 + 5 files changed, 208 insertions(+), 9 deletions(-) diff --git a/Loom/Core/Store/SessionStore.swift b/Loom/Core/Store/SessionStore.swift index a4b9334..22fbd02 100644 --- a/Loom/Core/Store/SessionStore.swift +++ b/Loom/Core/Store/SessionStore.swift @@ -51,7 +51,7 @@ public struct ProviderSnapshot: Identifiable, Hashable, Equatable, Sendable { public var hasWarning: Bool { guard isEnabled else { return false } - if providerID == .antigravity && usage == nil && (usageErrorMessage?.contains("language_server") == true || usageErrorMessage?.contains("not running") == true) { + if providerID == .antigravity && usage == nil && Self.isAntigravityNotRunningMessage(usageErrorMessage) { return errorMessage != nil || !availability.isAvailable } return errorMessage != nil || @@ -60,6 +60,16 @@ public struct ProviderSnapshot: Identifiable, Hashable, Equatable, Sendable { (usage?.isStale ?? false) || usage?.errorMessage != nil } + + private static func isAntigravityNotRunningMessage(_ msg: String?) -> Bool { + guard let lower = msg?.lowercased(), !lower.isEmpty else { return true } + return lower.contains("language_server") + || lower.contains("processnotfound") + || lower.contains("process not found") + || lower.contains("not running") + || lower.contains("open the provider app") + || lower.contains("unavailable") + } } public struct ProjectGroup: Identifiable, Equatable, Sendable { diff --git a/Loom/Providers/ProviderStorageLocations.swift b/Loom/Providers/ProviderStorageLocations.swift index bc0af03..fd27c4e 100644 --- a/Loom/Providers/ProviderStorageLocations.swift +++ b/Loom/Providers/ProviderStorageLocations.swift @@ -19,11 +19,19 @@ public struct ProviderStorageLocations: Sendable, Identifiable { func root(_ path: String, _ label: String) -> StorageLocation { StorageLocation(label: label, url: home.appendingPathComponent(path, isDirectory: true)) } + let t3Root = environment["T3CODE_HOME"].flatMap { $0.hasPrefix("/") ? URL(fileURLWithPath: $0) : nil } + ?? home.appendingPathComponent(".t3", isDirectory: true) let codexRoot = environment["CODEX_HOME"].flatMap { $0.hasPrefix("/") ? URL(fileURLWithPath: $0) : nil } ?? home.appendingPathComponent(".codex", isDirectory: true) let claudeRoot = environment["CLAUDE_CONFIG_DIR"].flatMap { $0.hasPrefix("/") ? URL(fileURLWithPath: $0) : nil } ?? home.appendingPathComponent(".claude", isDirectory: true) return [ + Self(id: .t3Code, name: "T3 Code", locations: [ + StorageLocation(label: "Git worktrees", url: t3Root.appendingPathComponent("worktrees", isDirectory: true)), + StorageLocation(label: "Orchestration state & logs", url: t3Root.appendingPathComponent("userdata", isDirectory: true)), + root("Library/Application Support/T3 Code", "Desktop data"), + root("Library/Application Support/t3code", "CLI support data"), + root("Library/Caches/T3 Code", "Desktop cache")]), Self(id: .codex, name: "Codex", locations: [ StorageLocation(label: "CLI data", url: codexRoot), root("Library/Application Support/Codex", "Desktop data"), diff --git a/Loom/UI/MenuBar/MenuBarRootView.swift b/Loom/UI/MenuBar/MenuBarRootView.swift index 0410414..e66ee96 100644 --- a/Loom/UI/MenuBar/MenuBarRootView.swift +++ b/Loom/UI/MenuBar/MenuBarRootView.swift @@ -426,6 +426,12 @@ public struct MenuBarRootView: View { onSessionExpansionChange: { id, expanded in if expanded { expandedSessionIds.insert(id) } else { expandedSessionIds.remove(id) } + }, + onAfterLaunchApp: { + Task { + try? await Task.sleep(for: .seconds(2.5)) + await store.refresh() + } } ) } @@ -447,6 +453,12 @@ public struct MenuBarRootView: View { onSessionExpansionChange: { id, expanded in if expanded { expandedSessionIds.insert(id) } else { expandedSessionIds.remove(id) } + }, + onAfterLaunchApp: { + Task { + try? await Task.sleep(for: .seconds(2.5)) + await store.refresh() + } } ) } @@ -464,7 +476,13 @@ public struct MenuBarRootView: View { showsSessions: false, showsUsage: true, expandedSessionIds: expandedSessionIds, - settings: settings + settings: settings, + onAfterLaunchApp: { + Task { + try? await Task.sleep(for: .seconds(2.5)) + await store.refresh() + } + } ) if snapshot.id != usageSnapshots.last?.id { Divider().padding(.vertical, 4) diff --git a/Loom/UI/MenuBar/ProviderSectionView.swift b/Loom/UI/MenuBar/ProviderSectionView.swift index ff1e7e8..395179b 100644 --- a/Loom/UI/MenuBar/ProviderSectionView.swift +++ b/Loom/UI/MenuBar/ProviderSectionView.swift @@ -20,6 +20,7 @@ public struct ProviderSectionView: View { public let selectedFilter: SessionFilterCriterion public let onClearFilters: (() -> Void)? public let onSessionExpansionChange: ((String, Bool) -> Void)? + public let onAfterLaunchApp: (() -> Void)? @State private var isShowingAdditionalQuotas = false @State private var isShowingRecentFinished = false @State private var isShowingUnidentifiedProcesses = false @@ -34,7 +35,8 @@ public struct ProviderSectionView: View { searchText: String = "", selectedFilter: SessionFilterCriterion = .all, onClearFilters: (() -> Void)? = nil, - onSessionExpansionChange: ((String, Bool) -> Void)? = nil + onSessionExpansionChange: ((String, Bool) -> Void)? = nil, + onAfterLaunchApp: (() -> Void)? = nil ) { self.snapshot = snapshot self.showsSessions = showsSessions @@ -46,6 +48,7 @@ public struct ProviderSectionView: View { self.selectedFilter = selectedFilter self.onClearFilters = onClearFilters self.onSessionExpansionChange = onSessionExpansionChange + self.onAfterLaunchApp = onAfterLaunchApp } private var status: (label: String, color: Color) { @@ -168,7 +171,7 @@ public struct ProviderSectionView: View { } } else if snapshot.id == .claude { claudeQuotaNotice - } else if snapshot.id == .antigravity && (snapshot.usageErrorMessage == nil || snapshot.usageErrorMessage?.contains("language_server") == true || snapshot.usageErrorMessage?.contains("processNotFound") == true) { + } else if snapshot.id == .antigravity && Self.isAppNotRunningUsageMessage(snapshot.usageErrorMessage) { antigravityQuotaNotice } else if snapshot.id == .t3Code { t3CodeQuotaNotice @@ -287,10 +290,24 @@ public struct ProviderSectionView: View { onClearFilters: onClearFilters ) } else if snapshot.availability.isAvailable && snapshot.errorMessage == nil && !snapshot.isRefreshing { - Text("No active sessions") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .padding(.vertical, 4) + HStack { + Text("No active sessions") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + + if ProviderAppLauncher.canLaunchApp(for: snapshot.id) { + Spacer() + Button { + ProviderAppLauncher.launchApp(for: snapshot.id, onAfterLaunch: onAfterLaunchApp) + } label: { + Label("Open \(ProviderAppLauncher.appName(for: snapshot.id))", systemImage: "arrow.up.forward.app") + .font(.system(size: 10.5, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 4) } DiagnosticsDisclosureView( @@ -361,6 +378,21 @@ public struct ProviderSectionView: View { .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + if ProviderAppLauncher.canLaunchApp(for: .antigravity) { + HStack { + Spacer() + Button { + ProviderAppLauncher.launchApp(for: .antigravity, onAfterLaunch: onAfterLaunchApp) + } label: { + Label("Open Antigravity", systemImage: "arrow.up.forward.app") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(.top, 2) + } } .padding(11) .frame(maxWidth: .infinity, alignment: .leading) @@ -383,6 +415,21 @@ public struct ProviderSectionView: View { .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + if ProviderAppLauncher.canLaunchApp(for: .t3Code) { + HStack { + Spacer() + Button { + ProviderAppLauncher.launchApp(for: .t3Code, onAfterLaunch: onAfterLaunchApp) + } label: { + Label("Open T3 Code", systemImage: "arrow.up.forward.app") + .font(.system(size: 11, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.top, 2) + } } .padding(11) .frame(maxWidth: .infinity, alignment: .leading) @@ -396,7 +443,21 @@ public struct ProviderSectionView: View { Label(issue.title, systemImage: "exclamationmark.triangle") .font(.system(size: 12, weight: .medium)) .foregroundStyle(color) - Text(issue.recovery).font(.caption).foregroundStyle(.secondary) + HStack(alignment: .center) { + Text(issue.recovery).font(.caption).foregroundStyle(.secondary) + + if ProviderAppLauncher.canLaunchApp(for: snapshot.id) { + Spacer() + Button { + ProviderAppLauncher.launchApp(for: snapshot.id, onAfterLaunch: onAfterLaunchApp) + } label: { + Text("Open \(ProviderAppLauncher.appName(for: snapshot.id))") + .font(.system(size: 10.5, weight: .medium)) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } DisclosureGroup("Technical details") { Text(sanitizedNotice(details)) .font(.caption).foregroundStyle(.secondary) @@ -410,6 +471,16 @@ public struct ProviderSectionView: View { .background(color.opacity(0.07), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) } + private static func isAppNotRunningUsageMessage(_ msg: String?) -> Bool { + guard let lower = msg?.lowercased(), !lower.isEmpty else { return true } + return lower.contains("language_server") + || lower.contains("processnotfound") + || lower.contains("process not found") + || lower.contains("not running") + || lower.contains("open the provider app") + || lower.contains("unavailable") + } + private func sanitizedNotice(_ text: String) -> String { if (settings?.hideProjectPaths ?? false) && (text.contains("/") || text.contains("~")) { return "Details hidden by privacy setting" @@ -506,3 +577,87 @@ public struct ProviderSectionView: View { .background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } } + +// MARK: - Provider App Launcher + +public enum ProviderAppLauncher { + public static func resolvedAppURL(for providerID: ProviderID) -> URL? { + let ws = NSWorkspace.shared + let fm = FileManager.default + let bundleIDs: [String] + let fallbackPaths: [String] + + switch providerID { + case .t3Code: + bundleIDs = ["gg.ping.t3code", "com.t3code.app", "com.ping.t3code"] + fallbackPaths = [ + "/Applications/T3 Code.app", + "/Applications/T3 Code (Alpha).app", + "/Applications/T3 Code (Nightly).app", + ("~/Applications/T3 Code.app" as NSString).expandingTildeInPath + ] + case .antigravity: + bundleIDs = ["com.google.antigravity", "com.google.antigravity.app"] + fallbackPaths = [ + "/Applications/Antigravity.app", + ("~/Applications/Antigravity.app" as NSString).expandingTildeInPath + ] + case .codex: + bundleIDs = ["com.openai.codex", "com.openai.chat"] + fallbackPaths = [ + "/Applications/Codex.app", + "/Applications/ChatGPT.app", + "/System/Applications/Utilities/Terminal.app" + ] + case .claude: + bundleIDs = ["com.anthropic.claudefordesktop"] + fallbackPaths = [ + "/Applications/Claude.app", + "/System/Applications/Utilities/Terminal.app" + ] + default: + bundleIDs = [] + fallbackPaths = [] + } + + for bid in bundleIDs { + if let url = ws.urlForApplication(withBundleIdentifier: bid) { + return url + } + } + + for path in fallbackPaths { + if fm.fileExists(atPath: path) { + return URL(fileURLWithPath: path) + } + } + + return nil + } + + public static func canLaunchApp(for providerID: ProviderID) -> Bool { + resolvedAppURL(for: providerID) != nil + } + + public static func appName(for providerID: ProviderID) -> String { + switch providerID { + case .t3Code: return "T3 Code" + case .antigravity: return "Antigravity" + case .codex: return "Codex" + case .claude: return "Claude" + default: return providerID.displayName + } + } + + @MainActor + public static func launchApp(for providerID: ProviderID, onAfterLaunch: (() -> Void)? = nil) { + guard let url = resolvedAppURL(for: providerID) else { return } + let config = NSWorkspace.OpenConfiguration() + config.activates = true + NSWorkspace.shared.openApplication(at: url, configuration: config) { _, _ in + DispatchQueue.main.async { + onAfterLaunch?() + } + } + } +} diff --git a/Tests/LoomCoreTests/StorageAndRefreshTests.swift b/Tests/LoomCoreTests/StorageAndRefreshTests.swift index 6fe26d0..52123af 100644 --- a/Tests/LoomCoreTests/StorageAndRefreshTests.swift +++ b/Tests/LoomCoreTests/StorageAndRefreshTests.swift @@ -221,11 +221,15 @@ final class StorageAndRefreshTests: XCTestCase { // 1. With absolute path overrides let envOverrides = [ + "T3CODE_HOME": "/custom/path/to/t3", "CODEX_HOME": "/custom/path/to/codex", "CLAUDE_CONFIG_DIR": "/custom/path/to/claude" ] let overridden = ProviderStorageLocations.local(home: customHome, environment: envOverrides) + let t3Locations = overridden.first(where: { $0.id == .t3Code })?.locations + XCTAssertEqual(t3Locations?.first?.url.path, "/custom/path/to/t3/worktrees") + let codexLocations = overridden.first(where: { $0.id == .codex })?.locations XCTAssertEqual(codexLocations?.first?.url.path, "/custom/path/to/codex") @@ -234,10 +238,14 @@ final class StorageAndRefreshTests: XCTestCase { // 2. Reject non-absolute (relative) paths and fallback to home let relativeEnv = [ + "T3CODE_HOME": "relative/t3", "CODEX_HOME": "relative/codex", "CLAUDE_CONFIG_DIR": "relative/claude" ] let fallback = ProviderStorageLocations.local(home: customHome, environment: relativeEnv) + let t3Fallback = fallback.first(where: { $0.id == .t3Code })?.locations + XCTAssertEqual(t3Fallback?.first?.url.path, "/Users/testuser/.t3/worktrees") + let codexFallback = fallback.first(where: { $0.id == .codex })?.locations XCTAssertEqual(codexFallback?.first?.url.path, "/Users/testuser/.codex") From 9e98773898580a971f77f52bcffbeb8ada12ce36 Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Sun, 1 Nov 2026 22:54:46 +0000 Subject: [PATCH 10/11] =?UTF-8?q?release:=20Loom=20v2.1.0=20=E2=80=94=20T3?= =?UTF-8?q?=20Code=20support,=20multi-agent=20CLI=20telemetry,=20provider?= =?UTF-8?q?=20reordering=20&=20app=20launchers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loom.xcodeproj/project.pbxproj | 16 +-- Loom/Core/Models/AppVersion.swift | 2 +- Loom/Core/Models/LoomSettings.swift | 60 +++++++++- Loom/Core/Models/ProviderID.swift | 1 + Loom/Core/Store/SessionStore+Widget.swift | 2 +- Loom/Core/Store/SessionStore.swift | 21 +++- Loom/UI/MenuBar/MenuBarRootView.swift | 10 +- Loom/UI/Settings/SettingsView.swift | 128 +++++++++++++++++++--- Loom/UI/Settings/StorageSettingsTab.swift | 11 +- LoomWidget/Info.plist | 4 +- Tests/LoomCoreTests/AppVersionTests.swift | 10 +- Tests/LoomCoreTests/SettingsTests.swift | 70 ++++++++++++ 12 files changed, 294 insertions(+), 41 deletions(-) diff --git a/Loom.xcodeproj/project.pbxproj b/Loom.xcodeproj/project.pbxproj index ca3f260..523ba70 100644 --- a/Loom.xcodeproj/project.pbxproj +++ b/Loom.xcodeproj/project.pbxproj @@ -1176,7 +1176,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = NO; @@ -1194,7 +1194,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom; PRODUCT_NAME = Loom; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1210,7 +1210,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = YES; @@ -1228,7 +1228,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom; PRODUCT_NAME = Loom; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1328,7 +1328,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = NO; @@ -1344,7 +1344,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom.widget; PRODUCT_NAME = LoomWidgetExtension; SKIP_INSTALL = YES; @@ -1361,7 +1361,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = K9UNF256S9; ENABLE_HARDENED_RUNTIME = YES; @@ -1377,7 +1377,7 @@ "@executable_path/../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 2.0.0; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom.widget; PRODUCT_NAME = LoomWidgetExtension; SKIP_INSTALL = YES; diff --git a/Loom/Core/Models/AppVersion.swift b/Loom/Core/Models/AppVersion.swift index 4e4ef08..1d886b6 100644 --- a/Loom/Core/Models/AppVersion.swift +++ b/Loom/Core/Models/AppVersion.swift @@ -13,7 +13,7 @@ public struct AppVersion: Equatable, Hashable, Sendable { public let buildVersion: String? public let isDevelopmentBuild: Bool - public static let fallbackMarketingVersion = "2.0.0" + public static let fallbackMarketingVersion = "2.1.0" public init( marketingVersion: String, diff --git a/Loom/Core/Models/LoomSettings.swift b/Loom/Core/Models/LoomSettings.swift index 9611a0e..88073ff 100644 --- a/Loom/Core/Models/LoomSettings.swift +++ b/Loom/Core/Models/LoomSettings.swift @@ -158,8 +158,10 @@ public final class LoomSettings { public nonisolated static let defaultRefreshIntervalSeconds = 60 public nonisolated static let supportedRefreshIntervals = [30, 60, 120, 300] public nonisolated static let didMigrateT3CodeProviderKey = "com.loom.didMigrateT3CodeProvider_v1" - public nonisolated static let orderedProviderIDs: [ProviderID] = [.t3Code, .codex, .claude, .antigravity] - public nonisolated static let defaultProviderIDs: Set = Set(orderedProviderIDs) + public nonisolated static let orderedProviderIDsKey = "com.loom.orderedProviderIDs" + public nonisolated static let defaultOrderedProviderIDs: [ProviderID] = [.t3Code, .codex, .claude, .antigravity] + public nonisolated static let orderedProviderIDs: [ProviderID] = defaultOrderedProviderIDs + public nonisolated static let defaultProviderIDs: Set = Set(defaultOrderedProviderIDs) public nonisolated static let defaultProviderID: ProviderID = .codex public nonisolated static let defaultIncludeRecentSessions = true public nonisolated static let defaultHideFinishedAfter: HideFinishedInterval = .never @@ -201,10 +203,32 @@ public final class LoomSettings { public var onMinimumWorkingDurationChanged: (@MainActor (MinimumWorkingDuration) -> Void)? public var onMenuBarStatusModeChanged: (@MainActor (MenuBarStatusMode) -> Void)? public var onHasDismissedGitHubStarPromptChanged: (@MainActor (Bool) -> Void)? + public var onOrderedProviderIDsChanged: (@MainActor ([ProviderID]) -> Void)? @ObservationIgnored private var isNormalizingRefreshInterval = false @ObservationIgnored private var isNormalizingDefaultProvider = false + public var orderedProviderIDs: [ProviderID] { + didSet { + var sanitized: [ProviderID] = [] + for id in orderedProviderIDs where Self.defaultProviderIDs.contains(id) && !sanitized.contains(id) { + sanitized.append(id) + } + for defaultID in Self.defaultOrderedProviderIDs where !sanitized.contains(defaultID) { + sanitized.append(defaultID) + } + if sanitized != orderedProviderIDs { + orderedProviderIDs = sanitized + return + } + let rawList = orderedProviderIDs.map(\.rawValue) + defaults.set(rawList, forKey: Self.orderedProviderIDsKey) + if orderedProviderIDs != oldValue { + onOrderedProviderIDsChanged?(orderedProviderIDs) + } + } + } + public var showInDock: Bool { didSet { defaults.set(showInDock, forKey: Self.showInDockKey) @@ -484,6 +508,23 @@ public final class LoomSettings { self.enabledProviderIDs = Self.defaultProviderIDs } + // orderedProviderIDs: + if let rawArray = defaults.object(forKey: Self.orderedProviderIDsKey) as? [String] { + var loaded: [ProviderID] = [] + for raw in rawArray { + let pid = ProviderID(raw) + if Self.defaultProviderIDs.contains(pid) && !loaded.contains(pid) { + loaded.append(pid) + } + } + for defaultID in Self.defaultOrderedProviderIDs where !loaded.contains(defaultID) { + loaded.append(defaultID) + } + self.orderedProviderIDs = loaded + } else { + self.orderedProviderIDs = Self.defaultOrderedProviderIDs + } + // defaultProviderID: invalid restores Codex if let rawDefault = defaults.string(forKey: Self.defaultProviderIDKey) { let candidate = ProviderID(rawDefault) @@ -637,6 +678,7 @@ public final class LoomSettings { showInDock = Self.defaultShowInDock refreshIntervalSeconds = Self.defaultRefreshIntervalSeconds enabledProviderIDs = Self.defaultProviderIDs + orderedProviderIDs = Self.defaultOrderedProviderIDs defaultProviderID = Self.defaultProviderID includeRecentSessions = Self.defaultIncludeRecentSessions hideFinishedAfter = Self.defaultHideFinishedAfter @@ -654,6 +696,20 @@ public final class LoomSettings { hasDismissedGitHubStarPrompt = Self.defaultHasDismissedGitHubStarPrompt } + public func moveProvider(fromOffsets source: IndexSet, toOffset destination: Int) { + var current = orderedProviderIDs + current.move(fromOffsets: source, toOffset: destination) + // Ensure any newly added providers not currently in orderedProviderIDs are appended + for provider in ProviderID.knownProviders where !current.contains(provider) { + current.append(provider) + } + orderedProviderIDs = current + } + + public func setProviderEnabled(_ providerID: ProviderID, enabled: Bool) { + setProvider(providerID, enabled: enabled) + } + public func recordDiscoveredSessions(_ sessionIDs: [String]) { let nonEmpty = sessionIDs.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } guard !nonEmpty.isEmpty else { return } diff --git a/Loom/Core/Models/ProviderID.swift b/Loom/Core/Models/ProviderID.swift index 1d79ffe..154d8f2 100644 --- a/Loom/Core/Models/ProviderID.swift +++ b/Loom/Core/Models/ProviderID.swift @@ -26,6 +26,7 @@ public struct ProviderID: Hashable, Equatable, Sendable, RawRepresentable, Ident public static let claude = ProviderID("claude") public static let antigravity = ProviderID("antigravity") public static let t3Code = ProviderID("t3code") + public static let knownProviders: [ProviderID] = [.t3Code, .codex, .claude, .antigravity] public var displayName: String { switch self { diff --git a/Loom/Core/Store/SessionStore+Widget.swift b/Loom/Core/Store/SessionStore+Widget.swift index ccfee19..d4f5c2d 100644 --- a/Loom/Core/Store/SessionStore+Widget.swift +++ b/Loom/Core/Store/SessionStore+Widget.swift @@ -20,7 +20,7 @@ extension LoomWidgetSnapshot { let total = store.allSessions.count var summaries: [ProviderWidgetSummary] = [] - for snapshot in store.providerSnapshots { + for snapshot in store.orderedSnapshots { let isDegradedOrUnavailable: Bool let healthStr: String if !snapshot.isEnabled { diff --git a/Loom/Core/Store/SessionStore.swift b/Loom/Core/Store/SessionStore.swift index 22fbd02..8270c94 100644 --- a/Loom/Core/Store/SessionStore.swift +++ b/Loom/Core/Store/SessionStore.swift @@ -257,10 +257,27 @@ public final class SessionStore { public var onMonitoringEvent: (@Sendable (MonitoringEvent) -> Void)? /// Reference to settings for recording discovered monitoring sessions. - public weak var settings: LoomSettings? + public weak var settings: LoomSettings? { + didSet { + guard let settings else { return } + let prior = settings.onOrderedProviderIDsChanged + settings.onOrderedProviderIDsChanged = { [weak self] order in + prior?(order) + self?.publishWidgetSnapshot() + } + } + } + + public var orderedSnapshots: [ProviderSnapshot] { + let order = settings?.orderedProviderIDs ?? LoomSettings.defaultOrderedProviderIDs + let rank = Dictionary(uniqueKeysWithValues: order.enumerated().map { ($1, $0) }) + return providerSnapshots.sorted { + (rank[$0.providerID] ?? Int.max) < (rank[$1.providerID] ?? Int.max) + } + } public var enabledSnapshots: [ProviderSnapshot] { - providerSnapshots.filter(\.isEnabled) + orderedSnapshots.filter(\.isEnabled) } private let registeredProviders: [any AgentProvider] diff --git a/Loom/UI/MenuBar/MenuBarRootView.swift b/Loom/UI/MenuBar/MenuBarRootView.swift index e66ee96..43cc099 100644 --- a/Loom/UI/MenuBar/MenuBarRootView.swift +++ b/Loom/UI/MenuBar/MenuBarRootView.swift @@ -269,7 +269,7 @@ public struct MenuBarRootView: View { } return ProviderFilterPill( - title: "All Providers", + title: "All", iconName: "square.grid.2x2", providerID: nil, sessionCount: sessionCount, @@ -543,8 +543,14 @@ public struct MenuBarRootView: View { .foregroundStyle(.secondary) .padding(.vertical, 8) } else { + let order = settings?.orderedProviderIDs ?? LoomSettings.defaultOrderedProviderIDs + let rank = Dictionary(uniqueKeysWithValues: order.enumerated().map { ($1, $0) }) + let sortedSummaries = overview.summaries.sorted { + (rank[$0.providerID] ?? Int.max) < (rank[$1.providerID] ?? Int.max) + } + VStack(spacing: 8) { - ForEach(overview.summaries) { summary in + ForEach(sortedSummaries) { summary in providerStorageRow(summary, overview: overview) } } diff --git a/Loom/UI/Settings/SettingsView.swift b/Loom/UI/Settings/SettingsView.swift index 1cd615f..502a6f2 100644 --- a/Loom/UI/Settings/SettingsView.swift +++ b/Loom/UI/Settings/SettingsView.swift @@ -102,7 +102,7 @@ public struct SettingsView: View { case .notifications: NotificationsSettingsTab(settings: settings) case .storage: - StorageSettingsTab(hidePaths: settings.hideProjectPaths) + StorageSettingsTab(hidePaths: settings.hideProjectPaths, orderedProviderIDs: settings.orderedProviderIDs) case .privacy: PrivacySettingsTab(settings: settings) case .about: @@ -256,9 +256,11 @@ private struct ProvidersSettingsTab: View { @Bindable var settings: LoomSettings let store: SessionStore? let focusedProviderID: ProviderID? + @State private var targetedProviderID: ProviderID? = nil + @AppStorage("com.loom.defaultToAllProviders") private var defaultToAllProviders: Bool = false private var supportedProviders: [ProviderRowItem] { - let supported = LoomSettings.orderedProviderIDs + let supported = settings.orderedProviderIDs return supported.map { id in let snapshot = store?.providerSnapshots.first(where: { $0.providerID == id }) let displayName = snapshot?.displayName ?? id.displayName @@ -272,9 +274,20 @@ private struct ProvidersSettingsTab: View { HStack { Text("Default Provider") Spacer(minLength: 16) - Picker("Default Provider", selection: $settings.defaultProviderID) { - ForEach(LoomSettings.orderedProviderIDs) { id in - Text(id.displayName).tag(id) + Picker("Default Provider", selection: Binding( + get: { defaultToAllProviders ? nil : settings.defaultProviderID }, + set: { newValue in + if let newValue { + defaultToAllProviders = false + settings.defaultProviderID = newValue + } else { + defaultToAllProviders = true + } + } + )) { + Text("All").tag(Optional.none) + ForEach(settings.orderedProviderIDs) { id in + Text(id.displayName).tag(Optional.some(id)) } } .labelsHidden() @@ -284,17 +297,37 @@ private struct ProvidersSettingsTab: View { } } - SettingsSection("Monitoring") { - ForEach(supportedProviders) { item in - providerRow(item) + SettingsSection( + "Monitored Providers", + subtitle: "Drag to reorder how providers appear across Sessions, Usage, Storage, and Widgets." + ) { + VStack(spacing: 0) { + ForEach(Array(supportedProviders.enumerated()), id: \.element.id) { index, item in + providerRow(item, index: index, count: supportedProviders.count) + + if index < supportedProviders.count - 1 { + Divider() + .opacity(0.35) + .padding(.leading, 52) + } + } } } } } - private func providerRow(_ item: ProviderRowItem) -> some View { + private func providerRow(_ item: ProviderRowItem, index: Int, count: Int) -> some View { let isEnabled = settings.isProviderEnabled(item.id) return HStack(alignment: .center, spacing: 10) { + Image(systemName: "line.3.horizontal") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.tertiary) + .frame(width: 16, height: 24) + .contentShape(Rectangle()) + .help("Drag to reorder") + + ProviderIconView(providerID: item.id, size: 18) + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Circle() @@ -324,12 +357,64 @@ private struct ProvidersSettingsTab: View { .accessibilityIdentifier("toggle-monitor-\(item.id.rawValue)") .padding(.top, 2) } - .padding(.vertical, 4) + .padding(.vertical, 6) .padding(.horizontal, 6) .background { - if focusedProviderID == item.id { - RoundedRectangle(cornerRadius: 6) + if targetedProviderID == item.id { + RoundedRectangle(cornerRadius: 8) .fill(Color.accentColor.opacity(0.12)) + } else if focusedProviderID == item.id { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.08)) + } + } + .draggable(item.id.rawValue) + .dropDestination(for: String.self) { droppedIDs, _ in + targetedProviderID = nil + guard let droppedRaw = droppedIDs.first else { return false } + let sourceID = ProviderID(droppedRaw) + guard sourceID != item.id, + let sourceIndex = settings.orderedProviderIDs.firstIndex(of: sourceID), + let targetIndex = settings.orderedProviderIDs.firstIndex(of: item.id) else { + return false + } + withAnimation(.snappy(duration: 0.22)) { + settings.moveProvider( + fromOffsets: IndexSet(integer: sourceIndex), + toOffset: targetIndex > sourceIndex ? targetIndex + 1 : targetIndex + ) + } + return true + } isTargeted: { isTargeted in + targetedProviderID = isTargeted ? item.id : (targetedProviderID == item.id ? nil : targetedProviderID) + } + .contextMenu { + Button("Move Up") { + guard index > 0 else { return } + withAnimation(.snappy(duration: 0.22)) { + settings.moveProvider(fromOffsets: IndexSet(integer: index), toOffset: index - 1) + } + } + .disabled(index == 0) + + Button("Move Down") { + guard index < count - 1 else { return } + withAnimation(.snappy(duration: 0.22)) { + settings.moveProvider(fromOffsets: IndexSet(integer: index), toOffset: index + 2) + } + } + .disabled(index >= count - 1) + } + .accessibilityAction(named: "Move Up") { + guard index > 0 else { return } + withAnimation(.snappy(duration: 0.22)) { + settings.moveProvider(fromOffsets: IndexSet(integer: index), toOffset: index - 1) + } + } + .accessibilityAction(named: "Move Down") { + guard index < count - 1 else { return } + withAnimation(.snappy(duration: 0.22)) { + settings.moveProvider(fromOffsets: IndexSet(integer: index), toOffset: index + 2) } } .accessibilityIdentifier("provider-row-\(item.id.rawValue)") @@ -559,20 +644,29 @@ struct SettingsForm: View { struct SettingsSection: View { @Environment(\.colorSchemeContrast) private var contrast private let title: String? + private let subtitle: String? private let content: Content - init(_ title: String? = nil, @ViewBuilder content: () -> Content) { + init(_ title: String? = nil, subtitle: String? = nil, @ViewBuilder content: () -> Content) { self.title = title + self.subtitle = subtitle self.content = content() } var body: some View { VStack(alignment: .leading, spacing: 8) { if let title { - Text(title) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.leading, 3) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + if let subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.leading, 3) } VStack(alignment: .leading, spacing: 14) { content } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Loom/UI/Settings/StorageSettingsTab.swift b/Loom/UI/Settings/StorageSettingsTab.swift index 13b7676..933359d 100644 --- a/Loom/UI/Settings/StorageSettingsTab.swift +++ b/Loom/UI/Settings/StorageSettingsTab.swift @@ -12,12 +12,21 @@ import LoomCore struct StorageSettingsTab: View { let hidePaths: Bool + var orderedProviderIDs: [ProviderID]? = nil @State private var measurements: [ProviderID: [StorageMeasurement]] = [:] @State private var scanning = false @State private var scannedAt: Date? @State private var scanID = UUID() private let providers = ProviderStorageLocations.local() + private var sortedProviders: [ProviderStorageLocations] { + let order = orderedProviderIDs ?? LoomSettings.defaultOrderedProviderIDs + let rank = Dictionary(uniqueKeysWithValues: order.enumerated().map { ($1, $0) }) + return providers.sorted { + (rank[$0.id] ?? Int.max) < (rank[$1.id] ?? Int.max) + } + } + var body: some View { SettingsForm { SettingsSection { @@ -36,7 +45,7 @@ struct StorageSettingsTab: View { .font(.caption).foregroundStyle(.secondary) } } - ForEach(providers) { provider in + ForEach(sortedProviders) { provider in SettingsSection(provider.name) { if let results = measurements[provider.id] { let bytes = results.reduce(Int64(0)) { $0 + $1.bytes } diff --git a/LoomWidget/Info.plist b/LoomWidget/Info.plist index 9cfda58..b0bcb36 100644 --- a/LoomWidget/Info.plist +++ b/LoomWidget/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 2.0.0 + 2.1.0 CFBundleVersion - 5 + 6 NSExtension NSExtensionPointIdentifier diff --git a/Tests/LoomCoreTests/AppVersionTests.swift b/Tests/LoomCoreTests/AppVersionTests.swift index 23e9f81..da3c29e 100644 --- a/Tests/LoomCoreTests/AppVersionTests.swift +++ b/Tests/LoomCoreTests/AppVersionTests.swift @@ -46,9 +46,9 @@ final class AppVersionTests: XCTestCase { XCTAssertEqual(version.marketingVersion, AppVersion.fallbackMarketingVersion) XCTAssertEqual(version.buildVersion, "dev") XCTAssertTrue(version.isDevelopmentBuild) - XCTAssertEqual(version.menuBarString, "v2.0.0-dev") - XCTAssertEqual(version.aboutString, "Version 2.0.0 (Dev)") - XCTAssertEqual(version.menuBarAccessibilityLabel, "Loom, version 2.0.0 development") + XCTAssertEqual(version.menuBarString, "v2.1.0-dev") + XCTAssertEqual(version.aboutString, "Version 2.1.0 (Dev)") + XCTAssertEqual(version.menuBarAccessibilityLabel, "Loom, version 2.1.0 development") } func testResolveFromEmptyInfoDictionaryGracefulFallback() { @@ -57,8 +57,8 @@ final class AppVersionTests: XCTestCase { XCTAssertEqual(version.marketingVersion, AppVersion.fallbackMarketingVersion) XCTAssertEqual(version.buildVersion, "dev") XCTAssertTrue(version.isDevelopmentBuild) - XCTAssertEqual(version.menuBarString, "v2.0.0-dev") - XCTAssertEqual(version.aboutString, "Version 2.0.0 (Dev)") + XCTAssertEqual(version.menuBarString, "v2.1.0-dev") + XCTAssertEqual(version.aboutString, "Version 2.1.0 (Dev)") } func testResolveWithWhitespaceTrimming() { diff --git a/Tests/LoomCoreTests/SettingsTests.swift b/Tests/LoomCoreTests/SettingsTests.swift index cdce249..4383204 100644 --- a/Tests/LoomCoreTests/SettingsTests.swift +++ b/Tests/LoomCoreTests/SettingsTests.swift @@ -817,4 +817,74 @@ final class SettingsTests: XCTestCase { XCTAssertTrue(settings.hasDismissedGitHubStarPrompt) XCTAssertFalse(settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: true, now: Date())) } + + @MainActor + func testOrderedProviderIDsDefaultsAndPersistence() { + let settings = LoomSettings(defaults: testDefaults) + XCTAssertEqual(settings.orderedProviderIDs, LoomSettings.defaultOrderedProviderIDs) + + var notifiedOrder: [ProviderID]? + settings.onOrderedProviderIDsChanged = { notifiedOrder = $0 } + + // Change order: Antigravity first + let newOrder: [ProviderID] = [.antigravity, .t3Code, .codex, .claude] + settings.orderedProviderIDs = newOrder + XCTAssertEqual(settings.orderedProviderIDs, newOrder) + XCTAssertEqual(notifiedOrder, newOrder) + + // Verify persisted to UserDefaults + let reloaded = LoomSettings(defaults: testDefaults) + XCTAssertEqual(reloaded.orderedProviderIDs, newOrder) + } + + @MainActor + func testMoveProviderReordering() { + let settings = LoomSettings(defaults: testDefaults) + // Default: [.t3Code, .codex, .claude, .antigravity] + XCTAssertEqual(settings.orderedProviderIDs, [.t3Code, .codex, .claude, .antigravity]) + + // Move .antigravity (index 3) to top (destination 0) + settings.moveProvider(fromOffsets: IndexSet(integer: 3), toOffset: 0) + XCTAssertEqual(settings.orderedProviderIDs, [.antigravity, .t3Code, .codex, .claude]) + + // Move .codex (now at index 2) down after .claude (index 3 -> destination 4) + settings.moveProvider(fromOffsets: IndexSet(integer: 2), toOffset: 4) + XCTAssertEqual(settings.orderedProviderIDs, [.antigravity, .t3Code, .claude, .codex]) + } + + @MainActor + func testOrderedProviderIDsSanitization() { + // Unknown or duplicate IDs are sanitized + testDefaults.set(["unknown", "antigravity", "antigravity", "claude"], forKey: LoomSettings.orderedProviderIDsKey) + let settings = LoomSettings(defaults: testDefaults) + // Antigravity and claude loaded first, then remaining known providers (.t3Code, .codex) appended + XCTAssertEqual(settings.orderedProviderIDs, [.antigravity, .claude, .t3Code, .codex]) + } + + @MainActor + func testSessionStoreEnabledSnapshotsRespectsOrderedProviderIDs() { + let settings = LoomSettings(defaults: testDefaults) + settings.orderedProviderIDs = [.antigravity, .claude, .codex, .t3Code] + + let fixture = DemoFixture.resolve() + let providers = fixture.makeProviders() + let store = SessionStore(providers: providers, fixture: fixture, enabledProviderIDs: LoomSettings.defaultProviderIDs) + store.settings = settings + + let order = store.enabledSnapshots.map(\.providerID) + XCTAssertEqual(order, [.antigravity, .claude, .codex, .t3Code], "enabledSnapshots must reflect settings.orderedProviderIDs order") + XCTAssertEqual(store.orderedSnapshots.map(\.providerID), [.antigravity, .claude, .codex, .t3Code], "orderedSnapshots must reflect settings.orderedProviderIDs order") + + let widgetSnapshot = LoomWidgetSnapshot.make(from: store) + XCTAssertEqual(widgetSnapshot.providers.map { ProviderID($0.id) }, [.antigravity, .claude, .codex, .t3Code], "Widget snapshot must reflect orderedProviderIDs order") + + // Reorder via moveProvider + settings.moveProvider(fromOffsets: IndexSet(integer: 3), toOffset: 0) // move .t3Code to top + let updatedOrder = store.enabledSnapshots.map(\.providerID) + XCTAssertEqual(updatedOrder, [.t3Code, .antigravity, .claude, .codex], "enabledSnapshots must dynamically update after reordering") + XCTAssertEqual(store.orderedSnapshots.map(\.providerID), [.t3Code, .antigravity, .claude, .codex], "orderedSnapshots must dynamically update after reordering") + + let updatedWidgetSnapshot = LoomWidgetSnapshot.make(from: store) + XCTAssertEqual(updatedWidgetSnapshot.providers.map { ProviderID($0.id) }, [.t3Code, .antigravity, .claude, .codex], "Widget snapshot must update after reordering") + } } From 5005e7119d46b0da241c25d5f0b64a7d7631968a Mon Sep 17 00:00:00 2001 From: ChaiWithCode <142526048+chaiwithcode@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:32:02 +0000 Subject: [PATCH 11/11] chore(release): bump script version defaults to 2.1.0 (build 6) --- scripts/build-app.sh | 4 ++-- scripts/release-package.sh | 2 +- scripts/verify-notifications.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/build-app.sh b/scripts/build-app.sh index f86927e..e058a4a 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -106,9 +106,9 @@ cat > "${CONTENTS_DIR}/Info.plist" <<'EOF' CFBundlePackageType APPL CFBundleShortVersionString - 2.0.0 + 2.1.0 CFBundleVersion - 5 + 6 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/scripts/release-package.sh b/scripts/release-package.sh index 0da417d..42e182c 100755 --- a/scripts/release-package.sh +++ b/scripts/release-package.sh @@ -14,7 +14,7 @@ if [[ -f "${ROOT_DIR}/.release.local" ]]; then source "${ROOT_DIR}/.release.local" fi -VERSION="${1:-${VERSION:-2.0.0}}" +VERSION="${1:-${VERSION:-2.1.0}}" SKIP_NOTARIZATION="${SKIP_NOTARIZATION:-0}" for arg in "$@"; do if [[ "${arg}" == "--skip-notarization" ]]; then diff --git a/scripts/verify-notifications.sh b/scripts/verify-notifications.sh index 2e9eb22..eef0597 100755 --- a/scripts/verify-notifications.sh +++ b/scripts/verify-notifications.sh @@ -39,7 +39,7 @@ cat << 'EOF' > "$probe_dir/NotificationProbe.app/Contents/Info.plist" CFBundleExecutable NotificationProbe CFBundleShortVersionString - 2.0.0 + 2.1.0 NSPrincipalClass NSApplication