diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 931b23b0..1bdd7a59 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1423,7 +1423,8 @@ struct CMUXCLI { if let sourceSurface = try normalizeSurfaceHandle(surfaceRaw, client: client) { params["surface_id"] = sourceSurface } - if let workspaceRaw = workspaceOpt { + let workspaceRaw = workspaceOpt ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + if let workspaceRaw { if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) { params["workspace_id"] = workspace } @@ -3128,7 +3129,7 @@ struct CMUXCLI { simulate-app-active browser [--surface | ] ... - browser open [url] (create browser split; if surface supplied, behaves like navigate) + browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate) browser open-split [url] browser goto|navigate [--snapshot-after] browser back|forward|reload [--snapshot-after] @@ -3171,7 +3172,10 @@ struct CMUXCLI { help Environment: - CMUX_WORKSPACE_ID, CMUX_SURFACE_ID, CMUX_SOCKET_PATH + CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for + browser open, new-surface, notify, and other commands. + CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. + CMUX_SOCKET_PATH Override the default Unix socket path (/tmp/cmux.sock). """ } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 45529a73..18b7b15d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -895,6 +895,12 @@ private struct TabItemView: View { @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints + @AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true + @AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false + @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true + @AppStorage("sidebarShowLog") private var sidebarShowLog = true + @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true + @AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true var isActive: Bool { tabManager.selectedTabId == tab.id @@ -1008,14 +1014,75 @@ private struct TabItemView: View { .multilineTextAlignment(.leading) } - if let directories = directorySummary { - Text(directories) - .font(.system(size: 10, design: .monospaced)) - .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) - .lineLimit(1) - .truncationMode(.tail) + if sidebarShowStatusPills, !tab.statusEntries.isEmpty { + SidebarStatusPillsRow( + entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in + if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp } + return lhs.key < rhs.key + }), + isActive: isActive, + onFocus: { updateSelection() } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Latest log entry + if sidebarShowLog, let latestLog = tab.logEntries.last { + HStack(spacing: 4) { + Image(systemName: logLevelIcon(latestLog.level)) + .font(.system(size: 8)) + .foregroundColor(logLevelColor(latestLog.level, isActive: isActive)) + Text(latestLog.message) + .font(.system(size: 10)) + .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .lineLimit(1) + .truncationMode(.tail) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Progress bar + if sidebarShowProgress, let progress = tab.progress { + VStack(alignment: .leading, spacing: 2) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2)) + Capsule() + .fill(isActive ? Color.white.opacity(0.8) : Color.accentColor) + .frame(width: max(0, geo.size.width * CGFloat(progress.value))) + } + } + .frame(height: 3) + + if let label = progress.label { + Text(label) + .font(.system(size: 9)) + .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + .lineLimit(1) + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Branch + directory + ports row + if let dirRow = branchDirectoryRow { + HStack(spacing: 3) { + if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundColor(isActive ? .white.opacity(0.6) : .secondary) + } + Text(dirRow) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isActive ? .white.opacity(0.75) : .secondary) + .lineLimit(1) + .truncationMode(.tail) + } } } + .animation(.easeInOut(duration: 0.2), value: tab.logEntries.count) + .animation(.easeInOut(duration: 0.2), value: tab.progress != nil) .padding(.horizontal, 10) .padding(.vertical, 8) .background( @@ -1306,7 +1373,31 @@ private struct TabItemView: View { return trimmed.isEmpty ? nil : trimmed } - private var directorySummary: String? { + private var branchDirectoryRow: String? { + var parts: [String] = [] + + // Git branch (if enabled and available) + if sidebarShowGitBranch, let git = tab.gitBranch { + let dirty = git.isDirty ? "*" : "" + parts.append("\(git.branch)\(dirty)") + } + + // Directory summary + if let dirs = directorySummaryText { + parts.append(dirs) + } + + // Ports (if enabled and available) + if sidebarShowPorts, !tab.listeningPorts.isEmpty { + let portsStr = tab.listeningPorts.map { ":\($0)" }.joined(separator: ",") + parts.append(portsStr) + } + + let result = parts.joined(separator: " ยท ") + return result.isEmpty ? nil : result + } + + private var directorySummaryText: String? { guard !tab.panels.isEmpty else { return nil } let home = FileManager.default.homeDirectoryForCurrentUser.path var seen: Set = [] @@ -1322,6 +1413,35 @@ private struct TabItemView: View { return entries.isEmpty ? nil : entries.joined(separator: " | ") } + private func logLevelIcon(_ level: SidebarLogLevel) -> String { + switch level { + case .info: return "circle.fill" + case .progress: return "arrowtriangle.right.fill" + case .success: return "checkmark.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .error: return "xmark.circle.fill" + } + } + + private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color { + if isActive { + switch level { + case .info: return .white.opacity(0.5) + case .progress: return .white.opacity(0.8) + case .success: return .white.opacity(0.9) + case .warning: return .white.opacity(0.9) + case .error: return .white.opacity(0.9) + } + } + switch level { + case .info: return .secondary + case .progress: return .blue + case .success: return .green + case .warning: return .orange + case .error: return .red + } + } + private func shortenPath(_ path: String, home: String) -> String { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return path } @@ -1356,6 +1476,62 @@ private struct TabItemView: View { } } +private struct SidebarStatusPillsRow: View { + let entries: [SidebarStatusEntry] + let isActive: Bool + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(statusText) + .font(.system(size: 10)) + .foregroundColor(isActive ? .white.opacity(0.8) : .secondary) + .lineLimit(isExpanded ? nil : 3) + .truncationMode(.tail) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + onFocus() + guard shouldShowToggle else { return } + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + + if shouldShowToggle { + Button(isExpanded ? "Show less" : "Show more") { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .help(statusText) + } + + private var statusText: String { + entries + .map { entry in + let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { return value } + return entry.key + } + .joined(separator: "\n") + } + + private var shouldShowToggle: Bool { + entries.count > 1 || statusText.count > 120 + } +} + enum SidebarDropEdge { case top case bottom diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 0fab49b5..a5b8e708 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1219,6 +1219,13 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? { guard !trimmed.isEmpty else { return nil } guard !trimmed.contains(" ") else { return nil } + // Check localhost/loopback before generic URL parsing because + // URL(string: "localhost:3777") treats "localhost" as a scheme. + let lower = trimmed.lowercased() + if lower.hasPrefix("localhost") || lower.hasPrefix("127.0.0.1") || lower.hasPrefix("[::1]") { + return URL(string: "http://\(trimmed)") + } + if let url = URL(string: trimmed), let scheme = url.scheme?.lowercased() { if scheme == "http" || scheme == "https" { return url @@ -1226,11 +1233,6 @@ func resolveBrowserNavigableURL(_ input: String) -> URL? { return nil } - let lower = trimmed.lowercased() - if lower.hasPrefix("localhost") || lower.hasPrefix("127.0.0.1") || lower.hasPrefix("[::1]") { - return URL(string: "http://\(trimmed)") - } - if trimmed.contains(":") || trimmed.contains("/") { return URL(string: "https://\(trimmed)") } diff --git a/Sources/PostHogAnalytics.swift b/Sources/PostHogAnalytics.swift index 465a56d3..2d181bcf 100644 --- a/Sources/PostHogAnalytics.swift +++ b/Sources/PostHogAnalytics.swift @@ -1,14 +1,12 @@ import AppKit import Foundation import PostHog -import Security @MainActor final class PostHogAnalytics { static let shared = PostHogAnalytics() // The PostHog project API key is intentionally embedded in the app (it's a public key). - // Replace with the real key for the cmux PostHog project. private let apiKey = "phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP" // PostHog Cloud US default (matches other cmux properties). @@ -16,9 +14,6 @@ final class PostHogAnalytics { private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC" - private let keychainService = "com.cmuxterm.posthog" - private let keychainAccount = "distinct_id" - private var didStart = false private var activeCheckTimer: Timer? @@ -47,8 +42,7 @@ final class PostHogAnalytics { // Tag every event so PostHog can distinguish desktop from web. PostHogSDK.shared.register(["platform": "cmuxterm"]) - // Keep a stable distinct id so DAU is "unique installs active" and doesn't churn. - PostHogSDK.shared.identify(getOrCreateDistinctId()) + // The SDK automatically generates and persists an anonymous distinct ID. didStart = true @@ -88,70 +82,6 @@ final class PostHogAnalytics { PostHogSDK.shared.flush() } - // MARK: - Distinct Id - - private func getOrCreateDistinctId() -> String { - if let existing = readKeychainString(service: keychainService, account: keychainAccount), - !existing.isEmpty { - return existing - } - - let fresh = UUID().uuidString - if writeKeychainString(service: keychainService, account: keychainAccount, value: fresh) { - return fresh - } - - // Keychain can fail in some environments; fall back to a per-install id in defaults. - let defaultsKey = "posthog.distinctId.fallback" - if let existing = UserDefaults.standard.string(forKey: defaultsKey), !existing.isEmpty { - return existing - } - UserDefaults.standard.set(fresh, forKey: defaultsKey) - return fresh - } - - private func readKeychainString(service: String, account: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnData as String: true, - ] - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - private func writeKeychainString(service: String, account: String, value: String) -> Bool { - guard let data = value.data(using: .utf8) else { return false } - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - - let attributes: [String: Any] = [ - kSecValueData as String: data, - ] - - let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) - if status == errSecSuccess { - return true - } - - if status != errSecItemNotFound { - return false - } - - var addQuery = query - addQuery[kSecValueData as String] = data - return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess - } - private func utcDayString(_ date: Date) -> String { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .iso8601) diff --git a/skills/cmux-browser/references/commands.md b/skills/cmux-browser/references/commands.md index eade56ab..5cc37625 100644 --- a/skills/cmux-browser/references/commands.md +++ b/skills/cmux-browser/references/commands.md @@ -20,12 +20,15 @@ This maps common `agent-browser` usage to `cmux browser` usage. ### Navigation ```bash -cmux browser open +cmux browser open # opens in caller's workspace (uses CMUX_WORKSPACE_ID) +cmux browser open --workspace # opens in a specific workspace cmux browser goto cmux browser back|forward|reload cmux browser get url|title ``` +> **Workspace context:** `browser open` targets the workspace of the terminal where the command is run (via `CMUX_WORKSPACE_ID`), even if a different workspace is currently focused. Use `--workspace` to override. + ### Snapshot and Inspection ```bash