Sidebar metadata + tagged reload isolation (#16)

* Sidebar primitives + tagged dev isolation

* Allow wider sidebar resize

* Fix tagged socket selection + panel id errors

* Fix progress label quoting + bundle suffix sanitize

* Skip ctrl-enter keybind test when keystrokes blocked

* Fix shell nc hang + prune stale per-surface sidebar metadata
This commit is contained in:
Lawrence Chen 2026-02-06 18:09:56 -08:00 committed by GitHub
parent b3c2a8c7c3
commit 7e69751e1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2538 additions and 72 deletions

View file

@ -325,6 +325,122 @@ struct CMUXCLI {
let response = try client.send(command: "simulate_app_active")
print(response)
case "set-status":
guard commandArgs.count >= 2 else {
throw CLIError(message: "set-status requires <key> <value>")
}
let key = commandArgs[0]
let value = commandArgs[1]
let icon = optionValue(commandArgs, name: "--icon")
let color = optionValue(commandArgs, name: "--color")
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "set_status \(key) \(value)"
if let icon { cmd += " --icon=\(icon)" }
if let color { cmd += " --color=\(color)" }
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "clear-status":
let key = commandArgs.first
guard let key else {
throw CLIError(message: "clear-status requires <key>")
}
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "clear_status \(key)"
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "log":
// Remove options by position (flag + following value), not by string value,
// so message tokens that happen to equal an option value aren't dropped.
let (level, argsWithoutLevel) = parseOption(commandArgs, name: "--level")
let (source, argsWithoutSource) = parseOption(argsWithoutLevel, name: "--source")
let (explicitTab, remaining) = parseOption(argsWithoutSource, name: "--tab")
let tabArg = explicitTab ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
let message = remaining.joined(separator: " ")
guard !message.isEmpty else { throw CLIError(message: "log requires a message") }
var cmd = "log \(message)"
if let level { cmd += " --level=\(level)" }
if let source { cmd += " --source=\(source)" }
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "clear-log":
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "clear_log"
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "set-progress":
guard let value = commandArgs.first else {
throw CLIError(message: "set-progress requires a value (0.0-1.0)")
}
let label = optionValue(commandArgs, name: "--label")
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "set_progress \(value)"
if let label { cmd += " --label=\(quoteOptionValue(label))" }
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "clear-progress":
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "clear_progress"
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "report-git-branch":
guard let branch = commandArgs.first else {
throw CLIError(message: "report-git-branch requires a branch name")
}
let status = optionValue(commandArgs, name: "--status")
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "report_git_branch \(branch)"
if let status { cmd += " --status=\(status)" }
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "report-ports":
// Remove options by position (flag + following value), not by string value,
// so a port token that equals the tab arg isn't accidentally dropped.
let (explicitTab, remaining) = parseOption(commandArgs, name: "--tab")
let tabArg = explicitTab ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
let ports = remaining
guard !ports.isEmpty else {
throw CLIError(message: "report-ports requires at least one port number")
}
var cmd = "report_ports \(ports.joined(separator: " "))"
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "clear-ports":
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "clear_ports"
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "sidebar-state":
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "sidebar_state"
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "reset-sidebar":
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
var cmd = "reset_sidebar"
if let tabArg { cmd += " --tab=\(tabArg)" }
let response = try client.send(command: cmd)
print(response)
case "help":
print(usage())
@ -487,6 +603,15 @@ struct CMUXCLI {
.replacingOccurrences(of: "\t", with: "\\t")
}
private func quoteOptionValue(_ value: String) -> String {
// TerminalController.parseOptions supports quoted strings with basic
// backslash escapes (\" and \\) inside quotes.
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
private func isUUID(_ value: String) -> Bool {
return UUID(uuidString: value) != nil
}
@ -526,6 +651,17 @@ struct CMUXCLI {
clear-notifications
set-app-focus <active|inactive|clear>
simulate-app-active
set-status <key> <value> [--icon <name>] [--color <hex>] [--tab <id|index>]
clear-status <key> [--tab <id|index>]
log <message> [--level <level>] [--source <name>] [--tab <id|index>]
clear-log [--tab <id|index>]
set-progress <value> [--label <text>] [--tab <id|index>]
clear-progress [--tab <id|index>]
report-git-branch <branch> [--status <clean|dirty>] [--tab <id>]
report-ports <port1> [port2...] [--tab <id>]
clear-ports [--tab <id>]
sidebar-state [--tab <id>]
reset-sidebar [--tab <id>]
help
Environment:

View file

@ -45,6 +45,7 @@
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */; };
B900000BA1B2C3D4E5F60719 /* cmuxterm in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmuxterm */; };
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
C1D2E3F4A5B6C7D8E9F00001 /* shell-integration in Resources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B6C7D8E9F00002 /* shell-integration */; };
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
@ -133,6 +134,7 @@
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
C1D2E3F4A5B6C7D8E9F00002 /* shell-integration */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "shell-integration"; sourceTree = "<group>"; };
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxterm.swift; sourceTree = "<group>"; };
B9000004A1B2C3D4E5F60719 /* cmuxterm */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmuxterm; sourceTree = BUILT_PRODUCTS_DIR; };
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = "<group>"; };
@ -173,6 +175,7 @@
files = (
A5001100 /* Assets.xcassets in Resources */,
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */,
C1D2E3F4A5B6C7D8E9F00001 /* shell-integration in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -271,6 +274,7 @@
isa = PBXGroup;
children = (
B2E7294509CC42FE9191870E /* xterm-ghostty */,
C1D2E3F4A5B6C7D8E9F00002 /* shell-integration */,
);
path = Resources;
sourceTree = "<group>";

View file

@ -0,0 +1,4 @@
# cmuxterm ZDOTDIR wrapper — sources user's .zlogin
_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}"
[ -f "$_cmux_real_zdotdir/.zlogin" ] && source "$_cmux_real_zdotdir/.zlogin"
unset _cmux_real_zdotdir

View file

@ -0,0 +1,4 @@
# cmuxterm ZDOTDIR wrapper — sources user's .zprofile
_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}"
[ -f "$_cmux_real_zdotdir/.zprofile" ] && source "$_cmux_real_zdotdir/.zprofile"
unset _cmux_real_zdotdir

View file

@ -0,0 +1,6 @@
# cmuxterm ZDOTDIR wrapper — sources user's .zshenv
# NOTE: Do NOT restore ZDOTDIR here. It must stay pointed at our wrapper dir
# so that zsh finds our .zshrc next. Restoration happens in .zshrc.
_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}"
[ -f "$_cmux_real_zdotdir/.zshenv" ] && source "$_cmux_real_zdotdir/.zshenv"
unset _cmux_real_zdotdir

View file

@ -0,0 +1,16 @@
# cmuxterm ZDOTDIR wrapper — restore ZDOTDIR, source user's .zshrc, then load integration
# Restore original ZDOTDIR so user configs and subsequent shells work normally
if [ -n "$CMUX_ORIGINAL_ZDOTDIR" ]; then
ZDOTDIR="$CMUX_ORIGINAL_ZDOTDIR"
else
ZDOTDIR="$HOME"
fi
# Source user's .zshrc
[ -f "$ZDOTDIR/.zshrc" ] && source "$ZDOTDIR/.zshrc"
# Load cmux shell integration (unless disabled)
if [ "$CMUX_SHELL_INTEGRATION" != "0" ]; then
source "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh"
fi

View file

