diff --git a/README.md b/README.md
index c804e44b..071bd186 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,10 @@
+
+
+
+
## Features
- **Native macOS app** — Built with Swift and AppKit, not Electron. Fast startup, low memory.
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index a70c9ed8..1f50aa21 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -30,12 +30,79 @@ typeset -g _CMUX_GIT_LAST_PWD=""
typeset -g _CMUX_GIT_LAST_RUN=0
typeset -g _CMUX_GIT_JOB_PID=""
typeset -g _CMUX_GIT_FORCE=0
+typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
+typeset -g _CMUX_GIT_HEAD_PATH=""
+typeset -g _CMUX_GIT_HEAD_MTIME=0
+typeset -g _CMUX_HAVE_ZSTAT=0
typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_PORTS_JOB_PID=""
typeset -g _CMUX_CMD_START=0
typeset -g _CMUX_TTY_NAME=""
+_cmux_ensure_zstat() {
+ # zstat is substantially cheaper than spawning external `stat`.
+ if (( _CMUX_HAVE_ZSTAT != 0 )); then
+ return 0
+ fi
+ if zmodload -F zsh/stat b:zstat 2>/dev/null; then
+ _CMUX_HAVE_ZSTAT=1
+ return 0
+ fi
+ _CMUX_HAVE_ZSTAT=-1
+ return 1
+}
+
+_cmux_git_resolve_head_path() {
+ # Resolve the HEAD file path without invoking git (fast; works for worktrees).
+ local dir="$PWD"
+ while true; do
+ if [[ -d "$dir/.git" ]]; then
+ print -r -- "$dir/.git/HEAD"
+ return 0
+ fi
+ if [[ -f "$dir/.git" ]]; then
+ local line gitdir
+ line="$(<"$dir/.git")"
+ if [[ "$line" == gitdir:* ]]; then
+ gitdir="${line#gitdir:}"
+ gitdir="${gitdir## }"
+ gitdir="${gitdir%% }"
+ [[ -n "$gitdir" ]] || return 1
+ [[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir"
+ print -r -- "$gitdir/HEAD"
+ return 0
+ fi
+ fi
+ [[ "$dir" == "/" || -z "$dir" ]] && break
+ dir="${dir:h}"
+ done
+ return 1
+}
+
+_cmux_git_head_mtime() {
+ local head_path="$1"
+ [[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; }
+
+ if _cmux_ensure_zstat; then
+ typeset -A st
+ if zstat -H st +mtime -- "$head_path" 2>/dev/null; then
+ print -r -- "${st[mtime]:-0}"
+ return 0
+ fi
+ fi
+
+ # Fallback for environments where zsh/stat isn't available.
+ if command -v stat >/dev/null 2>&1; then
+ local mtime
+ mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)"
+ print -r -- "$mtime"
+ return 0
+ fi
+
+ print -r -- 0
+}
+
_cmux_ports_scan() {
[[ -n "$CMUX_PANEL_ID" ]] || return 0
@@ -173,6 +240,23 @@ _cmux_precmd() {
# Git branch/dirty: update immediately on directory change, otherwise every ~3s.
local should_git=0
+
+ # Git branch can change without a `git ...`-prefixed command (aliases like `gco`,
+ # tools like `gh pr checkout`, etc.). Detect HEAD changes and force a refresh.
+ if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
+ _CMUX_GIT_HEAD_LAST_PWD="$pwd"
+ _CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
+ _CMUX_GIT_HEAD_MTIME=0
+ fi
+ if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
+ local head_mtime
+ head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)"
+ if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then
+ _CMUX_GIT_HEAD_MTIME="$head_mtime"
+ should_git=1
+ fi
+ fi
+
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
should_git=1
elif (( _CMUX_GIT_FORCE )); then
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index a1001b1b..2710515c 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -554,7 +554,8 @@ struct TabItemView: View {
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}),
- isActive: isActive
+ isActive: isActive,
+ onFocus: { updateSelection() }
)
.transition(.opacity.combined(with: .move(edge: .top)))
}
@@ -921,90 +922,65 @@ struct TabItemView: View {
}
private struct SidebarStatusPillsRow: View {
+ // Renamed/replaced: we now render status as normal text with an optional expand/collapse.
+ // Kept as a separate view for minimal churn in call sites.
let entries: [SidebarStatusEntry]
let isActive: Bool
+ let onFocus: () -> Void
- private let maxVisiblePills = 3
+ @State private var isExpanded: Bool = false
var body: some View {
- let visible = Array(entries.prefix(maxVisiblePills))
- let overflow = max(0, entries.count - visible.count)
-
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 6) {
- ForEach(visible) { entry in
- SidebarStatusPill(entry: entry, isActive: isActive)
- }
- if overflow > 0 {
- SidebarStatusOverflowPill(count: overflow, isActive: isActive)
- }
- }
- .padding(.vertical, 1)
- }
- .frame(maxWidth: .infinity, alignment: .leading)
- }
-}
-
-private struct SidebarStatusPill: View {
- let entry: SidebarStatusEntry
- let isActive: Bool
-
- var body: some View {
- HStack(spacing: 4) {
- if let icon = entry.icon, !icon.isEmpty {
- Image(systemName: icon)
- .font(.system(size: 8, weight: .semibold))
- .foregroundColor(iconColor)
- }
- Text("\(entry.key)=\(entry.value)")
- .font(.system(size: 9, design: .monospaced))
- .foregroundColor(textColor)
- .lineLimit(1)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(statusText)
+ .font(.system(size: 10))
+ .foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
+ .lineLimit(isExpanded ? nil : 3)
.truncationMode(.tail)
- .frame(maxWidth: 150, alignment: .leading)
+ .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)
+ }
}
- .padding(.horizontal, 6)
- .padding(.vertical, 3)
- .background(
- Capsule()
- .fill(backgroundColor)
- )
- .help("\(entry.key)=\(entry.value)")
+ .help(statusText)
}
- private var backgroundColor: Color {
- isActive ? .white.opacity(0.15) : .secondary.opacity(0.14)
+ private var statusText: String {
+ entries
+ .map { entry in
+ // Render like notification text: show the status contents only.
+ // If the value is empty, fall back to the key so the line isn't blank.
+ let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !value.isEmpty { return value }
+ return entry.key
+ }
+ .joined(separator: "\n")
}
- private var textColor: Color {
- isActive ? .white.opacity(0.9) : .secondary
- }
-
- private var iconColor: Color {
- guard !isActive else { return .white.opacity(0.85) }
- if let hex = entry.color, let nsColor = NSColor(hex: hex) {
- return Color(nsColor: nsColor)
- }
- return .secondary
- }
-}
-
-private struct SidebarStatusOverflowPill: View {
- let count: Int
- let isActive: Bool
-
- var body: some View {
- Text("+\(count)")
- .font(.system(size: 9, weight: .semibold, design: .monospaced))
- .foregroundColor(isActive ? .white.opacity(0.85) : .secondary)
- .lineLimit(1)
- .padding(.horizontal, 6)
- .padding(.vertical, 3)
- .background(
- Capsule()
- .fill(isActive ? .white.opacity(0.15) : .secondary.opacity(0.14))
- )
- .help("\(count) more status entries")
+ private var shouldShowToggle: Bool {
+ // We can't reliably measure truncation in SwiftUI without extra layout plumbing.
+ // Heuristic: show toggle when there are multiple entries or the text is long enough
+ // that it likely wraps past 3 lines in the sidebar.
+ entries.count > 1 || statusText.count > 120
}
}
@@ -1096,6 +1072,7 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable {
private final class DraggableFolderNSView: NSView, NSDraggingSource {
var directory: String
private var imageView: NSImageView!
+ private static let iconSide: CGFloat = 16
init(directory: String) {
self.directory = directory
@@ -1121,9 +1098,15 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
updateIcon()
}
+ override var intrinsicContentSize: NSSize {
+ NSSize(width: Self.iconSide, height: Self.iconSide)
+ }
+
func updateIcon() {
- let icon = NSWorkspace.shared.icon(forFile: directory)
- icon.size = NSSize(width: 16, height: 16)
+ // NSWorkspace may return cached/shared NSImage instances. Never mutate the shared image size,
+ // since other callsites (e.g. dragging preview) may resize it and inadvertently affect layout.
+ let icon = (NSWorkspace.shared.icon(forFile: directory).copy() as? NSImage) ?? NSImage()
+ icon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
imageView.image = icon
}
@@ -1135,7 +1118,7 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
let fileURL = URL(fileURLWithPath: directory)
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
- let iconImage = NSWorkspace.shared.icon(forFile: directory)
+ let iconImage = (NSWorkspace.shared.icon(forFile: directory).copy() as? NSImage) ?? NSImage()
iconImage.size = NSSize(width: 32, height: 32)
draggingItem.setDraggingFrame(bounds, contents: iconImage)
@@ -1164,8 +1147,8 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
// Add path components (current dir at top, root at bottom - matches native macOS)
for pathURL in pathComponents {
- let icon = NSWorkspace.shared.icon(forFile: pathURL.path)
- icon.size = NSSize(width: 16, height: 16)
+ let icon = (NSWorkspace.shared.icon(forFile: pathURL.path).copy() as? NSImage) ?? NSImage()
+ icon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
let displayName: String
if pathURL.path == "/" {
@@ -1188,8 +1171,8 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
// Add computer name at the bottom (like native proxy icon)
let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
- let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage()
- computerIcon.size = NSSize(width: 16, height: 16)
+ let computerIcon = (NSImage(named: NSImage.computerName)?.copy() as? NSImage) ?? NSImage()
+ computerIcon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "")
computerItem.target = self
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index 3e812ec5..1bd0e44f 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -298,7 +298,7 @@ class TerminalController {
clear_notifications - Clear all notifications
set_app_focus - Override app focus state
simulate_app_active - Trigger app active handler
- set_status [--icon=X] [--color=#hex] - Set a status entry
+ set_status [--icon=X] [--color=#hex] [--tab=X] - Set a status entry
clear_status [--tab=X] - Remove a status entry
list_status [--tab=X] - List all status entries
log [--level=X] [--source=X] [--tab=X] -- - Append a log entry
@@ -490,14 +490,19 @@ class TerminalController {
result = "ERROR: No tab selected"
return
}
+ guard let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
+ result = "ERROR: Tab not found"
+ return
+ }
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
let (title, subtitle, body) = parseNotificationPayload(args)
+ let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
- body: body
+ body: bodyWithStatus
)
}
return result
@@ -524,12 +529,13 @@ class TerminalController {
return
}
let (title, subtitle, body) = parseNotificationPayload(payload)
+ let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
- body: body
+ body: bodyWithStatus
)
}
return result
@@ -559,17 +565,43 @@ class TerminalController {
return
}
let (title, subtitle, body) = parseNotificationPayload(payload)
+ let bodyWithStatus = appendStatusTextIfPresent(body: body, tab: tab)
TerminalNotificationStore.shared.addNotification(
tabId: tab.id,
surfaceId: panelId,
title: title,
subtitle: subtitle,
- body: body
+ body: bodyWithStatus
)
}
return result
}
+ private func appendStatusTextIfPresent(body: String, tab: Tab) -> String {
+ let statusText = statusTextForNotification(tab: tab)
+ guard !statusText.isEmpty else { return body }
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmedBody.isEmpty {
+ return statusText
+ }
+ return body + "\n\n" + statusText
+ }
+
+ private func statusTextForNotification(tab: Tab) -> String {
+ let entries = tab.statusEntries.values.sorted(by: { (lhs, rhs) in
+ if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
+ return lhs.key < rhs.key
+ })
+
+ let lines = entries.compactMap { entry -> String? in
+ let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !value.isEmpty { return value }
+ let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines)
+ return key.isEmpty ? nil : key
+ }
+ return lines.joined(separator: "\n")
+ }
+
private func listNotifications() -> String {
var result = ""
DispatchQueue.main.sync {
@@ -1010,9 +1042,9 @@ class TerminalController {
// MARK: - Option Parsing
- private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
+ private func tokenizeArgs(_ args: String) -> [String] {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return ([], [:]) }
+ guard !trimmed.isEmpty else { return [] }
// Tokenize respecting quoted strings. Support basic backslash escapes inside quotes
// (e.g. \" within "...") so shell integrations can safely escape embedded quotes.
@@ -1072,6 +1104,12 @@ class TerminalController {
if !current.isEmpty {
tokens.append(current)
}
+ return tokens
+ }
+
+ private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
+ let tokens = tokenizeArgs(args)
+ guard !tokens.isEmpty else { return ([], [:]) }
var positional: [String] = []
var options: [String: String] = [:]
@@ -1105,6 +1143,43 @@ class TerminalController {
return (positional, options)
}
+ private func parseOptionsNoStop(_ args: String) -> (positional: [String], options: [String: String]) {
+ // Like parseOptions, but continues parsing `--key` options even after a `--` token.
+ // Used for commands where we never want UI-facing content to accidentally include flags.
+ let tokens = tokenizeArgs(args)
+ guard !tokens.isEmpty else { return ([], [:]) }
+
+ var positional: [String] = []
+ var options: [String: String] = [:]
+ var i = 0
+ while i < tokens.count {
+ let token = tokens[i]
+ if token == "--" {
+ i += 1
+ continue
+ }
+ if token.hasPrefix("--") {
+ if let eqIndex = token.firstIndex(of: "=") {
+ let key = String(token[token.index(token.startIndex, offsetBy: 2).. Tab? {
@@ -1119,7 +1194,9 @@ class TerminalController {
private func setStatus(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
- let parsed = parseOptions(args)
+ // Parse options even if the caller used `--` before/inside the value.
+ // This avoids leaking flags like `--tab` into the stored (and rendered) status text.
+ let parsed = parseOptionsNoStop(args)
guard parsed.positional.count >= 2 else {
return "ERROR: Missing status key or value — usage: set_status [--icon=X] [--color=#hex] [--tab=X]"
}
@@ -1130,7 +1207,16 @@ class TerminalController {
var result = "OK"
DispatchQueue.main.sync {
- guard let tab = resolveTabForReport(args) else {
+ guard let tabManager else { result = "ERROR: TabManager not available"; return }
+ let tab: Tab?
+ if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
+ tab = resolveTab(from: tabArg, tabManager: tabManager)
+ } else if let selectedId = tabManager.selectedTabId {
+ tab = tabManager.tabs.first(where: { $0.id == selectedId })
+ } else {
+ tab = nil
+ }
+ guard let tab else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index 1c826fef..8252f2d7 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -962,7 +962,7 @@ struct SettingsView: View {
Text("Sidebar")
.font(.headline)
- Toggle("Show status pills", isOn: $sidebarShowStatusPills)
+ Toggle("Show status", isOn: $sidebarShowStatusPills)
Toggle("Show git branch", isOn: $sidebarShowGitBranch)
Toggle("Show branch icon", isOn: $sidebarShowGitBranchIcon)
.disabled(!sidebarShowGitBranch)
diff --git a/docs/assets/screenshot.png b/docs/assets/screenshot.png
new file mode 100644
index 00000000..bc8399e4
Binary files /dev/null and b/docs/assets/screenshot.png differ
diff --git a/tests/test_sidebar_cwd_git.py b/tests/test_sidebar_cwd_git.py
index b9d5d42a..401a80a0 100644
--- a/tests/test_sidebar_cwd_git.py
+++ b/tests/test_sidebar_cwd_git.py
@@ -139,9 +139,16 @@ def main() -> int:
_wait_for_git_branch(client, "main")
# Branch change should update.
- client.send("git checkout -b feature/sidebar\n")
+ # Cover alias/non-`git ...` command paths too (regression: branch could
+ # stick for ~3s when switching via alias/tools like `gh pr checkout`).
+ client.send("alias gco='git checkout'\n")
+ time.sleep(0.2)
+ client.send("gco -b feature/sidebar\n")
_wait_for_git_branch(client, "feature/sidebar")
+ client.send("gco main\n")
+ _wait_for_git_branch(client, "main")
+
# Leaving the repo should clear the branch.
client.send(f"cd {other}\n")
_wait_for_state_field(client, "cwd", str(other))
@@ -167,4 +174,3 @@ def main() -> int:
if __name__ == "__main__":
raise SystemExit(main())
-