Sidebar status as text + detect git HEAD changes instantly (#30)

* Sidebar status as text + detect git HEAD changes instantly

- Replace sidebar status pills with plain text + show more/less toggle
  for a cleaner, more readable sidebar layout
- Watch .git/HEAD mtime in zsh precmd to detect branch changes from
  aliases (gco), tools (gh pr checkout), etc. without waiting for the
  3s polling interval
- Fix NSImage shared instance mutation in DraggableFolderNSView by
  copying before resizing to prevent layout side-effects
- Fix set_status --tab flag being swallowed by -- stop token via
  new parseOptionsNoStop parser
- Update sidebar test to cover alias-based branch switching

* Append status text to notification body automatically

When creating notifications, include the tab's current status entries
in the notification body so users see context (e.g. git branch, ports)
alongside the notification message.

* Add screenshot to README
This commit is contained in:
Lawrence Chen 2026-02-09 14:18:33 -08:00 committed by GitHub
parent 119511f774
commit 7d6f33c143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 255 additions and 92 deletions

View file

@ -7,6 +7,10 @@
</a>
</p>
<p align="center">
<img src="./docs/assets/screenshot.png" alt="cmuxterm screenshot" width="900" />
</p>
## Features
- **Native macOS app** — Built with Swift and AppKit, not Electron. Fast startup, low memory.

View file

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

View file

@ -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)
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()
}
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(visible) { entry in
SidebarStatusPill(entry: entry, isActive: isActive)
}
if overflow > 0 {
SidebarStatusOverflowPill(count: overflow, isActive: isActive)
if shouldShowToggle {
Button(isExpanded ? "Show less" : "Show more") {
onFocus()
withAnimation(.easeInOut(duration: 0.15)) {
isExpanded.toggle()
}
}
.padding(.vertical, 1)
}
.buttonStyle(.plain)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
.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)
.truncationMode(.tail)
.frame(maxWidth: 150, 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

View file

@ -298,7 +298,7 @@ class TerminalController {
clear_notifications - Clear all notifications
set_app_focus <active|inactive|clear> - Override app focus state
simulate_app_active - Trigger app active handler
set_status <key> <value> [--icon=X] [--color=#hex] - Set a status entry
set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X] - Set a status entry
clear_status <key> [--tab=X] - Remove a status entry
list_status [--tab=X] - List all status entries
log [--level=X] [--source=X] [--tab=X] -- <message> - 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)..<eqIndex])
let value = String(token[token.index(after: eqIndex)...])
options[key] = value
} else {
let key = String(token.dropFirst(2))
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
options[key] = tokens[i + 1]
i += 1
} else {
options[key] = ""
}
}
} else {
positional.append(token)
}
i += 1
}
return (positional, options)
}
// MARK: - Sidebar Commands
private func resolveTabForReport(_ args: String) -> 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 <key> <value> [--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
}

View file

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

BIN
docs/assets/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

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