@ -0,0 +1,154 @@
# cmuxterm shell integration for bash
_cmux_send() {
local payload="$1"
if command -v ncat >/dev/null 2>&1; then
printf '%s\n' "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
elif command -v socat >/dev/null 2>&1; then
printf '%s\n' "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
elif command -v nc >/dev/null 2>&1; then
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
#
# Important: macOS/BSD nc will often wait for the peer to close the socket
# after it has finished writing. cmuxterm keeps the connection open, so
# a plain `nc -U` can hang indefinitely and leak background processes.
#
# Prefer flags that guarantee we exit after sending, and fall back to a
# short timeout so we never block sidebar updates.
if printf '%s\n' "$payload" | nc -N -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1; then
:
else
printf '%s\n' "$payload" | nc -w 1 -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1 || true
fi
fi
}
# Throttle heavy work to avoid prompt latency.
_CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_PORTS_JOB_PID="${_CMUX_PORTS_JOB_PID:-}"
_cmux_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local now=$SECONDS
local pwd="$PWD"
local tty_name=""
tty_name="$(tty 2>/dev/null || true)"
tty_name="${tty_name##*/}"
if [[ "$tty_name" == "not a tty" ]]; then
tty_name=""
fi
# CWD: keep the app in sync with the actual shell directory.
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
_CMUX_PWD_LAST_PWD="$pwd"
{
local qpwd="${pwd//\"/\\\"}"
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
fi
# Git branch/dirty can change without a directory change (e.g. `git checkout`),
# so update on every prompt (still async + de-duped by the running-job check).
local should_git=1
if (( should_git )); then
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
:
else
_CMUX_GIT_LAST_PWD="$pwd"
_CMUX_GIT_LAST_RUN=$now
{
local branch dirty_opt=""
branch=$(git branch --show-current 2>/dev/null)
if [[ -n "$branch" ]]; then
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
fi
} >/dev/null 2>&1 &
_CMUX_GIT_JOB_PID=$!
fi
fi
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then
: # previous scan still running
else
_CMUX_PORTS_LAST_RUN=$now
{
local ports=()
local pids_csv=""
if [[ -n "$tty_name" ]]; then
pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)"
pids_csv="${pids_csv%,}"
fi
if [[ -n "$pids_csv" ]]; then
local line name port
while IFS= read -r line; do
[[ "$line" == n* ]] || continue
name="${line#n}"
name="${name%%->*}"
port="${name##*:}"
port="${port%%[^0-9]*}"
[[ -n "$port" ]] && ports+=("$port")
done < <(
lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true
)
fi
if ((${#ports[@]} > 0)); then
local ports_sorted
ports_sorted=$(printf '%s\n' "${ports[@]}" | sort -n | uniq | tr '\n' ' ')
ports_sorted="${ports_sorted%% }"
_cmux_send "report_ports $ports_sorted --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
} >/dev/null 2>&1 &
_CMUX_PORTS_JOB_PID=$!
fi
fi
}
_cmux_install_prompt_command() {
[[ -n "${_CMUX_PROMPT_INSTALLED:-}" ]] && return 0
_CMUX_PROMPT_INSTALLED=1
local decl
decl="$(declare -p PROMPT_COMMAND 2>/dev/null || true)"
if [[ "$decl" == "declare -a"* ]]; then
local existing=0
local item
for item in "${PROMPT_COMMAND[@]}"; do
[[ "$item" == "_cmux_prompt_command" ]] && existing=1 && break
done
if (( existing == 0 )); then
PROMPT_COMMAND=("_cmux_prompt_command" "${PROMPT_COMMAND[@]}")
fi
else
case ";$PROMPT_COMMAND;" in
*";_cmux_prompt_command;"*) ;;
*)
if [[ -n "$PROMPT_COMMAND" ]]; then
PROMPT_COMMAND="_cmux_prompt_command;$PROMPT_COMMAND"
else
PROMPT_COMMAND="_cmux_prompt_command"
fi
;;
esac
fi
}
_cmux_install_prompt_command

View file

