Sidebar metadata + tagged reload isolation (#16)
* Sidebar primitives + tagged dev isolation * Allow wider sidebar resize * Fix tagged socket selection + panel id errors * Fix progress label quoting + bundle suffix sanitize * Skip ctrl-enter keybind test when keystrokes blocked * Fix shell nc hang + prune stale per-surface sidebar metadata
This commit is contained in:
parent
b3c2a8c7c3
commit
7e69751e1b
22 changed files with 2538 additions and 72 deletions
|
|
@ -325,6 +325,122 @@ struct CMUXCLI {
|
|||
let response = try client.send(command: "simulate_app_active")
|
||||
print(response)
|
||||
|
||||
case "set-status":
|
||||
guard commandArgs.count >= 2 else {
|
||||
throw CLIError(message: "set-status requires <key> <value>")
|
||||
}
|
||||
let key = commandArgs[0]
|
||||
let value = commandArgs[1]
|
||||
let icon = optionValue(commandArgs, name: "--icon")
|
||||
let color = optionValue(commandArgs, name: "--color")
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "set_status \(key) \(value)"
|
||||
if let icon { cmd += " --icon=\(icon)" }
|
||||
if let color { cmd += " --color=\(color)" }
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "clear-status":
|
||||
let key = commandArgs.first
|
||||
guard let key else {
|
||||
throw CLIError(message: "clear-status requires <key>")
|
||||
}
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "clear_status \(key)"
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "log":
|
||||
// Remove options by position (flag + following value), not by string value,
|
||||
// so message tokens that happen to equal an option value aren't dropped.
|
||||
let (level, argsWithoutLevel) = parseOption(commandArgs, name: "--level")
|
||||
let (source, argsWithoutSource) = parseOption(argsWithoutLevel, name: "--source")
|
||||
let (explicitTab, remaining) = parseOption(argsWithoutSource, name: "--tab")
|
||||
let tabArg = explicitTab ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
let message = remaining.joined(separator: " ")
|
||||
guard !message.isEmpty else { throw CLIError(message: "log requires a message") }
|
||||
var cmd = "log \(message)"
|
||||
if let level { cmd += " --level=\(level)" }
|
||||
if let source { cmd += " --source=\(source)" }
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "clear-log":
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "clear_log"
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "set-progress":
|
||||
guard let value = commandArgs.first else {
|
||||
throw CLIError(message: "set-progress requires a value (0.0-1.0)")
|
||||
}
|
||||
let label = optionValue(commandArgs, name: "--label")
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "set_progress \(value)"
|
||||
if let label { cmd += " --label=\(quoteOptionValue(label))" }
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "clear-progress":
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "clear_progress"
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "report-git-branch":
|
||||
guard let branch = commandArgs.first else {
|
||||
throw CLIError(message: "report-git-branch requires a branch name")
|
||||
}
|
||||
let status = optionValue(commandArgs, name: "--status")
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "report_git_branch \(branch)"
|
||||
if let status { cmd += " --status=\(status)" }
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "report-ports":
|
||||
// Remove options by position (flag + following value), not by string value,
|
||||
// so a port token that equals the tab arg isn't accidentally dropped.
|
||||
let (explicitTab, remaining) = parseOption(commandArgs, name: "--tab")
|
||||
let tabArg = explicitTab ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
let ports = remaining
|
||||
guard !ports.isEmpty else {
|
||||
throw CLIError(message: "report-ports requires at least one port number")
|
||||
}
|
||||
var cmd = "report_ports \(ports.joined(separator: " "))"
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "clear-ports":
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "clear_ports"
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "sidebar-state":
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "sidebar_state"
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "reset-sidebar":
|
||||
let tabArg = optionValue(commandArgs, name: "--tab") ?? ProcessInfo.processInfo.environment["CMUX_TAB_ID"]
|
||||
var cmd = "reset_sidebar"
|
||||
if let tabArg { cmd += " --tab=\(tabArg)" }
|
||||
let response = try client.send(command: cmd)
|
||||
print(response)
|
||||
|
||||
case "help":
|
||||
print(usage())
|
||||
|
||||
|
|
@ -487,6 +603,15 @@ struct CMUXCLI {
|
|||
.replacingOccurrences(of: "\t", with: "\\t")
|
||||
}
|
||||
|
||||
private func quoteOptionValue(_ value: String) -> String {
|
||||
// TerminalController.parseOptions supports quoted strings with basic
|
||||
// backslash escapes (\" and \\) inside quotes.
|
||||
let escaped = value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
|
||||
private func isUUID(_ value: String) -> Bool {
|
||||
return UUID(uuidString: value) != nil
|
||||
}
|
||||
|
|
@ -526,6 +651,17 @@ struct CMUXCLI {
|
|||
clear-notifications
|
||||
set-app-focus <active|inactive|clear>
|
||||
simulate-app-active
|
||||
set-status <key> <value> [--icon <name>] [--color <hex>] [--tab <id|index>]
|
||||
clear-status <key> [--tab <id|index>]
|
||||
log <message> [--level <level>] [--source <name>] [--tab <id|index>]
|
||||
clear-log [--tab <id|index>]
|
||||
set-progress <value> [--label <text>] [--tab <id|index>]
|
||||
clear-progress [--tab <id|index>]
|
||||
report-git-branch <branch> [--status <clean|dirty>] [--tab <id>]
|
||||
report-ports <port1> [port2...] [--tab <id>]
|
||||
clear-ports [--tab <id>]
|
||||
sidebar-state [--tab <id>]
|
||||
reset-sidebar [--tab <id>]
|
||||
help
|
||||
|
||||
Environment:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
B9000002A1B2C3D4E5F60719 /* cmuxterm.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */; };
|
||||
B900000BA1B2C3D4E5F60719 /* cmuxterm in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmuxterm */; };
|
||||
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
|
||||
C1D2E3F4A5B6C7D8E9F00001 /* shell-integration in Resources */ = {isa = PBXBuildFile; fileRef = C1D2E3F4A5B6C7D8E9F00002 /* shell-integration */; };
|
||||
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
|
||||
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
|
||||
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
|
||||
|
|
@ -133,6 +134,7 @@
|
|||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
|
||||
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
|
||||
C1D2E3F4A5B6C7D8E9F00002 /* shell-integration */ = {isa = PBXFileReference; lastKnownFileType = folder; path = "shell-integration"; sourceTree = "<group>"; };
|
||||
B9000001A1B2C3D4E5F60719 /* cmuxterm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxterm.swift; sourceTree = "<group>"; };
|
||||
B9000004A1B2C3D4E5F60719 /* cmuxterm */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmuxterm; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -173,6 +175,7 @@
|
|||
files = (
|
||||
A5001100 /* Assets.xcassets in Resources */,
|
||||
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */,
|
||||
C1D2E3F4A5B6C7D8E9F00001 /* shell-integration in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -271,6 +274,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
B2E7294509CC42FE9191870E /* xterm-ghostty */,
|
||||
C1D2E3F4A5B6C7D8E9F00002 /* shell-integration */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
|
|
|
|||
4
Resources/shell-integration/.zlogin
Normal file
4
Resources/shell-integration/.zlogin
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# cmuxterm ZDOTDIR wrapper — sources user's .zlogin
|
||||
_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}"
|
||||
[ -f "$_cmux_real_zdotdir/.zlogin" ] && source "$_cmux_real_zdotdir/.zlogin"
|
||||
unset _cmux_real_zdotdir
|
||||
4
Resources/shell-integration/.zprofile
Normal file
4
Resources/shell-integration/.zprofile
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# cmuxterm ZDOTDIR wrapper — sources user's .zprofile
|
||||
_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}"
|
||||
[ -f "$_cmux_real_zdotdir/.zprofile" ] && source "$_cmux_real_zdotdir/.zprofile"
|
||||
unset _cmux_real_zdotdir
|
||||
6
Resources/shell-integration/.zshenv
Normal file
6
Resources/shell-integration/.zshenv
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# cmuxterm ZDOTDIR wrapper — sources user's .zshenv
|
||||
# NOTE: Do NOT restore ZDOTDIR here. It must stay pointed at our wrapper dir
|
||||
# so that zsh finds our .zshrc next. Restoration happens in .zshrc.
|
||||
_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}"
|
||||
[ -f "$_cmux_real_zdotdir/.zshenv" ] && source "$_cmux_real_zdotdir/.zshenv"
|
||||
unset _cmux_real_zdotdir
|
||||
16
Resources/shell-integration/.zshrc
Normal file
16
Resources/shell-integration/.zshrc
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# cmuxterm ZDOTDIR wrapper — restore ZDOTDIR, source user's .zshrc, then load integration
|
||||
|
||||
# Restore original ZDOTDIR so user configs and subsequent shells work normally
|
||||
if [ -n "$CMUX_ORIGINAL_ZDOTDIR" ]; then
|
||||
ZDOTDIR="$CMUX_ORIGINAL_ZDOTDIR"
|
||||
else
|
||||
ZDOTDIR="$HOME"
|
||||
fi
|
||||
|
||||
# Source user's .zshrc
|
||||
[ -f "$ZDOTDIR/.zshrc" ] && source "$ZDOTDIR/.zshrc"
|
||||
|
||||
# Load cmux shell integration (unless disabled)
|
||||
if [ "$CMUX_SHELL_INTEGRATION" != "0" ]; then
|
||||
source "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh"
|
||||
fi
|
||||
154
Resources/shell-integration/cmux-bash-integration.bash
Normal file
154
Resources/shell-integration/cmux-bash-integration.bash
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# cmuxterm shell integration for bash
|
||||
|
||||
_cmux_send() {
|
||||
local payload="$1"
|
||||
if command -v ncat >/dev/null 2>&1; then
|
||||
printf '%s\n' "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
|
||||
elif command -v socat >/dev/null 2>&1; then
|
||||
printf '%s\n' "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
|
||||
elif command -v nc >/dev/null 2>&1; then
|
||||
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
|
||||
#
|
||||
# Important: macOS/BSD nc will often wait for the peer to close the socket
|
||||
# after it has finished writing. cmuxterm keeps the connection open, so
|
||||
# a plain `nc -U` can hang indefinitely and leak background processes.
|
||||
#
|
||||
# Prefer flags that guarantee we exit after sending, and fall back to a
|
||||
# short timeout so we never block sidebar updates.
|
||||
if printf '%s\n' "$payload" | nc -N -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1; then
|
||||
:
|
||||
else
|
||||
printf '%s\n' "$payload" | nc -w 1 -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Throttle heavy work to avoid prompt latency.
|
||||
_CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
|
||||
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
|
||||
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
|
||||
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
|
||||
|
||||
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
|
||||
_CMUX_PORTS_JOB_PID="${_CMUX_PORTS_JOB_PID:-}"
|
||||
|
||||
_cmux_prompt_command() {
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
local now=$SECONDS
|
||||
local pwd="$PWD"
|
||||
local tty_name=""
|
||||
tty_name="$(tty 2>/dev/null || true)"
|
||||
tty_name="${tty_name##*/}"
|
||||
if [[ "$tty_name" == "not a tty" ]]; then
|
||||
tty_name=""
|
||||
fi
|
||||
|
||||
# CWD: keep the app in sync with the actual shell directory.
|
||||
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
|
||||
_CMUX_PWD_LAST_PWD="$pwd"
|
||||
{
|
||||
local qpwd="${pwd//\"/\\\"}"
|
||||
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
# Git branch/dirty can change without a directory change (e.g. `git checkout`),
|
||||
# so update on every prompt (still async + de-duped by the running-job check).
|
||||
local should_git=1
|
||||
|
||||
if (( should_git )); then
|
||||
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
|
||||
:
|
||||
else
|
||||
_CMUX_GIT_LAST_PWD="$pwd"
|
||||
_CMUX_GIT_LAST_RUN=$now
|
||||
{
|
||||
local branch dirty_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -n "$branch" ]]; then
|
||||
local first
|
||||
first=$(git status --porcelain -uno 2>/dev/null | head -1)
|
||||
[[ -n "$first" ]] && dirty_opt="--status=dirty"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
|
||||
else
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
|
||||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_GIT_JOB_PID=$!
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
|
||||
if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then
|
||||
: # previous scan still running
|
||||
else
|
||||
_CMUX_PORTS_LAST_RUN=$now
|
||||
{
|
||||
local ports=()
|
||||
local pids_csv=""
|
||||
if [[ -n "$tty_name" ]]; then
|
||||
pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)"
|
||||
pids_csv="${pids_csv%,}"
|
||||
fi
|
||||
|
||||
if [[ -n "$pids_csv" ]]; then
|
||||
local line name port
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" == n* ]] || continue
|
||||
name="${line#n}"
|
||||
name="${name%%->*}"
|
||||
port="${name##*:}"
|
||||
port="${port%%[^0-9]*}"
|
||||
[[ -n "$port" ]] && ports+=("$port")
|
||||
done < <(
|
||||
lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true
|
||||
)
|
||||
fi
|
||||
|
||||
if ((${#ports[@]} > 0)); then
|
||||
local ports_sorted
|
||||
ports_sorted=$(printf '%s\n' "${ports[@]}" | sort -n | uniq | tr '\n' ' ')
|
||||
ports_sorted="${ports_sorted%% }"
|
||||
_cmux_send "report_ports $ports_sorted --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
} >/dev/null 2>&1 &
|
||||
_CMUX_PORTS_JOB_PID=$!
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
_cmux_install_prompt_command() {
|
||||
[[ -n "${_CMUX_PROMPT_INSTALLED:-}" ]] && return 0
|
||||
_CMUX_PROMPT_INSTALLED=1
|
||||
|
||||
local decl
|
||||
decl="$(declare -p PROMPT_COMMAND 2>/dev/null || true)"
|
||||
if [[ "$decl" == "declare -a"* ]]; then
|
||||
local existing=0
|
||||
local item
|
||||
for item in "${PROMPT_COMMAND[@]}"; do
|
||||
[[ "$item" == "_cmux_prompt_command" ]] && existing=1 && break
|
||||
done
|
||||
if (( existing == 0 )); then
|
||||
PROMPT_COMMAND=("_cmux_prompt_command" "${PROMPT_COMMAND[@]}")
|
||||
fi
|
||||
else
|
||||
case ";$PROMPT_COMMAND;" in
|
||||
*";_cmux_prompt_command;"*) ;;
|
||||
*)
|
||||
if [[ -n "$PROMPT_COMMAND" ]]; then
|
||||
PROMPT_COMMAND="_cmux_prompt_command;$PROMPT_COMMAND"
|
||||
else
|
||||
PROMPT_COMMAND="_cmux_prompt_command"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
_cmux_install_prompt_command
|
||||
222
Resources/shell-integration/cmux-zsh-integration.zsh
Normal file
222
Resources/shell-integration/cmux-zsh-integration.zsh
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# cmuxterm shell integration for zsh
|
||||
# Injected automatically — do not source manually
|
||||
|
||||
_cmux_send() {
|
||||
local payload="$1"
|
||||
if command -v ncat >/dev/null 2>&1; then
|
||||
print -r -- "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
|
||||
elif command -v socat >/dev/null 2>&1; then
|
||||
print -r -- "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
|
||||
elif command -v nc >/dev/null 2>&1; then
|
||||
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
|
||||
#
|
||||
# Important: macOS/BSD nc will often wait for the peer to close the socket
|
||||
# after it has finished writing. cmuxterm keeps the connection open, so
|
||||
# a plain `nc -U` can hang indefinitely and leak background processes.
|
||||
#
|
||||
# Prefer flags that guarantee we exit after sending, and fall back to a
|
||||
# short timeout so we never block sidebar updates.
|
||||
if print -r -- "$payload" | nc -N -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1; then
|
||||
:
|
||||
else
|
||||
print -r -- "$payload" | nc -w 1 -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Throttle heavy work to avoid prompt latency.
|
||||
typeset -g _CMUX_PWD_LAST_PWD=""
|
||||
typeset -g _CMUX_GIT_LAST_PWD=""
|
||||
typeset -g _CMUX_GIT_LAST_RUN=0
|
||||
typeset -g _CMUX_GIT_JOB_PID=""
|
||||
typeset -g _CMUX_GIT_FORCE=0
|
||||
|
||||
typeset -g _CMUX_PORTS_LAST_RUN=0
|
||||
typeset -g _CMUX_PORTS_JOB_PID=""
|
||||
typeset -g _CMUX_CMD_START=0
|
||||
typeset -g _CMUX_TTY_NAME=""
|
||||
|
||||
_cmux_ports_scan() {
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
# Report listening TCP ports for the current shell session only (so a fresh
|
||||
# tab doesn't inherit unrelated machine-wide ports). We restrict the scan to
|
||||
# the current controlling TTY which keeps this cheap enough to run often.
|
||||
local -a ports
|
||||
local line name port
|
||||
|
||||
# Best-effort: restrict to the current controlling TTY so a fresh tab doesn't
|
||||
# inherit unrelated machine-wide ports. This is a pragmatic heuristic that
|
||||
# works well for typical dev servers started from that shell.
|
||||
local tty_name pids_csv
|
||||
tty_name="$_CMUX_TTY_NAME"
|
||||
if [[ -z "$tty_name" ]]; then
|
||||
local t
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ "$t" != "not a tty" ]] && tty_name="$t"
|
||||
fi
|
||||
|
||||
if [[ -z "$tty_name" ]]; then
|
||||
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)"
|
||||
pids_csv="${pids_csv%,}"
|
||||
if [[ -z "$pids_csv" ]]; then
|
||||
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" == n* ]] || continue
|
||||
name="${line#n}"
|
||||
# Defensive: if the format ever includes a remote endpoint, keep the local side.
|
||||
name="${name%%->*}"
|
||||
port="${name##*:}"
|
||||
# Strip anything non-numeric (paranoia: "8000 (LISTEN)" etc).
|
||||
port="${port%%[^0-9]*}"
|
||||
[[ -n "$port" ]] && ports+=("$port")
|
||||
done < <(
|
||||
lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true
|
||||
)
|
||||
|
||||
ports=("${(@u)ports}")
|
||||
ports=("${(@on)ports}")
|
||||
|
||||
if (( ${#ports[@]} > 0 )); then
|
||||
_cmux_send "report_ports ${(j: :)ports} --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
else
|
||||
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
fi
|
||||
}
|
||||
|
||||
_cmux_ports_kick() {
|
||||
# De-duped, async scans (with a short burst) so we still update when a command
|
||||
# runs in the foreground (no prompt updates while it is running).
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
_CMUX_PORTS_LAST_RUN=$EPOCHSECONDS
|
||||
{
|
||||
# Scan over ~10 seconds so slow-starting servers (e.g. `npm run dev`)
|
||||
# still show ports while the command is in the foreground.
|
||||
sleep 0.5 2>/dev/null || true
|
||||
_cmux_ports_scan
|
||||
sleep 1.0 2>/dev/null || true
|
||||
_cmux_ports_scan
|
||||
sleep 1.5 2>/dev/null || true
|
||||
_cmux_ports_scan
|
||||
sleep 2.0 2>/dev/null || true
|
||||
_cmux_ports_scan
|
||||
sleep 2.5 2>/dev/null || true
|
||||
_cmux_ports_scan
|
||||
sleep 2.5 2>/dev/null || true
|
||||
_cmux_ports_scan
|
||||
} >/dev/null 2>&1 &!
|
||||
_CMUX_PORTS_JOB_PID=$!
|
||||
}
|
||||
|
||||
_cmux_preexec() {
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
_CMUX_CMD_START=$EPOCHSECONDS
|
||||
|
||||
# Heuristic: git commands can change branch/dirty state without changing $PWD.
|
||||
local cmd="${1## }"
|
||||
if [[ "$cmd" == git\ * || "$cmd" == git ]]; then
|
||||
_CMUX_GIT_FORCE=1
|
||||
fi
|
||||
|
||||
# Ports can change due to long-running foreground commands (servers), so start
|
||||
# a short scan burst after command launch.
|
||||
_cmux_ports_kick
|
||||
}
|
||||
|
||||
_cmux_precmd() {
|
||||
# Skip if socket doesn't exist yet
|
||||
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
|
||||
[[ -n "$CMUX_TAB_ID" ]] || return 0
|
||||
[[ -n "$CMUX_PANEL_ID" ]] || return 0
|
||||
|
||||
if [[ -z "$_CMUX_TTY_NAME" ]]; then
|
||||
local t
|
||||
t="$(tty 2>/dev/null || true)"
|
||||
t="${t##*/}"
|
||||
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
|
||||
fi
|
||||
|
||||
local now=$EPOCHSECONDS
|
||||
local pwd="$PWD"
|
||||
local cmd_start="$_CMUX_CMD_START"
|
||||
_CMUX_CMD_START=0
|
||||
|
||||
# CWD: keep the app in sync with the actual shell directory.
|
||||
# This is also the simplest way to test sidebar directory behavior end-to-end.
|
||||
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
|
||||
_CMUX_PWD_LAST_PWD="$pwd"
|
||||
{
|
||||
# Quote to preserve spaces.
|
||||
local qpwd="${pwd//\"/\\\"}"
|
||||
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
|
||||
} >/dev/null 2>&1 &!
|
||||
fi
|
||||
|
||||
# Git branch/dirty: update immediately on directory change, otherwise every ~3s.
|
||||
local should_git=0
|
||||
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
|
||||
should_git=1
|
||||
elif (( _CMUX_GIT_FORCE )); then
|
||||
should_git=1
|
||||
elif (( now - _CMUX_GIT_LAST_RUN >= 3 )); then
|
||||
should_git=1
|
||||
fi
|
||||
|
||||
if (( should_git )); then
|
||||
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
|
||||
: # previous update still running
|
||||
else
|
||||
_CMUX_GIT_FORCE=0
|
||||
_CMUX_GIT_LAST_PWD="$pwd"
|
||||
_CMUX_GIT_LAST_RUN=$now
|
||||
{
|
||||
local branch dirty_opt=""
|
||||
branch=$(git branch --show-current 2>/dev/null)
|
||||
if [[ -n "$branch" ]]; then
|
||||
local first
|
||||
first=$(git status --porcelain -uno 2>/dev/null | head -1)
|
||||
[[ -n "$first" ]] && dirty_opt="--status=dirty"
|
||||
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
|
||||
else
|
||||
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
|
||||
fi
|
||||
} >/dev/null 2>&1 &!
|
||||
_CMUX_GIT_JOB_PID=$!
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ports:
|
||||
# - Periodic scan to avoid stale values.
|
||||
# - Forced scan when a long-running command returns to the prompt (common when stopping a server).
|
||||
local cmd_dur=0
|
||||
if [[ -n "$cmd_start" && "$cmd_start" != 0 ]]; then
|
||||
cmd_dur=$(( now - cmd_start ))
|
||||
fi
|
||||
|
||||
if (( cmd_dur >= 2 || now - _CMUX_PORTS_LAST_RUN >= 10 )); then
|
||||
_cmux_ports_kick
|
||||
fi
|
||||
}
|
||||
|
||||
autoload -Uz add-zsh-hook
|
||||
add-zsh-hook preexec _cmux_preexec
|
||||
add-zsh-hook precmd _cmux_precmd
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,27 @@ import AppKit
|
|||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
struct SidebarStatusEntry: Identifiable {
|
||||
let id = UUID()
|
||||
let key: String
|
||||
var value: String
|
||||
var icon: String?
|
||||
var color: String?
|
||||
var timestamp: Date
|
||||
}
|
||||
|
||||
enum SidebarLogLevel: String {
|
||||
case info, progress, success, warning, error
|
||||
}
|
||||
|
||||
struct SidebarLogEntry: Identifiable {
|
||||
let id = UUID()
|
||||
let message: String
|
||||
let level: SidebarLogLevel
|
||||
let source: String?
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
class Tab: Identifiable, ObservableObject {
|
||||
let id: UUID
|
||||
@Published var title: String
|
||||
|
|
@ -28,6 +49,17 @@ class Tab: Identifiable, ObservableObject {
|
|||
@Published var surfaceTitles: [UUID: String] = [:]
|
||||
var splitViewSize: CGSize = .zero
|
||||
|
||||
// Sidebar metadata
|
||||
@Published var statusEntries: [String: SidebarStatusEntry] = [:]
|
||||
@Published var logEntries: [SidebarLogEntry] = []
|
||||
@Published var progress: (value: Double, label: String?)? = nil
|
||||
@Published var gitBranch: (branch: String, isDirty: Bool)? = nil
|
||||
// Per-surface ports reported by shell integration. `listeningPorts` is the union for display.
|
||||
@Published var surfaceListeningPorts: [UUID: [Int]] = [:]
|
||||
@Published var listeningPorts: [Int] = []
|
||||
|
||||
static let maxLogEntries = 50
|
||||
|
||||
private var processTitle: String
|
||||
|
||||
init(title: String = "Terminal", workingDirectory: String? = nil) {
|
||||
|
|
@ -114,6 +146,34 @@ class Tab: Identifiable, ObservableObject {
|
|||
currentDirectory = trimmed
|
||||
}
|
||||
|
||||
func recomputeListeningPorts() {
|
||||
let merged = surfaceListeningPorts.values.reduce(into: Set<Int>()) { acc, ports in
|
||||
for port in ports {
|
||||
acc.insert(port)
|
||||
}
|
||||
}
|
||||
let sorted = merged.sorted()
|
||||
if listeningPorts != sorted {
|
||||
listeningPorts = sorted
|
||||
}
|
||||
}
|
||||
|
||||
func pruneSurfaceMetadata(validSurfaceIds: Set<UUID>? = nil) {
|
||||
let validIds = validSurfaceIds ?? Set((splitTree.root?.leaves() ?? []).map { $0.id })
|
||||
|
||||
if surfaceDirectories.keys.contains(where: { !validIds.contains($0) }) {
|
||||
surfaceDirectories = surfaceDirectories.filter { validIds.contains($0.key) }
|
||||
}
|
||||
if surfaceTitles.keys.contains(where: { !validIds.contains($0) }) {
|
||||
surfaceTitles = surfaceTitles.filter { validIds.contains($0.key) }
|
||||
}
|
||||
if surfaceListeningPorts.keys.contains(where: { !validIds.contains($0) }) {
|
||||
surfaceListeningPorts = surfaceListeningPorts.filter { validIds.contains($0.key) }
|
||||
}
|
||||
|
||||
recomputeListeningPorts()
|
||||
}
|
||||
|
||||
func triggerNotificationFocusFlash(
|
||||
surfaceId: UUID,
|
||||
requiresSplit: Bool = false,
|
||||
|
|
@ -274,6 +334,10 @@ class Tab: Identifiable, ObservableObject {
|
|||
: nil
|
||||
|
||||
splitTree = splitTree.removing(targetNode)
|
||||
// A closed split can race with shell-integration hooks that report ports/cwd.
|
||||
// Prune any per-surface metadata to the remaining leaves so the sidebar
|
||||
// doesn't display stale ports/directories.
|
||||
pruneSurfaceMetadata()
|
||||
|
||||
if splitTree.isEmpty {
|
||||
focusedSurfaceId = nil
|
||||
|
|
|
|||
|
|
@ -222,6 +222,51 @@ class TerminalController {
|
|||
return resetFlashCounts()
|
||||
#endif
|
||||
|
||||
case "set_status":
|
||||
return setStatus(args)
|
||||
|
||||
case "clear_status":
|
||||
return clearStatus(args)
|
||||
|
||||
case "list_status":
|
||||
return listStatus(args)
|
||||
|
||||
case "log":
|
||||
return appendLog(args)
|
||||
|
||||
case "clear_log":
|
||||
return clearLog(args)
|
||||
|
||||
case "list_log":
|
||||
return listLog(args)
|
||||
|
||||
case "set_progress":
|
||||
return setProgress(args)
|
||||
|
||||
case "clear_progress":
|
||||
return clearProgress(args)
|
||||
|
||||
case "report_git_branch":
|
||||
return reportGitBranch(args)
|
||||
|
||||
case "clear_git_branch":
|
||||
return clearGitBranch(args)
|
||||
|
||||
case "report_ports":
|
||||
return reportPorts(args)
|
||||
|
||||
case "clear_ports":
|
||||
return clearPorts(args)
|
||||
|
||||
case "report_pwd":
|
||||
return reportPwd(args)
|
||||
|
||||
case "sidebar_state":
|
||||
return sidebarState(args)
|
||||
|
||||
case "reset_sidebar":
|
||||
return resetSidebar(args)
|
||||
|
||||
case "help":
|
||||
return helpText()
|
||||
|
||||
|
|
@ -253,6 +298,20 @@ class TerminalController {
|
|||
clear_notifications - Clear all notifications
|
||||
set_app_focus <active|inactive|clear> - Override app focus state
|
||||
simulate_app_active - Trigger app active handler
|
||||
set_status <key> <value> [--icon=X] [--color=#hex] - Set a status entry
|
||||
clear_status <key> [--tab=X] - Remove a status entry
|
||||
list_status [--tab=X] - List all status entries
|
||||
log <message> [--level=X] [--source=X] [--tab=X] - Append a log entry
|
||||
clear_log [--tab=X] - Clear log entries
|
||||
list_log [--limit=N] [--tab=X] - List log entries
|
||||
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
|
||||
clear_progress [--tab=X] - Clear progress bar
|
||||
report_git_branch <branch> [--status=dirty] [--tab=X] - Report git branch
|
||||
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
|
||||
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
|
||||
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
|
||||
sidebar_state [--tab=X] - Dump all sidebar metadata
|
||||
reset_sidebar [--tab=X] - Clear all sidebar metadata
|
||||
help - Show this help
|
||||
"""
|
||||
#if DEBUG
|
||||
|
|
@ -278,7 +337,22 @@ class TerminalController {
|
|||
"notify_surface",
|
||||
"notify_target",
|
||||
"list_notifications",
|
||||
"clear_notifications"
|
||||
"clear_notifications",
|
||||
"set_status",
|
||||
"clear_status",
|
||||
"list_status",
|
||||
"log",
|
||||
"clear_log",
|
||||
"list_log",
|
||||
"set_progress",
|
||||
"clear_progress",
|
||||
"report_git_branch",
|
||||
"clear_git_branch",
|
||||
"report_ports",
|
||||
"clear_ports",
|
||||
"report_pwd",
|
||||
"sidebar_state",
|
||||
"reset_sidebar"
|
||||
]
|
||||
return allowed.contains(command)
|
||||
case .off:
|
||||
|
|
@ -934,6 +1008,587 @@ class TerminalController {
|
|||
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
|
||||
}
|
||||
|
||||
// MARK: - Option Parsing
|
||||
|
||||
private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
|
||||
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return ([], [:]) }
|
||||
|
||||
// Tokenize respecting quoted strings. Support basic backslash escapes inside quotes
|
||||
// (e.g. \" within "...") so shell integrations can safely escape embedded quotes.
|
||||
var tokens: [String] = []
|
||||
var current = ""
|
||||
var inQuote = false
|
||||
var quoteChar: Character = "'"
|
||||
let chars = Array(trimmed)
|
||||
var cursor = 0
|
||||
while cursor < chars.count {
|
||||
let char = chars[cursor]
|
||||
if inQuote {
|
||||
if char == "\\" {
|
||||
if cursor + 1 < chars.count {
|
||||
let next = chars[cursor + 1]
|
||||
if next == quoteChar || next == "\\" {
|
||||
current.append(next)
|
||||
cursor += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
current.append(char)
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if char == quoteChar {
|
||||
inQuote = false
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
current.append(char)
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if char == "'" || char == "\"" {
|
||||
inQuote = true
|
||||
quoteChar = char
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if char.isWhitespace {
|
||||
if !current.isEmpty {
|
||||
tokens.append(current)
|
||||
current = ""
|
||||
}
|
||||
cursor += 1
|
||||
continue
|
||||
}
|
||||
|
||||
current.append(char)
|
||||
cursor += 1
|
||||
}
|
||||
if !current.isEmpty {
|
||||
tokens.append(current)
|
||||
}
|
||||
|
||||
var positional: [String] = []
|
||||
var options: [String: String] = [:]
|
||||
var stopParsingOptions = false
|
||||
var i = 0
|
||||
while i < tokens.count {
|
||||
let token = tokens[i]
|
||||
if stopParsingOptions {
|
||||
positional.append(token)
|
||||
} else if token == "--" {
|
||||
stopParsingOptions = true
|
||||
} else if token.hasPrefix("--") {
|
||||
if let eqIndex = token.firstIndex(of: "=") {
|
||||
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
|
||||
let value = String(token[token.index(after: eqIndex)...])
|
||||
options[key] = value
|
||||
} else {
|
||||
let key = String(token.dropFirst(2))
|
||||
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
|
||||
options[key] = tokens[i + 1]
|
||||
i += 1
|
||||
} else {
|
||||
options[key] = ""
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positional.append(token)
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return (positional, options)
|
||||
}
|
||||
|
||||
// MARK: - Sidebar Commands
|
||||
|
||||
private func resolveTabForReport(_ args: String) -> Tab? {
|
||||
guard let tabManager else { return nil }
|
||||
let parsed = parseOptions(args)
|
||||
if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
|
||||
return resolveTab(from: tabArg, tabManager: tabManager)
|
||||
}
|
||||
guard let selectedId = tabManager.selectedTabId else { return nil }
|
||||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}
|
||||
|
||||
private func setStatus(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
guard parsed.positional.count >= 2 else {
|
||||
return "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--tab=X]"
|
||||
}
|
||||
let key = parsed.positional[0]
|
||||
let value = parsed.positional[1...].joined(separator: " ")
|
||||
let icon = parsed.options["icon"]
|
||||
let color = parsed.options["color"]
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
tab.statusEntries[key] = SidebarStatusEntry(
|
||||
key: key, value: value, icon: icon, color: color, timestamp: Date()
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearStatus(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
|
||||
return "ERROR: Missing status key — usage: clear_status <key> [--tab=X]"
|
||||
}
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
if tab.statusEntries.removeValue(forKey: key) == nil {
|
||||
result = "OK (key not found)"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func listStatus(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = ""
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
if tab.statusEntries.isEmpty {
|
||||
result = "No status entries"
|
||||
return
|
||||
}
|
||||
let lines = tab.statusEntries.values.sorted(by: { $0.key < $1.key }).map { entry in
|
||||
var line = "\(entry.key)=\(entry.value)"
|
||||
if let icon = entry.icon { line += " icon=\(icon)" }
|
||||
if let color = entry.color { line += " color=\(color)" }
|
||||
return line
|
||||
}
|
||||
result = lines.joined(separator: "\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func appendLog(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
guard !parsed.positional.isEmpty else {
|
||||
return "ERROR: Missing message — usage: log <message> [--level=X] [--source=X] [--tab=X]"
|
||||
}
|
||||
let message = parsed.positional.joined(separator: " ")
|
||||
let levelStr = parsed.options["level"] ?? "info"
|
||||
guard let level = SidebarLogLevel(rawValue: levelStr) else {
|
||||
return "ERROR: Unknown log level '\(levelStr)' — use: info, progress, success, warning, error"
|
||||
}
|
||||
let source = parsed.options["source"]
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
let entry = SidebarLogEntry(message: message, level: level, source: source, timestamp: Date())
|
||||
tab.logEntries.append(entry)
|
||||
let defaultLimit = Tab.maxLogEntries
|
||||
let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? defaultLimit
|
||||
let limit = max(1, min(500, configuredLimit))
|
||||
if tab.logEntries.count > limit {
|
||||
tab.logEntries.removeFirst(tab.logEntries.count - limit)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearLog(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
tab.logEntries.removeAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func listLog(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
var limit: Int?
|
||||
if let limitStr = parsed.options["limit"] {
|
||||
if limitStr.isEmpty {
|
||||
return "ERROR: Missing limit value — usage: list_log [--limit=N] [--tab=X]"
|
||||
}
|
||||
guard let parsedLimit = Int(limitStr) else {
|
||||
return "ERROR: Invalid limit '\(limitStr)' — must be >= 0"
|
||||
}
|
||||
guard parsedLimit >= 0 else {
|
||||
return "ERROR: Invalid limit '\(parsedLimit)' — must be >= 0"
|
||||
}
|
||||
limit = parsedLimit
|
||||
}
|
||||
|
||||
var result = ""
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
if tab.logEntries.isEmpty {
|
||||
result = "No log entries"
|
||||
return
|
||||
}
|
||||
let entries: [SidebarLogEntry]
|
||||
if let limit = limit {
|
||||
entries = Array(tab.logEntries.suffix(limit))
|
||||
} else {
|
||||
entries = tab.logEntries
|
||||
}
|
||||
if entries.isEmpty {
|
||||
result = "No log entries"
|
||||
return
|
||||
}
|
||||
let lines = entries.map { entry in
|
||||
var line = "[\(entry.level.rawValue)] \(entry.message)"
|
||||
if let source = entry.source { line += " (source=\(source))" }
|
||||
return line
|
||||
}
|
||||
result = lines.joined(separator: "\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func setProgress(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
guard let valueStr = parsed.positional.first else {
|
||||
return "ERROR: Missing progress value — usage: set_progress <0.0-1.0> [--label=X] [--tab=X]"
|
||||
}
|
||||
guard let value = Double(valueStr), value >= 0.0, value <= 1.0 else {
|
||||
return "ERROR: Invalid progress value '\(valueStr)' — must be 0.0 to 1.0"
|
||||
}
|
||||
let label = parsed.options["label"]
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
tab.progress = (value: value, label: label)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearProgress(_ args: String) -> String {
|
||||
guard tabManager != nil else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
tab.progress = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func reportGitBranch(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
guard let branch = parsed.positional.first else {
|
||||
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]"
|
||||
}
|
||||
let isDirty = parsed.options["status"]?.lowercased() == "dirty"
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) ?? {
|
||||
guard let selectedId = tabManager.selectedTabId else { return nil }
|
||||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}() else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
tab.gitBranch = (branch: branch, isDirty: isDirty)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearGitBranch(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) ?? {
|
||||
guard let selectedId = tabManager.selectedTabId else { return nil }
|
||||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}() else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
tab.gitBranch = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func reportPorts(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
guard !parsed.positional.isEmpty else {
|
||||
return "ERROR: Missing ports — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
|
||||
}
|
||||
var ports: [Int] = []
|
||||
for portStr in parsed.positional {
|
||||
guard let port = Int(portStr), port > 0, port <= 65535 else {
|
||||
return "ERROR: Invalid port '\(portStr)' — must be 1-65535"
|
||||
}
|
||||
ports.append(port)
|
||||
}
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) ?? {
|
||||
guard let selectedId = tabManager.selectedTabId else { return nil }
|
||||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}() else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
|
||||
// Support both --panel and --surface as synonyms.
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedSurfaceId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
tab.surfaceListeningPorts[surfaceId] = ports
|
||||
tab.recomputeListeningPorts()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func reportPwd(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
let parsed = parseOptions(args)
|
||||
guard !parsed.positional.isEmpty else {
|
||||
return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]"
|
||||
}
|
||||
|
||||
let directory = parsed.positional.joined(separator: " ")
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
|
||||
// Support both --panel and --surface as synonyms.
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
let surfaceId: UUID
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: report_pwd <path> [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let parsedId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
surfaceId = parsedId
|
||||
} else {
|
||||
guard let focused = tab.focusedSurfaceId else {
|
||||
result = "ERROR: Missing panel id (no focused surface)"
|
||||
return
|
||||
}
|
||||
surfaceId = focused
|
||||
}
|
||||
|
||||
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
|
||||
tabManager.updateSurfaceDirectory(tabId: tab.id, surfaceId: surfaceId, directory: directory)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func clearPorts(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
let parsed = parseOptions(args)
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) ?? {
|
||||
guard let selectedId = tabManager.selectedTabId else { return nil }
|
||||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}() else {
|
||||
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
|
||||
return
|
||||
}
|
||||
|
||||
let validSurfaceIds = Set((tab.splitTree.root?.leaves() ?? []).map { $0.id })
|
||||
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
|
||||
|
||||
// If a panel is specified, clear only that surface's ports. Otherwise clear all.
|
||||
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
|
||||
if let panelArg {
|
||||
if panelArg.isEmpty {
|
||||
result = "ERROR: Missing panel id — usage: clear_ports [--tab=X] [--panel=Y]"
|
||||
return
|
||||
}
|
||||
guard let surfaceId = UUID(uuidString: panelArg) else {
|
||||
result = "ERROR: Invalid panel id '\(panelArg)'"
|
||||
return
|
||||
}
|
||||
guard validSurfaceIds.contains(surfaceId) else {
|
||||
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
|
||||
return
|
||||
}
|
||||
tab.surfaceListeningPorts.removeValue(forKey: surfaceId)
|
||||
} else {
|
||||
tab.surfaceListeningPorts.removeAll()
|
||||
}
|
||||
|
||||
tab.recomputeListeningPorts()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func sidebarState(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = ""
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) ?? {
|
||||
guard let selectedId = tabManager.selectedTabId else { return nil }
|
||||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}() else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append("tab=\(tab.id.uuidString)")
|
||||
lines.append("cwd=\(tab.currentDirectory)")
|
||||
if let focused = tab.focusedSurfaceId,
|
||||
let focusedDir = tab.surfaceDirectories[focused] {
|
||||
lines.append("focused_cwd=\(focusedDir)")
|
||||
lines.append("focused_panel=\(focused.uuidString)")
|
||||
} else {
|
||||
lines.append("focused_cwd=unknown")
|
||||
lines.append("focused_panel=unknown")
|
||||
}
|
||||
|
||||
// Git branch
|
||||
if let git = tab.gitBranch {
|
||||
lines.append("git_branch=\(git.branch)\(git.isDirty ? " dirty" : " clean")")
|
||||
} else {
|
||||
lines.append("git_branch=none")
|
||||
}
|
||||
|
||||
// Ports
|
||||
if tab.listeningPorts.isEmpty {
|
||||
lines.append("ports=none")
|
||||
} else {
|
||||
lines.append("ports=\(tab.listeningPorts.map(String.init).joined(separator: ","))")
|
||||
}
|
||||
|
||||
// Progress
|
||||
if let progress = tab.progress {
|
||||
let label = progress.label ?? ""
|
||||
lines.append("progress=\(String(format: "%.2f", progress.value)) \(label)".trimmingCharacters(in: .whitespaces))
|
||||
} else {
|
||||
lines.append("progress=none")
|
||||
}
|
||||
|
||||
// Status entries
|
||||
lines.append("status_count=\(tab.statusEntries.count)")
|
||||
for entry in tab.statusEntries.values.sorted(by: { $0.key < $1.key }) {
|
||||
var line = " \(entry.key)=\(entry.value)"
|
||||
if let icon = entry.icon { line += " icon=\(icon)" }
|
||||
if let color = entry.color { line += " color=\(color)" }
|
||||
lines.append(line)
|
||||
}
|
||||
|
||||
// Log entries
|
||||
lines.append("log_count=\(tab.logEntries.count)")
|
||||
for entry in tab.logEntries.suffix(5) {
|
||||
lines.append(" [\(entry.level.rawValue)] \(entry.message)")
|
||||
}
|
||||
|
||||
result = lines.joined(separator: "\n")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func resetSidebar(_ args: String) -> String {
|
||||
guard let tabManager else { return "ERROR: TabManager not available" }
|
||||
|
||||
var result = "OK"
|
||||
DispatchQueue.main.sync {
|
||||
guard let tab = resolveTabForReport(args) ?? {
|
||||
guard let selectedId = tabManager.selectedTabId else { return nil }
|
||||
return tabManager.tabs.first(where: { $0.id == selectedId })
|
||||
}() else {
|
||||
result = "ERROR: Tab not found"
|
||||
return
|
||||
}
|
||||
tab.statusEntries.removeAll()
|
||||
tab.logEntries.removeAll()
|
||||
tab.progress = nil
|
||||
tab.gitBranch = nil
|
||||
tab.surfaceListeningPorts.removeAll()
|
||||
tab.listeningPorts.removeAll()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
deinit {
|
||||
stop()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
229
tests/cmux.py
229
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,32 @@ MONITOR_DURATION = 3.0
|
|||
|
||||
def get_cmuxterm_pid() -> Optional[int]:
|
||||
"""Get the PID of the running cmuxterm process."""
|
||||
socket_path = os.environ.get("CMUX_SOCKET_PATH")
|
||||
if not socket_path:
|
||||
# Ask cmux.py to resolve default socket path (supports CMUX_TAG and last-socket file).
|
||||
try:
|
||||
socket_path = cmux().socket_path
|
||||
except Exception:
|
||||
socket_path = None
|
||||
|
||||
if socket_path and os.path.exists(socket_path):
|
||||
result = subprocess.run(
|
||||
["lsof", "-t", socket_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
pid = int(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if pid != os.getpid():
|
||||
return pid
|
||||
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", r"cmuxterm\.app/Contents/MacOS/cmuxterm$"],
|
||||
capture_output=True,
|
||||
|
|
@ -141,6 +167,14 @@ def test_cpu_after_popover_close(client: cmux, pid: int) -> tuple[bool, str]:
|
|||
time.sleep(0.1)
|
||||
time.sleep(0.5)
|
||||
|
||||
# Ensure the correct cmuxterm instance is frontmost (tag-safe).
|
||||
bundle_id = cmux.default_bundle_id()
|
||||
subprocess.run(
|
||||
["osascript", "-e", f'tell application id "{bundle_id}" to activate'],
|
||||
capture_output=True,
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
# Simulate opening and closing the popover via keyboard shortcut
|
||||
# We can't directly control the popover, but we can toggle it
|
||||
subprocess.run([
|
||||
|
|
@ -212,6 +246,8 @@ def main():
|
|||
print("cmuxterm Notification CPU Tests")
|
||||
print("=" * 60)
|
||||
|
||||
socket_path = cmux().socket_path
|
||||
|
||||
pid = get_cmuxterm_pid()
|
||||
if pid is None:
|
||||
print("\n❌ SKIP: cmuxterm is not running")
|
||||
|
|
@ -220,20 +256,13 @@ def main():
|
|||
print(f"\nFound cmuxterm process: PID {pid}")
|
||||
|
||||
# Try to connect to the socket
|
||||
socket_paths = ["/tmp/cmuxterm.sock", "/tmp/cmuxterm-debug.sock"]
|
||||
client = None
|
||||
for socket_path in socket_paths:
|
||||
if os.path.exists(socket_path):
|
||||
try:
|
||||
client = cmux(socket_path)
|
||||
client.connect()
|
||||
print(f"Connected to {socket_path}")
|
||||
break
|
||||
except cmuxError:
|
||||
continue
|
||||
|
||||
if client is None:
|
||||
print(f"\n❌ SKIP: Could not connect to cmuxterm socket")
|
||||
client = cmux(socket_path)
|
||||
try:
|
||||
client.connect()
|
||||
print(f"Connected to {socket_path}")
|
||||
except cmuxError:
|
||||
print("\n❌ SKIP: Could not connect to cmuxterm socket")
|
||||
print("Tip: set CMUX_TAG=<tag> or CMUX_SOCKET_PATH=<path> to target a tagged instance.")
|
||||
return 0
|
||||
|
||||
results = []
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -271,10 +271,11 @@ def run_tests():
|
|||
print("=" * 60)
|
||||
print()
|
||||
|
||||
socket_path = cmux.DEFAULT_SOCKET_PATH
|
||||
socket_path = cmux().socket_path
|
||||
if not os.path.exists(socket_path):
|
||||
print(f"Error: Socket not found at {socket_path}")
|
||||
print("Please make sure cmux is running.")
|
||||
print("Tip: set CMUX_TAG=<tag> or CMUX_SOCKET_PATH=<path> to target a tagged instance.")
|
||||
return 1
|
||||
|
||||
results = []
|
||||
|
|
|
|||
170
tests/test_sidebar_cwd_git.py
Normal file
170
tests/test_sidebar_cwd_git.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for sidebar CWD + git branch updates.
|
||||
|
||||
This specifically covers the regression where the sidebar directory can get
|
||||
stuck (e.g. showing "~" even after multiple `cd`s).
|
||||
|
||||
Run with a tagged instance to avoid unix socket conflicts:
|
||||
CMUX_TAG=<tag> python3 tests/test_sidebar_cwd_git.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout: float, interval: float, label: str):
|
||||
start = time.time()
|
||||
last_error: Exception | None = None
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
time.sleep(interval)
|
||||
if last_error is not None:
|
||||
raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}")
|
||||
raise AssertionError(f"Timed out waiting for {label}.")
|
||||
|
||||
|
||||
def _wait_for_state_field(
|
||||
client: cmux,
|
||||
key: str,
|
||||
expected: str,
|
||||
timeout: float = 6.0,
|
||||
interval: float = 0.1,
|
||||
) -> dict[str, str]:
|
||||
def pred():
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
return state if state.get(key) == expected else None
|
||||
|
||||
return _wait_for(pred, timeout=timeout, interval=interval, label=f"{key}={expected!r}")
|
||||
|
||||
|
||||
def _wait_for_git_branch(
|
||||
client: cmux,
|
||||
expected: str,
|
||||
timeout: float = 8.0,
|
||||
interval: float = 0.15,
|
||||
) -> dict[str, str]:
|
||||
def pred():
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
raw = state.get("git_branch", "")
|
||||
branch = raw.split(" ", 1)[0] # "main dirty" -> "main", "none" -> "none"
|
||||
return state if branch == expected else None
|
||||
|
||||
return _wait_for(pred, timeout=timeout, interval=interval, label=f"git_branch={expected!r}")
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=str(cwd), check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
def _init_git_repo(repo: Path) -> None:
|
||||
repo.mkdir(parents=True, exist_ok=True)
|
||||
_git(repo, "init")
|
||||
_git(repo, "config", "user.email", "cmuxterm-test@example.com")
|
||||
_git(repo, "config", "user.name", "cmuxterm-test")
|
||||
(repo / "README.md").write_text("hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-m", "init")
|
||||
# Normalize the initial branch to "main" so the test is deterministic.
|
||||
branch = subprocess.check_output(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=str(repo)
|
||||
).decode("utf-8", errors="replace").strip()
|
||||
if branch and branch != "main":
|
||||
_git(repo, "branch", "-m", "main")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG") or os.environ.get("CMUXTERM_TAG") or ""
|
||||
if not tag:
|
||||
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
|
||||
|
||||
base = Path("/tmp") / f"cmux_sidebar_test_{os.getpid()}"
|
||||
repo = base / "repo"
|
||||
other = base / "other"
|
||||
|
||||
try:
|
||||
if base.exists():
|
||||
shutil.rmtree(base)
|
||||
other.mkdir(parents=True, exist_ok=True)
|
||||
_init_git_repo(repo)
|
||||
|
||||
with cmux() as client:
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.6)
|
||||
|
||||
# Initial: sync via `pwd` to a file, then wait for sidebar_state cwd.
|
||||
marker = base / "pwd.txt"
|
||||
client.send(f"pwd > {marker}\n")
|
||||
_wait_for(lambda: marker.exists(), timeout=4.0, interval=0.1, label="pwd marker file")
|
||||
expected_pwd = marker.read_text(encoding="utf-8").strip()
|
||||
_wait_for_state_field(client, "cwd", expected_pwd)
|
||||
|
||||
# Multiple cd's: ensure cwd tracks changes.
|
||||
client.send(f"cd {other}\n")
|
||||
_wait_for_state_field(client, "cwd", str(other))
|
||||
_wait_for_git_branch(client, "none")
|
||||
|
||||
client.send(f"cd {repo}\n")
|
||||
_wait_for_state_field(client, "cwd", str(repo))
|
||||
_wait_for_git_branch(client, "main")
|
||||
|
||||
# Branch change should update.
|
||||
client.send("git checkout -b feature/sidebar\n")
|
||||
_wait_for_git_branch(client, "feature/sidebar")
|
||||
|
||||
# Leaving the repo should clear the branch.
|
||||
client.send(f"cd {other}\n")
|
||||
_wait_for_state_field(client, "cwd", str(other))
|
||||
_wait_for_git_branch(client, "none")
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar CWD + git branch test passed.")
|
||||
return 0
|
||||
|
||||
except (cmuxError, subprocess.CalledProcessError, AssertionError) as e:
|
||||
print(f"Sidebar CWD + git branch test failed: {e}")
|
||||
return 1
|
||||
finally:
|
||||
try:
|
||||
shutil.rmtree(base)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
127
tests/test_sidebar_invalid_panel.py
Normal file
127
tests/test_sidebar_invalid_panel.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression: report_ports/report_pwd must validate panel IDs.
|
||||
|
||||
If shell-integration hooks fire late (after a split is closed) they can report
|
||||
ports/cwd for a stale surface UUID. These updates should not pollute the sidebar
|
||||
state (stale ports/cwd).
|
||||
|
||||
Run with a tagged instance to avoid unix socket conflicts:
|
||||
CMUX_TAG=<tag> python3 tests/test_sidebar_invalid_panel.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _parse_ports(raw: str) -> set[int]:
|
||||
raw = (raw or "").strip()
|
||||
if not raw or raw == "none":
|
||||
return set()
|
||||
ports: set[int] = set()
|
||||
for item in raw.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
ports.add(int(item))
|
||||
except ValueError:
|
||||
continue
|
||||
return ports
|
||||
|
||||
|
||||
def _pick_absent_port(exclude: set[int]) -> int:
|
||||
# Pick a random port that isn't already showing and also isn't currently
|
||||
# listening machine-wide (avoid false failures if something is bound).
|
||||
for _ in range(200):
|
||||
port = random.randint(20000, 65000)
|
||||
if port in exclude:
|
||||
continue
|
||||
result = subprocess.run(
|
||||
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0 and not (result.stdout or "").strip():
|
||||
return port
|
||||
# Fall back to a fixed high port; still validate against exclude.
|
||||
for port in (54321, 54322, 54323, 61999):
|
||||
if port not in exclude:
|
||||
return port
|
||||
return 65000
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
with cmux() as client:
|
||||
tab_id = client.new_tab()
|
||||
client.select_tab(tab_id)
|
||||
time.sleep(0.8)
|
||||
|
||||
initial_state = client.sidebar_state(tab_id)
|
||||
initial_ports = _parse_ports(_parse_sidebar_state(initial_state).get("ports", ""))
|
||||
test_port = _pick_absent_port(initial_ports)
|
||||
|
||||
surface_ids = {surface_id for _, surface_id, _ in client.list_surfaces(tab_id)}
|
||||
fake_panel = uuid.uuid4()
|
||||
while str(fake_panel) in surface_ids:
|
||||
fake_panel = uuid.uuid4()
|
||||
|
||||
# Ports: reporting against a bogus panel must not update the union.
|
||||
client._send_command(f"report_ports {test_port} --tab={tab_id} --panel={fake_panel}")
|
||||
time.sleep(0.3)
|
||||
state = client.sidebar_state(tab_id)
|
||||
ports = _parse_ports(_parse_sidebar_state(state).get("ports", ""))
|
||||
if test_port in ports:
|
||||
print(f"FAIL: invalid panel report_ports leaked into sidebar ports: {ports}")
|
||||
return 1
|
||||
|
||||
# CWD: reporting against a bogus panel must not set cwd to that value.
|
||||
unique_dir = f"/tmp/cmux_invalid_pwd_{os.getpid()}"
|
||||
client._send_command(f"report_pwd {unique_dir} --tab={tab_id} --panel={fake_panel}")
|
||||
time.sleep(0.3)
|
||||
state = client.sidebar_state(tab_id)
|
||||
if unique_dir in state:
|
||||
print("FAIL: invalid panel report_pwd leaked into sidebar_state")
|
||||
print(state)
|
||||
return 1
|
||||
|
||||
try:
|
||||
client.close_tab(tab_id)
|
||||
except cmuxError:
|
||||
pass
|
||||
|
||||
print("PASS: invalid panel reports do not pollute sidebar metadata")
|
||||
return 0
|
||||
|
||||
except (cmuxError, RuntimeError, ValueError) as e:
|
||||
print(f"FAIL: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
306
tests/test_sidebar_ports.py
Normal file
306
tests/test_sidebar_ports.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end test for sidebar listening ports auto-detection.
|
||||
|
||||
This covers regressions where a listening server (e.g. `python3 -m http.server`)
|
||||
doesn't show up in the sidebar ports row.
|
||||
|
||||
Run with a tagged instance to avoid unix socket conflicts:
|
||||
CMUX_TAG=<tag> python3 tests/test_sidebar_ports.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Add the directory containing cmux.py to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cmux import cmux, cmuxError # noqa: E402
|
||||
|
||||
|
||||
# Historically, ports detection only checked a small allowlist. This test
|
||||
# intentionally uses a port outside that set to avoid regressions where ports
|
||||
# "work" only for the allowlist.
|
||||
_HISTORICAL_ALLOWLIST = {8000, 8080, 8888, 5173, 3000, 3001, 5000, 5432}
|
||||
_PREFERRED_BIND_HOST = "127.0.0.1"
|
||||
|
||||
|
||||
def _parse_sidebar_state(text: str) -> dict[str, str]:
|
||||
data: dict[str, str] = {}
|
||||
for raw in (text or "").splitlines():
|
||||
line = raw.rstrip("\n")
|
||||
if not line or line.startswith(" "):
|
||||
continue
|
||||
if "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip()
|
||||
return data
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout: float, interval: float, label: str):
|
||||
start = time.time()
|
||||
last_error: Exception | None = None
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
value = predicate()
|
||||
if value:
|
||||
return value
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
time.sleep(interval)
|
||||
if last_error is not None:
|
||||
raise AssertionError(f"Timed out waiting for {label}. Last error: {last_error}")
|
||||
raise AssertionError(f"Timed out waiting for {label}.")
|
||||
|
||||
|
||||
def _find_free_allowed_port() -> int:
|
||||
# Prefer a random ephemeral port to avoid flakiness from well-known ports
|
||||
# being grabbed by background services.
|
||||
for _ in range(50):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((_PREFERRED_BIND_HOST, 0))
|
||||
port = int(s.getsockname()[1])
|
||||
if port not in _HISTORICAL_ALLOWLIST:
|
||||
return port
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise RuntimeError("Failed to find a free test port (outside historical allowlist).")
|
||||
|
||||
|
||||
def _start_external_server(base: Path, port: int) -> subprocess.Popen:
|
||||
"""
|
||||
Start an http.server outside cmuxterm and ensure it is actually listening.
|
||||
Retries are handled by the caller by picking a different port.
|
||||
"""
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "http.server", str(port), "--bind", _PREFERRED_BIND_HOST],
|
||||
cwd=str(base),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
_wait_for_lsof_listen_pid(port, expected_pid=proc.pid, timeout=6.0)
|
||||
return proc
|
||||
|
||||
|
||||
def _wait_for_port(client: cmux, port: int, timeout: float = 18.0) -> dict[str, str]:
|
||||
def pred():
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
raw = state.get("ports", "")
|
||||
if raw == "none" or not raw:
|
||||
return None
|
||||
ports = []
|
||||
for item in raw.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
ports.append(int(item))
|
||||
except ValueError:
|
||||
continue
|
||||
return state if port in ports else None
|
||||
|
||||
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports include {port}")
|
||||
|
||||
|
||||
def _wait_for_port_absent(client: cmux, port: int, timeout: float = 18.0) -> dict[str, str]:
|
||||
def pred():
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
raw = state.get("ports", "")
|
||||
if raw == "none" or not raw:
|
||||
return state
|
||||
ports = []
|
||||
for item in raw.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
ports.append(int(item))
|
||||
except ValueError:
|
||||
continue
|
||||
return state if port not in ports else None
|
||||
|
||||
return _wait_for(pred, timeout=timeout, interval=0.15, label=f"ports do not include {port}")
|
||||
|
||||
|
||||
def _assert_port_absent_for_duration(client: cmux, port: int, duration: float = 6.0, interval: float = 0.15) -> None:
|
||||
"""
|
||||
Assert the port does not appear in sidebar_state during the full duration.
|
||||
This is important to catch "machine-wide ports" leaking into a fresh tab.
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < duration:
|
||||
state = _parse_sidebar_state(client.sidebar_state())
|
||||
raw = state.get("ports", "")
|
||||
if raw and raw != "none":
|
||||
try:
|
||||
ports = {int(p.strip()) for p in raw.split(",") if p.strip()}
|
||||
except ValueError:
|
||||
ports = set()
|
||||
if port in ports:
|
||||
raise AssertionError(f"Port {port} unexpectedly appeared in sidebar ports: {raw}")
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def _wait_for_lsof_listen_pid(port: int, expected_pid: int | None, timeout: float = 8.0) -> int:
|
||||
"""
|
||||
Wait until `lsof -iTCP:<port> -sTCP:LISTEN` returns a pid.
|
||||
If expected_pid is provided, require that pid to be present.
|
||||
"""
|
||||
|
||||
def pred():
|
||||
result = subprocess.run(
|
||||
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
pids = []
|
||||
for line in (result.stdout or "").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
pids.append(int(line))
|
||||
except ValueError:
|
||||
continue
|
||||
if not pids:
|
||||
return None
|
||||
if expected_pid is not None and expected_pid not in pids:
|
||||
return None
|
||||
return expected_pid if expected_pid is not None else pids[0]
|
||||
|
||||
value = _wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof LISTEN pid for {port}")
|
||||
return int(value)
|
||||
|
||||
|
||||
def _wait_for_lsof_listen_gone(port: int, timeout: float = 8.0) -> None:
|
||||
def pred():
|
||||
result = subprocess.run(
|
||||
["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode != 0 or not (result.stdout or "").strip()
|
||||
|
||||
_wait_for(pred, timeout=timeout, interval=0.15, label=f"lsof no LISTEN for {port}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
tag = os.environ.get("CMUX_TAG") or os.environ.get("CMUXTERM_TAG") or ""
|
||||
if not tag:
|
||||
print("Tip: set CMUX_TAG=<tag> when running this test to avoid socket conflicts.")
|
||||
|
||||
base = Path("/tmp") / f"cmux_ports_test_{os.getpid()}"
|
||||
pid_file = base / "server.pid"
|
||||
log_file = base / "server.log"
|
||||
external_proc: subprocess.Popen | None = None
|
||||
|
||||
try:
|
||||
if base.exists():
|
||||
shutil.rmtree(base)
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Start a listening server outside cmuxterm. A fresh tab should NOT show this port,
|
||||
# since ports should be attributed to the shell session in the tab.
|
||||
port = None
|
||||
last_start_err: Exception | None = None
|
||||
for _ in range(8):
|
||||
try:
|
||||
port = _find_free_allowed_port()
|
||||
external_proc = _start_external_server(base, port)
|
||||
break
|
||||
except Exception as e:
|
||||
last_start_err = e
|
||||
if external_proc is not None:
|
||||
try:
|
||||
external_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
external_proc = None
|
||||
continue
|
||||
if port is None or external_proc is None:
|
||||
raise RuntimeError(f"Failed to start external http.server. Last error: {last_start_err}")
|
||||
|
||||
with cmux() as client:
|
||||
new_tab_id = client.new_tab()
|
||||
client.select_tab(new_tab_id)
|
||||
time.sleep(0.8)
|
||||
|
||||
# Trigger a prompt cycle (and thus a ports scan burst) before checking absence.
|
||||
client.send("echo cmux_ports_test\n")
|
||||
_assert_port_absent_for_duration(client, port, duration=6.0)
|
||||
|
||||
# Stop the external server, then reuse the port inside the tab.
|
||||
external_proc.terminate()
|
||||
try:
|
||||
external_proc.wait(timeout=3.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
external_proc.kill()
|
||||
external_proc = None
|
||||
_wait_for_lsof_listen_gone(port, timeout=8.0)
|
||||
|
||||
# Start a server in the background and capture its PID so we can clean up.
|
||||
client.send(f"rm -f {pid_file} {log_file}\n")
|
||||
client.send(
|
||||
f"python3 -m http.server {port} --bind {_PREFERRED_BIND_HOST} > {log_file} 2>&1 & echo $! > {pid_file}\n"
|
||||
)
|
||||
|
||||
_wait_for(lambda: pid_file.exists(), timeout=4.0, interval=0.1, label="pid file")
|
||||
pid = int(pid_file.read_text(encoding="utf-8").strip())
|
||||
|
||||
# Ensure the server is actually listening (sanity check + reduces flakiness).
|
||||
_wait_for_lsof_listen_pid(port, expected_pid=pid, timeout=8.0)
|
||||
|
||||
# Wait for the sidebar to report the port.
|
||||
_wait_for_port(client, port, timeout=18.0)
|
||||
|
||||
# Cleanup server.
|
||||
client.send(f"kill {pid} >/dev/null 2>&1 || true\n")
|
||||
|
||||
_wait_for_lsof_listen_gone(port, timeout=8.0)
|
||||
_wait_for_port_absent(client, port, timeout=18.0)
|
||||
|
||||
try:
|
||||
client.close_tab(new_tab_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("Sidebar ports test passed.")
|
||||
return 0
|
||||
|
||||
except (cmuxError, AssertionError, RuntimeError, ValueError) as e:
|
||||
print(f"Sidebar ports test failed: {e}")
|
||||
return 1
|
||||
finally:
|
||||
if external_proc is not None:
|
||||
try:
|
||||
external_proc.terminate()
|
||||
external_proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
external_proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
shutil.rmtree(base)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue