From 47fa5e43483cb366a101339e8137b04949e9238e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Sun, 3 May 2026 20:47:18 -0700 Subject: [PATCH 01/14] Add task manager navigation actions --- Resources/Localizable.xcstrings | 70 ++++++++++ Sources/TaskManagerSnapshot.swift | 147 ++++++++++++++++--- Sources/TaskManagerTypes.swift | 36 +++++ Sources/TaskManagerWindowController.swift | 163 +++++++++++++++++++++- 4 files changed, 391 insertions(+), 25 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2b410bcef4fc..b1c2469b6179 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110231,6 +110231,76 @@ "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": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Kill process?" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了しますか?" } } + } + }, + "taskManager.killProcess.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Send SIGTERM to %@ (PID %lld)." } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@(PID %lld)に SIGTERM を送信します。" } } + } + }, + "taskManager.killProcess.pluralTitle": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Kill processes?" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了しますか?" } } + } + }, + "taskManager.killProcess.pluralMessage": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Send SIGTERM to %@ processes (PIDs %@)." } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@ のプロセス(PID %@)に SIGTERM を送信します。" } } + } + }, + "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 %lld: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセス %lld を終了できません: %@" } } + } + }, "taskManager.updated.never": { "extractionState": "manual", "localizations": { diff --git a/Sources/TaskManagerSnapshot.swift b/Sources/TaskManagerSnapshot.swift index d3925269477c..b5fb9897ec19 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,35 @@ 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 processRow = row( + process, + kind: .process, + level: level, + title: title, + detail: detail, + context: context, + workspaceId: workspaceId, + surfaceId: surfaceId, + terminalSurfaceId: terminalSurfaceId, + processId: pid, + 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: surfaceId, + terminalSurfaceId: terminalSurfaceId, + to: &rows + ) } } @@ -205,7 +282,12 @@ 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, + agentAssetName: String? = nil ) -> CmuxTaskManagerRow { CmuxTaskManagerRow( id: rowID(payload, kind: kind, context: context), @@ -214,7 +296,12 @@ 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, + agentAssetName: agentAssetName ) } @@ -269,6 +356,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 } diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index 4bc47a0151ad..00361784b81f 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -46,6 +46,34 @@ 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 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() + } } struct CmuxTaskManagerResources { @@ -54,17 +82,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 +127,11 @@ struct CmuxTaskManagerResources { } return nil } + + private static func intArray(_ raw: Any?) -> [Int] { + guard let values = raw as? [Any] else { return [] } + return values.compactMap(int) + } } enum CmuxTaskManagerFormat { diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index b209b0dc39a2..2ff4d20a79e8 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -1,5 +1,6 @@ import AppKit import Combine +import Darwin import SwiftUI @MainActor @@ -109,6 +110,90 @@ private final class CmuxTaskManagerModel: ObservableObject { self?.refreshTask = nil } } + + func viewBestTarget(for row: CmuxTaskManagerRow) { + if row.canViewTerminal { + viewTerminal(for: row) + } else { + viewWorkspace(for: row) + } + } + + func viewWorkspace(for row: CmuxTaskManagerRow) { + guard let workspaceId = row.workspaceId, + let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } + manager.focusTab(workspaceId, suppressFlash: true) + flashSelection(workspaceId: workspaceId, surfaceId: row.surfaceId) + } + + 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) + } + + func killProcess(for row: CmuxTaskManagerRow) { + let processIds = row.killableProcessIds + guard !processIds.isEmpty else { return } + guard confirmKillProcess(row: row, processIds: processIds) else { return } + + var failures: [(processId: Int, reason: String)] = [] + for processId in processIds { + let result = Darwin.kill(pid_t(processId), SIGTERM) + if result != 0 { + let failureErrno = errno + failures.append((processId, String(cString: strerror(failureErrno)))) + } + } + + if failures.isEmpty { + refresh(force: true) + } else { + let detail = failures + .map { "\($0.processId): \($0.reason)" } + .joined(separator: ", ") + errorMessage = String( + localized: "taskManager.killProcess.error", + defaultValue: "Unable to kill process: \(detail)" + ) + } + } + + 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", defaultValue: "Kill process?") + alert.informativeText = String( + localized: "taskManager.killProcess.message", + defaultValue: "Send SIGTERM to \(row.title) (PID \(processId))." + ) + } else { + let pidList = processIds.map(String.init).joined(separator: ", ") + alert.messageText = String(localized: "taskManager.killProcess.pluralTitle", defaultValue: "Kill processes?") + alert.informativeText = String( + localized: "taskManager.killProcess.pluralMessage", + defaultValue: "Send SIGTERM to \(row.title) processes (PIDs \(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 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) + } } private struct CmuxTaskManagerView: View { @@ -230,7 +315,21 @@ private struct CmuxTaskManagerView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(model.snapshot.rows) { row in - CmuxTaskManagerRowView(row: row) + 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) } @@ -261,16 +360,17 @@ private struct CmuxTaskManagerMessageView: View { private struct CmuxTaskManagerRowView: View { let row: CmuxTaskManagerRow + let onViewWorkspace: () -> Void + let onViewTerminal: () -> Void + let onKillProcess: () -> Void + let onActivate: () -> Void 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) + rowIcon VStack(alignment: .leading, spacing: 0) { Text(row.title) .font(.system(size: 12.5)) @@ -297,5 +397,58 @@ private struct CmuxTaskManagerRowView: View { .padding(.horizontal, 16) .padding(.vertical, 3) .opacity(row.isDimmed ? 0.68 : 1) + .contentShape(Rectangle()) + .onTapGesture { + onActivate() + } + .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" + ) + } + } + } + } + + @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) + } } } From 1e74b4f83a34dedd4080cc654d3d6d6dac95d8bc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 17:19:53 -0700 Subject: [PATCH 02/14] Fix task manager process ownership for agent terminals --- Sources/CmuxTopSnapshot.swift | 140 +++++++++++++++++++++ Sources/TerminalControllerTopSupport.swift | 19 +++ 2 files changed, 159 insertions(+) diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index a10fc6fe831b..4cc6b73144a7 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 } +private 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 }) @@ -182,6 +201,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 } @@ -231,6 +270,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 = ttyDevice == nil ? nil : cmuxScope(for: pid) + 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 +282,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 +293,98 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { ) } + private 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)))) + } + + private 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/TerminalControllerTopSupport.swift b/Sources/TerminalControllerTopSupport.swift index 1e0c62151585..3d1172b81a82 100644 --- a/Sources/TerminalControllerTopSupport.swift +++ b/Sources/TerminalControllerTopSupport.swift @@ -132,6 +132,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() @@ -218,4 +227,14 @@ extension TerminalController { } return nil } + + 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 + } } From 2a30ecd71534f29485f00699929c987c2fc9b00a Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 17:21:02 -0700 Subject: [PATCH 03/14] Address task manager review feedback --- Sources/TaskManagerWindowController.swift | 43 +++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 2ff4d20a79e8..9ca6f963cd0a 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -114,7 +114,7 @@ private final class CmuxTaskManagerModel: ObservableObject { func viewBestTarget(for row: CmuxTaskManagerRow) { if row.canViewTerminal { viewTerminal(for: row) - } else { + } else if row.canViewWorkspace { viewWorkspace(for: row) } } @@ -122,7 +122,13 @@ private final class CmuxTaskManagerModel: ObservableObject { func viewWorkspace(for row: CmuxTaskManagerRow) { guard let workspaceId = row.workspaceId, let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } - manager.focusTab(workspaceId, suppressFlash: true) +#if DEBUG + cmuxDebugLog( + "taskManager.focus.workspace workspace=\(workspaceId.uuidString) " + + "surface=\(row.surfaceId?.uuidString ?? "nil") row=\(row.id)" + ) +#endif + manager.focusTab(workspaceId, surfaceId: row.surfaceId, suppressFlash: true) flashSelection(workspaceId: workspaceId, surfaceId: row.surfaceId) } @@ -130,6 +136,12 @@ private final class CmuxTaskManagerModel: ObservableObject { guard let workspaceId = row.workspaceId, let terminalSurfaceId = row.terminalSurfaceId, let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } +#if DEBUG + cmuxDebugLog( + "taskManager.focus.terminal workspace=\(workspaceId.uuidString) " + + "surface=\(terminalSurfaceId.uuidString) row=\(row.id)" + ) +#endif manager.focusTab(workspaceId, surfaceId: terminalSurfaceId, suppressFlash: true) flashSelection(workspaceId: workspaceId, surfaceId: terminalSurfaceId) } @@ -318,15 +330,42 @@ private struct CmuxTaskManagerView: View { CmuxTaskManagerRowView( row: row, onViewWorkspace: { +#if DEBUG + cmuxDebugLog( + "taskManager.ui.viewWorkspace row=\(row.id) " + + "workspace=\(row.workspaceId?.uuidString ?? "nil") " + + "surface=\(row.surfaceId?.uuidString ?? "nil")" + ) +#endif model.viewWorkspace(for: row) }, onViewTerminal: { +#if DEBUG + cmuxDebugLog( + "taskManager.ui.viewTerminal row=\(row.id) " + + "workspace=\(row.workspaceId?.uuidString ?? "nil") " + + "surface=\(row.terminalSurfaceId?.uuidString ?? "nil")" + ) +#endif model.viewTerminal(for: row) }, onKillProcess: { +#if DEBUG + cmuxDebugLog( + "taskManager.ui.killProcess row=\(row.id) " + + "pids=\(row.killableProcessIds.map(String.init).joined(separator: ","))" + ) +#endif model.killProcess(for: row) }, onActivate: { +#if DEBUG + cmuxDebugLog( + "taskManager.ui.activate row=\(row.id) " + + "workspace=\(row.workspaceId?.uuidString ?? "nil") " + + "terminal=\(row.terminalSurfaceId?.uuidString ?? "nil")" + ) +#endif model.viewBestTarget(for: row) } ) From ec02017f6ca9fad27bfff77e01b7e79c37844d55 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 17:25:33 -0700 Subject: [PATCH 04/14] Clarify debug logging review policy --- .coderabbit.yaml | 2 +- .github/review-bot-rules/swift-logging.md | 4 +++ CLAUDE.md | 8 ++++- Sources/TaskManagerWindowController.swift | 39 ----------------------- 4 files changed, 12 insertions(+), 41 deletions(-) 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/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 9ca6f963cd0a..0d0f177b39fe 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -122,12 +122,6 @@ private final class CmuxTaskManagerModel: ObservableObject { func viewWorkspace(for row: CmuxTaskManagerRow) { guard let workspaceId = row.workspaceId, let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } -#if DEBUG - cmuxDebugLog( - "taskManager.focus.workspace workspace=\(workspaceId.uuidString) " + - "surface=\(row.surfaceId?.uuidString ?? "nil") row=\(row.id)" - ) -#endif manager.focusTab(workspaceId, surfaceId: row.surfaceId, suppressFlash: true) flashSelection(workspaceId: workspaceId, surfaceId: row.surfaceId) } @@ -136,12 +130,6 @@ private final class CmuxTaskManagerModel: ObservableObject { guard let workspaceId = row.workspaceId, let terminalSurfaceId = row.terminalSurfaceId, let manager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } -#if DEBUG - cmuxDebugLog( - "taskManager.focus.terminal workspace=\(workspaceId.uuidString) " + - "surface=\(terminalSurfaceId.uuidString) row=\(row.id)" - ) -#endif manager.focusTab(workspaceId, surfaceId: terminalSurfaceId, suppressFlash: true) flashSelection(workspaceId: workspaceId, surfaceId: terminalSurfaceId) } @@ -330,42 +318,15 @@ private struct CmuxTaskManagerView: View { CmuxTaskManagerRowView( row: row, onViewWorkspace: { -#if DEBUG - cmuxDebugLog( - "taskManager.ui.viewWorkspace row=\(row.id) " + - "workspace=\(row.workspaceId?.uuidString ?? "nil") " + - "surface=\(row.surfaceId?.uuidString ?? "nil")" - ) -#endif model.viewWorkspace(for: row) }, onViewTerminal: { -#if DEBUG - cmuxDebugLog( - "taskManager.ui.viewTerminal row=\(row.id) " + - "workspace=\(row.workspaceId?.uuidString ?? "nil") " + - "surface=\(row.terminalSurfaceId?.uuidString ?? "nil")" - ) -#endif model.viewTerminal(for: row) }, onKillProcess: { -#if DEBUG - cmuxDebugLog( - "taskManager.ui.killProcess row=\(row.id) " + - "pids=\(row.killableProcessIds.map(String.init).joined(separator: ","))" - ) -#endif model.killProcess(for: row) }, onActivate: { -#if DEBUG - cmuxDebugLog( - "taskManager.ui.activate row=\(row.id) " + - "workspace=\(row.workspaceId?.uuidString ?? "nil") " + - "terminal=\(row.terminalSurfaceId?.uuidString ?? "nil")" - ) -#endif model.viewBestTarget(for: row) } ) From 21b10a442abf9299710d6228c81505a3e8aa59f5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 17:39:16 -0700 Subject: [PATCH 05/14] Make task manager process kills graceful --- Resources/Localizable.xcstrings | 12 +-- Sources/CmuxTopSnapshot.swift | 22 +++++ Sources/ContentView.swift | 11 +++ Sources/TaskManagerSnapshot.swift | 12 +++ Sources/TaskManagerTypes.swift | 28 ++++++ Sources/TaskManagerWindowController.swift | 101 ++++++++++++++++++--- Sources/TerminalControllerTopSupport.swift | 39 ++++++++ 7 files changed, 207 insertions(+), 18 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0540ac96d2d6..76b9ab4e3be1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110596,8 +110596,8 @@ "taskManager.killProcess.message": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Send SIGTERM to %@ (PID %lld)." } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@(PID %lld)に SIGTERM を送信します。" } } + "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.pluralTitle": { @@ -110610,8 +110610,8 @@ "taskManager.killProcess.pluralMessage": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Send SIGTERM to %@ processes (PIDs %@)." } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@ のプロセス(PID %@)に SIGTERM を送信します。" } } + "en": { "stringUnit": { "state": "translated", "value": "Ask %@ processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: %@." } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@ のプロセスに正常終了を要求します。短い猶予時間の後も残っているプロセスは cmux が強制終了します。PID: %@。" } } } }, "taskManager.killProcess.confirm": { @@ -110631,8 +110631,8 @@ "taskManager.killProcess.error": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Unable to kill process %lld: %@" } }, - "ja": { "stringUnit": { "state": "translated", "value": "プロセス %lld を終了できません: %@" } } + "en": { "stringUnit": { "state": "translated", "value": "Unable to kill process: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了できません: %@" } } } }, "taskManager.updated.never": { diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 4cc6b73144a7..837753efcd90 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -171,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, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ed4009f4130f..dfe496c9d860 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7041,6 +7041,14 @@ struct ContentView: View { keywords: ["flash", "highlight", "focus", "panel"] ) ) + contributions.append( + 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"] + ) + ) contributions.append( CommandPaletteCommandContribution( commandId: "palette.showNotifications", @@ -7918,6 +7926,9 @@ struct ContentView: View { registry.register(commandId: "palette.triggerFlash") { tabManager.triggerFocusFlash() } + registry.register(commandId: "palette.openTaskManager") { + TaskManagerWindowController.shared.show() + } registry.register(commandId: "palette.showNotifications") { AppDelegate.shared?.toggleNotificationsPopover(animated: false) } diff --git a/Sources/TaskManagerSnapshot.swift b/Sources/TaskManagerSnapshot.swift index b5fb9897ec19..97d687fc227e 100644 --- a/Sources/TaskManagerSnapshot.swift +++ b/Sources/TaskManagerSnapshot.swift @@ -243,6 +243,7 @@ struct CmuxTaskManagerSnapshot { let detail = pid.map { String(localized: "taskManager.row.pid", defaultValue: "PID \($0)") } ?? "" + let processRootIds = pid.map { [$0] } ?? [] let processRow = row( process, kind: .process, @@ -254,6 +255,7 @@ struct CmuxTaskManagerSnapshot { surfaceId: surfaceId, terminalSurfaceId: terminalSurfaceId, processId: pid, + rootProcessIds: processRootIds, agentAssetName: agentAssetName(for: [ nonEmptyString(process["name"]), nonEmptyString(process["path"]).map { URL(fileURLWithPath: $0).lastPathComponent } @@ -287,6 +289,8 @@ struct CmuxTaskManagerSnapshot { surfaceId: UUID? = nil, terminalSurfaceId: UUID? = nil, processId: Int? = nil, + rootProcessIds: [Int]? = nil, + foregroundProcessGroupIds: [Int]? = nil, agentAssetName: String? = nil ) -> CmuxTaskManagerRow { CmuxTaskManagerRow( @@ -301,6 +305,8 @@ struct CmuxTaskManagerSnapshot { surfaceId: surfaceId, terminalSurfaceId: terminalSurfaceId, processId: processId, + rootProcessIds: rootProcessIds ?? intArray(payload["top_level_pids"]), + foregroundProcessGroupIds: foregroundProcessGroupIds ?? intArray(payload["foreground_pgids"]), agentAssetName: agentAssetName ) } @@ -390,4 +396,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 00361784b81f..21e912c2eb44 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import SwiftUI @@ -50,6 +51,8 @@ struct CmuxTaskManagerRow: Identifiable { let surfaceId: UUID? let terminalSurfaceId: UUID? let processId: Int? + let rootProcessIds: [Int] + let foregroundProcessGroupIds: [Int] let agentAssetName: String? var canViewWorkspace: Bool { @@ -74,6 +77,31 @@ struct CmuxTaskManagerRow: Identifiable { .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 { diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 0d0f177b39fe..8b336a452186 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -61,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 { @@ -139,26 +141,34 @@ private final class CmuxTaskManagerModel: ObservableObject { guard !processIds.isEmpty else { return } guard confirmKillProcess(row: row, processIds: processIds) else { return } - var failures: [(processId: Int, reason: String)] = [] - for processId in processIds { - let result = Darwin.kill(pid_t(processId), SIGTERM) - if result != 0 { - let failureErrno = errno - failures.append((processId, String(cString: strerror(failureErrno)))) + var failures: [(target: String, reason: String)] = [] + let gracefulProcessGroupIds = row.gracefulProcessGroupIds + if gracefulProcessGroupIds.isEmpty { + for processId in row.gracefulProcessIds { + if let reason = sendSignal(SIGTERM, toProcessId: processId) { + failures.append(("PID \(processId)", reason)) + } + } + } else { + for processGroupId in gracefulProcessGroupIds { + if let reason = sendSignal(SIGTERM, toProcessGroupId: processGroupId) { + failures.append(("process group \(processGroupId)", reason)) + } } } - if failures.isEmpty { - refresh(force: true) - } else { + guard failures.isEmpty else { let detail = failures - .map { "\($0.processId): \($0.reason)" } + .map { "\($0.target): \($0.reason)" } .joined(separator: ", ") errorMessage = String( localized: "taskManager.killProcess.error", defaultValue: "Unable to kill process: \(detail)" ) + return } + + scheduleForceKillIfNeeded(processIds: processIds) } private func confirmKillProcess(row: CmuxTaskManagerRow, processIds: [Int]) -> Bool { @@ -167,14 +177,14 @@ private final class CmuxTaskManagerModel: ObservableObject { alert.messageText = String(localized: "taskManager.killProcess.title", defaultValue: "Kill process?") alert.informativeText = String( localized: "taskManager.killProcess.message", - defaultValue: "Send SIGTERM to \(row.title) (PID \(processId))." + defaultValue: "Ask \(row.title) (PID \(processId)) to terminate gracefully. cmux will force-kill it if it is still running after a short grace period." ) } else { let pidList = processIds.map(String.init).joined(separator: ", ") alert.messageText = String(localized: "taskManager.killProcess.pluralTitle", defaultValue: "Kill processes?") alert.informativeText = String( localized: "taskManager.killProcess.pluralMessage", - defaultValue: "Send SIGTERM to \(row.title) processes (PIDs \(pidList))." + defaultValue: "Ask \(row.title) processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: \(pidList)." ) } alert.alertStyle = .warning @@ -186,6 +196,73 @@ private final class CmuxTaskManagerModel: ObservableObject { return alert.runModal() == .alertFirstButtonReturn } + 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 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)) + } + + 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) + } + } + timer.tolerance = 0.25 + terminationTimers[operationId] = timer + refresh(force: true) + } + + private func forceKillSurvivors(processIds: [Int]) { + let survivors = processIds.filter(isProcessRunning) + guard !survivors.isEmpty else { + refresh(force: true) + return + } + + var failures: [(target: String, reason: String)] = [] + for processId in survivors { + if let reason = sendSignal(SIGKILL, toProcessId: processId) { + failures.append(("PID \(processId)", reason)) + } + } + + if failures.isEmpty { + refresh(force: true) + } else { + let detail = failures + .map { "\($0.target): \($0.reason)" } + .joined(separator: ", ") + errorMessage = String( + localized: "taskManager.killProcess.error", + defaultValue: "Unable to kill process: \(detail)" + ) + } + } + + 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 + } + 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 } diff --git a/Sources/TerminalControllerTopSupport.swift b/Sources/TerminalControllerTopSupport.swift index 3d1172b81a82..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 } @@ -167,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 @@ -181,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 [] @@ -190,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 @@ -202,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 [] @@ -210,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 @@ -228,6 +258,14 @@ 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 @@ -237,4 +275,5 @@ extension TerminalController { } return nil } + } From 4b6c433a4eb5379db6070b4c2372e057ccd36417 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 17:56:04 -0700 Subject: [PATCH 06/14] Address task manager review findings --- Resources/Localizable.xcstrings | 8 +++--- Sources/CmuxTopSnapshot.swift | 2 +- Sources/TaskManagerSnapshot.swift | 11 +++++--- Sources/TaskManagerWindowController.swift | 32 +++++++++++------------ 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 76b9ab4e3be1..13e49163bf05 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110586,28 +110586,28 @@ "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了..." } } } }, - "taskManager.killProcess.title": { + "taskManager.killProcess.title.one": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Kill process?" } }, "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了しますか?" } } } }, - "taskManager.killProcess.message": { + "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.pluralTitle": { + "taskManager.killProcess.title.other": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Kill processes?" } }, "ja": { "stringUnit": { "state": "translated", "value": "プロセスを終了しますか?" } } } }, - "taskManager.killProcess.pluralMessage": { + "taskManager.killProcess.message.other": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Ask %@ processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: %@." } }, diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 837753efcd90..c1e2aad0037e 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -292,7 +292,7 @@ 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 = ttyDevice == nil ? nil : cmuxScope(for: pid) + let cmuxScope = cmuxScope(for: pid) let rawProcessGroupID = Int(kinfo.kp_eproc.e_pgid) let processGroupID = rawProcessGroupID > 0 ? rawProcessGroupID : nil let rawTerminalProcessGroupID = Int(kinfo.kp_eproc.e_tpgid) diff --git a/Sources/TaskManagerSnapshot.swift b/Sources/TaskManagerSnapshot.swift index 97d687fc227e..351921364ff7 100644 --- a/Sources/TaskManagerSnapshot.swift +++ b/Sources/TaskManagerSnapshot.swift @@ -244,6 +244,9 @@ struct CmuxTaskManagerSnapshot { String(localized: "taskManager.row.pid", defaultValue: "PID \($0)") } ?? "" 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, @@ -252,8 +255,8 @@ struct CmuxTaskManagerSnapshot { detail: detail, context: context, workspaceId: workspaceId, - surfaceId: surfaceId, - terminalSurfaceId: terminalSurfaceId, + surfaceId: processSurfaceId, + terminalSurfaceId: processTerminalSurfaceId, processId: pid, rootProcessIds: processRootIds, agentAssetName: agentAssetName(for: [ @@ -270,8 +273,8 @@ struct CmuxTaskManagerSnapshot { level: level + 1, context: processRow.id, workspaceId: workspaceId, - surfaceId: surfaceId, - terminalSurfaceId: terminalSurfaceId, + surfaceId: processSurfaceId, + terminalSurfaceId: processTerminalSurfaceId, to: &rows ) } diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 8b336a452186..e8eda3cfb60e 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -161,10 +161,10 @@ private final class CmuxTaskManagerModel: ObservableObject { let detail = failures .map { "\($0.target): \($0.reason)" } .joined(separator: ", ") - errorMessage = String( + errorMessage = String(format: String( localized: "taskManager.killProcess.error", - defaultValue: "Unable to kill process: \(detail)" - ) + defaultValue: "Unable to kill process: %@" + ), detail) return } @@ -174,18 +174,18 @@ private final class CmuxTaskManagerModel: ObservableObject { 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", defaultValue: "Kill process?") - alert.informativeText = String( - localized: "taskManager.killProcess.message", - defaultValue: "Ask \(row.title) (PID \(processId)) to terminate gracefully. cmux will force-kill it if it is still running after a short grace period." - ) + 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.pluralTitle", defaultValue: "Kill processes?") - alert.informativeText = String( - localized: "taskManager.killProcess.pluralMessage", - defaultValue: "Ask \(row.title) processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: \(pidList)." - ) + alert.messageText = String(localized: "taskManager.killProcess.title.other", defaultValue: "Kill processes?") + alert.informativeText = String(format: String( + localized: "taskManager.killProcess.message.other", + defaultValue: "Ask %@ processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: %@." + ), row.title, pidList) } alert.alertStyle = .warning alert.addButton(withTitle: String(localized: "taskManager.killProcess.confirm", defaultValue: "Kill")) @@ -246,10 +246,10 @@ private final class CmuxTaskManagerModel: ObservableObject { let detail = failures .map { "\($0.target): \($0.reason)" } .joined(separator: ", ") - errorMessage = String( + errorMessage = String(format: String( localized: "taskManager.killProcess.error", - defaultValue: "Unable to kill process: \(detail)" - ) + defaultValue: "Unable to kill process: %@" + ), detail) } } From 826cee9c17128f5666f816fb71260efc8a6e6b1e Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 17:56:59 -0700 Subject: [PATCH 07/14] Refresh task manager after kill failures --- Sources/TaskManagerWindowController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index e8eda3cfb60e..f704fa5b2b16 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -165,6 +165,7 @@ private final class CmuxTaskManagerModel: ObservableObject { localized: "taskManager.killProcess.error", defaultValue: "Unable to kill process: %@" ), detail) + refresh(force: true) return } @@ -250,6 +251,7 @@ private final class CmuxTaskManagerModel: ObservableObject { localized: "taskManager.killProcess.error", defaultValue: "Unable to kill process: %@" ), detail) + refresh(force: true) } } From c642bc3251a5cb05c07e69a30c9edba4a15285e5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 18:03:15 -0700 Subject: [PATCH 08/14] Split task manager UI from window controller --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/ContentView+ViewCommandPalette.swift | 33 +++ Sources/ContentView.swift | 24 +- Sources/TaskManagerView.swift | 258 ++++++++++++++++++ Sources/TaskManagerWindowController.swift | 259 +------------------ 5 files changed, 302 insertions(+), 280 deletions(-) create mode 100644 Sources/ContentView+ViewCommandPalette.swift create mode 100644 Sources/TaskManagerView.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 6e032d607468..ccb2a54b027a 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 */; }; @@ -195,6 +196,7 @@ A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A501000000000000000001 /* CmuxTopSnapshot.swift */; }; C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A502000000000000000001 /* TaskManagerWindowController.swift */; }; + C7A506000000000000000002 /* TaskManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A506000000000000000001 /* TaskManagerView.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 +449,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 = ""; }; @@ -488,6 +491,7 @@ 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 = ""; }; C7A502000000000000000001 /* TaskManagerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerWindowController.swift; sourceTree = ""; }; + C7A506000000000000000001 /* TaskManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerView.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 +848,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 */, @@ -909,6 +914,7 @@ C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, C7A502000000000000000001 /* TaskManagerWindowController.swift */, + C7A506000000000000000001 /* TaskManagerView.swift */, C7A503000000000000000001 /* TaskManagerSnapshot.swift */, C7A504000000000000000001 /* TaskManagerTypes.swift */, A5001541 /* PortScanner.swift */, @@ -1352,6 +1358,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 */, @@ -1417,6 +1424,7 @@ C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */, C7A501000000000000000002 /* CmuxTopSnapshot.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 */, 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 dfe496c9d860..687f65150a2c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7033,22 +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( - 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"] - ) - ) + contributions.append(contentsOf: Self.commandPaletteViewCommandContributions()) contributions.append( CommandPaletteCommandContribution( commandId: "palette.showNotifications", @@ -7923,12 +7908,7 @@ struct ContentView: View { registry.register(commandId: "palette.disableMinimalMode") { workspacePresentationMode = WorkspacePresentationModeSettings.Mode.standard.rawValue } - registry.register(commandId: "palette.triggerFlash") { - tabManager.triggerFocusFlash() - } - registry.register(commandId: "palette.openTaskManager") { - TaskManagerWindowController.shared.show() - } + registerViewCommandHandlers(®istry) registry.register(commandId: "palette.showNotifications") { AppDelegate.shared?.toggleNotificationsPopover(animated: false) } diff --git a/Sources/TaskManagerView.swift b/Sources/TaskManagerView.swift new file mode 100644 index 000000000000..9121196f80d3 --- /dev/null +++ b/Sources/TaskManagerView.swift @@ -0,0 +1,258 @@ +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 { + 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()) + .onTapGesture { + onActivate() + } + .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" + ) + } + } + } + } + + @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 f704fa5b2b16..4f13bf605fde 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -48,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? @@ -274,260 +274,3 @@ private final class CmuxTaskManagerModel: ObservableObject { panel.triggerFlash(reason: .debug) } } - -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() - } - } - - 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 { - 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()) - .onTapGesture { - onActivate() - } - .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" - ) - } - } - } - } - - @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) - } - } -} From 0ab22a04612aaec9c2c46c3fedee5e290dc94d44 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 18:05:00 -0700 Subject: [PATCH 09/14] Send graceful signal before force kill --- Sources/TaskManagerWindowController.swift | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 4f13bf605fde..5cf1181fbbb5 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -142,18 +142,16 @@ final class CmuxTaskManagerModel: ObservableObject { guard confirmKillProcess(row: row, processIds: processIds) else { return } var failures: [(target: String, reason: String)] = [] - let gracefulProcessGroupIds = row.gracefulProcessGroupIds - if gracefulProcessGroupIds.isEmpty { - for processId in row.gracefulProcessIds { - if let reason = sendSignal(SIGTERM, toProcessId: processId) { - failures.append(("PID \(processId)", reason)) - } + for processGroupId in row.gracefulProcessGroupIds { + if let reason = sendSignal(SIGTERM, toProcessGroupId: processGroupId) { + failures.append(("process group \(processGroupId)", reason)) } - } else { - for processGroupId in gracefulProcessGroupIds { - if let reason = sendSignal(SIGTERM, toProcessGroupId: processGroupId) { - failures.append(("process group \(processGroupId)", reason)) - } + } + + let gracefulProcessIds = Array(Set(row.gracefulProcessIds + processIds)).sorted() + for processId in gracefulProcessIds { + if let reason = sendSignal(SIGTERM, toProcessId: processId) { + failures.append(("PID \(processId)", reason)) } } From d82e089d42dd330389cfd84e26b28572aafd1520 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 18:22:05 -0700 Subject: [PATCH 10/14] Fix task manager review follow-ups --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ Resources/Localizable.xcstrings | 4 +- Sources/TaskManagerTypes.swift | 1 + Sources/TaskManagerView.swift | 72 ++++++++++++----------- Sources/TaskManagerWindowController.swift | 22 ++++--- cmuxTests/TaskManagerResourcesTests.swift | 22 +++++++ 6 files changed, 82 insertions(+), 43 deletions(-) create mode 100644 cmuxTests/TaskManagerResourcesTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ccb2a54b027a..550fc2096931 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -197,6 +197,7 @@ C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A501000000000000000001 /* CmuxTopSnapshot.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 */; }; 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 */; }; @@ -492,6 +493,7 @@ C7A501000000000000000001 /* CmuxTopSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshot.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 = ""; }; 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 = ""; }; @@ -1093,6 +1095,7 @@ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */, + C7A507000000000000000001 /* TaskManagerResourcesTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */, @@ -1640,6 +1643,7 @@ 0F2C25F9170130F8DC09DD1B /* WorkspaceManualUnreadTests.swift in Sources */, CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */, 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */, + C7A507000000000000000002 /* TaskManagerResourcesTests.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 13e49163bf05..d4b31e6c44a9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110610,8 +110610,8 @@ "taskManager.killProcess.message.other": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Ask %@ processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: %@." } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@ のプロセスに正常終了を要求します。短い猶予時間の後も残っているプロセスは cmux が強制終了します。PID: %@。" } } + "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": { diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index 21e912c2eb44..a4d7873a7968 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -157,6 +157,7 @@ struct CmuxTaskManagerResources { } 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/TaskManagerView.swift b/Sources/TaskManagerView.swift index 9121196f80d3..c9e236cfc899 100644 --- a/Sources/TaskManagerView.swift +++ b/Sources/TaskManagerView.swift @@ -170,41 +170,10 @@ private struct CmuxTaskManagerRowView: View { let onActivate: () -> Void var body: 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()) - .onTapGesture { - onActivate() + Button(action: onActivate) { + rowContent } + .buttonStyle(.plain) .contextMenu { if row.canViewWorkspace { Button { @@ -240,6 +209,41 @@ private struct CmuxTaskManagerRowView: View { } } + 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 { diff --git a/Sources/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 5cf1181fbbb5..13b0e76b0b15 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -142,9 +142,12 @@ final class CmuxTaskManagerModel: ObservableObject { guard confirmKillProcess(row: row, processIds: processIds) else { return } var failures: [(target: String, reason: String)] = [] + var sentGracefulSignal = false for processGroupId in row.gracefulProcessGroupIds { if let reason = sendSignal(SIGTERM, toProcessGroupId: processGroupId) { failures.append(("process group \(processGroupId)", reason)) + } else { + sentGracefulSignal = true } } @@ -152,10 +155,14 @@ final class CmuxTaskManagerModel: ObservableObject { for processId in gracefulProcessIds { if let reason = sendSignal(SIGTERM, toProcessId: processId) { failures.append(("PID \(processId)", reason)) + } else { + sentGracefulSignal = true } } - guard failures.isEmpty else { + if failures.isEmpty { + scheduleForceKillIfNeeded(processIds: processIds) + } else { let detail = failures .map { "\($0.target): \($0.reason)" } .joined(separator: ", ") @@ -163,11 +170,12 @@ final class CmuxTaskManagerModel: ObservableObject { localized: "taskManager.killProcess.error", defaultValue: "Unable to kill process: %@" ), detail) - refresh(force: true) - return + if sentGracefulSignal { + scheduleForceKillIfNeeded(processIds: processIds) + } else { + refresh(force: true) + } } - - scheduleForceKillIfNeeded(processIds: processIds) } private func confirmKillProcess(row: CmuxTaskManagerRow, processIds: [Int]) -> Bool { @@ -183,8 +191,8 @@ final class CmuxTaskManagerModel: ObservableObject { alert.messageText = String(localized: "taskManager.killProcess.title.other", defaultValue: "Kill processes?") alert.informativeText = String(format: String( localized: "taskManager.killProcess.message.other", - defaultValue: "Ask %@ processes to terminate gracefully. cmux will force-kill remaining processes after a short grace period. PIDs: %@." - ), row.title, pidList) + 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")) diff --git a/cmuxTests/TaskManagerResourcesTests.swift b/cmuxTests/TaskManagerResourcesTests.swift new file mode 100644 index 000000000000..6179c8d5fb6d --- /dev/null +++ b/cmuxTests/TaskManagerResourcesTests.swift @@ -0,0 +1,22 @@ +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]) + } +} From 47db5b5188e152ed9597244f1d3df15da196cac1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 18:37:58 -0700 Subject: [PATCH 11/14] Address task manager kill fallback review --- Resources/Localizable.xcstrings | 14 ++++++++++++ Sources/TaskManagerWindowController.swift | 26 +++++++++++++++++------ cmuxTests/TaskManagerResourcesTests.swift | 13 ++++++++++++ 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d4b31e6c44a9..2be2861f8bc8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110635,6 +110635,20 @@ "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/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 13b0e76b0b15..3563fb3e4832 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -145,23 +145,23 @@ final class CmuxTaskManagerModel: ObservableObject { var sentGracefulSignal = false for processGroupId in row.gracefulProcessGroupIds { if let reason = sendSignal(SIGTERM, toProcessGroupId: processGroupId) { - failures.append(("process group \(processGroupId)", reason)) + failures.append((processGroupTargetLabel(processGroupId), reason)) } else { sentGracefulSignal = true } } - let gracefulProcessIds = Array(Set(row.gracefulProcessIds + processIds)).sorted() - for processId in gracefulProcessIds { + let escalationProcessIds = Array(Set(row.gracefulProcessIds + processIds)).sorted() + for processId in escalationProcessIds { if let reason = sendSignal(SIGTERM, toProcessId: processId) { - failures.append(("PID \(processId)", reason)) + failures.append((processTargetLabel(processId), reason)) } else { sentGracefulSignal = true } } if failures.isEmpty { - scheduleForceKillIfNeeded(processIds: processIds) + scheduleForceKillIfNeeded(processIds: escalationProcessIds) } else { let detail = failures .map { "\($0.target): \($0.reason)" } @@ -171,7 +171,7 @@ final class CmuxTaskManagerModel: ObservableObject { defaultValue: "Unable to kill process: %@" ), detail) if sentGracefulSignal { - scheduleForceKillIfNeeded(processIds: processIds) + scheduleForceKillIfNeeded(processIds: escalationProcessIds) } else { refresh(force: true) } @@ -203,6 +203,20 @@ final class CmuxTaskManagerModel: ObservableObject { return alert.runModal() == .alertFirstButtonReturn } + private func processGroupTargetLabel(_ processGroupId: Int) -> String { + String(format: String( + localized: "taskManager.killProcess.target.processGroup", + defaultValue: "process group %lld" + ), Int64(processGroupId)) + } + + 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)) diff --git a/cmuxTests/TaskManagerResourcesTests.swift b/cmuxTests/TaskManagerResourcesTests.swift index 6179c8d5fb6d..2b7ef9185163 100644 --- a/cmuxTests/TaskManagerResourcesTests.swift +++ b/cmuxTests/TaskManagerResourcesTests.swift @@ -19,4 +19,17 @@ final class TaskManagerResourcesTests: XCTestCase { 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]) + } } From 690e0eb85a272a19b8f107d1c16c5cb364ef2496 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 18:57:12 -0700 Subject: [PATCH 12/14] Cache task manager cmux scope lookups --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ Sources/CmuxTopSnapshot.swift | 12 ++++-- Sources/CmuxTopSnapshotScopeCache.swift | 52 +++++++++++++++++++++++ Sources/TaskManagerWindowController.swift | 2 +- 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 Sources/CmuxTopSnapshotScopeCache.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 550fc2096931..c59c71ca87ee 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -195,6 +195,7 @@ 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 */; }; @@ -491,6 +492,7 @@ 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 = ""; }; @@ -915,6 +917,7 @@ D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */, C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, + C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */, C7A502000000000000000001 /* TaskManagerWindowController.swift */, C7A506000000000000000001 /* TaskManagerView.swift */, C7A503000000000000000001 /* TaskManagerSnapshot.swift */, @@ -1426,6 +1429,7 @@ 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 */, diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index c1e2aad0037e..b1695e96ef9c 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -39,7 +39,7 @@ struct CmuxTopProcessInfo: Sendable { let threadCount: Int } -private struct CmuxTopProcessScope: Sendable { +struct CmuxTopProcessScope: Sendable { let workspaceID: UUID? let surfaceID: UUID? } @@ -267,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 { @@ -292,7 +296,7 @@ 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 = cmuxScope(for: pid) + 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) @@ -315,7 +319,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { ) } - private static func cmuxScope(for pid: Int) -> CmuxTopProcessScope? { + 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)] diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift new file mode 100644 index 000000000000..cb782783edb6 --- /dev/null +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -0,0 +1,52 @@ +import Foundation +import Darwin + +struct CmuxTopProcessScopeCacheKey: Hashable { + let pid: Int + let startSeconds: Int + let startMicroseconds: Int +} + +private struct CmuxTopProcessScopeCacheValue { + let scope: CmuxTopProcessScope? +} + +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() + + let scope = cmuxScope(for: pid) + + 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/TaskManagerWindowController.swift b/Sources/TaskManagerWindowController.swift index 3563fb3e4832..f035d56b2ceb 100644 --- a/Sources/TaskManagerWindowController.swift +++ b/Sources/TaskManagerWindowController.swift @@ -257,7 +257,7 @@ final class CmuxTaskManagerModel: ObservableObject { var failures: [(target: String, reason: String)] = [] for processId in survivors { if let reason = sendSignal(SIGKILL, toProcessId: processId) { - failures.append(("PID \(processId)", reason)) + failures.append((processTargetLabel(processId), reason)) } } From e9b1099a4316cb4366efa24bf6592f678a9064fc Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 19:18:14 -0700 Subject: [PATCH 13/14] Address task manager scope cache review --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ Sources/CmuxTopSnapshot.swift | 2 +- Sources/CmuxTopSnapshotScopeCache.swift | 6 +- cmuxTests/CmuxTopSnapshotScopeTests.swift | 81 +++++++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 cmuxTests/CmuxTopSnapshotScopeTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index c59c71ca87ee..9ea1f7913255 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -199,6 +199,7 @@ 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 */; }; @@ -496,6 +497,7 @@ 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 = ""; }; @@ -1099,6 +1101,7 @@ EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */, C7A507000000000000000001 /* TaskManagerResourcesTests.swift */, + C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */, @@ -1648,6 +1651,7 @@ 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/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index b1695e96ef9c..e0f621960e57 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -338,7 +338,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size)))) } - private static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? { + static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? { guard bytes.count > MemoryLayout.size else { return nil } var argcRaw: Int32 = 0 diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index cb782783edb6..c7053b282fb1 100644 --- a/Sources/CmuxTopSnapshotScopeCache.swift +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -8,7 +8,7 @@ struct CmuxTopProcessScopeCacheKey: Hashable { } private struct CmuxTopProcessScopeCacheValue { - let scope: CmuxTopProcessScope? + let scope: CmuxTopProcessScope } private let cmuxTopScopeCacheLock = NSLock() @@ -35,7 +35,9 @@ extension CmuxTopProcessSnapshot { } cmuxTopScopeCacheLock.unlock() - let scope = cmuxScope(for: pid) + guard let scope = cmuxScope(for: pid) else { + return nil + } cmuxTopScopeCacheLock.lock() cmuxTopScopeCache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope) 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) + } +} From 395ee82dc902cfd0dc40c3a384100094d5e8da72 Mon Sep 17 00:00:00 2001 From: Lawrence Chen Date: Mon, 4 May 2026 19:31:57 -0700 Subject: [PATCH 14/14] Document process scope cache synchronization --- Sources/CmuxTopSnapshotScopeCache.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index c7053b282fb1..97eafe3a7b03 100644 --- a/Sources/CmuxTopSnapshotScopeCache.swift +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -11,6 +11,10 @@ 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] = [:]