@ -0,0 +1,222 @@
# cmuxterm shell integration for zsh
# Injected automatically — do not source manually
_cmux_send() {
local payload="$1"
if command -v ncat >/dev/null 2>&1; then
print -r -- "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
elif command -v socat >/dev/null 2>&1; then
print -r -- "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
elif command -v nc >/dev/null 2>&1; then
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
#
# Important: macOS/BSD nc will often wait for the peer to close the socket
# after it has finished writing. cmuxterm keeps the connection open, so
# a plain `nc -U` can hang indefinitely and leak background processes.
#
# Prefer flags that guarantee we exit after sending, and fall back to a
# short timeout so we never block sidebar updates.
if print -r -- "$payload" | nc -N -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1; then
:
else
print -r -- "$payload" | nc -w 1 -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1 || true
fi
fi
}
# Throttle heavy work to avoid prompt latency.
typeset -g _CMUX_PWD_LAST_PWD=""
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_PORTS_LAST_RUN=0
typeset -g _CMUX_PORTS_JOB_PID=""
typeset -g _CMUX_CMD_START=0
typeset -g _CMUX_TTY_NAME=""
_cmux_ports_scan() {
[[ -n "$CMUX_PANEL_ID" ]] || return 0
# Report listening TCP ports for the current shell session only (so a fresh
# tab doesn't inherit unrelated machine-wide ports). We restrict the scan to
# the current controlling TTY which keeps this cheap enough to run often.
local -a ports
local line name port
# Best-effort: restrict to the current controlling TTY so a fresh tab doesn't
# inherit unrelated machine-wide ports. This is a pragmatic heuristic that
# works well for typical dev servers started from that shell.
local tty_name pids_csv
tty_name="$_CMUX_TTY_NAME"
if [[ -z "$tty_name" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ "$t" != "not a tty" ]] && tty_name="$t"
fi
if [[ -z "$tty_name" ]]; then
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
return 0
fi
pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)"
pids_csv="${pids_csv%,}"
if [[ -z "$pids_csv" ]]; then
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
return 0
fi
while IFS= read -r line; do
[[ "$line" == n* ]] || continue
name="${line#n}"
# Defensive: if the format ever includes a remote endpoint, keep the local side.
name="${name%%->*}"
port="${name##*:}"
# Strip anything non-numeric (paranoia: "8000 (LISTEN)" etc).
port="${port%%[^0-9]*}"
[[ -n "$port" ]] && ports+=("$port")
done < <(
lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true
)
ports=("${(@u)ports}")
ports=("${(@on)ports}")
if (( ${#ports[@]} > 0 )); then
_cmux_send "report_ports ${(j: :)ports} --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
}
_cmux_ports_kick() {
# De-duped, async scans (with a short burst) so we still update when a command
# runs in the foreground (no prompt updates while it is running).
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then
return 0
fi
_CMUX_PORTS_LAST_RUN=$EPOCHSECONDS
{
# Scan over ~10 seconds so slow-starting servers (e.g. `npm run dev`)
# still show ports while the command is in the foreground.
sleep 0.5 2>/dev/null || true
_cmux_ports_scan
sleep 1.0 2>/dev/null || true
_cmux_ports_scan
sleep 1.5 2>/dev/null || true
_cmux_ports_scan
sleep 2.0 2>/dev/null || true
_cmux_ports_scan
sleep 2.5 2>/dev/null || true
_cmux_ports_scan
sleep 2.5 2>/dev/null || true
_cmux_ports_scan
} >/dev/null 2>&1 &!
_CMUX_PORTS_JOB_PID=$!
}
_cmux_preexec() {
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
_CMUX_CMD_START=$EPOCHSECONDS
# Heuristic: git commands can change branch/dirty state without changing $PWD.
local cmd="${1## }"
if [[ "$cmd" == git\ * || "$cmd" == git ]]; then
_CMUX_GIT_FORCE=1
fi
# Ports can change due to long-running foreground commands (servers), so start
# a short scan burst after command launch.
_cmux_ports_kick
}
_cmux_precmd() {
# Skip if socket doesn't exist yet
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
local now=$EPOCHSECONDS
local pwd="$PWD"
local cmd_start="$_CMUX_CMD_START"
_CMUX_CMD_START=0
# CWD: keep the app in sync with the actual shell directory.
# This is also the simplest way to test sidebar directory behavior end-to-end.
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
_CMUX_PWD_LAST_PWD="$pwd"
{
# Quote to preserve spaces.
local qpwd="${pwd//\"/\\\"}"
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
fi
# Git branch/dirty: update immediately on directory change, otherwise every ~3s.
local should_git=0
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
should_git=1
elif (( _CMUX_GIT_FORCE )); then
should_git=1
elif (( now - _CMUX_GIT_LAST_RUN >= 3 )); then
should_git=1
fi
if (( should_git )); then
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
: # previous update still running
else
_CMUX_GIT_FORCE=0
_CMUX_GIT_LAST_PWD="$pwd"
_CMUX_GIT_LAST_RUN=$now
{
local branch dirty_opt=""
branch=$(git branch --show-current 2>/dev/null)
if [[ -n "$branch" ]]; then
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
fi
} >/dev/null 2>&1 &!
_CMUX_GIT_JOB_PID=$!
fi
fi
# Ports:
# - Periodic scan to avoid stale values.
# - Forced scan when a long-running command returns to the prompt (common when stopping a server).
local cmd_dur=0
if [[ -n "$cmd_start" && "$cmd_start" != 0 ]]; then
cmd_dur=$(( now - cmd_start ))
fi
if (( cmd_dur >= 2 || now - _CMUX_PORTS_LAST_RUN >= 10 )); then
_cmux_ports_kick
fi
}
autoload -Uz add-zsh-hook
add-zsh-hook preexec _cmux_preexec
add-zsh-hook precmd _cmux_precmd

View file

@ -179,7 +179,8 @@ struct ContentView: View {
isResizerHovering = true
}
}
let nextWidth = max(186, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
// Allow a wider sidebar so long paths and metadata aren't constantly truncated.
let nextWidth = max(186, min(640, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
@ -504,6 +505,13 @@ struct TabItemView: View {
selectedTabIds.contains(tab.id)
}
@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 body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
@ -553,14 +561,74 @@ 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
)
.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 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(
@ -760,7 +828,31 @@ 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 let root = tab.splitTree.root else { return nil }
let surfaces = root.leaves()
guard !surfaces.isEmpty else { return nil }
@ -778,6 +870,35 @@ 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 }
@ -812,6 +933,94 @@ struct TabItemView: View {
}
}
private struct SidebarStatusPillsRow: View {
let entries: [SidebarStatusEntry]
let isActive: Bool
private let maxVisiblePills = 3
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)
.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
}
}
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")
}
}
enum SidebarSelection {
case tabs
case notifications

View file

@ -886,6 +886,28 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
// Shell integration: inject ZDOTDIR wrapper for zsh
let shellIntegrationEnabled = UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true
if shellIntegrationEnabled,
let integrationDir = Bundle.main.resourceURL?
.appendingPathComponent("shell-integration").path {
env["CMUX_SHELL_INTEGRATION"] = "1"
env["CMUX_SHELL_INTEGRATION_DIR"] = integrationDir
// Prefer the per-surface environment (from Ghostty config/env vars),
// falling back to the app's environment. This avoids regressions when
// users override SHELL/ZDOTDIR at the surface level.
let shell = (env["SHELL"]?.isEmpty == false ? env["SHELL"] : nil)
?? ProcessInfo.processInfo.environment["SHELL"]
?? "/bin/zsh"
let shellName = URL(fileURLWithPath: shell).lastPathComponent
if shellName == "zsh" {
let originalZdotdir = env["ZDOTDIR"] ?? ProcessInfo.processInfo.environment["ZDOTDIR"] ?? ""
env["CMUX_ORIGINAL_ZDOTDIR"] = originalZdotdir
env["ZDOTDIR"] = integrationDir
}
}
if !env.isEmpty {
envVars.reserveCapacity(env.count)
envStorage.reserveCapacity(env.count)

View file

@ -2,6 +2,27 @@ import AppKit
import SwiftUI
import Foundation
struct SidebarStatusEntry: Identifiable {
let id = UUID()
let key: String
var value: String
var icon: String?
var color: String?
var timestamp: Date
}
enum SidebarLogLevel: String {
case info, progress, success, warning, error
}
struct SidebarLogEntry: Identifiable {
let id = UUID()
let message: String
let level: SidebarLogLevel
let source: String?
let timestamp: Date
}
class Tab: Identifiable, ObservableObject {
let id: UUID
@Published var title: String
@ -28,6 +49,17 @@ class Tab: Identifiable, ObservableObject {
@Published var surfaceTitles: [UUID: String] = [:]
var splitViewSize: CGSize = .zero
// Sidebar metadata
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
@Published var logEntries: [SidebarLogEntry] = []
@Published var progress: (value: Double, label: String?)? = nil
@Published var gitBranch: (branch: String, isDirty: Bool)? = nil
// Per-surface ports reported by shell integration. `listeningPorts` is the union for display.
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = []
static let maxLogEntries = 50
private var processTitle: String
init(title: String = "Terminal", workingDirectory: String? = nil) {
@ -114,6 +146,34 @@ class Tab: Identifiable, ObservableObject {
currentDirectory = trimmed
}
func recomputeListeningPorts() {
let merged = surfaceListeningPorts.values.reduce(into: Set<Int>()) { acc, ports in
for port in ports {
acc.insert(port)
}
}
let sorted = merged.sorted()
if listeningPorts != sorted {
listeningPorts = sorted
}
}
func pruneSurfaceMetadata(validSurfaceIds: Set<UUID>? = nil) {
let validIds = validSurfaceIds ?? Set((splitTree.root?.leaves() ?? []).map { $0.id })
if surfaceDirectories.keys.contains(where: { !validIds.contains($0) }) {
surfaceDirectories = surfaceDirectories.filter { validIds.contains($0.key) }
}
if surfaceTitles.keys.contains(where: { !validIds.contains($0) }) {
surfaceTitles = surfaceTitles.filter { validIds.contains($0.key) }
}
if surfaceListeningPorts.keys.contains(where: { !validIds.contains($0) }) {
surfaceListeningPorts = surfaceListeningPorts.filter { validIds.contains($0.key) }
}
recomputeListeningPorts()
}
func triggerNotificationFocusFlash(
surfaceId: UUID,
requiresSplit: Bool = false,
@ -274,6 +334,10 @@ class Tab: Identifiable, ObservableObject {
: nil
splitTree = splitTree.removing(targetNode)
// A closed split can race with shell-integration hooks that report ports/cwd.
// Prune any per-surface metadata to the remaining leaves so the sidebar
// doesn't display stale ports/directories.
pruneSurfaceMetadata()
if splitTree.isEmpty {
focusedSurfaceId = nil

View file

@ -222,6 +222,51 @@ class TerminalController {
return resetFlashCounts()
#endif
case "set_status":
return setStatus(args)
case "clear_status":
return clearStatus(args)
case "list_status":
return listStatus(args)
case "log":
return appendLog(args)
case "clear_log":
return clearLog(args)
case "list_log":
return listLog(args)
case "set_progress":
return setProgress(args)
case "clear_progress":
return clearProgress(args)
case "report_git_branch":
return reportGitBranch(args)
case "clear_git_branch":
return clearGitBranch(args)
case "report_ports":
return reportPorts(args)
case "clear_ports":
return clearPorts(args)
case "report_pwd":
return reportPwd(args)
case "sidebar_state":
return sidebarState(args)
case "reset_sidebar":
return resetSidebar(args)
case "help":
return helpText()
@ -253,6 +298,20 @@ 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
clear_status <key> [--tab=X] - Remove a status entry
list_status [--tab=X] - List all status entries
log <message> [--level=X] [--source=X] [--tab=X] - Append a log entry
clear_log [--tab=X] - Clear log entries
list_log [--limit=N] [--tab=X] - List log entries
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
clear_progress [--tab=X] - Clear progress bar
report_git_branch <branch> [--status=dirty] [--tab=X] - Report git branch
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
sidebar_state [--tab=X] - Dump all sidebar metadata
reset_sidebar [--tab=X] - Clear all sidebar metadata
help - Show this help
"""
#if DEBUG
@ -278,7 +337,22 @@ class TerminalController {
"notify_surface",
"notify_target",
"list_notifications",
"clear_notifications"
"clear_notifications",
"set_status",
"clear_status",
"list_status",
"log",
"clear_log",
"list_log",
"set_progress",
"clear_progress",
"report_git_branch",
"clear_git_branch",
"report_ports",
"clear_ports",
"report_pwd",
"sidebar_state",
"reset_sidebar"
]
return allowed.contains(command)
case .off:
@ -934,6 +1008,587 @@ class TerminalController {
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
}
// MARK: - Option Parsing
private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
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.
var tokens: [String] = []
var current = ""
var inQuote = false
var quoteChar: Character = "'"
let chars = Array(trimmed)
var cursor = 0
while cursor < chars.count {
let char = chars[cursor]
if inQuote {
if char == "\\" {
if cursor + 1 < chars.count {
let next = chars[cursor + 1]
if next == quoteChar || next == "\\" {
current.append(next)
cursor += 2
continue
}
}
current.append(char)
cursor += 1
continue
}
if char == quoteChar {
inQuote = false
cursor += 1
continue
}
current.append(char)
cursor += 1
continue
}
if char == "'" || char == "\"" {
inQuote = true
quoteChar = char
cursor += 1
continue
}
if char.isWhitespace {
if !current.isEmpty {
tokens.append(current)
current = ""
}
cursor += 1
continue
}
current.append(char)
cursor += 1
}
if !current.isEmpty {
tokens.append(current)
}
var positional: [String] = []
var options: [String: String] = [:]
var stopParsingOptions = false
var i = 0
while i < tokens.count {
let token = tokens[i]
if stopParsingOptions {
positional.append(token)
} else if token == "--" {
stopParsingOptions = true
} else 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? {
guard let tabManager else { return nil }
let parsed = parseOptions(args)
if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
return resolveTab(from: tabArg, tabManager: tabManager)
}
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}
private func setStatus(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptions(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]"
}
let key = parsed.positional[0]
let value = parsed.positional[1...].joined(separator: " ")
let icon = parsed.options["icon"]
let color = parsed.options["color"]
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
tab.statusEntries[key] = SidebarStatusEntry(
key: key, value: value, icon: icon, color: color, timestamp: Date()
)
}
return result
}
private func clearStatus(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
return "ERROR: Missing status key — usage: clear_status <key> [--tab=X]"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
if tab.statusEntries.removeValue(forKey: key) == nil {
result = "OK (key not found)"
}
}
return result
}
private func listStatus(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
if tab.statusEntries.isEmpty {
result = "No status entries"
return
}
let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in
var line = "\(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" }
if let color = entry.color { line += " color=\(color)" }
return line
}
result = lines.joined(separator: "\n")
}
return result
}
private func appendLog(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
return "ERROR: Missing message — usage: log <message> [--level=X] [--source=X] [--tab=X]"
}
let message = parsed.positional.joined(separator: " ")
let levelStr = parsed.options["level"] ?? "info"
guard let level = SidebarLogLevel(rawValue: levelStr) else {
return "ERROR: Unknown log level '\(levelStr)' — use: info, progress, success, warning, error"
}
let source = parsed.options["source"]
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let entry = SidebarLogEntry(message: message, level: level, source: source, timestamp: Date())
tab.logEntries.append(entry)
let defaultLimit = Tab.maxLogEntries
let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? defaultLimit
let limit = max(1, min(500, configuredLimit))
if tab.logEntries.count > limit {
tab.logEntries.removeFirst(tab.logEntries.count - limit)
}
}
return result
}
private func clearLog(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
tab.logEntries.removeAll()
}
return result
}
private func listLog(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
var limit: Int?
if let limitStr = parsed.options["limit"] {
if limitStr.isEmpty {
return "ERROR: Missing limit value — usage: list_log [--limit=N] [--tab=X]"
}
guard let parsedLimit = Int(limitStr) else {
return "ERROR: Invalid limit '\(limitStr)' — must be >= 0"
}
guard parsedLimit >= 0 else {
return "ERROR: Invalid limit '\(parsedLimit)' — must be >= 0"
}
limit = parsedLimit
}
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
if tab.logEntries.isEmpty {
result = "No log entries"
return
}
let entries: [SidebarLogEntry]
if let limit = limit {
entries = Array(tab.logEntries.suffix(limit))
} else {
entries = tab.logEntries
}
if entries.isEmpty {
result = "No log entries"
return
}
let lines = entries.map { entry in
var line = "[\(entry.level.rawValue)] \(entry.message)"
if let source = entry.source { line += " (source=\(source))" }
return line
}
result = lines.joined(separator: "\n")
}
return result
}
private func setProgress(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
guard let valueStr = parsed.positional.first else {
return "ERROR: Missing progress value — usage: set_progress <0.0-1.0> [--label=X] [--tab=X]"
}
guard let value = Double(valueStr), value >= 0.0, value <= 1.0 else {
return "ERROR: Invalid progress value '\(valueStr)' — must be 0.0 to 1.0"
}
let label = parsed.options["label"]
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
tab.progress = (value: value, label: label)
}
return result
}
private func clearProgress(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
tab.progress = nil
}
return result
}
private func reportGitBranch(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
guard let branch = parsed.positional.first else {
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]"
}
let isDirty = parsed.options["status"]?.lowercased() == "dirty"
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) ?? {
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}() else {
result = "ERROR: Tab not found"
return
}
tab.gitBranch = (branch: branch, isDirty: isDirty)
}
return result
}
private func clearGitBranch(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) ?? {
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}() else {
result = "ERROR: Tab not found"
return
}
tab.gitBranch = nil
}
return result
}
private func reportPorts(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
return "ERROR: Missing ports — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
}
var ports: [Int] = []
for portStr in parsed.positional {
guard let port = Int(portStr), port > 0, port <= 65535 else {
return "ERROR: Invalid port '\(portStr)' — must be 1-65535"
}
ports.append(port)
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) ?? {
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}() else {
result = "ERROR: Tab not found"
return
}
// Support both --panel and --surface as synonyms.
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedSurfaceId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.surfaceListeningPorts[surfaceId] = ports
tab.recomputeListeningPorts()
}
return result
}
private func reportPwd(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]"
}
let directory = parsed.positional.joined(separator: " ")
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
// Support both --panel and --surface as synonyms.
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_pwd <path> [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedSurfaceId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tabManager.updateSurfaceDirectory(tabId: tab.id, surfaceId: surfaceId, directory: directory)
}
return result
}
private func clearPorts(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let parsed = parseOptions(args)
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) ?? {
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}() else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
// If a panel is specified, clear only that surface's ports. Otherwise clear all.
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_ports [--tab=X] [--panel=Y]"
return
}
guard let surfaceId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.surfaceListeningPorts.removeValue(forKey: surfaceId)
} else {
tab.surfaceListeningPorts.removeAll()
}
tab.recomputeListeningPorts()
}
return result
}
private func sidebarState(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) ?? {
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}() else {
result = "ERROR: Tab not found"
return
}
var lines: [String] = []
lines.append("tab=\(tab.id.uuidString)")
lines.append("cwd=\(tab.currentDirectory)")
if let focused = tab.focusedSurfaceId,
let focusedDir = tab.surfaceDirectories[focused] {
lines.append("focused_cwd=\(focusedDir)")
lines.append("focused_panel=\(focused.uuidString)")
} else {
lines.append("focused_cwd=unknown")
lines.append("focused_panel=unknown")
}
// Git branch
if let git = tab.gitBranch {
lines.append("git_branch=\(git.branch)\(git.isDirty ? " dirty" : " clean")")
} else {
lines.append("git_branch=none")
}
// Ports
if tab.listeningPorts.isEmpty {
lines.append("ports=none")
} else {
lines.append("ports=\(tab.listeningPorts.map(String.init).joined(separator: ","))")
}
// Progress
if let progress = tab.progress {
let label = progress.label ?? ""
lines.append("progress=\(String(format: "%.2f", progress.value)) \(label)".trimmingCharacters(in: .whitespaces))
} else {
lines.append("progress=none")
}
// Status entries
lines.append("status_count=\(tab.statusEntries.count)")
for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) {
var line = " \(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" }
if let color = entry.color { line += " color=\(color)" }
lines.append(line)
}
// Log entries
lines.append("log_count=\(tab.logEntries.count)")
for entry in tab.logEntries.suffix(5) {
lines.append(" [\(entry.level.rawValue)] \(entry.message)")
}
result = lines.joined(separator: "\n")
}
return result
}
private func resetSidebar(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) ?? {
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}() else {
result = "ERROR: Tab not found"
return
}
tab.statusEntries.removeAll()
tab.logEntries.removeAll()
tab.progress = nil
tab.gitBranch = nil
tab.surfaceListeningPorts.removeAll()
tab.listeningPorts.removeAll()
}
return result
}
deinit {
stop()
}

View file

@ -918,6 +918,14 @@ enum AppearanceMode: String, CaseIterable, Identifiable {
struct SettingsView: View {
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
@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("sidebarShellIntegration") private var sidebarShellIntegration = true
@AppStorage("sidebarMaxLogEntries") private var sidebarMaxLogEntries = 50
@State private var notificationsShortcut = KeyboardShortcutSettings.showNotificationsShortcut()
@State private var jumpToUnreadShortcut = KeyboardShortcutSettings.jumpToUnreadShortcut()
@ -937,6 +945,26 @@ struct SettingsView: View {
Divider()
Text("Sidebar")
.font(.headline)
Toggle("Show status pills", isOn: $sidebarShowStatusPills)
Toggle("Show git branch", isOn: $sidebarShowGitBranch)
Toggle("Show branch icon", isOn: $sidebarShowGitBranchIcon)
.disabled(!sidebarShowGitBranch)
Toggle("Show listening ports", isOn: $sidebarShowPorts)
Toggle("Show latest log entry", isOn: $sidebarShowLog)
Toggle("Show progress bar", isOn: $sidebarShowProgress)
Toggle("Shell integration (auto-detect git branch and ports)", isOn: $sidebarShellIntegration)
Stepper("Max log entries: \(sidebarMaxLogEntries)", value: $sidebarMaxLogEntries, in: 10...500, step: 10)
Text("Shell integration injects a precmd hook to report git branch and ports automatically.")
.font(.caption)
.foregroundColor(.secondary)
Divider()
Text("Keyboard Shortcuts")
.font(.headline)
@ -1002,6 +1030,14 @@ struct SettingsView: View {
private func resetAllSettings() {
appearanceMode = AppearanceMode.dark.rawValue
socketControlMode = SocketControlSettings.defaultMode.rawValue
sidebarShowStatusPills = true
sidebarShowGitBranch = true
sidebarShowGitBranchIcon = false
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true
sidebarShellIntegration = true
sidebarMaxLogEntries = 50
KeyboardShortcutSettings.resetAll()
notificationsShortcut = KeyboardShortcutSettings.showNotificationsDefault
jumpToUnreadShortcut = KeyboardShortcutSettings.jumpToUnreadDefault

View file

@ -183,6 +183,7 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
APP_SUPPORT_DIR="$HOME/Library/Application Support/cmuxterm"
CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-dev-${TAG_SLUG}.sock"
CMUX_SOCKET="/tmp/cmuxterm-debug-${TAG_SLUG}.sock"
echo "$CMUX_SOCKET" > /tmp/cmuxterm-last-socket-path || true
/usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST"
@ -206,9 +207,12 @@ fi
# Ensure any running instance is fully terminated, regardless of DerivedData path.
/usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true
sleep 0.3
pkill -f "/${BASE_APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true
if [[ "${APP_NAME}" != "${BASE_APP_NAME}" ]]; then
pkill -f "/${APP_NAME}.app/Contents/MacOS/${APP_NAME}" || true
if [[ -z "$TAG" ]]; then
# Non-tag mode: kill any running instance (across any DerivedData path) to avoid socket conflicts.
pkill -f "/${BASE_APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true
else
# Tag mode: only kill the tagged instance; allow side-by-side with the main app.
pkill -f "${APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true
fi
sleep 0.3
CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd"
@ -221,7 +225,33 @@ if [[ -x "$CMUXD_SRC" ]]; then
cp "$CMUXD_SRC" "$BIN_DIR/cmuxd"
chmod +x "$BIN_DIR/cmuxd"
fi
open "$APP_PATH"
# Avoid inheriting cmuxterm/ghostty environment variables from the terminal that
# runs this script (often inside another cmuxterm instance), which can cause
# socket and resource-path conflicts.
OPEN_CLEAN_ENV=(
env
-u CMUX_SOCKET_PATH
-u CMUX_TAB_ID
-u CMUX_PANEL_ID
-u CMUXD_UNIX_PATH
-u CMUX_TAG
-u CMUXTERM_TAG
-u CMUX_BUNDLE_ID
-u CMUXTERM_BUNDLE_ID
-u CMUX_SHELL_INTEGRATION
-u GHOSTTY_BIN_DIR
-u GHOSTTY_RESOURCES_DIR
-u GHOSTTY_SHELL_FEATURES
-u TERMINFO
-u XDG_DATA_DIRS
)
if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then
# Ensure tag-specific socket paths win even if the caller has CMUX_* overrides.
"${OPEN_CLEAN_ENV[@]}" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" open "$APP_PATH"
else
"${OPEN_CLEAN_ENV[@]}" open "$APP_PATH"
fi
osascript -e "tell application id \"${BUNDLE_ID}\" to activate" || true
# Safety: ensure only one instance is running.

View file

@ -33,6 +33,8 @@ import select
import os
import time
import errno
import glob
import re
from typing import Optional, List, Tuple, Union
@ -41,24 +43,136 @@ class cmuxError(Exception):
pass
def _default_socket_path() -> str:
override = os.environ.get("CMUX_SOCKET_PATH")
_LAST_SOCKET_PATH_FILE = "/tmp/cmuxterm-last-socket-path"
_DEFAULT_DEBUG_BUNDLE_ID = "com.cmuxterm.app.debug"
def _sanitize_tag_slug(raw: str) -> str:
cleaned = re.sub(r"[^a-z0-9]+", "-", (raw or "").strip().lower())
cleaned = re.sub(r"-+", "-", cleaned).strip("-")
return cleaned or "agent"
def _sanitize_bundle_suffix(raw: str) -> str:
# Must match scripts/reload.sh sanitize_bundle() so tagged tests can
# reliably target the correct app via AppleScript.
cleaned = re.sub(r"[^a-z0-9]+", ".", (raw or "").strip().lower())
cleaned = re.sub(r"\.+", ".", cleaned).strip(".")
return cleaned or "agent"
def _quote_option_value(value: str) -> str:
# Must match TerminalController.parseOptions() quoting rules.
escaped = (value or "").replace("\\", "\\\\").replace('"', '\\"')
return f"\"{escaped}\""
def _default_bundle_id() -> str:
override = os.environ.get("CMUX_BUNDLE_ID") or os.environ.get("CMUXTERM_BUNDLE_ID")
if override:
return override
tag = os.environ.get("CMUX_TAG") or os.environ.get("CMUXTERM_TAG")
if tag:
suffix = _sanitize_bundle_suffix(tag)
return f"{_DEFAULT_DEBUG_BUNDLE_ID}.{suffix}"
return _DEFAULT_DEBUG_BUNDLE_ID
def _read_last_socket_path() -> Optional[str]:
try:
with open(_LAST_SOCKET_PATH_FILE, "r", encoding="utf-8") as f:
path = f.read().strip()
if path:
return path
except OSError:
pass
return None
def _can_connect(path: str, timeout: float = 0.15, retries: int = 4) -> bool:
# Best-effort check to avoid getting stuck on stale socket files.
for _ in range(max(1, retries)):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
s.settimeout(timeout)
s.connect(path)
return True
except OSError:
time.sleep(0.05)
finally:
try:
s.close()
except Exception:
pass
return False
def _default_socket_path() -> str:
tag = os.environ.get("CMUX_TAG") or os.environ.get("CMUXTERM_TAG")
if tag:
slug = _sanitize_tag_slug(tag)
tagged_candidates = [
f"/tmp/cmuxterm-debug-{slug}.sock",
f"/tmp/cmuxterm-{slug}.sock",
]
for path in tagged_candidates:
if os.path.exists(path) and _can_connect(path):
return path
# If nothing is connectable yet (e.g. the app is still starting),
# fall back to the first existing candidate.
for path in tagged_candidates:
if os.path.exists(path):
return path
# Prefer the debug naming convention when we have to guess.
return tagged_candidates[0]
override = os.environ.get("CMUX_SOCKET_PATH")
if override:
if os.path.exists(override) and _can_connect(override):
return override
# Fall back to other heuristics if the override points at a stale socket file.
if not os.path.exists(override):
return override
last_socket = _read_last_socket_path()
if last_socket:
if os.path.exists(last_socket) and _can_connect(last_socket):
return last_socket
# Prefer the non-tagged sockets when present.
candidates = ["/tmp/cmuxterm-debug.sock", "/tmp/cmuxterm.sock"]
for path in candidates:
if os.path.exists(path):
if os.path.exists(path) and _can_connect(path):
return path
# Otherwise, fall back to the newest tagged debug socket if there is one.
tagged = glob.glob("/tmp/cmuxterm-debug-*.sock")
tagged = [p for p in tagged if os.path.exists(p)]
if tagged:
tagged.sort(key=lambda p: os.path.getmtime(p), reverse=True)
for p in tagged:
if _can_connect(p, timeout=0.1, retries=2):
return p
return candidates[0]
class cmux:
"""Client for controlling cmux via Unix socket"""
DEFAULT_SOCKET_PATH = _default_socket_path()
@staticmethod
def default_socket_path() -> str:
return _default_socket_path()
@staticmethod
def default_bundle_id() -> str:
return _default_bundle_id()
def __init__(self, socket_path: str = None):
self.socket_path = socket_path or self.DEFAULT_SOCKET_PATH
# Resolve at init time so imports don't "lock in" a stale path.
self.socket_path = socket_path or _default_socket_path()
self._socket: Optional[socket.socket] = None
self._recv_buffer: str = ""
@ -359,6 +473,107 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None:
"""Set a sidebar status entry."""
cmd = f"set_status {key} {value}"
if icon:
cmd += f" --icon={icon}"
if color:
cmd += f" --color={color}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def clear_status(self, key: str, tab: str = None) -> None:
"""Remove a sidebar status entry."""
cmd = f"clear_status {key}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None:
"""Append a sidebar log entry."""
cmd = f"log {message}"
if level:
cmd += f" --level={level}"
if source:
cmd += f" --source={source}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def set_progress(self, value: float, label: str = None, tab: str = None) -> None:
"""Set sidebar progress bar (0.0-1.0)."""
cmd = f"set_progress {value}"
if label:
cmd += f" --label={_quote_option_value(label)}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def clear_progress(self, tab: str = None) -> None:
"""Clear sidebar progress bar."""
cmd = "clear_progress"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def report_git_branch(self, branch: str, status: str = None, tab: str = None) -> None:
"""Report git branch for sidebar display."""
cmd = f"report_git_branch {branch}"
if status:
cmd += f" --status={status}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def report_ports(self, *ports: int, tab: str = None) -> None:
"""Report listening ports for sidebar display."""
port_str = " ".join(str(p) for p in ports)
cmd = f"report_ports {port_str}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def clear_ports(self, tab: str = None) -> None:
"""Clear listening ports for sidebar display."""
cmd = "clear_ports"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def sidebar_state(self, tab: str = None) -> str:
"""Dump all sidebar metadata for a tab."""
cmd = "sidebar_state"
if tab:
cmd += f" --tab={tab}"
return self._send_command(cmd)
def reset_sidebar(self, tab: str = None) -> None:
"""Clear all sidebar metadata for a tab."""
cmd = "reset_sidebar"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def focus_notification(self, tab: Union[str, int], surface: Union[str, int, None] = None) -> None:
"""Focus tab/surface using the notification flow."""
if surface is None:
@ -391,8 +606,8 @@ def main():
parser = argparse.ArgumentParser(description="cmux CLI")
parser.add_argument("command", nargs="?", help="Command to send")
parser.add_argument("args", nargs="*", help="Command arguments")
parser.add_argument("-s", "--socket", default=cmux.DEFAULT_SOCKET_PATH,
help="Socket path")
parser.add_argument("-s", "--socket", default=None,
help="Socket path (default: auto-detect)")
args = parser.parse_args()

View file

@ -41,6 +41,32 @@ MONITOR_DURATION = 3.0
def get_cmuxterm_pid() -> Optional[int]:
"""Get the PID of the running cmuxterm process."""
socket_path = os.environ.get("CMUX_SOCKET_PATH")
if not socket_path:
# Ask cmux.py to resolve default socket path (supports CMUX_TAG and last-socket file).
try:
socket_path = cmux().socket_path
except Exception:
socket_path = None
if socket_path and os.path.exists(socket_path):
result = subprocess.run(
["lsof", "-t", socket_path],
capture_output=True,
text=True,
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid():
return pid
result = subprocess.run(
["pgrep", "-f", r"cmuxterm\.app/Contents/MacOS/cmuxterm$"],
capture_output=True,
@ -141,6 +167,14 @@ def test_cpu_after_popover_close(client: cmux, pid: int) -> tuple[bool, str]:
time.sleep(0.1)
time.sleep(0.5)
# Ensure the correct cmuxterm instance is frontmost (tag-safe).
bundle_id = cmux.default_bundle_id()
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to activate'],
capture_output=True,
)
time.sleep(0.2)
# Simulate opening and closing the popover via keyboard shortcut
# We can't directly control the popover, but we can toggle it
subprocess.run([
@ -212,6 +246,8 @@ def main():
print("cmuxterm Notification CPU Tests")
print("=" * 60)
socket_path = cmux().socket_path
pid = get_cmuxterm_pid()
if pid is None:
print("\n❌ SKIP: cmuxterm is not running")
@ -220,20 +256,13 @@ def main():
print(f"\nFound cmuxterm process: PID {pid}")
# Try to connect to the socket
socket_paths = ["/tmp/cmuxterm.sock", "/tmp/cmuxterm-debug.sock"]
client = None
for socket_path in socket_paths:
if os.path.exists(socket_path):
try:
client = cmux(socket_path)
client.connect()
print(f"Connected to {socket_path}")
break
except cmuxError:
continue
if client is None:
print(f"\n❌ SKIP: Could not connect to cmuxterm socket")
client = cmux(socket_path)
try:
client.connect()
print(f"Connected to {socket_path}")
except cmuxError:
print("\n❌ SKIP: Could not connect to cmuxterm socket")
print("Tip: set CMUX_TAG=<tag> or CMUX_SOCKET_PATH=<path> to target a tagged instance.")
return 0
results = []

View file

@ -19,9 +19,15 @@ import subprocess
import sys
import time
import re
import os
from pathlib import Path
from typing import List, Optional
# Allow importing tests/cmux.py when running from repo root.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux
# Maximum acceptable CPU usage during idle (percentage)
MAX_IDLE_CPU_PERCENT = 15.0
@ -45,6 +51,31 @@ SUSPICIOUS_PATTERNS = [
def get_cmuxterm_pid() -> Optional[int]:
"""Get the PID of the running cmuxterm process."""
socket_path = os.environ.get("CMUX_SOCKET_PATH")
if not socket_path:
try:
socket_path = cmux().socket_path
except Exception:
socket_path = None
if socket_path and os.path.exists(socket_path):
result = subprocess.run(
["lsof", "-t", socket_path],
capture_output=True,
text=True,
)
if result.returncode == 0:
for line in result.stdout.strip().split("\n"):
line = line.strip()
if not line:
continue
try:
pid = int(line)
except ValueError:
continue
if pid != os.getpid():
return pid
result = subprocess.run(
["pgrep", "-f", r"cmuxterm\.app/Contents/MacOS/cmuxterm$"],
capture_output=True,
@ -156,7 +187,7 @@ def main():
print(f" - {issue}")
# Save sample for debugging
sample_file = Path("/tmp/cmuxterm_cpu_test_sample.txt")
sample_file = Path(f"/tmp/cmuxterm_cpu_test_sample_{pid}.txt")
sample_file.write_text(sample_output)
print(f"\nFull sample saved to: {sample_file}")

View file

@ -21,8 +21,26 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def run_osascript(script: str) -> None:
subprocess.run(["osascript", "-e", script], check=True)
def run_osascript(script: str) -> subprocess.CompletedProcess[str]:
# Use capture_output so we can detect common permission failures and skip.
result = subprocess.run(
["osascript", "-e", script],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
return result
def is_keystroke_permission_error(err: subprocess.CalledProcessError) -> bool:
text = f"{getattr(err, 'stderr', '') or ''}\n{getattr(err, 'output', '') or ''}"
return "not allowed to send keystrokes" in text or "(1002)" in text
def has_ctrl_enter_keybind(config_text: str) -> bool:
@ -63,34 +81,36 @@ def test_ctrl_enter_keybind(client: cmux) -> tuple[bool, str]:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.3)
# Make sure the app is focused for keystrokes
run_osascript('tell application "cmuxterm" to activate')
time.sleep(0.2)
# Clear any running command
try:
client.send_key("ctrl-c")
# Make sure the app is focused for keystrokes
bundle_id = cmux.default_bundle_id()
run_osascript(f'tell application id "{bundle_id}" to activate')
time.sleep(0.2)
except Exception:
pass
# Type the command (without pressing Enter)
run_osascript(f'tell application "System Events" to keystroke "touch {marker}"')
time.sleep(0.1)
# Clear any running command
try:
client.send_key("ctrl-c")
time.sleep(0.2)
except Exception:
pass
# Send Ctrl+Enter (key code 36 = Return)
run_osascript('tell application "System Events" to key code 36 using control down')
time.sleep(0.5)
# Type the command (without pressing Enter)
run_osascript(f'tell application "System Events" to keystroke "touch {marker}"')
time.sleep(0.1)
ok = marker.exists()
if ok:
marker.unlink(missing_ok=True)
try:
client.close_tab(new_tab_id)
except Exception:
pass
return ok, ("Ctrl+Enter keybind executed command" if ok else "Marker not created by Ctrl+Enter")
# Send Ctrl+Enter (key code 36 = Return)
run_osascript('tell application "System Events" to key code 36 using control down')
time.sleep(0.5)
ok = marker.exists()
return ok, ("Ctrl+Enter keybind executed command" if ok else "Marker not created by Ctrl+Enter")
finally:
if marker.exists():
marker.unlink(missing_ok=True)
try:
client.close_tab(new_tab_id)
except Exception:
pass
def run_tests() -> int:
@ -99,19 +119,17 @@ def run_tests() -> int:
print("=" * 60)
print()
socket_path = cmux.DEFAULT_SOCKET_PATH
socket_path = cmux.default_socket_path()
if not os.path.exists(socket_path):
print(f"Error: Socket not found at {socket_path}")
print("Please make sure cmuxterm is running.")
return 1
print(f"SKIP: Socket not found at {socket_path}")
print("Tip: start cmuxterm first (or set CMUX_TAG / CMUX_SOCKET_PATH).")
return 0
config_path = find_config_with_keybind()
if not config_path:
print("Error: Required keybind not found in Ghostty config.")
print("Add a line like:")
print(" keybind = ctrl+enter=text:\\r")
print("Then restart cmuxterm and re-run this test.")
return 1
print("SKIP: Required keybind not found in Ghostty config.")
print("Expected a line like: keybind = ctrl+enter=text:\\r")
return 0
print(f"Using keybind from: {config_path}")
print()
@ -123,10 +141,17 @@ def run_tests() -> int:
print(f"{status} {message}")
return 0 if ok else 1
except cmuxError as e:
print(f"Error: {e}")
return 1
print(f"SKIP: {e}")
return 0
except subprocess.CalledProcessError as e:
if is_keystroke_permission_error(e):
print("SKIP: osascript/System Events not allowed to send keystrokes (Accessibility permission missing)")
return 0
print(f"Error: osascript failed: {e}")
if getattr(e, "stderr", None):
print(e.stderr.strip())
if getattr(e, "output", None):
print(e.output.strip())
return 1

View file

@ -271,10 +271,11 @@ def run_tests():
print("=" * 60)
print()
socket_path = cmux.DEFAULT_SOCKET_PATH
socket_path = cmux().socket_path
if not os.path.exists(socket_path):
print(f"Error: Socket not found at {socket_path}")
print("Please make sure cmux is running.")
print("Tip: set CMUX_TAG=<tag> or CMUX_SOCKET_PATH=<path> to target a tagged instance.")
return 1
results = []

View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
End-to-end test for sidebar CWD + git branch updates.
This specifically covers the regression where the sidebar directory can get
stuck (e.g. showing "~" even after multiple `cd`s).
Run with a tagged instance to avoid unix socket conflicts:
CMUX_TAG=<tag> python3 tests/test_sidebar_cwd_git.py
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
# Add the directory containing cmux.py to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for(predicate, timeout: float, interval: float, label: str):
start = time.time()
last_error: Exception | None = None
while time.time() - start < timeout:
try:
value = predicate()
if value:
return value
except Exception as e:
last_error = e
time.sleep(interval)
if last_error is not None:
raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}")
raise AssertionError(f"Timed out waiting for {label}.")
def _wait_for_state_field(
client: cmux,
key: str,
expected: str,
timeout: float = 6.0,
interval: float = 0.1,
) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
return state if state.get(key) == expected else None
return _wait_for(pred, timeout=timeout, interval=interval, label=f"{key}={expected!r}")
def _wait_for_git_branch(
client: cmux,
expected: str,
timeout: float = 8.0,
interval: float = 0.15,
) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("git_branch", "")
branch = raw.split(" ", 1)[0] # "main dirty" -> "main", "none" -> "none"
return state if branch == expected else None
return _wait_for(pred, timeout=timeout, interval=interval, label=f"git_branch={expected!r}")
def _git(cwd: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=str(cwd), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _init_git_repo(repo: Path) -> None:
repo.mkdir(parents=True, exist_ok=True)
_git(repo, "init")
_git(repo, "config", "user.email", "cmuxterm-test@example.com")
_git(repo, "config", "user.name", "cmuxterm-test")
(repo / "README.md").write_text("hello\n", encoding="utf-8")
_git(repo, "add", "README.md")
_git(repo, "commit", "-m", "init")
# Normalize the initial branch to "main" so the test is deterministic.
branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=str(repo)
).decode("utf-8", errors="replace").strip()
if branch and branch != "main":
_git(repo, "branch", "-m", "main")
def main() -> int:
tag = os.environ.get("CMUX_TAG") or os.environ.get("CMUXTERM_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
base = Path("/tmp") / f"cmux_sidebar_test_{os.getpid()}"
repo = base / "repo"
other = base / "other"
try:
if base.exists():
shutil.rmtree(base)
other.mkdir(parents=True, exist_ok=True)
_init_git_repo(repo)
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.6)
# Initial: sync via `pwd` to a file, then wait for sidebar_state cwd.
marker = base / "pwd.txt"
client.send(f"pwd > {marker}\n")
_wait_for(lambda: marker.exists(), timeout=4.0, interval=0.1, label="pwd marker file")
expected_pwd = marker.read_text(encoding="utf-8").strip()
_wait_for_state_field(client, "cwd", expected_pwd)
# Multiple cd's: ensure cwd tracks changes.
client.send(f"cd {other}\n")
_wait_for_state_field(client, "cwd", str(other))
_wait_for_git_branch(client, "none")
client.send(f"cd {repo}\n")
_wait_for_state_field(client, "cwd", str(repo))
_wait_for_git_branch(client, "main")
# Branch change should update.
client.send("git checkout -b feature/sidebar\n")
_wait_for_git_branch(client, "feature/sidebar")
# Leaving the repo should clear the branch.
client.send(f"cd {other}\n")
_wait_for_state_field(client, "cwd", str(other))
_wait_for_git_branch(client, "none")
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar CWD + git branch test passed.")
return 0
except (cmuxError, subprocess.CalledProcessError, AssertionError) as e:
print(f"Sidebar CWD + git branch test failed: {e}")
return 1
finally:
try:
shutil.rmtree(base)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Regression: report_ports/report_pwd must validate panel IDs.
If shell-integration hooks fire late (after a split is closed) they can report
ports/cwd for a stale surface UUID. These updates should not pollute the sidebar
state (stale ports/cwd).
Run with a tagged instance to avoid unix socket conflicts:
CMUX_TAG=<tag> python3 tests/test_sidebar_invalid_panel.py
"""
from __future__ import annotations
import os
import random
import subprocess
import sys
import time
import uuid
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _parse_ports(raw: str) -> set[int]:
raw = (raw or "").strip()
if not raw or raw == "none":
return set()
ports: set[int] = set()
for item in raw.split(","):
item = item.strip()
if not item:
continue
try:
ports.add(int(item))
except ValueError:
continue
return ports
def _pick_absent_port(exclude: set[int]) -> int:
# Pick a random port that isn't already showing and also isn't currently
# listening machine-wide (avoid false failures if something is bound).
for _ in range(200):
port = random.randint(20000, 65000)
if port in exclude:
continue
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
)
if result.returncode != 0 and not (result.stdout or "").strip():
return port
# Fall back to a fixed high port; still validate against exclude.
for port in (54321, 54322, 54323, 61999):
if port not in exclude:
return port
return 65000
def main() -> int:
try:
with cmux() as client:
tab_id = client.new_tab()
client.select_tab(tab_id)
time.sleep(0.8)
initial_state = client.sidebar_state(tab_id)
initial_ports = _parse_ports(_parse_sidebar_state(initial_state).get("ports", ""))
test_port = _pick_absent_port(initial_ports)
surface_ids = {surface_id for _, surface_id, _ in client.list_surfaces(tab_id)}
fake_panel = uuid.uuid4()
while str(fake_panel) in surface_ids:
fake_panel = uuid.uuid4()
# Ports: reporting against a bogus panel must not update the union.
client._send_command(f"report_ports {test_port} --tab={tab_id} --panel={fake_panel}")
time.sleep(0.3)
state = client.sidebar_state(tab_id)
ports = _parse_ports(_parse_sidebar_state(state).get("ports", ""))
if test_port in ports:
print(f"FAIL: invalid panel report_ports leaked into sidebar ports: {ports}")
return 1
# CWD: reporting against a bogus panel must not set cwd to that value.
unique_dir = f"/tmp/cmux_invalid_pwd_{os.getpid()}"
client._send_command(f"report_pwd {unique_dir} --tab={tab_id} --panel={fake_panel}")
time.sleep(0.3)
state = client.sidebar_state(tab_id)
if unique_dir in state:
print("FAIL: invalid panel report_pwd leaked into sidebar_state")
print(state)
return 1
try:
client.close_tab(tab_id)
except cmuxError:
pass
print("PASS: invalid panel reports do not pollute sidebar metadata")
return 0
except (cmuxError, RuntimeError, ValueError) as e:
print(f"FAIL: {e}")
return 1
if __name__ == "__main__":
raise SystemExit(main())

306
tests/test_sidebar_ports.py Normal file
View file

@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
End-to-end test for sidebar listening ports auto-detection.
This covers regressions where a listening server (e.g. `python3 -m http.server`)
doesn't show up in the sidebar ports row.
Run with a tagged instance to avoid unix socket conflicts:
CMUX_TAG=<tag> python3 tests/test_sidebar_ports.py
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
# Add the directory containing cmux.py to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError # noqa: E402
# Historically, ports detection only checked a small allowlist. This test
# intentionally uses a port outside that set to avoid regressions where ports
# "work" only for the allowlist.
_HISTORICAL_ALLOWLIST = {8000, 8080, 8888, 5173, 3000, 3001, 5000, 5432}
_PREFERRED_BIND_HOST = "127.0.0.1"
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for(predicate, timeout: float, interval: float, label: str):
start = time.time()
last_error: Exception | None = None
while time.time() - start < timeout:
try:
value = predicate()
if value:
return value
except Exception as e:
last_error = e
time.sleep(interval)
if last_error is not None:
raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}")
raise AssertionError(f"Timed out waiting for {label}.")
def _find_free_allowed_port() -> int:
# Prefer a random ephemeral port to avoid flakiness from well-known ports
# being grabbed by background services.
for _ in range(50):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((_PREFERRED_BIND_HOST, 0))
port = int(s.getsockname()[1])
if port not in _HISTORICAL_ALLOWLIST:
return port
finally:
try:
s.close()
except Exception:
pass
raise RuntimeError("Failed to find a free test port (outside historical allowlist).")
def _start_external_server(base: Path, port: int) -> subprocess.Popen:
"""
Start an http.server outside cmuxterm and ensure it is actually listening.
Retries are handled by the caller by picking a different port.
"""
proc = subprocess.Popen(
[sys.executable, "-m", "http.server", str(port), "--bind", _PREFERRED_BIND_HOST],
cwd=str(base),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_wait_for_lsof_listen_pid(port, expected_pid=proc.pid, timeout=6.0)
return proc
def _wait_for_port(client: cmux, port: int, timeout: float = 18.0) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("ports", "")
if raw == "none" or not raw:
return None
ports = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
try:
ports.append(int(item))
except ValueError:
continue
return state if port in ports else None
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports include {port}")
def _wait_for_port_absent(client: cmux, port: int, timeout: float = 18.0) -> dict[str, str]:
def pred():
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("ports", "")
if raw == "none" or not raw:
return state
ports = []
for item in raw.split(","):
item = item.strip()
if not item:
continue
try:
ports.append(int(item))
except ValueError:
continue
return state if port not in ports else None
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports do not include {port}")
def _assert_port_absent_for_duration(client: cmux, port: int, duration: float = 6.0, interval: float = 0.15) -> None:
"""
Assert the port does not appear in sidebar_state during the full duration.
This is important to catch "machine-wide ports" leaking into a fresh tab.
"""
start = time.time()
while time.time() - start < duration:
state = _parse_sidebar_state(client.sidebar_state())
raw = state.get("ports", "")
if raw and raw != "none":
try:
ports = {int(p.strip()) for p in raw.split(",") if p.strip()}
except ValueError:
ports = set()
if port in ports:
raise AssertionError(f"Port {port} unexpectedly appeared in sidebar ports: {raw}")
time.sleep(interval)
def _wait_for_lsof_listen_pid(port: int, expected_pid: int | None, timeout: float = 8.0) -> int:
"""
Wait until `lsof -iTCP:<port> -sTCP:LISTEN` returns a pid.
If expected_pid is provided, require that pid to be present.
"""
def pred():
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
pids = []
for line in (result.stdout or "").splitlines():
line = line.strip()
if not line:
continue
try:
pids.append(int(line))
except ValueError:
continue
if not pids:
return None
if expected_pid is not None and expected_pid not in pids:
return None
return expected_pid if expected_pid is not None else pids[0]
value = _wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof LISTEN pid for {port}")
return int(value)
def _wait_for_lsof_listen_gone(port: int, timeout: float = 8.0) -> None:
def pred():
result = subprocess.run(
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
capture_output=True,
text=True,
)
return result.returncode != 0 or not (result.stdout or "").strip()
_wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof no LISTEN for {port}")
def main() -> int:
tag = os.environ.get("CMUX_TAG") or os.environ.get("CMUXTERM_TAG") or ""
if not tag:
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
base = Path("/tmp") / f"cmux_ports_test_{os.getpid()}"
pid_file = base / "server.pid"
log_file = base / "server.log"
external_proc: subprocess.Popen | None = None
try:
if base.exists():
shutil.rmtree(base)
base.mkdir(parents=True, exist_ok=True)
# Start a listening server outside cmuxterm. A fresh tab should NOT show this port,
# since ports should be attributed to the shell session in the tab.
port = None
last_start_err: Exception | None = None
for _ in range(8):
try:
port = _find_free_allowed_port()
external_proc = _start_external_server(base, port)
break
except Exception as e:
last_start_err = e
if external_proc is not None:
try:
external_proc.kill()
except Exception:
pass
external_proc = None
continue
if port is None or external_proc is None:
raise RuntimeError(f"Failed to start external http.server. Last error: {last_start_err}")
with cmux() as client:
new_tab_id = client.new_tab()
client.select_tab(new_tab_id)
time.sleep(0.8)
# Trigger a prompt cycle (and thus a ports scan burst) before checking absence.
client.send("echo cmux_ports_test\n")
_assert_port_absent_for_duration(client, port, duration=6.0)
# Stop the external server, then reuse the port inside the tab.
external_proc.terminate()
try:
external_proc.wait(timeout=3.0)
except subprocess.TimeoutExpired:
external_proc.kill()
external_proc = None
_wait_for_lsof_listen_gone(port, timeout=8.0)
# Start a server in the background and capture its PID so we can clean up.
client.send(f"rm -f {pid_file} {log_file}\n")
client.send(
f"python3 -m http.server {port} --bind {_PREFERRED_BIND_HOST} > {log_file} 2>&1 & echo $! > {pid_file}\n"
)
_wait_for(lambda: pid_file.exists(), timeout=4.0, interval=0.1, label="pid file")
pid = int(pid_file.read_text(encoding="utf-8").strip())
# Ensure the server is actually listening (sanity check + reduces flakiness).
_wait_for_lsof_listen_pid(port, expected_pid=pid, timeout=8.0)
# Wait for the sidebar to report the port.
_wait_for_port(client, port, timeout=18.0)
# Cleanup server.
client.send(f"kill {pid} >/dev/null 2>&1 || true\n")
_wait_for_lsof_listen_gone(port, timeout=8.0)
_wait_for_port_absent(client, port, timeout=18.0)
try:
client.close_tab(new_tab_id)
except Exception:
pass
print("Sidebar ports test passed.")
return 0
except (cmuxError, AssertionError, RuntimeError, ValueError) as e:
print(f"Sidebar ports test failed: {e}")
return 1
finally:
if external_proc is not None:
try:
external_proc.terminate()
external_proc.wait(timeout=2.0)
except Exception:
try:
external_proc.kill()
except Exception:
pass
try:
shutil.rmtree(base)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())