diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b338a3991871..8780380e6094 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -48,7 +48,7 @@ reviews: - name: "cmux Swift logging" mode: error instructions: | - For production Swift changes, fail when the diff violates `.github/review-bot-rules/swift-logging.md`: `print`, `debugPrint`, `dump`, or `NSLog` in app/runtime code; ad hoc file/stdout logging for diagnostics; MainActor-coupled file-scoped Logger constants; or logs that expose secrets or personal data. Pass for CLI output, tests, debug-only logs, and explicitly sanitized provider diagnostics. + For production Swift changes, fail when the diff violates `.github/review-bot-rules/swift-logging.md`: `print`, `debugPrint`, `dump`, or `NSLog` in app/runtime code; ad hoc file/stdout logging for diagnostics; MainActor-coupled file-scoped Logger constants; or logs that expose secrets or personal data. Do not require new logs for new code paths; only check logging that the diff adds or materially changes. Pass for CLI output, tests, debug-only logs, and explicitly sanitized provider diagnostics. - name: "cmux SwiftUI state layout" mode: error instructions: | diff --git a/.github/review-bot-rules/swift-logging.md b/.github/review-bot-rules/swift-logging.md index c6a3d47b04ea..6df4b59df643 100644 --- a/.github/review-bot-rules/swift-logging.md +++ b/.github/review-bot-rules/swift-logging.md @@ -1,6 +1,10 @@ # Swift Unified Logging Flag production Swift logging that bypasses Apple's unified logging system. +Do not require new logs for new code paths. This rule only checks logging that +the diff adds or materially changes. Temporary debug probes belong in the +dogfood debug loop and should be removed before merge unless the PR explicitly +states that a sparse permanent diagnostic is intended. Report a failure when the diff introduces or materially expands: diff --git a/CLAUDE.md b/CLAUDE.md index aaa168637d92..9cb30cf1ed8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,13 @@ the source of truth for VM lifecycle, active VM limits, idempotency, and usage e ## Debug event log -All debug events (keys, mouse, focus, splits, tabs) go to a unified log in DEBUG builds: +When adding debug event instrumentation, put events (keys, mouse, focus, splits, tabs) +in the unified DEBUG build log: + +This section describes the required destination and shape for debug logs when they +are added. It is not a blanket requirement to add debug logs to every new code path. +Most temporary probes should be added only during the dogfood debug loop and removed +before merge. ```bash tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug.log)" diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 6e032d607468..9ea1f7913255 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ C0DE35010000000000000001 /* SidebarScrim.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35010000000000000002 /* SidebarScrim.swift */; }; 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */; }; + C3408A000000000000000005 /* ContentView+ViewCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */; }; D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */; }; C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */; }; C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */; }; @@ -194,7 +195,11 @@ A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A501000000000000000001 /* CmuxTopSnapshot.swift */; }; + C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */; }; C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A502000000000000000001 /* TaskManagerWindowController.swift */; }; + C7A506000000000000000002 /* TaskManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A506000000000000000001 /* TaskManagerView.swift */; }; + C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A507000000000000000001 /* TaskManagerResourcesTests.swift */; }; + C7A509000000000000000002 /* CmuxTopSnapshotScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */; }; C7A503000000000000000002 /* TaskManagerSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A503000000000000000001 /* TaskManagerSnapshot.swift */; }; C7A504000000000000000002 /* TaskManagerTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A504000000000000000001 /* TaskManagerTypes.swift */; }; C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A505000000000000000001 /* TerminalControllerTopSupport.swift */; }; @@ -447,6 +452,7 @@ A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; C0DE35010000000000000002 /* SidebarScrim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarScrim.swift; sourceTree = ""; }; C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarCommandPalette.swift"; sourceTree = ""; }; + C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+ViewCommandPalette.swift"; sourceTree = ""; }; D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; D7AB34300000000000000002 /* SidebarDropPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDropPlanner.swift; sourceTree = ""; }; D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; @@ -487,7 +493,11 @@ A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; C7A501000000000000000001 /* CmuxTopSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshot.swift; sourceTree = ""; }; + C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeCache.swift; sourceTree = ""; }; C7A502000000000000000001 /* TaskManagerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerWindowController.swift; sourceTree = ""; }; + C7A506000000000000000001 /* TaskManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerView.swift; sourceTree = ""; }; + C7A507000000000000000001 /* TaskManagerResourcesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerResourcesTests.swift; sourceTree = ""; }; + C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeTests.swift; sourceTree = ""; }; C7A503000000000000000001 /* TaskManagerSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerSnapshot.swift; sourceTree = ""; }; C7A504000000000000000001 /* TaskManagerTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerTypes.swift; sourceTree = ""; }; C7A505000000000000000001 /* TerminalControllerTopSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerTopSupport.swift; sourceTree = ""; }; @@ -844,6 +854,7 @@ A5001012 /* ContentView.swift */, C0DE35010000000000000002 /* SidebarScrim.swift */, C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */, + C3408A000000000000000006 /* ContentView+ViewCommandPalette.swift */, D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, D7AB34300000000000000002 /* SidebarDropPlanner.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, @@ -908,7 +919,9 @@ D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */, C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, + C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */, C7A502000000000000000001 /* TaskManagerWindowController.swift */, + C7A506000000000000000001 /* TaskManagerView.swift */, C7A503000000000000000001 /* TaskManagerSnapshot.swift */, C7A504000000000000000001 /* TaskManagerTypes.swift */, A5001541 /* PortScanner.swift */, @@ -1087,6 +1100,8 @@ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */, + C7A507000000000000000001 /* TaskManagerResourcesTests.swift */, + C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */, @@ -1352,6 +1367,7 @@ A5001002 /* ContentView.swift in Sources */, C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */, + C3408A000000000000000005 /* ContentView+ViewCommandPalette.swift in Sources */, D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */, D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */, D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, @@ -1416,7 +1432,9 @@ D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */, C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */, + C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, + C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A503000000000000000002 /* TaskManagerSnapshot.swift in Sources */, C7A504000000000000000002 /* TaskManagerTypes.swift in Sources */, A5001540 /* PortScanner.swift in Sources */, @@ -1632,6 +1650,8 @@ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */, + C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */, + C7A509000000000000000002 /* CmuxTopSnapshotScopeTests.swift in Sources */, D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, 9C1BEA3D2E6F49709A71C020 /* TerminalControllerSocketWriteTests.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 314676a0ad0d..2be2861f8bc8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110565,6 +110565,90 @@ "ja": { "stringUnit": { "state": "translated", "value": "ワークスペース、ターミナル、またはブラウザサーフェスを開くとここに表示されます。" } } } }, + "taskManager.contextMenu.viewWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "View Workspace" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースを表示" } } + } + }, + "taskManager.contextMenu.viewTerminal": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "View Terminal" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ターミナルを表示" } } + } + }, + "taskManager.contextMenu.killProcess": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Kill Process..." } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了..." } } + } + }, + "taskManager.killProcess.title.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Kill process?" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了しますか?" } } + } + }, + "taskManager.killProcess.message.one": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask %@ (PID %lld) to terminate gracefully. cmux will force-kill it if it is still running after a short grace period." } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@(PID %lld)に正常終了を要求します。短い猶予時間の後も実行中の場合、cmux が強制終了します。" } } + } + }, + "taskManager.killProcess.title.other": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Kill processes?" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了しますか?" } } + } + }, + "taskManager.killProcess.message.other": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ask %lld processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: %@." } }, + "ja": { "stringUnit": { "state": "translated", "value": "%lld 個のプロセスに正常終了を要求します。短い猶予時間の後も残っているプロセスは cmux が強制終了します。PID: %@。" } } + } + }, + "taskManager.killProcess.confirm": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Kill" } }, + "ja": { "stringUnit": { "state": "translated", "value": "終了" } } + } + }, + "taskManager.killProcess.cancel": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Cancel" } }, + "ja": { "stringUnit": { "state": "translated", "value": "キャンセル" } } + } + }, + "taskManager.killProcess.error": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Unable to kill process: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了できません: %@" } } + } + }, + "taskManager.killProcess.target.processGroup": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "process group %lld" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスグループ %lld" } } + } + }, + "taskManager.killProcess.target.pid": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "PID %lld" } }, + "ja": { "stringUnit": { "state": "translated", "value": "PID %lld" } } + } + }, "taskManager.updated.never": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index a10fc6fe831b..e0f621960e57 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -29,12 +29,21 @@ struct CmuxTopProcessInfo: Sendable { let name: String let path: String? let ttyDevice: Int64? + let cmuxWorkspaceID: UUID? + let cmuxSurfaceID: UUID? + let processGroupID: Int? + let terminalProcessGroupID: Int? let cpuPercent: Double let residentBytes: Int64 let virtualBytes: Int64 let threadCount: Int } +struct CmuxTopProcessScope: Sendable { + let workspaceID: UUID? + let surfaceID: UUID? +} + final class CmuxTopProcessSnapshot: @unchecked Sendable { private static let cpuScale = 2048.0 private static let pidPathBufferSize = 4096 @@ -44,6 +53,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { private let processesByPID: [Int: CmuxTopProcessInfo] private let childrenByParentPID: [Int: [Int]] private let pidsByTTYDevice: [Int64: [Int]] + private let pidsByCMUXSurfaceID: [UUID: [Int]] static func capture(includeProcessDetails: Bool = false) -> CmuxTopProcessSnapshot { CmuxTopProcessSnapshot( @@ -64,6 +74,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { var children: [Int: [Int]] = [:] var ttyMap: [Int64: [Int]] = [:] + var cmuxSurfaceMap: [UUID: [Int]] = [:] for process in processes { if process.parentPID > 0 { children[process.parentPID, default: []].append(process.pid) @@ -71,9 +82,13 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { if let ttyDevice = process.ttyDevice { ttyMap[ttyDevice, default: []].append(process.pid) } + if let cmuxSurfaceID = process.cmuxSurfaceID { + cmuxSurfaceMap[cmuxSurfaceID, default: []].append(process.pid) + } } self.childrenByParentPID = children.mapValues { $0.sorted() } self.pidsByTTYDevice = ttyMap.mapValues { $0.sorted() } + self.pidsByCMUXSurfaceID = cmuxSurfaceMap.mapValues { $0.sorted() } } func samplePayload() -> [String: Any] { @@ -93,6 +108,10 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return Set(pidsByTTYDevice[device] ?? []) } + func pids(forCMUXSurfaceID surfaceID: UUID) -> Set { + Set(pidsByCMUXSurfaceID[surfaceID] ?? []) + } + func expandedPIDs(rootPIDs: Set) -> Set { var result: Set = [] var stack = Array(rootPIDs.filter { $0 > 0 }) @@ -152,6 +171,28 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return roots.compactMap { processTreeNode(pid: $0, allowedPIDs: allowedPIDs, visited: &visited) } } + func topLevelPIDs(for pids: Set) -> Set { + let allowedPIDs = Set(pids.filter { processesByPID[$0] != nil }) + return allowedPIDs.filter { pid in + guard let parent = processesByPID[pid]?.parentPID else { return true } + return !allowedPIDs.contains(parent) + } + } + + func foregroundProcessGroupIDs(for pids: Set) -> Set { + Set( + pids.compactMap { pid in + guard let process = processesByPID[pid], + let processGroupID = process.processGroupID, + let foregroundGroupID = process.terminalProcessGroupID, + processGroupID == foregroundGroupID else { + return nil + } + return foregroundGroupID + } + ) + } + private func processTreeNode( pid: Int, allowedPIDs: Set, @@ -182,6 +223,26 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { } else { payload["tty_device"] = NSNull() } + if let cmuxWorkspaceID = process.cmuxWorkspaceID { + payload["cmux_workspace_id"] = cmuxWorkspaceID.uuidString + } else { + payload["cmux_workspace_id"] = NSNull() + } + if let cmuxSurfaceID = process.cmuxSurfaceID { + payload["cmux_surface_id"] = cmuxSurfaceID.uuidString + } else { + payload["cmux_surface_id"] = NSNull() + } + if let processGroupID = process.processGroupID { + payload["pgid"] = processGroupID + } else { + payload["pgid"] = NSNull() + } + if let terminalProcessGroupID = process.terminalProcessGroupID { + payload["tpgid"] = terminalProcessGroupID + } else { + payload["tpgid"] = NSNull() + } return payload } @@ -206,9 +267,13 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { } if result == 0 { let count = min(processes.count, length / stride) - return processes.prefix(count).compactMap { + let sampledProcesses = Array(processes.prefix(count)) + let activeScopeKeys = Set(sampledProcesses.map { scopeCacheKey(from: $0) }) + let processInfos = sampledProcesses.compactMap { processInfo(from: $0, includeProcessDetails: includeProcessDetails) } + pruneCMUXScopeCache(activeKeys: activeScopeKeys) + return processInfos } guard errno == ENOMEM else { @@ -231,6 +296,11 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { let path = includeProcessDetails ? processPath(pid: pid) : nil let rawTTY = Int64(kinfo.kp_eproc.e_tdev) let ttyDevice = rawTTY > 0 ? rawTTY : nil + let cmuxScope = cachedCMUXScope(for: pid, cacheKey: scopeCacheKey(from: kinfo)) + let rawProcessGroupID = Int(kinfo.kp_eproc.e_pgid) + let processGroupID = rawProcessGroupID > 0 ? rawProcessGroupID : nil + let rawTerminalProcessGroupID = Int(kinfo.kp_eproc.e_tpgid) + let terminalProcessGroupID = rawTerminalProcessGroupID > 0 ? rawTerminalProcessGroupID : nil return CmuxTopProcessInfo( pid: pid, @@ -238,6 +308,10 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { name: name.isEmpty ? "pid-\(pid)" : name, path: path, ttyDevice: ttyDevice, + cmuxWorkspaceID: cmuxScope?.workspaceID, + cmuxSurfaceID: cmuxScope?.surfaceID, + processGroupID: processGroupID, + terminalProcessGroupID: terminalProcessGroupID, cpuPercent: max(0, Double(kinfo.kp_proc.p_pctcpu) / cpuScale * 100.0), residentBytes: int64Clamped(taskInfo?.pti_resident_size ?? 0), virtualBytes: int64Clamped(taskInfo?.pti_virtual_size ?? 0), @@ -245,6 +319,98 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { ) } + static func cmuxScope(for pid: Int) -> CmuxTopProcessScope? { + guard pid > 0, pid <= Int(Int32.max) else { return nil } + + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] + var size: size_t = 0 + guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, + size > MemoryLayout.size else { + return nil + } + + var buffer = [UInt8](repeating: 0, count: size) + let success = buffer.withUnsafeMutableBytes { rawBuffer in + sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0 + } + guard success else { return nil } + + return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size)))) + } + + static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? { + guard bytes.count > MemoryLayout.size else { return nil } + + var argcRaw: Int32 = 0 + withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in + rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout.size)) + } + let argc = Int(Int32(littleEndian: argcRaw)) + guard argc > 0 else { return nil } + + var index = MemoryLayout.size + skipString(in: bytes, index: &index) + skipNulls(in: bytes, index: &index) + + for _ in 0.. String? { + let prefix = "\(key)=" + guard entry.hasPrefix(prefix) else { return nil } + let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private static func skipString(in bytes: [UInt8], index: inout Int) { + while index < bytes.count, bytes[index] != 0 { + index += 1 + } + } + + private static func skipNulls(in bytes: [UInt8], index: inout Int) { + while index < bytes.count, bytes[index] == 0 { + index += 1 + } + } + private static func taskInfo(for pid: Int) -> proc_taskinfo? { var info = proc_taskinfo() let expectedSize = MemoryLayout.stride diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift new file mode 100644 index 000000000000..97eafe3a7b03 --- /dev/null +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -0,0 +1,58 @@ +import Foundation +import Darwin + +struct CmuxTopProcessScopeCacheKey: Hashable { + let pid: Int + let startSeconds: Int + let startMicroseconds: Int +} + +private struct CmuxTopProcessScopeCacheValue { + let scope: CmuxTopProcessScope +} + +// CmuxTopProcessSnapshot.capture is intentionally synchronous because it backs +// both async task-manager sampling and sync v2 system.top socket handling. Keep +// this tiny lock isolated to dictionary reads/writes; procargs/sysctl work must +// happen outside the critical section. +private let cmuxTopScopeCacheLock = NSLock() +private var cmuxTopScopeCache: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue] = [:] + +extension CmuxTopProcessSnapshot { + static func scopeCacheKey(from kinfo: kinfo_proc) -> CmuxTopProcessScopeCacheKey { + let startTime = kinfo.kp_proc.p_un.__p_starttime + return CmuxTopProcessScopeCacheKey( + pid: Int(kinfo.kp_proc.p_pid), + startSeconds: Int(startTime.tv_sec), + startMicroseconds: Int(startTime.tv_usec) + ) + } + + static func cachedCMUXScope( + for pid: Int, + cacheKey: CmuxTopProcessScopeCacheKey + ) -> CmuxTopProcessScope? { + cmuxTopScopeCacheLock.lock() + if let cached = cmuxTopScopeCache[cacheKey] { + cmuxTopScopeCacheLock.unlock() + return cached.scope + } + cmuxTopScopeCacheLock.unlock() + + guard let scope = cmuxScope(for: pid) else { + return nil + } + + cmuxTopScopeCacheLock.lock() + cmuxTopScopeCache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope) + cmuxTopScopeCacheLock.unlock() + + return scope + } + + static func pruneCMUXScopeCache(activeKeys: Set) { + cmuxTopScopeCacheLock.lock() + cmuxTopScopeCache = cmuxTopScopeCache.filter { activeKeys.contains($0.key) } + cmuxTopScopeCacheLock.unlock() + } +} diff --git a/Sources/ContentView+ViewCommandPalette.swift b/Sources/ContentView+ViewCommandPalette.swift new file mode 100644 index 000000000000..6a434867acbf --- /dev/null +++ b/Sources/ContentView+ViewCommandPalette.swift @@ -0,0 +1,33 @@ +import Foundation + +extension ContentView { + static func commandPaletteViewCommandContributions() -> [CommandPaletteCommandContribution] { + func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { + { _ in value } + } + + return [ + CommandPaletteCommandContribution( + commandId: "palette.triggerFlash", + title: constant(String(localized: "command.triggerFlash.title", defaultValue: "Flash Focused Panel")), + subtitle: constant(String(localized: "command.triggerFlash.subtitle", defaultValue: "View")), + keywords: ["flash", "highlight", "focus", "panel"] + ), + CommandPaletteCommandContribution( + commandId: "palette.openTaskManager", + title: constant(String(localized: "taskManager.title", defaultValue: "Task Manager")), + subtitle: constant(String(localized: "command.closeWindow.subtitle", defaultValue: "Window")), + keywords: ["task", "manager", "process", "cpu", "memory", "kill"] + ), + ] + } + + func registerViewCommandHandlers(_ registry: inout CommandPaletteHandlerRegistry) { + registry.register(commandId: "palette.triggerFlash") { + tabManager.triggerFocusFlash() + } + registry.register(commandId: "palette.openTaskManager") { + TaskManagerWindowController.shared.show() + } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ed4009f4130f..687f65150a2c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7033,14 +7033,7 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.workspaceMinimalModeEnabled) } ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.triggerFlash", - title: constant(String(localized: "command.triggerFlash.title", defaultValue: "Flash Focused Panel")), - subtitle: constant(String(localized: "command.triggerFlash.subtitle", defaultValue: "View")), - keywords: ["flash", "highlight", "focus", "panel"] - ) - ) + contributions.append(contentsOf: Self.commandPaletteViewCommandContributions()) contributions.append( CommandPaletteCommandContribution( commandId: "palette.showNotifications", @@ -7915,9 +7908,7 @@ struct ContentView: View { registry.register(commandId: "palette.disableMinimalMode") { workspacePresentationMode = WorkspacePresentationModeSettings.Mode.standard.rawValue } - registry.register(commandId: "palette.triggerFlash") { - tabManager.triggerFocusFlash() - } + registerViewCommandHandlers(®istry) registry.register(commandId: "palette.showNotifications") { AppDelegate.shared?.toggleNotificationsPopover(animated: false) } diff --git a/Sources/TaskManagerSnapshot.swift b/Sources/TaskManagerSnapshot.swift index d3925269477c..351921364ff7 100644 --- a/Sources/TaskManagerSnapshot.swift +++ b/Sources/TaskManagerSnapshot.swift @@ -61,6 +61,7 @@ struct CmuxTaskManagerSnapshot { } private static func appendWorkspace(_ workspace: [String: Any], to rows: inout [CmuxTaskManagerRow]) { + let workspaceId = uuid(workspace["id"]) let title = nonEmptyString(workspace["title"]) ?? displayHandle(workspace) var detailParts: [String] = [] if bool(workspace["selected"]) { @@ -74,55 +75,72 @@ struct CmuxTaskManagerSnapshot { kind: .workspace, level: 1, title: title, - detail: detailParts.joined(separator: " / ") + detail: detailParts.joined(separator: " / "), + workspaceId: workspaceId )) let tags = workspace["tags"] as? [[String: Any]] ?? [] for tag in tags { - appendTag(tag, to: &rows) + appendTag(tag, workspaceId: workspaceId, to: &rows) } let panes = workspace["panes"] as? [[String: Any]] ?? [] for pane in panes { - appendPane(pane, to: &rows) + appendPane(pane, workspaceId: workspaceId, to: &rows) } } - private static func appendTag(_ tag: [String: Any], to rows: inout [CmuxTaskManagerRow]) { + private static func appendTag( + _ tag: [String: Any], + workspaceId: UUID?, + to rows: inout [CmuxTaskManagerRow] + ) { let key = nonEmptyString(tag["key"]) ?? String(localized: "taskManager.row.unknownTag", defaultValue: "Unknown tag") let value = nonEmptyString(tag["value"]) let title = value.map { "\(key): \($0)" } ?? key let detail = int(tag["pid"]).map { String(localized: "taskManager.row.pid", defaultValue: "PID \($0)") } ?? "" - rows.append(row(tag, kind: .tag, level: 2, title: title, detail: detail, isDimmed: bool(tag["visible"]) == false)) + rows.append(row( + tag, + kind: .tag, + level: 2, + title: title, + detail: detail, + isDimmed: bool(tag["visible"]) == false, + workspaceId: workspaceId, + agentAssetName: agentAssetName(for: [key, value]) + )) let processes = tag["processes"] as? [[String: Any]] ?? [] let context = rowID(tag, kind: .tag) for process in processes { - appendProcess(process, level: 3, context: context, to: &rows) + appendProcess(process, level: 3, context: context, workspaceId: workspaceId, terminalSurfaceId: nil, to: &rows) } } - private static func appendPane(_ pane: [String: Any], to rows: inout [CmuxTaskManagerRow]) { + private static func appendPane(_ pane: [String: Any], workspaceId: UUID?, to rows: inout [CmuxTaskManagerRow]) { let handle = displayHandle(pane) rows.append(row( pane, kind: .pane, level: 2, title: String(localized: "taskManager.row.pane", defaultValue: "Pane \(handle)"), - detail: bool(pane["focused"]) ? String(localized: "taskManager.row.focused", defaultValue: "Focused") : "" + detail: bool(pane["focused"]) ? String(localized: "taskManager.row.focused", defaultValue: "Focused") : "", + workspaceId: workspaceId )) let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] for surface in surfaces { - appendSurface(surface, to: &rows) + appendSurface(surface, workspaceId: workspaceId, to: &rows) } } - private static func appendSurface(_ surface: [String: Any], to rows: inout [CmuxTaskManagerRow]) { + private static func appendSurface(_ surface: [String: Any], workspaceId: UUID?, to rows: inout [CmuxTaskManagerRow]) { let type = (nonEmptyString(surface["type"]) ?? "unknown").lowercased() let title = nonEmptyString(surface["title"]) ?? displayHandle(surface) + let surfaceId = uuid(surface["id"]) + let terminalSurfaceId = type == "terminal" ? surfaceId : nil var detailParts = [surfaceTypeLabel(type)] if bool(surface["selected"]) { detailParts.append(String(localized: "taskManager.row.selected", defaultValue: "Selected")) @@ -138,23 +156,40 @@ struct CmuxTaskManagerSnapshot { kind: type == "browser" ? .browserSurface : .terminalSurface, level: 3, title: title, - detail: detailParts.joined(separator: " / ") + detail: detailParts.joined(separator: " / "), + workspaceId: workspaceId, + surfaceId: surfaceId, + terminalSurfaceId: terminalSurfaceId, + agentAssetName: agentAssetName(for: [title]) )) let webviews = surface["webviews"] as? [[String: Any]] ?? [] if !webviews.isEmpty { for webview in webviews { - appendWebView(webview, to: &rows) + appendWebView(webview, workspaceId: workspaceId, surfaceId: surfaceId, to: &rows) } } let processes = surface["processes"] as? [[String: Any]] ?? [] let context = rowID(surface, kind: type == "browser" ? .browserSurface : .terminalSurface) for process in processes { - appendProcess(process, level: 4, context: context, to: &rows) + appendProcess( + process, + level: 4, + context: context, + workspaceId: workspaceId, + surfaceId: surfaceId, + terminalSurfaceId: terminalSurfaceId, + to: &rows + ) } } - private static func appendWebView(_ webview: [String: Any], to rows: inout [CmuxTaskManagerRow]) { + private static func appendWebView( + _ webview: [String: Any], + workspaceId: UUID?, + surfaceId: UUID?, + to rows: inout [CmuxTaskManagerRow] + ) { let title = nonEmptyString(webview["title"]) ?? String(localized: "taskManager.row.webview", defaultValue: "WebView") var detailParts: [String] = [] @@ -167,12 +202,28 @@ struct CmuxTaskManagerSnapshot { if let url = nonEmptyString(webview["url"]) { detailParts.append(url) } - rows.append(row(webview, kind: .webview, level: 4, title: title, detail: detailParts.joined(separator: " / "))) + rows.append(row( + webview, + kind: .webview, + level: 4, + title: title, + detail: detailParts.joined(separator: " / "), + workspaceId: workspaceId, + surfaceId: surfaceId + )) let processes = webview["processes"] as? [[String: Any]] ?? [] let context = rowID(webview, kind: .webview) for process in processes { - appendProcess(process, level: 5, context: context, to: &rows) + appendProcess( + process, + level: 5, + context: context, + workspaceId: workspaceId, + surfaceId: surfaceId, + terminalSurfaceId: nil, + to: &rows + ) } } @@ -180,6 +231,9 @@ struct CmuxTaskManagerSnapshot { _ process: [String: Any], level: Int, context: String, + workspaceId: UUID?, + surfaceId: UUID? = nil, + terminalSurfaceId: UUID?, to rows: inout [CmuxTaskManagerRow] ) { let pid = int(process["pid"]) @@ -189,12 +243,40 @@ struct CmuxTaskManagerSnapshot { let detail = pid.map { String(localized: "taskManager.row.pid", defaultValue: "PID \($0)") } ?? "" - let processRow = row(process, kind: .process, level: level, title: title, detail: detail, context: context) + let processRootIds = pid.map { [$0] } ?? [] + let metadataSurfaceId = uuid(process["cmux_surface_id"]) + let processSurfaceId = surfaceId ?? metadataSurfaceId + let processTerminalSurfaceId = terminalSurfaceId ?? (surfaceId == nil ? metadataSurfaceId : nil) + let processRow = row( + process, + kind: .process, + level: level, + title: title, + detail: detail, + context: context, + workspaceId: workspaceId, + surfaceId: processSurfaceId, + terminalSurfaceId: processTerminalSurfaceId, + processId: pid, + rootProcessIds: processRootIds, + agentAssetName: agentAssetName(for: [ + nonEmptyString(process["name"]), + nonEmptyString(process["path"]).map { URL(fileURLWithPath: $0).lastPathComponent } + ]) + ) rows.append(processRow) let children = process["children"] as? [[String: Any]] ?? [] for child in children { - appendProcess(child, level: level + 1, context: processRow.id, to: &rows) + appendProcess( + child, + level: level + 1, + context: processRow.id, + workspaceId: workspaceId, + surfaceId: processSurfaceId, + terminalSurfaceId: processTerminalSurfaceId, + to: &rows + ) } } @@ -205,7 +287,14 @@ struct CmuxTaskManagerSnapshot { title: String, detail: String, isDimmed: Bool = false, - context: String? = nil + context: String? = nil, + workspaceId: UUID? = nil, + surfaceId: UUID? = nil, + terminalSurfaceId: UUID? = nil, + processId: Int? = nil, + rootProcessIds: [Int]? = nil, + foregroundProcessGroupIds: [Int]? = nil, + agentAssetName: String? = nil ) -> CmuxTaskManagerRow { CmuxTaskManagerRow( id: rowID(payload, kind: kind, context: context), @@ -214,7 +303,14 @@ struct CmuxTaskManagerSnapshot { title: title, detail: detail, resources: CmuxTaskManagerResources(payload["resources"] as? [String: Any] ?? [:]), - isDimmed: isDimmed + isDimmed: isDimmed, + workspaceId: workspaceId, + surfaceId: surfaceId, + terminalSurfaceId: terminalSurfaceId, + processId: processId, + rootProcessIds: rootProcessIds ?? intArray(payload["top_level_pids"]), + foregroundProcessGroupIds: foregroundProcessGroupIds ?? intArray(payload["foreground_pgids"]), + agentAssetName: agentAssetName ) } @@ -269,6 +365,26 @@ struct CmuxTaskManagerSnapshot { return trimmed.isEmpty ? nil : trimmed } + private static func uuid(_ raw: Any?) -> UUID? { + guard let value = nonEmptyString(raw) else { return nil } + return UUID(uuidString: value) + } + + private static func agentAssetName(for candidates: [String?]) -> String? { + for candidate in candidates.compactMap({ $0?.lowercased() }) { + if candidate.contains("opencode") { + return SessionAgent.opencode.assetName + } + if candidate.contains("claude") { + return SessionAgent.claude.assetName + } + if candidate.contains("codex") { + return SessionAgent.codex.assetName + } + } + return nil + } + private static func bool(_ raw: Any?) -> Bool { if let value = raw as? Bool { return value } if let value = raw as? NSNumber { return value.boolValue } @@ -283,4 +399,10 @@ struct CmuxTaskManagerSnapshot { } return nil } + + private static func intArray(_ raw: Any?) -> [Int] { + if let values = raw as? [Int] { return values } + guard let values = raw as? [Any] else { return [] } + return values.compactMap(int) + } } diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index 4bc47a0151ad..a4d7873a7968 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import SwiftUI @@ -46,6 +47,61 @@ struct CmuxTaskManagerRow: Identifiable { let detail: String let resources: CmuxTaskManagerResources let isDimmed: Bool + let workspaceId: UUID? + let surfaceId: UUID? + let terminalSurfaceId: UUID? + let processId: Int? + let rootProcessIds: [Int] + let foregroundProcessGroupIds: [Int] + let agentAssetName: String? + + var canViewWorkspace: Bool { + workspaceId != nil + } + + var canViewTerminal: Bool { + workspaceId != nil && terminalSurfaceId != nil + } + + var canKillProcess: Bool { + !killableProcessIds.isEmpty + } + + var killableProcessIds: [Int] { + var ids = resources.processIds + if let processId { + ids.append(processId) + } + let currentPID = Int(getpid()) + return Array(Set(ids)) + .filter { $0 > 1 && $0 != currentPID } + .sorted() + } + + var gracefulProcessIds: [Int] { + var ids = rootProcessIds + if ids.isEmpty, let processId { + ids.append(processId) + } + if ids.isEmpty { + ids = resources.processIds + } + return safeProcessIds(ids) + } + + var gracefulProcessGroupIds: [Int] { + let currentProcessGroupId = Int(getpgrp()) + return Array(Set(foregroundProcessGroupIds)) + .filter { $0 > 1 && $0 != currentProcessGroupId } + .sorted() + } + + private func safeProcessIds(_ ids: [Int]) -> [Int] { + let currentPID = Int(getpid()) + return Array(Set(ids)) + .filter { $0 > 1 && $0 != currentPID } + .sorted() + } } struct CmuxTaskManagerResources { @@ -54,17 +110,20 @@ struct CmuxTaskManagerResources { let cpuPercent: Double let residentBytes: Int64 let processCount: Int + let processIds: [Int] init(cpuPercent: Double, residentBytes: Int64, processCount: Int) { self.cpuPercent = cpuPercent self.residentBytes = residentBytes self.processCount = processCount + self.processIds = [] } init(_ payload: [String: Any]) { self.cpuPercent = Self.double(payload["cpu_percent"]) self.residentBytes = Self.int64(payload["resident_bytes"]) self.processCount = Self.int(payload["process_count"]) ?? 0 + self.processIds = Self.intArray(payload["pids"]) } private static func double(_ raw: Any?) -> Double { @@ -96,6 +155,12 @@ struct CmuxTaskManagerResources { } return nil } + + private static func intArray(_ raw: Any?) -> [Int] { + if let values = raw as? [Int] { return values } + guard let values = raw as? [Any] else { return [] } + return values.compactMap(int) + } } enum CmuxTaskManagerFormat { diff --git a/Sources/TaskManagerView.swift b/Sources/TaskManagerView.swift new file mode 100644 index 000000000000..c9e236cfc899 --- /dev/null +++ b/Sources/TaskManagerView.swift @@ -0,0 +1,262 @@ +import SwiftUI + +struct CmuxTaskManagerView: View { + @ObservedObject var model: CmuxTaskManagerModel + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + summary + Divider() + tableHeader + Divider() + tableBody + } + .frame(minWidth: 820, minHeight: 480) + .onAppear { + model.start() + } + .onDisappear { + model.stop() + } + } + + private var toolbar: some View { + HStack(spacing: 12) { + Text(String(localized: "taskManager.title", defaultValue: "Task Manager")) + .font(.title3.weight(.semibold)) + + if model.isRefreshing { + ProgressView() + .controlSize(.small) + .accessibilityLabel(String(localized: "taskManager.refreshing", defaultValue: "Refreshing")) + } + + Spacer() + + Toggle( + String(localized: "taskManager.showProcesses", defaultValue: "Processes"), + isOn: $model.includesProcesses + ) + .toggleStyle(.checkbox) + + Button { + model.refresh(force: true) + } label: { + Label(String(localized: "taskManager.refresh", defaultValue: "Refresh"), systemImage: "arrow.clockwise") + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private var summary: some View { + HStack(spacing: 24) { + metric( + title: String(localized: "taskManager.summary.cpu", defaultValue: "CPU"), + value: CmuxTaskManagerFormat.cpu(model.snapshot.total.cpuPercent) + ) + metric( + title: String(localized: "taskManager.summary.memory", defaultValue: "Memory"), + value: CmuxTaskManagerFormat.bytes(model.snapshot.total.residentBytes) + ) + metric( + title: String(localized: "taskManager.summary.processes", defaultValue: "Processes"), + value: "\(model.snapshot.total.processCount)" + ) + metric( + title: String(localized: "taskManager.summary.updated", defaultValue: "Updated"), + value: model.snapshot.updatedText + ) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func metric(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.body, design: .monospaced).weight(.semibold)) + .monospacedDigit() + } + } + + private var tableHeader: some View { + HStack(spacing: 8) { + Text(String(localized: "taskManager.column.name", defaultValue: "Name")) + .frame(maxWidth: .infinity, alignment: .leading) + Text(String(localized: "taskManager.column.cpu", defaultValue: "CPU")) + .frame(width: 82, alignment: .trailing) + Text(String(localized: "taskManager.column.memory", defaultValue: "Memory")) + .frame(width: 96, alignment: .trailing) + Text(String(localized: "taskManager.column.processes", defaultValue: "Proc")) + .frame(width: 58, alignment: .trailing) + } + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .padding(.horizontal, 16) + .padding(.vertical, 5) + } + + @ViewBuilder + private var tableBody: some View { + if let errorMessage = model.errorMessage { + CmuxTaskManagerMessageView( + title: String(localized: "taskManager.error.title", defaultValue: "Unable to load resource usage"), + detail: errorMessage + ) + } else if model.snapshot.rows.isEmpty { + CmuxTaskManagerMessageView( + title: String(localized: "taskManager.empty.title", defaultValue: "No resource usage"), + detail: String(localized: "taskManager.empty.detail", defaultValue: "Open a workspace, terminal, or browser surface to see it here.") + ) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(model.snapshot.rows) { row in + CmuxTaskManagerRowView( + row: row, + onViewWorkspace: { + model.viewWorkspace(for: row) + }, + onViewTerminal: { + model.viewTerminal(for: row) + }, + onKillProcess: { + model.killProcess(for: row) + }, + onActivate: { + model.viewBestTarget(for: row) + } + ) + Divider() + .padding(.leading, 16) + } + } + } + } + } +} + +private struct CmuxTaskManagerMessageView: View { + let title: String + let detail: String + + var body: some View { + VStack(spacing: 8) { + Text(title) + .font(.headline) + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .textSelection(.enabled) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(32) + } +} + +private struct CmuxTaskManagerRowView: View { + let row: CmuxTaskManagerRow + let onViewWorkspace: () -> Void + let onViewTerminal: () -> Void + let onKillProcess: () -> Void + let onActivate: () -> Void + + var body: some View { + Button(action: onActivate) { + rowContent + } + .buttonStyle(.plain) + .contextMenu { + if row.canViewWorkspace { + Button { + onViewWorkspace() + } label: { + Label( + String(localized: "taskManager.contextMenu.viewWorkspace", defaultValue: "View Workspace"), + systemImage: "rectangle.stack" + ) + } + } + if row.canViewTerminal { + Button { + onViewTerminal() + } label: { + Label( + String(localized: "taskManager.contextMenu.viewTerminal", defaultValue: "View Terminal"), + systemImage: "terminal" + ) + } + } + if row.canKillProcess { + Divider() + Button { + onKillProcess() + } label: { + Label( + String(localized: "taskManager.contextMenu.killProcess", defaultValue: "Kill Process..."), + systemImage: "xmark.octagon" + ) + } + } + } + } + + private var rowContent: some View { + HStack(spacing: 8) { + HStack(spacing: 5) { + Color.clear + .frame(width: CGFloat(row.level) * 14) + rowIcon + VStack(alignment: .leading, spacing: 0) { + Text(row.title) + .font(.system(size: 12.5)) + .lineLimit(1) + if !row.detail.isEmpty { + Text(row.detail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + Text(CmuxTaskManagerFormat.cpu(row.resources.cpuPercent)) + .frame(width: 82, alignment: .trailing) + Text(CmuxTaskManagerFormat.bytes(row.resources.residentBytes)) + .frame(width: 96, alignment: .trailing) + Text("\(row.resources.processCount)") + .frame(width: 58, alignment: .trailing) + } + .font(.system(size: 12.5, design: .default)) + .monospacedDigit() + .padding(.horizontal, 16) + .padding(.vertical, 3) + .opacity(row.isDimmed ? 0.68 : 1) + .contentShape(Rectangle()) + } + + @ViewBuilder + private var rowIcon: some View { + if let agentAssetName = row.agentAssetName { + Image(agentAssetName) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + } else { + Image(systemName: row.kind.systemImage) + .foregroundStyle(row.kind.tint) + .font(.system(size: 12)) + .frame(width: 14) + } + } +} diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index b209b0dc39a2..f035d56b2ceb 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import Darwin import SwiftUI @MainActor @@ -47,7 +48,7 @@ final class TaskManagerWindowController: NSWindowController, NSWindowDelegate { } @MainActor -private final class CmuxTaskManagerModel: ObservableObject { +final class CmuxTaskManagerModel: ObservableObject { @Published private(set) var snapshot = CmuxTaskManagerSnapshot.empty @Published private(set) var isRefreshing = false @Published private(set) var errorMessage: String? @@ -60,7 +61,9 @@ private final class CmuxTaskManagerModel: ObservableObject { private var refreshTimer: Timer? private var refreshTask: Task? + private var terminationTimers: [UUID: Timer] = [:] private let refreshInterval: TimeInterval = 3.0 + private let terminationGraceInterval: TimeInterval = 2.0 func start() { guard refreshTimer == nil else { @@ -109,193 +112,185 @@ private final class CmuxTaskManagerModel: ObservableObject { self?.refreshTask = nil } } -} - -private struct CmuxTaskManagerView: View { - @ObservedObject var model: CmuxTaskManagerModel - var body: some View { - VStack(spacing: 0) { - toolbar - Divider() - summary - Divider() - tableHeader - Divider() - tableBody - } - .frame(minWidth: 820, minHeight: 480) - .onAppear { - model.start() - } - .onDisappear { - model.stop() + func viewBestTarget(for row: CmuxTaskManagerRow) { + if row.canViewTerminal { + viewTerminal(for: row) + } else if row.canViewWorkspace { + viewWorkspace(for: row) } } - private var toolbar: some View { - HStack(spacing: 12) { - Text(String(localized: "taskManager.title", defaultValue: "Task Manager")) - .font(.title3.weight(.semibold)) + func viewWorkspace(for row: CmuxTaskManagerRow) { + guard let workspaceId = row.workspaceId, + let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } + manager.focusTab(workspaceId, surfaceId: row.surfaceId, suppressFlash: true) + flashSelection(workspaceId: workspaceId, surfaceId: row.surfaceId) + } - if model.isRefreshing { - ProgressView() - .controlSize(.small) - .accessibilityLabel(String(localized: "taskManager.refreshing", defaultValue: "Refreshing")) - } + func viewTerminal(for row: CmuxTaskManagerRow) { + guard let workspaceId = row.workspaceId, + let terminalSurfaceId = row.terminalSurfaceId, + let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } + manager.focusTab(workspaceId, surfaceId: terminalSurfaceId, suppressFlash: true) + flashSelection(workspaceId: workspaceId, surfaceId: terminalSurfaceId) + } - Spacer() + func killProcess(for row: CmuxTaskManagerRow) { + let processIds = row.killableProcessIds + guard !processIds.isEmpty else { return } + guard confirmKillProcess(row: row, processIds: processIds) else { return } - Toggle( - String(localized: "taskManager.showProcesses", defaultValue: "Processes"), - isOn: $model.includesProcesses - ) - .toggleStyle(.checkbox) + var failures: [(target: String, reason: String)] = [] + var sentGracefulSignal = false + for processGroupId in row.gracefulProcessGroupIds { + if let reason = sendSignal(SIGTERM, toProcessGroupId: processGroupId) { + failures.append((processGroupTargetLabel(processGroupId), reason)) + } else { + sentGracefulSignal = true + } + } - Button { - model.refresh(force: true) - } label: { - Label(String(localized: "taskManager.refresh", defaultValue: "Refresh"), systemImage: "arrow.clockwise") + let escalationProcessIds = Array(Set(row.gracefulProcessIds + processIds)).sorted() + for processId in escalationProcessIds { + if let reason = sendSignal(SIGTERM, toProcessId: processId) { + failures.append((processTargetLabel(processId), reason)) + } else { + sentGracefulSignal = true } } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - private var summary: some View { - HStack(spacing: 24) { - metric( - title: String(localized: "taskManager.summary.cpu", defaultValue: "CPU"), - value: CmuxTaskManagerFormat.cpu(model.snapshot.total.cpuPercent) - ) - metric( - title: String(localized: "taskManager.summary.memory", defaultValue: "Memory"), - value: CmuxTaskManagerFormat.bytes(model.snapshot.total.residentBytes) - ) - metric( - title: String(localized: "taskManager.summary.processes", defaultValue: "Processes"), - value: "\(model.snapshot.total.processCount)" - ) - metric( - title: String(localized: "taskManager.summary.updated", defaultValue: "Updated"), - value: model.snapshot.updatedText - ) - Spacer() + if failures.isEmpty { + scheduleForceKillIfNeeded(processIds: escalationProcessIds) + } else { + let detail = failures + .map { "\($0.target): \($0.reason)" } + .joined(separator: ", ") + errorMessage = String(format: String( + localized: "taskManager.killProcess.error", + defaultValue: "Unable to kill process: %@" + ), detail) + if sentGracefulSignal { + scheduleForceKillIfNeeded(processIds: escalationProcessIds) + } else { + refresh(force: true) + } } - .padding(.horizontal, 16) - .padding(.vertical, 10) } - private func metric(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.caption) - .foregroundStyle(.secondary) - Text(value) - .font(.system(.body, design: .monospaced).weight(.semibold)) - .monospacedDigit() + private func confirmKillProcess(row: CmuxTaskManagerRow, processIds: [Int]) -> Bool { + let alert = NSAlert() + if processIds.count == 1, let processId = processIds.first { + alert.messageText = String(localized: "taskManager.killProcess.title.one", defaultValue: "Kill process?") + alert.informativeText = String(format: String( + localized: "taskManager.killProcess.message.one", + defaultValue: "Ask %@ (PID %lld) to terminate gracefully. cmux will force-kill it if it is still running after a short grace period." + ), row.title, Int64(processId)) + } else { + let pidList = processIds.map(String.init).joined(separator: ", ") + alert.messageText = String(localized: "taskManager.killProcess.title.other", defaultValue: "Kill processes?") + alert.informativeText = String(format: String( + localized: "taskManager.killProcess.message.other", + defaultValue: "Ask %lld processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: %@." + ), Int64(processIds.count), pidList) } + alert.alertStyle = .warning + alert.addButton(withTitle: String(localized: "taskManager.killProcess.confirm", defaultValue: "Kill")) + alert.addButton(withTitle: String(localized: "taskManager.killProcess.cancel", defaultValue: "Cancel")) + if let cancelButton = alert.buttons.dropFirst().first { + cancelButton.keyEquivalent = "\u{1b}" + } + return alert.runModal() == .alertFirstButtonReturn } - private var tableHeader: some View { - HStack(spacing: 8) { - Text(String(localized: "taskManager.column.name", defaultValue: "Name")) - .frame(maxWidth: .infinity, alignment: .leading) - Text(String(localized: "taskManager.column.cpu", defaultValue: "CPU")) - .frame(width: 82, alignment: .trailing) - Text(String(localized: "taskManager.column.memory", defaultValue: "Memory")) - .frame(width: 96, alignment: .trailing) - Text(String(localized: "taskManager.column.processes", defaultValue: "Proc")) - .frame(width: 58, alignment: .trailing) - } - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 16) - .padding(.vertical, 5) + private func processGroupTargetLabel(_ processGroupId: Int) -> String { + String(format: String( + localized: "taskManager.killProcess.target.processGroup", + defaultValue: "process group %lld" + ), Int64(processGroupId)) } - @ViewBuilder - private var tableBody: some View { - if let errorMessage = model.errorMessage { - CmuxTaskManagerMessageView( - title: String(localized: "taskManager.error.title", defaultValue: "Unable to load resource usage"), - detail: errorMessage - ) - } else if model.snapshot.rows.isEmpty { - CmuxTaskManagerMessageView( - title: String(localized: "taskManager.empty.title", defaultValue: "No resource usage"), - detail: String(localized: "taskManager.empty.detail", defaultValue: "Open a workspace, terminal, or browser surface to see it here.") - ) - } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(model.snapshot.rows) { row in - CmuxTaskManagerRowView(row: row) - Divider() - .padding(.leading, 16) - } - } - } - } + private func processTargetLabel(_ processId: Int) -> String { + String(format: String( + localized: "taskManager.killProcess.target.pid", + defaultValue: "PID %lld" + ), Int64(processId)) + } + + private func sendSignal(_ signal: Int32, toProcessId processId: Int) -> String? { + guard processId > 1, processId != Int(getpid()) else { return nil } + return signalResult(Darwin.kill(pid_t(processId), signal)) } -} -private struct CmuxTaskManagerMessageView: View { - let title: String - let detail: String + private func sendSignal(_ signal: Int32, toProcessGroupId processGroupId: Int) -> String? { + guard processGroupId > 1, processGroupId != Int(getpgrp()) else { return nil } + return signalResult(Darwin.kill(pid_t(-processGroupId), signal)) + } - var body: some View { - VStack(spacing: 8) { - Text(title) - .font(.headline) - Text(detail) - .font(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .textSelection(.enabled) + private func signalResult(_ result: Int32) -> String? { + guard result != 0 else { return nil } + let failureErrno = errno + guard failureErrno != ESRCH else { return nil } + return String(cString: strerror(failureErrno)) + } + + private func scheduleForceKillIfNeeded(processIds: [Int]) { + let operationId = UUID() + let timer = Timer.scheduledTimer(withTimeInterval: terminationGraceInterval, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + self?.terminationTimers.removeValue(forKey: operationId) + self?.forceKillSurvivors(processIds: processIds) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(32) + timer.tolerance = 0.25 + terminationTimers[operationId] = timer + refresh(force: true) } -} -private struct CmuxTaskManagerRowView: View { - let row: CmuxTaskManagerRow + private func forceKillSurvivors(processIds: [Int]) { + let survivors = processIds.filter(isProcessRunning) + guard !survivors.isEmpty else { + refresh(force: true) + return + } - var body: some View { - HStack(spacing: 8) { - HStack(spacing: 5) { - Color.clear - .frame(width: CGFloat(row.level) * 14) - Image(systemName: row.kind.systemImage) - .foregroundStyle(row.kind.tint) - .font(.system(size: 12)) - .frame(width: 14) - VStack(alignment: .leading, spacing: 0) { - Text(row.title) - .font(.system(size: 12.5)) - .lineLimit(1) - if !row.detail.isEmpty { - Text(row.detail) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } + var failures: [(target: String, reason: String)] = [] + for processId in survivors { + if let reason = sendSignal(SIGKILL, toProcessId: processId) { + failures.append((processTargetLabel(processId), reason)) } - .frame(maxWidth: .infinity, alignment: .leading) + } + + if failures.isEmpty { + refresh(force: true) + } else { + let detail = failures + .map { "\($0.target): \($0.reason)" } + .joined(separator: ", ") + errorMessage = String(format: String( + localized: "taskManager.killProcess.error", + defaultValue: "Unable to kill process: %@" + ), detail) + refresh(force: true) + } + } - Text(CmuxTaskManagerFormat.cpu(row.resources.cpuPercent)) - .frame(width: 82, alignment: .trailing) - Text(CmuxTaskManagerFormat.bytes(row.resources.residentBytes)) - .frame(width: 96, alignment: .trailing) - Text("\(row.resources.processCount)") - .frame(width: 58, alignment: .trailing) + private func isProcessRunning(_ processId: Int) -> Bool { + guard processId > 1, processId != Int(getpid()) else { return false } + errno = 0 + let result = Darwin.kill(pid_t(processId), 0) + if result == 0 { + return true } - .font(.system(size: 12.5, design: .default)) - .monospacedDigit() - .padding(.horizontal, 16) - .padding(.vertical, 3) - .opacity(row.isDimmed ? 0.68 : 1) + return errno == EPERM + } + + private func flashSelection(workspaceId: UUID, surfaceId: UUID?) { + guard let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { return } + let targetSurfaceId = surfaceId ?? workspace.focusedPanelId + guard let targetSurfaceId, + let panel = workspace.panels[targetSurfaceId] else { return } + panel.triggerFlash(reason: .debug) } } diff --git a/Sources/TerminalControllerTopSupport.swift b/Sources/TerminalControllerTopSupport.swift index 1e0c62151585..b8561b2c16fd 100644 --- a/Sources/TerminalControllerTopSupport.swift +++ b/Sources/TerminalControllerTopSupport.swift @@ -46,6 +46,8 @@ extension TerminalController { for index in windows.indices { var workspaces = windows[index]["workspaces"] as? [[String: Any]] ?? [] var windowPIDs: Set = [] + var windowTopLevelPIDs: Set = [] + var windowForegroundProcessGroupIDs: Set = [] for workspaceIndex in workspaces.indices { windowPIDs.formUnion( v2AnnotateTopWorkspace( @@ -55,8 +57,12 @@ extension TerminalController { includeProcesses: includeProcesses ) ) + windowTopLevelPIDs.formUnion(v2TopIntArray(workspaces[workspaceIndex]["top_level_pids"])) + windowForegroundProcessGroupIDs.formUnion(v2TopIntArray(workspaces[workspaceIndex]["foreground_pgids"])) } windows[index]["workspaces"] = workspaces + windows[index]["top_level_pids"] = windowTopLevelPIDs.sorted() + windows[index]["foreground_pgids"] = windowForegroundProcessGroupIDs.sorted() windows[index]["resources"] = processSnapshot.summaryPayload(for: windowPIDs) allPIDs.formUnion(windowPIDs) } @@ -70,6 +76,8 @@ extension TerminalController { includeProcesses: Bool ) -> Set { var workspacePIDs: Set = [] + var workspaceTopLevelPIDs: Set = [] + var workspaceForegroundProcessGroupIDs: Set = [] var panes = workspace["panes"] as? [[String: Any]] ?? [] for paneIndex in panes.indices { @@ -81,6 +89,8 @@ extension TerminalController { includeProcesses: includeProcesses ) ) + workspaceTopLevelPIDs.formUnion(v2TopIntArray(panes[paneIndex]["top_level_pids"])) + workspaceForegroundProcessGroupIDs.formUnion(v2TopIntArray(panes[paneIndex]["foreground_pgids"])) } workspace["panes"] = panes @@ -93,9 +103,13 @@ extension TerminalController { includeProcesses: includeProcesses ) ) + workspaceTopLevelPIDs.formUnion(v2TopIntArray(tags[tagIndex]["top_level_pids"])) + workspaceForegroundProcessGroupIDs.formUnion(v2TopIntArray(tags[tagIndex]["foreground_pgids"])) } workspace["tags"] = tags + workspace["top_level_pids"] = workspaceTopLevelPIDs.sorted() + workspace["foreground_pgids"] = workspaceForegroundProcessGroupIDs.sorted() workspace["resources"] = processSnapshot.summaryPayload(for: workspacePIDs) return workspacePIDs } @@ -107,6 +121,8 @@ extension TerminalController { includeProcesses: Bool ) -> Set { var panePIDs: Set = [] + var paneTopLevelPIDs: Set = [] + var paneForegroundProcessGroupIDs: Set = [] var surfaces = pane["surfaces"] as? [[String: Any]] ?? [] for surfaceIndex in surfaces.indices { panePIDs.formUnion( @@ -117,8 +133,12 @@ extension TerminalController { includeProcesses: includeProcesses ) ) + paneTopLevelPIDs.formUnion(v2TopIntArray(surfaces[surfaceIndex]["top_level_pids"])) + paneForegroundProcessGroupIDs.formUnion(v2TopIntArray(surfaces[surfaceIndex]["foreground_pgids"])) } pane["surfaces"] = surfaces + pane["top_level_pids"] = paneTopLevelPIDs.sorted() + pane["foreground_pgids"] = paneForegroundProcessGroupIDs.sorted() pane["resources"] = processSnapshot.summaryPayload(for: panePIDs) return panePIDs } @@ -132,6 +152,15 @@ extension TerminalController { var rootPIDs: Set = [] var surfacePIDs: Set = [] + if let surfaceID = v2TopUUID(surface["id"]) { + let cmuxPIDs = processSnapshot.pids(forCMUXSurfaceID: surfaceID) + surface["cmux_process_pids"] = cmuxPIDs.sorted() + rootPIDs.formUnion(cmuxPIDs) + surfacePIDs.formUnion(processSnapshot.expandedPIDs(rootPIDs: cmuxPIDs)) + } else { + surface["cmux_process_pids"] = [] + } + if let ttyName = surface["tty"] as? String { let ttyPIDs = processSnapshot.pids(forTTYName: ttyName) surface["tty_process_pids"] = ttyPIDs.sorted() @@ -158,6 +187,8 @@ extension TerminalController { surface["webviews"] = webviews surface["root_pids"] = rootPIDs.sorted() + surface["top_level_pids"] = processSnapshot.topLevelPIDs(for: surfacePIDs).sorted() + surface["foreground_pgids"] = processSnapshot.foregroundProcessGroupIDs(for: surfacePIDs).sorted() surface["resources"] = processSnapshot.summaryPayload(for: surfacePIDs, rootPIDs: rootPIDs) surface["processes"] = includeProcesses ? processSnapshot.processTreePayload(for: surfacePIDs, rootPIDs: rootPIDs) : [] return surfacePIDs @@ -172,6 +203,8 @@ extension TerminalController { guard let pid = v2TopInt(webview["pid"]) else { webview["shared_process_count"] = NSNull() webview["root_pids"] = [] + webview["top_level_pids"] = [] + webview["foreground_pgids"] = [] webview["resources"] = processSnapshot.summaryPayload(for: []) webview["processes"] = [] return [] @@ -181,6 +214,8 @@ extension TerminalController { let pids = processSnapshot.expandedPIDs(rootPIDs: rootPIDs) webview["shared_process_count"] = browserPIDOccurrences[pid] ?? 1 webview["root_pids"] = rootPIDs.sorted() + webview["top_level_pids"] = processSnapshot.topLevelPIDs(for: pids).sorted() + webview["foreground_pgids"] = processSnapshot.foregroundProcessGroupIDs(for: pids).sorted() webview["resources"] = processSnapshot.summaryPayload(for: pids, rootPIDs: rootPIDs) webview["processes"] = includeProcesses ? processSnapshot.processTreePayload(for: pids, rootPIDs: rootPIDs) : [] return pids @@ -193,6 +228,8 @@ extension TerminalController { ) -> Set { guard let pid = v2TopInt(tag["pid"]) else { tag["root_pids"] = [] + tag["top_level_pids"] = [] + tag["foreground_pgids"] = [] tag["resources"] = processSnapshot.summaryPayload(for: []) tag["processes"] = [] return [] @@ -201,6 +238,8 @@ extension TerminalController { let rootPIDs: Set = [pid] let pids = processSnapshot.expandedPIDs(rootPIDs: rootPIDs) tag["root_pids"] = rootPIDs.sorted() + tag["top_level_pids"] = processSnapshot.topLevelPIDs(for: pids).sorted() + tag["foreground_pgids"] = processSnapshot.foregroundProcessGroupIDs(for: pids).sorted() tag["resources"] = processSnapshot.summaryPayload(for: pids, rootPIDs: rootPIDs) tag["processes"] = includeProcesses ? processSnapshot.processTreePayload(for: pids, rootPIDs: rootPIDs) : [] return pids @@ -218,4 +257,23 @@ extension TerminalController { } return nil } + + nonisolated func v2TopIntArray(_ raw: Any?) -> [Int] { + if let values = raw as? [Int] { + return values + } + guard let values = raw as? [Any] else { return [] } + return values.compactMap(v2TopInt) + } + + nonisolated func v2TopUUID(_ raw: Any?) -> UUID? { + if let value = raw as? UUID { + return value + } + if let value = raw as? String { + return UUID(uuidString: value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + } diff --git a/cmuxTests/CmuxTopSnapshotScopeTests.swift b/cmuxTests/CmuxTopSnapshotScopeTests.swift new file mode 100644 index 000000000000..a318f9aa0961 --- /dev/null +++ b/cmuxTests/CmuxTopSnapshotScopeTests.swift @@ -0,0 +1,81 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CmuxTopSnapshotScopeTests: XCTestCase { + func testKernProcArgsWorkspaceID() { + let workspaceID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let bytes = kernProcArgs(environment: [ + "CMUX_WORKSPACE_ID=\(workspaceID.uuidString)" + ]) + + let scope = CmuxTopProcessSnapshot.cmuxScope(fromKernProcArgs: bytes) + + XCTAssertEqual(scope?.workspaceID, workspaceID) + XCTAssertNil(scope?.surfaceID) + } + + func testKernProcArgsTabIDFallback() { + let tabID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + let bytes = kernProcArgs(environment: [ + "CMUX_TAB_ID=\(tabID.uuidString)" + ]) + + let scope = CmuxTopProcessSnapshot.cmuxScope(fromKernProcArgs: bytes) + + XCTAssertEqual(scope?.workspaceID, tabID) + XCTAssertNil(scope?.surfaceID) + } + + func testKernProcArgsSurfaceID() { + let surfaceID = UUID(uuidString: "33333333-3333-3333-3333-333333333333")! + let bytes = kernProcArgs(environment: [ + "CMUX_SURFACE_ID=\(surfaceID.uuidString)" + ]) + + let scope = CmuxTopProcessSnapshot.cmuxScope(fromKernProcArgs: bytes) + + XCTAssertNil(scope?.workspaceID) + XCTAssertEqual(scope?.surfaceID, surfaceID) + } + + func testKernProcArgsPanelIDFallback() { + let panelID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! + let bytes = kernProcArgs(environment: [ + "CMUX_PANEL_ID=\(panelID.uuidString)" + ]) + + let scope = CmuxTopProcessSnapshot.cmuxScope(fromKernProcArgs: bytes) + + XCTAssertNil(scope?.workspaceID) + XCTAssertEqual(scope?.surfaceID, panelID) + } + + private func kernProcArgs( + arguments: [String] = ["zsh"], + environment: [String] + ) -> [UInt8] { + var argc = Int32(arguments.count).littleEndian + var bytes = withUnsafeBytes(of: &argc) { Array($0) } + appendCString("/bin/zsh", to: &bytes) + bytes.append(0) + for argument in arguments { + appendCString(argument, to: &bytes) + } + bytes.append(0) + for entry in environment { + appendCString(entry, to: &bytes) + } + bytes.append(0) + return bytes + } + + private func appendCString(_ string: String, to bytes: inout [UInt8]) { + bytes.append(contentsOf: string.utf8) + bytes.append(0) + } +} diff --git a/cmuxTests/TaskManagerResourcesTests.swift b/cmuxTests/TaskManagerResourcesTests.swift new file mode 100644 index 000000000000..2b7ef9185163 --- /dev/null +++ b/cmuxTests/TaskManagerResourcesTests.swift @@ -0,0 +1,35 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class TaskManagerResourcesTests: XCTestCase { + func testParsesTypedIntPIDArrayFromSummaryPayload() { + let payload: [String: Any] = [ + "cpu_percent": 3.5, + "resident_bytes": 4096, + "process_count": 2, + "pids": [101, 202], + ] + + let resources = CmuxTaskManagerResources(payload) + + XCTAssertEqual(resources.processIds, [101, 202]) + } + + func testParsesAnyPIDArrayFromPayload() { + let payload: [String: Any] = [ + "cpu_percent": 3.5, + "resident_bytes": 4096, + "process_count": 2, + "pids": [101 as Any, "202" as Any], + ] + + let resources = CmuxTaskManagerResources(payload) + + XCTAssertEqual(resources.processIds, [101, 202]) + } +}