Add sidebar metadata (git branch, ports, logs, progress), fix localhost URL resolution, simplify analytics

- Sidebar now shows git branch, listening ports, log entries, progress bars, and status pills with expand/collapse
- Fix localhost/127.0.0.1 URL parsing by checking before generic URL(string:) which misinterprets the scheme
- Remove custom Keychain distinct ID in favor of PostHog SDK's built-in anonymous ID
- browser open now defaults to caller's workspace via CMUX_WORKSPACE_ID env var
- Improve CLI help text for environment variables
This commit is contained in:
Lawrence Chen 2026-02-16 02:46:39 -08:00
parent b41331b43d
commit 3fa72a0b0b
5 changed files with 202 additions and 87 deletions

View file

@ -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 <id|ref|index> | <surface>] <subcommand> ...
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 <url> [--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).
"""
}
}

View file

@ -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<String> = []
@ -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

View file

@ -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)")
}

View file

@ -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)

View file

@ -20,12 +20,15 @@ This maps common `agent-browser` usage to `cmux browser` usage.
### Navigation
```bash
cmux browser open <url>
cmux browser open <url> # opens in caller's workspace (uses CMUX_WORKSPACE_ID)
cmux browser open <url> --workspace <id|ref> # opens in a specific workspace
cmux browser <surface> goto <url>
cmux browser <surface> back|forward|reload
cmux browser <surface> 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