Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
84 changes: 84 additions & 0 deletions Resources/shell-integration/cmux-zsh-integration.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
139 changes: 61 additions & 78 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
Expand Down Expand Up @@ -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)
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 overflow > 0 {
SidebarStatusOverflowPill(count: overflow, isActive: isActive)

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(.vertical, 1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.help(statusText)
}
}

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

private var backgroundColor: Color {
isActive ? .white.opacity(0.15) : .secondary.opacity(0.14)
}

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
.joined(separator: "\n")
}
}

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

Expand Down Expand Up @@ -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
Expand All @@ -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
}

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

Expand Down Expand Up @@ -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 == "/" {
Expand All @@ -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
Expand Down
Loading