diff --git a/CLI/cmuxterm.swift b/CLI/cmuxterm.swift index 2d3748a2..aace114f 100644 --- a/CLI/cmuxterm.swift +++ b/CLI/cmuxterm.swift @@ -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 ") + } + 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 ") + } + 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 simulate-app-active + set-status [--icon ] [--color ] [--tab ] + clear-status [--tab ] + log [--level ] [--source ] [--tab ] + clear-log [--tab ] + set-progress [--label ] [--tab ] + clear-progress [--tab ] + report-git-branch [--status ] [--tab ] + report-ports [port2...] [--tab ] + clear-ports [--tab ] + sidebar-state [--tab ] + reset-sidebar [--tab ] help Environment: diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 57f03111..11e23fe7 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -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 = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = ""; }; + C1D2E3F4A5B6C7D8E9F00002 /* shell-integration */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "shell-integration"; sourceTree = ""; }; B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxterm.swift; sourceTree = ""; }; 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 = ""; }; @@ -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 = ""; diff --git a/Resources/shell-integration/.zlogin b/Resources/shell-integration/.zlogin new file mode 100644 index 00000000..ae72ee41 --- /dev/null +++ b/Resources/shell-integration/.zlogin @@ -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 diff --git a/Resources/shell-integration/.zprofile b/Resources/shell-integration/.zprofile new file mode 100644 index 00000000..63bfa3e9 --- /dev/null +++ b/Resources/shell-integration/.zprofile @@ -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 diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv new file mode 100644 index 00000000..1cc30c59 --- /dev/null +++ b/Resources/shell-integration/.zshenv @@ -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 diff --git a/Resources/shell-integration/.zshrc b/Resources/shell-integration/.zshrc new file mode 100644 index 00000000..7b335af6 --- /dev/null +++ b/Resources/shell-integration/.zshrc @@ -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 diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash new file mode 100644 index 00000000..f179f7cd --- /dev/null +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -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 diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh new file mode 100644 index 00000000..a70c9ed8 --- /dev/null +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -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 diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 26819559..9d06c2e5 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 7914ccf3..da6d1ef3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -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) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index bcedffa0..04d60c9c 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -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()) { acc, ports in + for port in ports { + acc.insert(port) + } + } + let sorted = merged.sorted() + if listeningPorts != sorted { + listeningPorts = sorted + } + } + + func pruneSurfaceMetadata(validSurfaceIds: Set? = 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 diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 51ff6a42..350bc7c0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -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 - Override app focus state simulate_app_active - Trigger app active handler + set_status [--icon=X] [--color=#hex] - Set a status entry + clear_status [--tab=X] - Remove a status entry + list_status [--tab=X] - List all status entries + log [--level=X] [--source=X] [--tab=X] - Append a log entry + 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 [--status=dirty] [--tab=X] - Report git branch + report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports + report_pwd [--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).. 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 [--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 [--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 [--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 [--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 [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 [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 [--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 [--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() } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index ea4f4ce6..71373799 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -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 diff --git a/scripts/reload.sh b/scripts/reload.sh index 396216d7..2124abaa 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -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. diff --git a/tests/cmux.py b/tests/cmux.py index 97c8f400..f0b6b36a 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -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() diff --git a/tests/test_cpu_notifications.py b/tests/test_cpu_notifications.py index b4003292..245bf812 100644 --- a/tests/test_cpu_notifications.py +++ b/tests/test_cpu_notifications.py @@ -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= or CMUX_SOCKET_PATH= to target a tagged instance.") return 0 results = [] diff --git a/tests/test_cpu_usage.py b/tests/test_cpu_usage.py index b854bdd3..30bd3366 100644 --- a/tests/test_cpu_usage.py +++ b/tests/test_cpu_usage.py @@ -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}") diff --git a/tests/test_ctrl_enter_keybind.py b/tests/test_ctrl_enter_keybind.py index 7d345d7a..a394e6a1 100644 --- a/tests/test_ctrl_enter_keybind.py +++ b/tests/test_ctrl_enter_keybind.py @@ -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 diff --git a/tests/test_ctrl_socket.py b/tests/test_ctrl_socket.py index 3e3fd4cb..4d4b9ee8 100755 --- a/tests/test_ctrl_socket.py +++ b/tests/test_ctrl_socket.py @@ -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= or CMUX_SOCKET_PATH= to target a tagged instance.") return 1 results = [] diff --git a/tests/test_sidebar_cwd_git.py b/tests/test_sidebar_cwd_git.py new file mode 100644 index 00000000..b9d5d42a --- /dev/null +++ b/tests/test_sidebar_cwd_git.py @@ -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= 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= 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()) + diff --git a/tests/test_sidebar_invalid_panel.py b/tests/test_sidebar_invalid_panel.py new file mode 100644 index 00000000..4b94e478 --- /dev/null +++ b/tests/test_sidebar_invalid_panel.py @@ -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= 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()) + diff --git a/tests/test_sidebar_ports.py b/tests/test_sidebar_ports.py new file mode 100644 index 00000000..51b242dd --- /dev/null +++ b/tests/test_sidebar_ports.py @@ -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= 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: -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= 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())