From 06cb63cc8d62618b4e18e3a6c89ad0482b6bbf34 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Mon, 9 Mar 2026 18:03:21 -0700
Subject: [PATCH 01/29] Add regression test for sidebar PR polling
---
tests/test_issue_1138_sidebar_pr_polling.py | 214 ++++++++++++++++++++
1 file changed, 214 insertions(+)
create mode 100644 tests/test_issue_1138_sidebar_pr_polling.py
diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py
new file mode 100644
index 00000000..78cd36e5
--- /dev/null
+++ b/tests/test_issue_1138_sidebar_pr_polling.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""
+Regression for issue #1138:
+sidebar PR badges should recover from a transient gh failure without waiting
+for another prompt, and a failed gh probe should not eagerly clear PR state.
+"""
+
+from __future__ import annotations
+
+import os
+import shutil
+import socket
+import subprocess
+import textwrap
+from pathlib import Path
+
+
+class BoundUnixSocket:
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self.sock: socket.socket | None = None
+
+ def __enter__(self) -> "BoundUnixSocket":
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ self.sock.bind(str(self.path))
+ self.sock.listen(1)
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ if self.sock is not None:
+ self.sock.close()
+ try:
+ self.path.unlink()
+ except FileNotFoundError:
+ pass
+
+
+def _write_executable(path: Path, contents: str) -> None:
+ path.write_text(contents, encoding="utf-8")
+ path.chmod(0o755)
+
+
+def _git_stub() -> str:
+ return textwrap.dedent(
+ """\
+ #!/bin/sh
+ if [ "$1" = "-C" ]; then
+ shift
+ shift
+ fi
+
+ if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then
+ printf '%s\\n' 'feature/issue-1138'
+ exit 0
+ fi
+
+ if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then
+ exit 0
+ fi
+
+ printf 'unexpected git args: %s\\n' "$*" >&2
+ exit 1
+ """
+ )
+
+
+def _gh_stub() -> str:
+ return textwrap.dedent(
+ """\
+ #!/bin/sh
+ count_file="${CMUX_TEST_GH_COUNT_FILE:?}"
+ count=0
+ if [ -f "$count_file" ]; then
+ count="$(cat "$count_file")"
+ fi
+ count=$((count + 1))
+ printf '%s\\n' "$count" > "$count_file"
+
+ if [ "$count" -eq 1 ]; then
+ printf 'rate limit exceeded\\n' >&2
+ exit 1
+ fi
+
+ printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
+ """
+ )
+
+
+def _shell_command(kind: str) -> str:
+ if kind == "zsh":
+ return textwrap.dedent(
+ """\
+ source "$CMUX_TEST_SCRIPT"
+ _cmux_send() { print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; }
+ cd "$CMUX_TEST_REPO"
+ _CMUX_PR_POLL_INTERVAL=1
+ _cmux_precmd
+ sleep 3
+ _cmux_zshexit
+ """
+ )
+
+ if kind == "bash":
+ return textwrap.dedent(
+ """\
+ source "$CMUX_TEST_SCRIPT"
+ _cmux_send() { printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }
+ cd "$CMUX_TEST_REPO"
+ _CMUX_PR_POLL_INTERVAL=1
+ _cmux_prompt_command
+ sleep 3
+ type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup
+ """
+ )
+
+ raise ValueError(f"Unsupported shell kind: {kind}")
+
+
+def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path) -> tuple[int, str]:
+ bindir = base / "bin"
+ repo = base / "repo"
+ repo_git = repo / ".git"
+ socket_path = base / "cmux.sock"
+ send_log = base / f"{shell}-send.log"
+ gh_count_file = base / f"{shell}-gh-count.txt"
+
+ bindir.mkdir(parents=True, exist_ok=True)
+ repo_git.mkdir(parents=True, exist_ok=True)
+ (repo_git / "HEAD").write_text("ref: refs/heads/feature/issue-1138\n", encoding="utf-8")
+ _write_executable(bindir / "git", _git_stub())
+ _write_executable(bindir / "gh", _gh_stub())
+
+ env = dict(os.environ)
+ env["PATH"] = f"{bindir}:{env.get('PATH', '')}"
+ env["CMUX_SOCKET_PATH"] = str(socket_path)
+ env["CMUX_TAB_ID"] = "00000000-0000-0000-0000-000000000001"
+ env["CMUX_PANEL_ID"] = "00000000-0000-0000-0000-000000000002"
+ env["CMUX_TEST_SCRIPT"] = str(script)
+ env["CMUX_TEST_REPO"] = str(repo)
+ env["CMUX_TEST_SEND_LOG"] = str(send_log)
+ env["CMUX_TEST_GH_COUNT_FILE"] = str(gh_count_file)
+
+ with BoundUnixSocket(socket_path):
+ result = subprocess.run(
+ [shell, *shell_args, _shell_command(shell)],
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+
+ output = (result.stdout or "") + (result.stderr or "")
+ if result.returncode != 0:
+ return (result.returncode, output)
+
+ send_lines = []
+ if send_log.exists():
+ send_lines = [line.strip() for line in send_log.read_text(encoding="utf-8").splitlines() if line.strip()]
+
+ gh_count = 0
+ if gh_count_file.exists():
+ gh_count = int(gh_count_file.read_text(encoding="utf-8").strip() or "0")
+
+ report_line = (
+ "report_pr 1138 https://github.com/manaflow-ai/cmux/pull/1138 "
+ "--state=open --tab=00000000-0000-0000-0000-000000000001 "
+ "--panel=00000000-0000-0000-0000-000000000002"
+ )
+ if gh_count < 2:
+ return (1, f"{shell}: expected at least 2 gh probes while idle, saw {gh_count}")
+ if any(line.startswith("clear_pr ") for line in send_lines):
+ return (1, f"{shell}: transient gh failure should not send clear_pr\n" + "\n".join(send_lines))
+ if report_line not in send_lines:
+ return (1, f"{shell}: expected recovered report_pr payload\n" + "\n".join(send_lines))
+
+ return (0, f"{shell}: ok")
+
+
+def main() -> int:
+ root = Path(__file__).resolve().parents[1]
+ cases = [
+ ("zsh", ["-f", "-c"], root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"),
+ ("bash", ["--noprofile", "--norc", "-c"], root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"),
+ ]
+
+ base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}"
+ try:
+ shutil.rmtree(base, ignore_errors=True)
+ base.mkdir(parents=True, exist_ok=True)
+
+ failures: list[str] = []
+ for shell, shell_args, script in cases:
+ if not script.exists():
+ print(f"SKIP: missing integration script at {script}")
+ continue
+ rc, detail = _run_case(base / shell, shell=shell, shell_args=shell_args, script=script)
+ if rc != 0:
+ failures.append(detail)
+
+ if failures:
+ print("FAIL:")
+ for failure in failures:
+ print(failure)
+ return 1
+
+ print("PASS: shell integrations keep polling PR state and recover after transient gh failures")
+ return 0
+ finally:
+ shutil.rmtree(base, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 844d85fe80bd8c72764ffd49fa5f8b8b8d77ea0a Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Mon, 9 Mar 2026 18:06:30 -0700
Subject: [PATCH 02/29] Poll sidebar PR status while shells are idle
---
.../cmux-bash-integration.bash | 207 +++++++++++++-----
.../cmux-zsh-integration.zsh | 180 +++++++++------
2 files changed, 261 insertions(+), 126 deletions(-)
diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash
index 643fc841..2395b7f9 100644
--- a/Resources/shell-integration/cmux-bash-integration.bash
+++ b/Resources/shell-integration/cmux-bash-integration.bash
@@ -44,11 +44,14 @@ _CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}"
_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}"
_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}"
_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}"
-_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
-_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
-_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
-_CMUX_PR_JOB_STARTED_AT="${_CMUX_PR_JOB_STARTED_AT:-0}"
+_CMUX_PR_POLL_PID="${_CMUX_PR_POLL_PID:-}"
+_CMUX_PR_POLL_PWD="${_CMUX_PR_POLL_PWD:-}"
+_CMUX_PR_POLL_INTERVAL="${_CMUX_PR_POLL_INTERVAL:-45}"
+_CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}"
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
+_CMUX_PREEXEC_READY="${_CMUX_PREEXEC_READY:-0}"
+_CMUX_IN_PROMPT_COMMAND="${_CMUX_IN_PROMPT_COMMAND:-0}"
+_CMUX_DEBUG_TRAP_INSTALLED="${_CMUX_DEBUG_TRAP_INSTALLED:-0}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
@@ -115,10 +118,136 @@ _cmux_ports_kick() {
} >/dev/null 2>&1 & disown
}
+_cmux_clear_pr_for_panel() {
+ [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
+ [[ -n "$CMUX_TAB_ID" ]] || return 0
+ [[ -n "$CMUX_PANEL_ID" ]] || return 0
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+}
+
+_cmux_report_pr_for_path() {
+ local repo_path="$1"
+ [[ -n "$repo_path" ]] || {
+ _cmux_clear_pr_for_panel
+ return 0
+ }
+ [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
+ [[ -n "$CMUX_TAB_ID" ]] || return 0
+ [[ -n "$CMUX_PANEL_ID" ]] || return 0
+
+ local branch pr_tsv gh_status number state url status_opt=""
+ branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
+ if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+
+ pr_tsv="$(
+ builtin cd "$repo_path" 2>/dev/null \
+ && gh pr list \
+ --head "$branch" \
+ --state all \
+ --json number,state,url,updatedAt \
+ --jq 'if length == 0 then "" else (sort_by(.updatedAt) | last | [.number, .state, .url] | @tsv) end' \
+ 2>/dev/null
+ )"
+ gh_status=$?
+ if (( gh_status != 0 )); then
+ # Preserve the last-known PR badge when gh fails transiently, then retry
+ # on the next background poll instead of clearing visible state.
+ return 1
+ fi
+ if [[ -z "$pr_tsv" ]]; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+
+ IFS=$'\t' read -r number state url <<< "$pr_tsv"
+ if [[ -z "$number" || -z "$url" ]]; then
+ return 1
+ fi
+
+ case "$state" in
+ MERGED) status_opt="--state=merged" ;;
+ OPEN) status_opt="--state=open" ;;
+ CLOSED) status_opt="--state=closed" ;;
+ *) return 1 ;;
+ esac
+
+ _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+}
+
+_cmux_stop_pr_poll_loop() {
+ if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
+ kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true
+ _CMUX_PR_POLL_PID=""
+ fi
+}
+
+_cmux_start_pr_poll_loop() {
+ [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
+ [[ -n "$CMUX_TAB_ID" ]] || return 0
+ [[ -n "$CMUX_PANEL_ID" ]] || return 0
+
+ local watch_pwd="${1:-$PWD}"
+ local force_restart="${2:-0}"
+ local watch_shell_pid="$$"
+ local interval="${_CMUX_PR_POLL_INTERVAL:-45}"
+
+ if [[ "$force_restart" != "1" && "$watch_pwd" == "$_CMUX_PR_POLL_PWD" && -n "$_CMUX_PR_POLL_PID" ]] \
+ && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then
+ return 0
+ fi
+
+ _cmux_stop_pr_poll_loop
+ _CMUX_PR_POLL_PWD="$watch_pwd"
+
+ {
+ while :; do
+ kill -0 "$watch_shell_pid" 2>/dev/null || break
+ _cmux_report_pr_for_path "$watch_pwd" || true
+ sleep "$interval"
+ done
+ } >/dev/null 2>&1 &
+ _CMUX_PR_POLL_PID=$!
+ disown "$_CMUX_PR_POLL_PID" 2>/dev/null || disown
+}
+
+_cmux_bash_cleanup() {
+ _cmux_stop_pr_poll_loop
+}
+
+_cmux_preexec_trap() {
+ (( _CMUX_IN_PROMPT_COMMAND )) && return 0
+ (( _CMUX_PREEXEC_READY )) || return 0
+ _CMUX_PREEXEC_READY=0
+
+ local cmd="${BASH_COMMAND## }"
+ case "$cmd" in
+ git\ *|git|gh\ *|gh|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
+ _CMUX_PR_FORCE=1
+ ;;
+ esac
+}
+
+_cmux_install_debug_trap() {
+ (( _CMUX_DEBUG_TRAP_INSTALLED )) && return 0
+
+ local existing
+ existing="$(trap -p DEBUG 2>/dev/null || true)"
+ if [[ -n "$existing" ]]; then
+ return 0
+ fi
+
+ trap '_cmux_preexec_trap' DEBUG
+ _CMUX_DEBUG_TRAP_INSTALLED=1
+}
+
_cmux_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
+ _CMUX_IN_PROMPT_COMMAND=1
local now=$SECONDS
local pwd="$PWD"
@@ -135,16 +264,6 @@ _cmux_prompt_command() {
fi
fi
- if [[ -n "$_CMUX_PR_JOB_PID" ]]; then
- if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
- _CMUX_PR_JOB_PID=""
- _CMUX_PR_JOB_STARTED_AT=0
- elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
- _CMUX_PR_JOB_PID=""
- _CMUX_PR_JOB_STARTED_AT=0
- fi
- fi
-
# Resolve TTY name once.
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
@@ -178,8 +297,8 @@ _cmux_prompt_command() {
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
- # Also invalidate the PR probe so it refreshes with the new branch.
- _CMUX_PR_LAST_RUN=0
+ # Also invalidate the PR poller so it refreshes with the new branch.
+ _CMUX_PR_FORCE=1
fi
fi
@@ -215,49 +334,20 @@ _cmux_prompt_command() {
_CMUX_GIT_JOB_STARTED_AT=$now
fi
- # Pull request metadata (number/state/url):
- # refresh on cwd change, HEAD change, and periodically to avoid stale status.
- if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
- if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then
- kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
- _CMUX_PR_JOB_PID=""
- _CMUX_PR_JOB_STARTED_AT=0
- fi
+ # Pull request metadata is remote state. Keep polling while the shell sits
+ # at a prompt so newly created or merged PRs appear without another command.
+ local should_restart_pr_poll=0
+ if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" || "$git_head_changed" == "1" ]]; then
+ should_restart_pr_poll=1
+ elif (( _CMUX_PR_FORCE )); then
+ should_restart_pr_poll=1
+ elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then
+ should_restart_pr_poll=1
fi
- if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
- if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
- _CMUX_PR_LAST_PWD="$pwd"
- _CMUX_PR_LAST_RUN=$now
- {
- local branch pr_tsv number state url status_opt=""
- branch=$(git branch --show-current 2>/dev/null)
- if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
- _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- else
- pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
- if [[ -z "$pr_tsv" ]]; then
- _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- else
- IFS=$'\t' read -r number state url <<< "$pr_tsv"
- if [[ -z "$number" || -z "$url" ]]; then
- _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- else
- case "$state" in
- MERGED) status_opt="--state=merged" ;;
- OPEN) status_opt="--state=open" ;;
- CLOSED) status_opt="--state=closed" ;;
- *) status_opt="" ;;
- esac
- _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- fi
- fi
- fi
- } >/dev/null 2>&1 &
- _CMUX_PR_JOB_PID=$!
- disown
- _CMUX_PR_JOB_STARTED_AT=$now
- fi
+ if (( should_restart_pr_poll )); then
+ _CMUX_PR_FORCE=0
+ _cmux_start_pr_poll_loop "$pwd" 1
fi
# Ports: lightweight kick to the app's batched scanner every ~10s.
@@ -265,6 +355,8 @@ _cmux_prompt_command() {
_cmux_ports_kick
fi
+ _CMUX_IN_PROMPT_COMMAND=0
+ _CMUX_PREEXEC_READY=1
}
_cmux_install_prompt_command() {
@@ -317,3 +409,4 @@ _cmux_fix_path
unset -f _cmux_fix_path
_cmux_install_prompt_command
+_cmux_install_debug_trap
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index f35814bc..763d14e9 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -47,10 +47,9 @@ typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
typeset -g _CMUX_GIT_HEAD_PATH=""
typeset -g _CMUX_GIT_HEAD_SIGNATURE=""
typeset -g _CMUX_GIT_HEAD_WATCH_PID=""
-typeset -g _CMUX_PR_LAST_PWD=""
-typeset -g _CMUX_PR_LAST_RUN=0
-typeset -g _CMUX_PR_JOB_PID=""
-typeset -g _CMUX_PR_JOB_STARTED_AT=0
+typeset -g _CMUX_PR_POLL_PID=""
+typeset -g _CMUX_PR_POLL_PWD=""
+typeset -g _CMUX_PR_POLL_INTERVAL=45
typeset -g _CMUX_PR_FORCE=0
typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
@@ -141,6 +140,101 @@ _cmux_report_git_branch_for_path() {
fi
}
+_cmux_clear_pr_for_panel() {
+ [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
+ [[ -n "$CMUX_TAB_ID" ]] || return 0
+ [[ -n "$CMUX_PANEL_ID" ]] || return 0
+ _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+}
+
+_cmux_report_pr_for_path() {
+ local repo_path="$1"
+ [[ -n "$repo_path" ]] || {
+ _cmux_clear_pr_for_panel
+ return 0
+ }
+ [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
+ [[ -n "$CMUX_TAB_ID" ]] || return 0
+ [[ -n "$CMUX_PANEL_ID" ]] || return 0
+
+ local branch pr_tsv number state url status_opt="" gh_status
+ branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
+ if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+
+ pr_tsv="$(
+ builtin cd "$repo_path" 2>/dev/null \
+ && gh pr list \
+ --head "$branch" \
+ --state all \
+ --json number,state,url,updatedAt \
+ --jq 'if length == 0 then "" else (sort_by(.updatedAt) | last | [.number, .state, .url] | @tsv) end' \
+ 2>/dev/null
+ )"
+ gh_status=$?
+ if (( gh_status != 0 )); then
+ # Keep the last-known PR badge on transient gh failures (auth hiccups,
+ # API lag after creation, or rate limiting) and retry on the next poll.
+ return 1
+ fi
+ if [[ -z "$pr_tsv" ]]; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+
+ local IFS=$'\t'
+ read -r number state url <<< "$pr_tsv"
+ if [[ -z "$number" ]] || [[ -z "$url" ]]; then
+ return 1
+ fi
+
+ case "$state" in
+ MERGED) status_opt="--state=merged" ;;
+ OPEN) status_opt="--state=open" ;;
+ CLOSED) status_opt="--state=closed" ;;
+ *) return 1 ;;
+ esac
+
+ _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
+}
+
+_cmux_stop_pr_poll_loop() {
+ if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
+ kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true
+ _CMUX_PR_POLL_PID=""
+ fi
+}
+
+_cmux_start_pr_poll_loop() {
+ [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
+ [[ -n "$CMUX_TAB_ID" ]] || return 0
+ [[ -n "$CMUX_PANEL_ID" ]] || return 0
+
+ local watch_pwd="${1:-$PWD}"
+ local force_restart="${2:-0}"
+ local watch_shell_pid="$$"
+ local interval="${_CMUX_PR_POLL_INTERVAL:-45}"
+
+ if [[ "$force_restart" != "1" && "$watch_pwd" == "$_CMUX_PR_POLL_PWD" && -n "$_CMUX_PR_POLL_PID" ]] \
+ && kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then
+ return 0
+ fi
+
+ _cmux_stop_pr_poll_loop
+ _CMUX_PR_POLL_PWD="$watch_pwd"
+
+ {
+ while true; do
+ kill -0 "$watch_shell_pid" >/dev/null 2>&1 || break
+ _cmux_report_pr_for_path "$watch_pwd" || true
+ sleep "$interval"
+ done
+ } >/dev/null 2>&1 &!
+ _CMUX_PR_POLL_PID=$!
+}
+
_cmux_stop_git_head_watch() {
if [[ -n "$_CMUX_GIT_HEAD_WATCH_PID" ]]; then
kill "$_CMUX_GIT_HEAD_WATCH_PID" >/dev/null 2>&1 || true
@@ -241,17 +335,6 @@ _cmux_precmd() {
fi
fi
- if [[ -n "$_CMUX_PR_JOB_PID" ]]; then
- if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
- _CMUX_PR_JOB_PID=""
- _CMUX_PR_JOB_STARTED_AT=0
- elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
- _CMUX_PR_JOB_PID=""
- _CMUX_PR_JOB_STARTED_AT=0
- _CMUX_PR_FORCE=1
- fi
- fi
-
# 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
@@ -326,63 +409,21 @@ _cmux_precmd() {
fi
fi
- # Pull request metadata (number/state/url):
- # - refresh on cwd change, explicit git/gh commands, and occasionally for status drift
- # - keep this independent from the git probe cadence to avoid hitting GitHub too often
- local should_pr=0
- if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
- should_pr=1
+ # Pull request metadata is remote state. Keep a lightweight background poll
+ # alive while the shell is idle so gh-created PRs and merge status changes
+ # appear even without another prompt.
+ local should_restart_pr_poll=0
+ if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then
+ should_restart_pr_poll=1
elif (( _CMUX_PR_FORCE )); then
- should_pr=1
- elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then
- should_pr=1
+ should_restart_pr_poll=1
+ elif [[ -z "$_CMUX_PR_POLL_PID" ]] || ! kill -0 "$_CMUX_PR_POLL_PID" 2>/dev/null; then
+ should_restart_pr_poll=1
fi
- if (( should_pr )); then
- local can_launch_pr=1
- if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
- if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then
- kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
- _CMUX_PR_JOB_PID=""
- _CMUX_PR_JOB_STARTED_AT=0
- else
- can_launch_pr=0
- fi
- fi
-
- if (( can_launch_pr )); then
- _CMUX_PR_FORCE=0
- _CMUX_PR_LAST_PWD="$pwd"
- _CMUX_PR_LAST_RUN=$now
- {
- local branch pr_tsv number state url status_opt=""
- branch=$(git branch --show-current 2>/dev/null)
- if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
- _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- else
- pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
- if [[ -z "$pr_tsv" ]]; then
- _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- else
- local IFS=$'\t'
- read -r number state url <<< "$pr_tsv"
- if [[ -z "$number" ]] || [[ -z "$url" ]]; then
- _cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- else
- case "$state" in
- MERGED) status_opt="--state=merged" ;;
- OPEN) status_opt="--state=open" ;;
- CLOSED) status_opt="--state=closed" ;;
- *) status_opt="" ;;
- esac
- _cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
- fi
- fi
- fi
- } >/dev/null 2>&1 &!
- _CMUX_PR_JOB_PID=$!
- _CMUX_PR_JOB_STARTED_AT=$now
- fi
+ if (( should_restart_pr_poll )); then
+ _CMUX_PR_FORCE=0
+ _cmux_start_pr_poll_loop "$pwd" 1
fi
# Ports: lightweight kick to the app's batched scanner.
@@ -419,6 +460,7 @@ _cmux_fix_path() {
_cmux_zshexit() {
_cmux_stop_git_head_watch
+ _cmux_stop_pr_poll_loop
}
autoload -Uz add-zsh-hook
From 0ad9775121a8aa207ec0a54d45c5b5f77ceaf6c3 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Mon, 9 Mar 2026 18:25:47 -0700
Subject: [PATCH 03/29] Harden sidebar PR polling against stale state
---
.../cmux-bash-integration.bash | 84 +++++-
.../cmux-zsh-integration.zsh | 85 +++++-
tests/test_issue_1138_sidebar_pr_polling.py | 253 ++++++++++++++----
3 files changed, 345 insertions(+), 77 deletions(-)
diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash
index 2395b7f9..cbb3f103 100644
--- a/Resources/shell-integration/cmux-bash-integration.bash
+++ b/Resources/shell-integration/cmux-bash-integration.bash
@@ -125,44 +125,65 @@ _cmux_clear_pr_for_panel() {
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
+_cmux_pr_output_indicates_no_pull_request() {
+ local output="$1"
+ output="$(printf '%s' "$output" | tr '[:upper:]' '[:lower:]')"
+ [[ "$output" == *"no pull requests found"* \
+ || "$output" == *"no pull request found"* \
+ || "$output" == *"no pull requests associated"* \
+ || "$output" == *"no pull request associated"* ]]
+}
+
_cmux_report_pr_for_path() {
local repo_path="$1"
[[ -n "$repo_path" ]] || {
_cmux_clear_pr_for_panel
return 0
}
+ [[ -d "$repo_path" ]] || {
+ _cmux_clear_pr_for_panel
+ return 0
+ }
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
- local branch pr_tsv gh_status number state url status_opt=""
+ local branch gh_output gh_error="" err_file="" gh_status number state url status_opt=""
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_clear_pr_for_panel
return 0
fi
- pr_tsv="$(
+ err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
+ [[ -n "$err_file" ]] || return 1
+ gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
- && gh pr list \
- --head "$branch" \
- --state all \
- --json number,state,url,updatedAt \
- --jq 'if length == 0 then "" else (sort_by(.updatedAt) | last | [.number, .state, .url] | @tsv) end' \
- 2>/dev/null
+ && gh pr view \
+ --json number,state,url \
+ --jq '[.number, .state, .url] | @tsv' \
+ 2>"$err_file"
)"
gh_status=$?
+ if [[ -f "$err_file" ]]; then
+ gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
+ /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
+ fi
if (( gh_status != 0 )); then
+ if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
# Preserve the last-known PR badge when gh fails transiently, then retry
# on the next background poll instead of clearing visible state.
return 1
fi
- if [[ -z "$pr_tsv" ]]; then
+ if [[ -z "$gh_output" ]]; then
_cmux_clear_pr_for_panel
return 0
fi
- IFS=$'\t' read -r number state url <<< "$pr_tsv"
+ IFS=$'\t' read -r number state url <<< "$gh_output"
if [[ -z "$number" || -z "$url" ]]; then
return 1
fi
@@ -177,6 +198,37 @@ _cmux_report_pr_for_path() {
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
+_cmux_run_pr_probe_with_timeout() {
+ local repo_path="$1"
+ local probe_pid=""
+ local started_at=$SECONDS
+ local now=$started_at
+
+ (
+ _cmux_report_pr_for_path "$repo_path"
+ ) &
+ probe_pid=$!
+
+ while kill -0 "$probe_pid" >/dev/null 2>&1; do
+ sleep 1
+ now=$SECONDS
+ if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then
+ kill "$probe_pid" >/dev/null 2>&1 || true
+ sleep 0.2
+ if kill -0 "$probe_pid" >/dev/null 2>&1; then
+ kill -9 "$probe_pid" >/dev/null 2>&1 || true
+ sleep 0.2
+ fi
+ if ! kill -0 "$probe_pid" >/dev/null 2>&1; then
+ wait "$probe_pid" >/dev/null 2>&1 || true
+ fi
+ return 1
+ fi
+ done
+
+ wait "$probe_pid"
+}
+
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true
@@ -205,7 +257,7 @@ _cmux_start_pr_poll_loop() {
{
while :; do
kill -0 "$watch_shell_pid" 2>/dev/null || break
- _cmux_report_pr_for_path "$watch_pwd" || true
+ _cmux_run_pr_probe_with_timeout "$watch_pwd" || true
sleep "$interval"
done
} >/dev/null 2>&1 &
@@ -221,6 +273,7 @@ _cmux_preexec_trap() {
(( _CMUX_IN_PROMPT_COMMAND )) && return 0
(( _CMUX_PREEXEC_READY )) || return 0
_CMUX_PREEXEC_READY=0
+ _cmux_stop_pr_poll_loop
local cmd="${BASH_COMMAND## }"
case "$cmd" in
@@ -337,6 +390,12 @@ _cmux_prompt_command() {
# Pull request metadata is remote state. Keep polling while the shell sits
# at a prompt so newly created or merged PRs appear without another command.
local should_restart_pr_poll=0
+ local pr_context_changed=0
+ if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then
+ pr_context_changed=1
+ elif [[ "$git_head_changed" == "1" ]]; then
+ pr_context_changed=1
+ fi
if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" || "$git_head_changed" == "1" ]]; then
should_restart_pr_poll=1
elif (( _CMUX_PR_FORCE )); then
@@ -347,6 +406,9 @@ _cmux_prompt_command() {
if (( should_restart_pr_poll )); then
_CMUX_PR_FORCE=0
+ if (( pr_context_changed )); then
+ _cmux_clear_pr_for_panel
+ fi
_cmux_start_pr_poll_loop "$pwd" 1
fi
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index 763d14e9..b6b82c88 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -147,45 +147,65 @@ _cmux_clear_pr_for_panel() {
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
+_cmux_pr_output_indicates_no_pull_request() {
+ local output="${1:l}"
+ [[ "$output" == *"no pull requests found"* \
+ || "$output" == *"no pull request found"* \
+ || "$output" == *"no pull requests associated"* \
+ || "$output" == *"no pull request associated"* ]]
+}
+
_cmux_report_pr_for_path() {
local repo_path="$1"
[[ -n "$repo_path" ]] || {
_cmux_clear_pr_for_panel
return 0
}
+ [[ -d "$repo_path" ]] || {
+ _cmux_clear_pr_for_panel
+ return 0
+ }
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
- local branch pr_tsv number state url status_opt="" gh_status
+ local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_clear_pr_for_panel
return 0
fi
- pr_tsv="$(
+ err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
+ [[ -n "$err_file" ]] || return 1
+ gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
- && gh pr list \
- --head "$branch" \
- --state all \
- --json number,state,url,updatedAt \
- --jq 'if length == 0 then "" else (sort_by(.updatedAt) | last | [.number, .state, .url] | @tsv) end' \
- 2>/dev/null
+ && gh pr view \
+ --json number,state,url \
+ --jq '[.number, .state, .url] | @tsv' \
+ 2>"$err_file"
)"
gh_status=$?
+ if [[ -f "$err_file" ]]; then
+ gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
+ /bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
+ fi
if (( gh_status != 0 )); then
+ if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
# Keep the last-known PR badge on transient gh failures (auth hiccups,
# API lag after creation, or rate limiting) and retry on the next poll.
return 1
fi
- if [[ -z "$pr_tsv" ]]; then
+ if [[ -z "$gh_output" ]]; then
_cmux_clear_pr_for_panel
return 0
fi
local IFS=$'\t'
- read -r number state url <<< "$pr_tsv"
+ read -r number state url <<< "$gh_output"
if [[ -z "$number" ]] || [[ -z "$url" ]]; then
return 1
fi
@@ -200,6 +220,37 @@ _cmux_report_pr_for_path() {
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
+_cmux_run_pr_probe_with_timeout() {
+ local repo_path="$1"
+ local probe_pid=""
+ local started_at=$EPOCHSECONDS
+ local now=$started_at
+
+ (
+ _cmux_report_pr_for_path "$repo_path"
+ ) &
+ probe_pid=$!
+
+ while kill -0 "$probe_pid" >/dev/null 2>&1; do
+ sleep 1
+ now=$EPOCHSECONDS
+ if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then
+ kill "$probe_pid" >/dev/null 2>&1 || true
+ sleep 0.2
+ if kill -0 "$probe_pid" >/dev/null 2>&1; then
+ kill -9 "$probe_pid" >/dev/null 2>&1 || true
+ sleep 0.2
+ fi
+ if ! kill -0 "$probe_pid" >/dev/null 2>&1; then
+ wait "$probe_pid" >/dev/null 2>&1 || true
+ fi
+ return 1
+ fi
+ done
+
+ wait "$probe_pid"
+}
+
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true
@@ -228,7 +279,7 @@ _cmux_start_pr_poll_loop() {
{
while true; do
kill -0 "$watch_shell_pid" >/dev/null 2>&1 || break
- _cmux_report_pr_for_path "$watch_pwd" || true
+ _cmux_run_pr_probe_with_timeout "$watch_pwd" || true
sleep "$interval"
done
} >/dev/null 2>&1 &!
@@ -297,6 +348,7 @@ _cmux_preexec() {
# Register TTY + kick batched port scan for foreground commands (servers).
_cmux_report_tty_once
_cmux_ports_kick
+ _cmux_stop_pr_poll_loop
_cmux_start_git_head_watch
}
@@ -350,6 +402,7 @@ _cmux_precmd() {
# While a foreground command is running, _cmux_start_git_head_watch probes HEAD
# once per second so agent-initiated git checkouts still surface quickly.
local should_git=0
+ local git_head_changed=0
# Git branch can change without a `git ...`-prefixed command (aliases like `gco`,
# tools like `gh pr checkout`, etc.). Detect HEAD changes and force a refresh.
@@ -363,6 +416,7 @@ _cmux_precmd() {
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
+ git_head_changed=1
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
@@ -413,6 +467,12 @@ _cmux_precmd() {
# alive while the shell is idle so gh-created PRs and merge status changes
# appear even without another prompt.
local should_restart_pr_poll=0
+ local pr_context_changed=0
+ if [[ -n "$_CMUX_PR_POLL_PWD" && "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then
+ pr_context_changed=1
+ elif (( git_head_changed )); then
+ pr_context_changed=1
+ fi
if [[ "$pwd" != "$_CMUX_PR_POLL_PWD" ]]; then
should_restart_pr_poll=1
elif (( _CMUX_PR_FORCE )); then
@@ -423,6 +483,9 @@ _cmux_precmd() {
if (( should_restart_pr_poll )); then
_CMUX_PR_FORCE=0
+ if (( pr_context_changed )); then
+ _cmux_clear_pr_for_panel
+ fi
_cmux_start_pr_poll_loop "$pwd" 1
fi
diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py
index 78cd36e5..92f48b24 100644
--- a/tests/test_issue_1138_sidebar_pr_polling.py
+++ b/tests/test_issue_1138_sidebar_pr_polling.py
@@ -1,8 +1,13 @@
#!/usr/bin/env python3
"""
-Regression for issue #1138:
-sidebar PR badges should recover from a transient gh failure without waiting
-for another prompt, and a failed gh probe should not eagerly clear PR state.
+Regression coverage for issue #1138.
+
+Validates that shell integration:
+1) keeps polling PR state while idle and recovers after a transient gh failure
+2) resolves the current branch PR via `gh pr view` instead of repository-wide
+ branch-name matching
+3) clears stale PR state when the branch changes and the new probe fails
+4) recovers when a gh probe wedges longer than the async timeout
"""
from __future__ import annotations
@@ -45,13 +50,28 @@ def _git_stub() -> str:
return textwrap.dedent(
"""\
#!/bin/sh
+ repo_path="$PWD"
if [ "$1" = "-C" ]; then
+ repo_path="$2"
shift
shift
fi
+ head_file="$repo_path/.git/HEAD"
+ branch=""
+ if [ -f "$head_file" ]; then
+ head_line="$(cat "$head_file")"
+ case "$head_line" in
+ ref:\ refs/heads/*)
+ branch="${head_line#ref: refs/heads/}"
+ ;;
+ esac
+ fi
+
if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then
- printf '%s\\n' 'feature/issue-1138'
+ if [ -n "$branch" ]; then
+ printf '%s\\n' "$branch"
+ fi
exit 0
fi
@@ -69,7 +89,13 @@ def _gh_stub() -> str:
return textwrap.dedent(
"""\
#!/bin/sh
+ args_log="${CMUX_TEST_GH_ARGS_LOG:?}"
count_file="${CMUX_TEST_GH_COUNT_FILE:?}"
+ scenario="${CMUX_TEST_SCENARIO:?}"
+ head_file="${CMUX_TEST_HEAD_FILE:?}"
+
+ printf '%s\\n' "$*" >> "$args_log"
+
count=0
if [ -f "$count_file" ]; then
count="$(cat "$count_file")"
@@ -77,57 +103,138 @@ def _gh_stub() -> str:
count=$((count + 1))
printf '%s\\n' "$count" > "$count_file"
- if [ "$count" -eq 1 ]; then
- printf 'rate limit exceeded\\n' >&2
- exit 1
+ if [ "$1" != "pr" ] || [ "$2" != "view" ]; then
+ printf 'unexpected gh args: %s\\n' "$*" >&2
+ exit 9
fi
- printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
+ branch=""
+ if [ -f "$head_file" ]; then
+ head_line="$(cat "$head_file")"
+ case "$head_line" in
+ ref:\ refs/heads/*)
+ branch="${head_line#ref: refs/heads/}"
+ ;;
+ esac
+ fi
+
+ case "$scenario" in
+ transient_same_context)
+ if [ "$count" -eq 1 ]; then
+ printf 'rate limit exceeded\\n' >&2
+ exit 1
+ fi
+ printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
+ ;;
+ branch_switch_clear)
+ if [ "$branch" = "feature/old" ]; then
+ printf '111\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/111\\n'
+ exit 0
+ fi
+ if [ "$branch" = "feature/new" ]; then
+ printf 'network unavailable\\n' >&2
+ exit 1
+ fi
+ printf 'no pull requests found for branch "%s"\\n' "$branch" >&2
+ exit 1
+ ;;
+ timeout_recovery)
+ if [ "$count" -eq 1 ]; then
+ sleep "${CMUX_TEST_HANG_SECONDS:-4}"
+ exit 0
+ fi
+ printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
+ ;;
+ *)
+ printf 'unknown scenario: %s\\n' "$scenario" >&2
+ exit 2
+ ;;
+ esac
"""
)
-def _shell_command(kind: str) -> str:
+def _shell_command(kind: str, scenario: str) -> str:
+ shared = {
+ "transient_same_context": (
+ 'cd "$CMUX_TEST_REPO"\n'
+ '_CMUX_PR_POLL_INTERVAL=1\n'
+ '_cmux_prompt_entry\n'
+ 'sleep 3\n'
+ '_cmux_cleanup\n'
+ ),
+ "branch_switch_clear": (
+ 'cd "$CMUX_TEST_REPO"\n'
+ '_CMUX_PR_POLL_INTERVAL=10\n'
+ '_cmux_prompt_entry\n'
+ 'sleep 1\n'
+ 'printf \'ref: refs/heads/feature/new\\n\' > "$CMUX_TEST_HEAD_FILE"\n'
+ '_cmux_prompt_entry\n'
+ 'sleep 2\n'
+ '_cmux_cleanup\n'
+ ),
+ "timeout_recovery": (
+ 'cd "$CMUX_TEST_REPO"\n'
+ '_CMUX_PR_POLL_INTERVAL=1\n'
+ '_CMUX_ASYNC_JOB_TIMEOUT=1\n'
+ '_cmux_prompt_entry\n'
+ 'sleep 4\n'
+ '_cmux_cleanup\n'
+ ),
+ }[scenario]
+
if kind == "zsh":
return textwrap.dedent(
- """\
+ f"""\
source "$CMUX_TEST_SCRIPT"
- _cmux_send() { print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; }
- cd "$CMUX_TEST_REPO"
- _CMUX_PR_POLL_INTERVAL=1
- _cmux_precmd
- sleep 3
- _cmux_zshexit
- """
+ _cmux_send() {{ print -r -- "$1" >> "$CMUX_TEST_SEND_LOG"; }}
+ _cmux_prompt_entry() {{ _cmux_precmd; }}
+ _cmux_cleanup() {{ _cmux_zshexit; }}
+ {shared}"""
)
if kind == "bash":
return textwrap.dedent(
- """\
+ f"""\
source "$CMUX_TEST_SCRIPT"
- _cmux_send() { printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }
- cd "$CMUX_TEST_REPO"
- _CMUX_PR_POLL_INTERVAL=1
- _cmux_prompt_command
- sleep 3
- type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup
- """
+ trap - DEBUG
+ _cmux_send() {{ printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }}
+ _cmux_prompt_entry() {{ _cmux_prompt_command; }}
+ _cmux_cleanup() {{ type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup; }}
+ {shared}"""
)
raise ValueError(f"Unsupported shell kind: {kind}")
-def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path) -> tuple[int, str]:
+def _read_lines(path: Path) -> list[str]:
+ if not path.exists():
+ return []
+ return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
+
+
+def _report_line(number: int) -> str:
+ return (
+ f"report_pr {number} https://github.com/manaflow-ai/cmux/pull/{number} "
+ "--state=open --tab=00000000-0000-0000-0000-000000000001 "
+ "--panel=00000000-0000-0000-0000-000000000002"
+ )
+
+
+def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, scenario: str) -> tuple[int, str]:
bindir = base / "bin"
repo = base / "repo"
repo_git = repo / ".git"
socket_path = base / "cmux.sock"
- send_log = base / f"{shell}-send.log"
- gh_count_file = base / f"{shell}-gh-count.txt"
+ send_log = base / f"{shell}-{scenario}-send.log"
+ gh_count_file = base / f"{shell}-{scenario}-gh-count.txt"
+ gh_args_log = base / f"{shell}-{scenario}-gh-args.log"
+ head_file = repo_git / "HEAD"
bindir.mkdir(parents=True, exist_ok=True)
repo_git.mkdir(parents=True, exist_ok=True)
- (repo_git / "HEAD").write_text("ref: refs/heads/feature/issue-1138\n", encoding="utf-8")
+ initial_branch = "feature/old" if scenario == "branch_switch_clear" else "feature/issue-1138"
+ head_file.write_text(f"ref: refs/heads/{initial_branch}\n", encoding="utf-8")
_write_executable(bindir / "git", _git_stub())
_write_executable(bindir / "gh", _gh_stub())
@@ -140,41 +247,65 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path) ->
env["CMUX_TEST_REPO"] = str(repo)
env["CMUX_TEST_SEND_LOG"] = str(send_log)
env["CMUX_TEST_GH_COUNT_FILE"] = str(gh_count_file)
+ env["CMUX_TEST_GH_ARGS_LOG"] = str(gh_args_log)
+ env["CMUX_TEST_SCENARIO"] = scenario
+ env["CMUX_TEST_HEAD_FILE"] = str(head_file)
+ env["CMUX_TEST_HANG_SECONDS"] = "4"
with BoundUnixSocket(socket_path):
result = subprocess.run(
- [shell, *shell_args, _shell_command(shell)],
+ [shell, *shell_args, _shell_command(shell, scenario)],
env=env,
capture_output=True,
text=True,
- timeout=10,
+ timeout=12,
)
- output = (result.stdout or "") + (result.stderr or "")
+ combined_output = (result.stdout or "") + (result.stderr or "")
if result.returncode != 0:
- return (result.returncode, output)
+ return (result.returncode, combined_output)
- send_lines = []
- if send_log.exists():
- send_lines = [line.strip() for line in send_log.read_text(encoding="utf-8").splitlines() if line.strip()]
+ send_lines = _read_lines(send_log)
+ gh_args_lines = _read_lines(gh_args_log)
+ gh_count = int((gh_count_file.read_text(encoding="utf-8").strip() or "0")) if gh_count_file.exists() else 0
- gh_count = 0
- if gh_count_file.exists():
- gh_count = int(gh_count_file.read_text(encoding="utf-8").strip() or "0")
+ if not gh_args_lines:
+ return (1, f"{shell}/{scenario}: expected at least one gh invocation")
+ if any(not line.startswith("pr view ") for line in gh_args_lines):
+ return (1, f"{shell}/{scenario}: expected gh pr view only\n" + "\n".join(gh_args_lines))
- report_line = (
- "report_pr 1138 https://github.com/manaflow-ai/cmux/pull/1138 "
- "--state=open --tab=00000000-0000-0000-0000-000000000001 "
- "--panel=00000000-0000-0000-0000-000000000002"
- )
- if gh_count < 2:
- return (1, f"{shell}: expected at least 2 gh probes while idle, saw {gh_count}")
- if any(line.startswith("clear_pr ") for line in send_lines):
- return (1, f"{shell}: transient gh failure should not send clear_pr\n" + "\n".join(send_lines))
- if report_line not in send_lines:
- return (1, f"{shell}: expected recovered report_pr payload\n" + "\n".join(send_lines))
+ if scenario == "transient_same_context":
+ if gh_count < 2:
+ return (1, f"{shell}/{scenario}: expected at least 2 gh probes while idle, saw {gh_count}")
+ if any(line.startswith("clear_pr ") for line in send_lines):
+ return (1, f"{shell}/{scenario}: transient failure should not clear PR state\n" + "\n".join(send_lines))
+ if _report_line(1138) not in send_lines:
+ return (1, f"{shell}/{scenario}: expected recovered report_pr payload\n" + "\n".join(send_lines))
+ return (0, f"{shell}/{scenario}: ok")
- return (0, f"{shell}: ok")
+ if scenario == "branch_switch_clear":
+ old_report = _report_line(111)
+ if old_report not in send_lines:
+ return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines))
+ try:
+ old_index = send_lines.index(old_report)
+ except ValueError:
+ return (1, f"{shell}/{scenario}: missing old-branch report\n" + "\n".join(send_lines))
+ clear_indices = [idx for idx, line in enumerate(send_lines) if line.startswith("clear_pr ")]
+ if not clear_indices:
+ return (1, f"{shell}/{scenario}: expected clear_pr after branch change\n" + "\n".join(send_lines))
+ if clear_indices[0] <= old_index:
+ return (1, f"{shell}/{scenario}: clear_pr happened before old report\n" + "\n".join(send_lines))
+ return (0, f"{shell}/{scenario}: ok")
+
+ if scenario == "timeout_recovery":
+ if gh_count < 2:
+ return (1, f"{shell}/{scenario}: expected timed-out probe to be retried, saw {gh_count}")
+ if _report_line(1138) not in send_lines:
+ return (1, f"{shell}/{scenario}: missing report_pr after timeout recovery\n" + "\n".join(send_lines))
+ return (0, f"{shell}/{scenario}: ok")
+
+ return (1, f"{shell}/{scenario}: unhandled scenario")
def main() -> int:
@@ -183,6 +314,11 @@ def main() -> int:
("zsh", ["-f", "-c"], root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"),
("bash", ["--noprofile", "--norc", "-c"], root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"),
]
+ scenarios = [
+ "transient_same_context",
+ "branch_switch_clear",
+ "timeout_recovery",
+ ]
base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}"
try:
@@ -194,9 +330,16 @@ def main() -> int:
if not script.exists():
print(f"SKIP: missing integration script at {script}")
continue
- rc, detail = _run_case(base / shell, shell=shell, shell_args=shell_args, script=script)
- if rc != 0:
- failures.append(detail)
+ for scenario in scenarios:
+ rc, detail = _run_case(
+ base / f"{shell}-{scenario}",
+ shell=shell,
+ shell_args=shell_args,
+ script=script,
+ scenario=scenario,
+ )
+ if rc != 0:
+ failures.append(detail)
if failures:
print("FAIL:")
@@ -204,7 +347,7 @@ def main() -> int:
print(failure)
return 1
- print("PASS: shell integrations keep polling PR state and recover after transient gh failures")
+ print("PASS: shell integrations poll PR state robustly across transient failures, branch changes, and timeouts")
return 0
finally:
shutil.rmtree(base, ignore_errors=True)
From 84edefa945052c7c7d9706c833d37f77b740c369 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 00:40:57 -0700
Subject: [PATCH 04/29] Fix shell PR poll cleanup regressions
---
.../cmux-bash-integration.bash | 66 ++++++++-----------
.../cmux-zsh-integration.zsh | 31 ++++++++-
tests/test_issue_1138_sidebar_pr_polling.py | 42 +++++++++++-
3 files changed, 97 insertions(+), 42 deletions(-)
diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash
index cbb3f103..ab4b6e2c 100644
--- a/Resources/shell-integration/cmux-bash-integration.bash
+++ b/Resources/shell-integration/cmux-bash-integration.bash
@@ -49,9 +49,6 @@ _CMUX_PR_POLL_PWD="${_CMUX_PR_POLL_PWD:-}"
_CMUX_PR_POLL_INTERVAL="${_CMUX_PR_POLL_INTERVAL:-45}"
_CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}"
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
-_CMUX_PREEXEC_READY="${_CMUX_PREEXEC_READY:-0}"
-_CMUX_IN_PROMPT_COMMAND="${_CMUX_IN_PROMPT_COMMAND:-0}"
-_CMUX_DEBUG_TRAP_INSTALLED="${_CMUX_DEBUG_TRAP_INSTALLED:-0}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
@@ -198,6 +195,27 @@ _cmux_report_pr_for_path() {
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
+_cmux_child_pids() {
+ local parent_pid="$1"
+ [[ -n "$parent_pid" ]] || return 0
+ /bin/ps -ax -o pid= -o ppid= 2>/dev/null | /usr/bin/awk -v parent="$parent_pid" '$2 == parent { print $1 }'
+}
+
+_cmux_kill_process_tree() {
+ local pid="$1"
+ local signal="${2:-TERM}"
+ local child_pid=""
+ [[ -n "$pid" ]] || return 0
+
+ while IFS= read -r child_pid; do
+ [[ -n "$child_pid" ]] || continue
+ [[ "$child_pid" == "$pid" ]] && continue
+ _cmux_kill_process_tree "$child_pid" "$signal"
+ done < <(_cmux_child_pids "$pid")
+
+ kill "-$signal" "$pid" >/dev/null 2>&1 || true
+}
+
_cmux_run_pr_probe_with_timeout() {
local repo_path="$1"
local probe_pid=""
@@ -213,10 +231,10 @@ _cmux_run_pr_probe_with_timeout() {
sleep 1
now=$SECONDS
if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then
- kill "$probe_pid" >/dev/null 2>&1 || true
+ _cmux_kill_process_tree "$probe_pid" TERM
sleep 0.2
if kill -0 "$probe_pid" >/dev/null 2>&1; then
- kill -9 "$probe_pid" >/dev/null 2>&1 || true
+ _cmux_kill_process_tree "$probe_pid" KILL
sleep 0.2
fi
if ! kill -0 "$probe_pid" >/dev/null 2>&1; then
@@ -231,7 +249,11 @@ _cmux_run_pr_probe_with_timeout() {
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
- kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true
+ _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM
+ sleep 0.1
+ if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then
+ _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
+ fi
_CMUX_PR_POLL_PID=""
fi
}
@@ -269,38 +291,10 @@ _cmux_bash_cleanup() {
_cmux_stop_pr_poll_loop
}
-_cmux_preexec_trap() {
- (( _CMUX_IN_PROMPT_COMMAND )) && return 0
- (( _CMUX_PREEXEC_READY )) || return 0
- _CMUX_PREEXEC_READY=0
- _cmux_stop_pr_poll_loop
-
- local cmd="${BASH_COMMAND## }"
- case "$cmd" in
- git\ *|git|gh\ *|gh|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
- _CMUX_PR_FORCE=1
- ;;
- esac
-}
-
-_cmux_install_debug_trap() {
- (( _CMUX_DEBUG_TRAP_INSTALLED )) && return 0
-
- local existing
- existing="$(trap -p DEBUG 2>/dev/null || true)"
- if [[ -n "$existing" ]]; then
- return 0
- fi
-
- trap '_cmux_preexec_trap' DEBUG
- _CMUX_DEBUG_TRAP_INSTALLED=1
-}
-
_cmux_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
- _CMUX_IN_PROMPT_COMMAND=1
local now=$SECONDS
local pwd="$PWD"
@@ -416,9 +410,6 @@ _cmux_prompt_command() {
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
_cmux_ports_kick
fi
-
- _CMUX_IN_PROMPT_COMMAND=0
- _CMUX_PREEXEC_READY=1
}
_cmux_install_prompt_command() {
@@ -471,4 +462,3 @@ _cmux_fix_path
unset -f _cmux_fix_path
_cmux_install_prompt_command
-_cmux_install_debug_trap
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index b6b82c88..821f3d19 100644
--- a/Resources/shell-integration/cmux-zsh-integration.zsh
+++ b/Resources/shell-integration/cmux-zsh-integration.zsh
@@ -220,6 +220,27 @@ _cmux_report_pr_for_path() {
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
+_cmux_child_pids() {
+ local parent_pid="$1"
+ [[ -n "$parent_pid" ]] || return 0
+ /bin/ps -ax -o pid= -o ppid= 2>/dev/null | /usr/bin/awk -v parent="$parent_pid" '$2 == parent { print $1 }'
+}
+
+_cmux_kill_process_tree() {
+ local pid="$1"
+ local signal="${2:-TERM}"
+ local child_pid=""
+ [[ -n "$pid" ]] || return 0
+
+ while IFS= read -r child_pid; do
+ [[ -n "$child_pid" ]] || continue
+ [[ "$child_pid" == "$pid" ]] && continue
+ _cmux_kill_process_tree "$child_pid" "$signal"
+ done < <(_cmux_child_pids "$pid")
+
+ kill "-$signal" "$pid" >/dev/null 2>&1 || true
+}
+
_cmux_run_pr_probe_with_timeout() {
local repo_path="$1"
local probe_pid=""
@@ -235,10 +256,10 @@ _cmux_run_pr_probe_with_timeout() {
sleep 1
now=$EPOCHSECONDS
if (( _CMUX_ASYNC_JOB_TIMEOUT > 0 )) && (( now - started_at >= _CMUX_ASYNC_JOB_TIMEOUT )); then
- kill "$probe_pid" >/dev/null 2>&1 || true
+ _cmux_kill_process_tree "$probe_pid" TERM
sleep 0.2
if kill -0 "$probe_pid" >/dev/null 2>&1; then
- kill -9 "$probe_pid" >/dev/null 2>&1 || true
+ _cmux_kill_process_tree "$probe_pid" KILL
sleep 0.2
fi
if ! kill -0 "$probe_pid" >/dev/null 2>&1; then
@@ -253,7 +274,11 @@ _cmux_run_pr_probe_with_timeout() {
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
- kill "$_CMUX_PR_POLL_PID" >/dev/null 2>&1 || true
+ _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM
+ sleep 0.1
+ if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then
+ _cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
+ fi
_CMUX_PR_POLL_PID=""
fi
}
diff --git a/tests/test_issue_1138_sidebar_pr_polling.py b/tests/test_issue_1138_sidebar_pr_polling.py
index 92f48b24..973e98dd 100644
--- a/tests/test_issue_1138_sidebar_pr_polling.py
+++ b/tests/test_issue_1138_sidebar_pr_polling.py
@@ -8,6 +8,8 @@ Validates that shell integration:
branch-name matching
3) clears stale PR state when the branch changes and the new probe fails
4) recovers when a gh probe wedges longer than the async timeout
+5) keeps polling in bash after prompt-render helper commands run
+6) tears down the timed-out gh probe instead of leaking it in the background
"""
from __future__ import annotations
@@ -91,6 +93,7 @@ def _gh_stub() -> str:
#!/bin/sh
args_log="${CMUX_TEST_GH_ARGS_LOG:?}"
count_file="${CMUX_TEST_GH_COUNT_FILE:?}"
+ pid_file="${CMUX_TEST_GH_PID_FILE:-}"
scenario="${CMUX_TEST_SCENARIO:?}"
head_file="${CMUX_TEST_HEAD_FILE:?}"
@@ -119,6 +122,9 @@ def _gh_stub() -> str:
fi
case "$scenario" in
+ prompt_helper_idle)
+ printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
+ ;;
transient_same_context)
if [ "$count" -eq 1 ]; then
printf 'rate limit exceeded\\n' >&2
@@ -140,6 +146,9 @@ def _gh_stub() -> str:
;;
timeout_recovery)
if [ "$count" -eq 1 ]; then
+ if [ -n "$pid_file" ]; then
+ printf '%s\\n' "$$" > "$pid_file"
+ fi
sleep "${CMUX_TEST_HANG_SECONDS:-4}"
exit 0
fi
@@ -156,6 +165,14 @@ def _gh_stub() -> str:
def _shell_command(kind: str, scenario: str) -> str:
shared = {
+ "prompt_helper_idle": (
+ 'cd "$CMUX_TEST_REPO"\n'
+ '_CMUX_PR_POLL_INTERVAL=1\n'
+ '_cmux_prompt_entry\n'
+ ': "$(/bin/printf helper)"\n'
+ 'sleep 3\n'
+ '_cmux_cleanup\n'
+ ),
"transient_same_context": (
'cd "$CMUX_TEST_REPO"\n'
'_CMUX_PR_POLL_INTERVAL=1\n'
@@ -197,7 +214,6 @@ def _shell_command(kind: str, scenario: str) -> str:
return textwrap.dedent(
f"""\
source "$CMUX_TEST_SCRIPT"
- trap - DEBUG
_cmux_send() {{ printf '%s\\n' "$1" >> "$CMUX_TEST_SEND_LOG"; }}
_cmux_prompt_entry() {{ _cmux_prompt_command; }}
_cmux_cleanup() {{ type _cmux_bash_cleanup >/dev/null 2>&1 && _cmux_bash_cleanup; }}
@@ -221,6 +237,16 @@ def _report_line(number: int) -> str:
)
+def _pid_exists(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ except PermissionError:
+ return True
+ return True
+
+
def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, scenario: str) -> tuple[int, str]:
bindir = base / "bin"
repo = base / "repo"
@@ -229,6 +255,7 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc
send_log = base / f"{shell}-{scenario}-send.log"
gh_count_file = base / f"{shell}-{scenario}-gh-count.txt"
gh_args_log = base / f"{shell}-{scenario}-gh-args.log"
+ gh_pid_file = base / f"{shell}-{scenario}-gh-pid.txt"
head_file = repo_git / "HEAD"
bindir.mkdir(parents=True, exist_ok=True)
@@ -248,6 +275,7 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc
env["CMUX_TEST_SEND_LOG"] = str(send_log)
env["CMUX_TEST_GH_COUNT_FILE"] = str(gh_count_file)
env["CMUX_TEST_GH_ARGS_LOG"] = str(gh_args_log)
+ env["CMUX_TEST_GH_PID_FILE"] = str(gh_pid_file)
env["CMUX_TEST_SCENARIO"] = scenario
env["CMUX_TEST_HEAD_FILE"] = str(head_file)
env["CMUX_TEST_HANG_SECONDS"] = "4"
@@ -274,6 +302,13 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc
if any(not line.startswith("pr view ") for line in gh_args_lines):
return (1, f"{shell}/{scenario}: expected gh pr view only\n" + "\n".join(gh_args_lines))
+ if scenario == "prompt_helper_idle":
+ if gh_count < 2:
+ return (1, f"{shell}/{scenario}: expected idle polling to survive prompt helpers, saw {gh_count}")
+ if _report_line(1138) not in send_lines:
+ return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines))
+ return (0, f"{shell}/{scenario}: ok")
+
if scenario == "transient_same_context":
if gh_count < 2:
return (1, f"{shell}/{scenario}: expected at least 2 gh probes while idle, saw {gh_count}")
@@ -303,6 +338,10 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc
return (1, f"{shell}/{scenario}: expected timed-out probe to be retried, saw {gh_count}")
if _report_line(1138) not in send_lines:
return (1, f"{shell}/{scenario}: missing report_pr after timeout recovery\n" + "\n".join(send_lines))
+ if gh_pid_file.exists():
+ gh_pid = int(gh_pid_file.read_text(encoding="utf-8").strip() or "0")
+ if gh_pid > 0 and _pid_exists(gh_pid):
+ return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}")
return (0, f"{shell}/{scenario}: ok")
return (1, f"{shell}/{scenario}: unhandled scenario")
@@ -315,6 +354,7 @@ def main() -> int:
("bash", ["--noprofile", "--norc", "-c"], root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"),
]
scenarios = [
+ "prompt_helper_idle",
"transient_same_context",
"branch_switch_clear",
"timeout_recovery",
From a9f870c2efc017dc296173e25d8d62dc6c25c688 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 11 Mar 2026 19:19:02 -0700
Subject: [PATCH 05/29] Add zsh prompt redraw regression test
---
...tty_zsh_prompt_redraw_uses_prompt_start.py | 174 ++++++++++++++++++
1 file changed, 174 insertions(+)
create mode 100644 tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
diff --git a/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
new file mode 100644
index 00000000..7827265d
--- /dev/null
+++ b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""
+Regression: zsh prompt redraws should not replay fresh-line OSC 133;A markers.
+
+Prompt themes with async redraws (such as Prezto-like setups) can call
+`zle reset-prompt` after the prompt is already visible. Ghostty's zsh shell
+integration should emit a single fresh prompt mark for the actual prompt, then
+use OSC 133;P for redraws so redraws stay in place instead of looking like
+extra prompt lines.
+"""
+
+from __future__ import annotations
+
+import os
+import pty
+import select
+import shutil
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+
+
+FRESH_PROMPT = b"\x1b]133;A;cl=line\x07"
+PROMPT_START = b"\x1b]133;P;k=i\x07"
+END_COMMAND = b"\x1b]133;D\x07"
+START_OUTPUT = b"\x1b]133;C\x07"
+
+
+def _write_redrawing_zshrc(path: Path) -> None:
+ path.write_text(
+ """
+autoload -Uz add-zsh-hook
+
+setopt prompt_cr prompt_percent prompt_sp prompt_subst
+PROMPT='%F{4}%1~%f %# '
+RPROMPT=''
+
+typeset -gi _cmux_redraw_done=0
+typeset -g _cmux_redraw_fd=''
+
+_cmux_redraw_precmd() {
+ _cmux_redraw_done=0
+}
+
+_cmux_redraw_ready() {
+ emulate -L zsh
+ local fd="${1:-$_cmux_redraw_fd}"
+ if [[ -n "$fd" ]]; then
+ zle -F "$fd"
+ exec {fd}<&-
+ fi
+ _cmux_redraw_fd=''
+ (( _cmux_redraw_done )) && return 0
+ _cmux_redraw_done=1
+ zle reset-prompt
+}
+
+_cmux_redraw_line_init() {
+ if (( !_cmux_redraw_done )) && [[ -z "$_cmux_redraw_fd" ]]; then
+ exec {_cmux_redraw_fd}< <(
+ sleep 0.05
+ printf 'ready\\n'
+ )
+ zle -F "$_cmux_redraw_fd" _cmux_redraw_ready
+ fi
+}
+
+add-zsh-hook precmd _cmux_redraw_precmd
+zle -N zle-line-init _cmux_redraw_line_init
+""".lstrip(),
+ encoding="utf-8",
+ )
+
+
+def _capture_session(env: dict[str, str]) -> bytes:
+ master, slave = pty.openpty()
+ proc = subprocess.Popen(
+ ["zsh", "-d", "-i"],
+ stdin=slave,
+ stdout=slave,
+ stderr=slave,
+ env=env,
+ close_fds=True,
+ )
+ os.close(slave)
+
+ output = bytearray()
+ start = time.time()
+ phase = 0
+ try:
+ while time.time() - start < 5:
+ readable, _, _ = select.select([master], [], [], 0.2)
+ if master in readable:
+ try:
+ chunk = os.read(master, 4096)
+ except OSError:
+ break
+ if not chunk:
+ break
+ output.extend(chunk)
+
+ elapsed = time.time() - start
+ if phase == 0 and elapsed > 1.0:
+ os.write(master, b"\n")
+ phase = 1
+ elif phase == 1 and elapsed > 2.5:
+ os.write(master, b"exit\n")
+ phase = 2
+ finally:
+ try:
+ proc.wait(timeout=5)
+ finally:
+ os.close(master)
+
+ return bytes(output)
+
+
+def main() -> int:
+ root = Path(__file__).resolve().parents[1]
+ wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh"
+ if not (wrapper_dir / ".zshenv").exists():
+ print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}")
+ return 0
+
+ if shutil.which("zsh") is None:
+ print("SKIP: zsh not installed")
+ return 0
+
+ base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_prompt_redraw_"))
+ try:
+ home = base / "home"
+ home.mkdir(parents=True, exist_ok=True)
+ _write_redrawing_zshrc(home / ".zshrc")
+
+ env = dict(os.environ)
+ env["HOME"] = str(home)
+ env["ZDOTDIR"] = str(wrapper_dir)
+ env["GHOSTTY_ZSH_ZDOTDIR"] = str(home)
+ env["GHOSTTY_RESOURCES_DIR"] = str(root / "ghostty" / "src")
+ env.pop("GHOSTTY_SHELL_FEATURES", None)
+ env.pop("GHOSTTY_BIN_DIR", None)
+
+ output = _capture_session(env)
+
+ marker = output.find(END_COMMAND)
+ if marker == -1:
+ print("FAIL: did not observe OSC 133;D for the empty command prompt cycle")
+ return 1
+
+ end = output.find(START_OUTPUT, marker + len(END_COMMAND))
+ if end == -1:
+ end = len(output)
+
+ prompt_cycle = output[marker:end]
+ fresh_count = prompt_cycle.count(FRESH_PROMPT)
+ prompt_start_count = prompt_cycle.count(PROMPT_START)
+
+ if fresh_count != 1:
+ print(f"FAIL: expected exactly 1 fresh prompt marker after redraw, saw {fresh_count}")
+ return 1
+
+ if prompt_start_count < 1:
+ print("FAIL: expected redraw path to emit OSC 133;P prompt-start markers")
+ return 1
+
+ print("PASS: zsh prompt redraws keep a single fresh prompt marker and reuse OSC 133;P")
+ return 0
+ finally:
+ shutil.rmtree(base, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From abf1deed8fa0062a4190719c408e9cd375b9336a Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 11 Mar 2026 19:19:25 -0700
Subject: [PATCH 06/29] Fix zsh prompt redraw markers
---
docs/ghostty-fork.md | 17 ++++++++++++++++-
ghostty | 2 +-
2 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md
index c98a76c3..dd3d6dc5 100644
--- a/docs/ghostty-fork.md
+++ b/docs/ghostty-fork.md
@@ -12,7 +12,7 @@ When we change the fork, update this document and the parent submodule SHA.
## Current fork changes
-Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 2026.
+Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 2026.
### 1) OSC 99 (kitty) notification parser
@@ -62,6 +62,17 @@ section 3 copy-mode commit, even though the section 4 resize commits were applie
- Replays the last rendered frame during resize and keeps its geometry anchored correctly.
- Reduces transient blank or scaled frames while a macOS window is being resized.
+### 5) zsh prompt redraw markers use OSC 133 P
+
+- Commit: `8ade43ce5` (zsh: use OSC 133 P for prompt redraws)
+- Files:
+ - `src/shell-integration/zsh/ghostty-integration`
+- Summary:
+ - Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions.
+ - Uses `OSC 133;P` markers for prompt redraws so async zsh themes do not look like extra prompt lines.
+
+The fork branch HEAD is now the section 5 zsh redraw commit.
+
## Upstreamed fork changes
### cursor-click-to-move respects OSC 133 click-to-move
@@ -80,4 +91,8 @@ These files change frequently upstream; be careful when rebasing the fork:
- `src/terminal/osc.zig`
- OSC dispatch logic moves often. Re-check the integration points for the OSC 99 parser.
+- `src/shell-integration/zsh/ghostty-integration`
+ - Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the
+ `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes.
+
If you resolve a conflict, update this doc with what changed.
diff --git a/ghostty b/ghostty
index a50579bd..8ade43ce 160000
--- a/ghostty
+++ b/ghostty
@@ -1 +1 @@
-Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec
+Subproject commit 8ade43ce52cda470ffe07e963ab7722c38380792
From 7d5d4d718df5f313fcdb625775c46015712c0400 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 22:13:45 -0700
Subject: [PATCH 07/29] wip
---
Sources/BrowserWindowPortal.swift | 49 ++-
Sources/Panels/BrowserPanel.swift | 167 ++++++--
Sources/Panels/BrowserPanelView.swift | 52 ++-
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 396 ++++++++++++++++++
4 files changed, 602 insertions(+), 62 deletions(-)
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index 200a9ef0..bbe13377 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -126,13 +126,11 @@ enum HostedInspectorDockSide {
inspectorFrame: NSRect,
expansion: CGFloat
) -> NSRect {
- let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY))
- let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
return NSRect(
x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion,
- y: minY,
+ y: bounds.minY,
width: expansion * 2,
- height: max(0, maxY - minY)
+ height: max(0, bounds.height)
)
}
@@ -168,35 +166,54 @@ enum HostedInspectorDockSide {
in containerBounds: NSRect,
pageFrame: NSRect,
inspectorFrame: NSRect,
- minimumInspectorWidth _: CGFloat
+ minimumInspectorWidth: CGFloat
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
+ let normalizedMinY = containerBounds.minY
+ let normalizedHeight = max(0, containerBounds.height)
+
switch self {
case .leading:
let maximumInspectorWidth = max(0, containerBounds.width)
- let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
+ let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth))
+ let clampedInspectorWidth = min(
+ maximumInspectorWidth,
+ max(clampedMinimumInspectorWidth, preferredWidth)
+ )
let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth)
var nextPageFrame = pageFrame
nextPageFrame.origin.x = dividerX
+ nextPageFrame.origin.y = normalizedMinY
nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX)
+ nextPageFrame.size.height = normalizedHeight
var nextInspectorFrame = inspectorFrame
nextInspectorFrame.origin.x = containerBounds.minX
+ nextInspectorFrame.origin.y = normalizedMinY
nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX)
+ nextInspectorFrame.size.height = normalizedHeight
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
case .trailing:
let maximumInspectorWidth = max(0, containerBounds.width)
- let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
+ let clampedMinimumInspectorWidth = min(maximumInspectorWidth, max(0, minimumInspectorWidth))
+ let clampedInspectorWidth = min(
+ maximumInspectorWidth,
+ max(clampedMinimumInspectorWidth, preferredWidth)
+ )
let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth)
var nextPageFrame = pageFrame
nextPageFrame.origin.x = containerBounds.minX
+ nextPageFrame.origin.y = normalizedMinY
nextPageFrame.size.width = max(0, dividerX - containerBounds.minX)
+ nextPageFrame.size.height = normalizedHeight
var nextInspectorFrame = inspectorFrame
nextInspectorFrame.origin.x = dividerX
+ nextInspectorFrame.origin.y = normalizedMinY
nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
+ nextInspectorFrame.size.height = normalizedHeight
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
}
}
@@ -572,6 +589,7 @@ final class WindowBrowserHostView: NSView {
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
),
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: "drag"
)
updateDividerCursor(
@@ -946,7 +964,12 @@ final class WindowBrowserHostView: NSView {
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return false }
let oldPageFrame = hit.pageView.frame
let oldInspectorFrame = hit.inspectorView.frame
- _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
+ _ = applyHostedInspectorDividerWidth(
+ preferredWidth,
+ to: hit,
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
+ reason: reason
+ )
return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) ||
!Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
}
@@ -955,6 +978,7 @@ final class WindowBrowserHostView: NSView {
private func applyHostedInspectorDividerWidth(
_ preferredWidth: CGFloat,
to hit: HostedInspectorDividerHit,
+ minimumInspectorWidth: CGFloat,
reason: String
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
let containerBounds = hit.containerView.bounds
@@ -963,7 +987,7 @@ final class WindowBrowserHostView: NSView {
in: containerBounds,
pageFrame: hit.pageView.frame,
inspectorFrame: hit.inspectorView.frame,
- minimumInspectorWidth: 0
+ minimumInspectorWidth: minimumInspectorWidth
)
let pageFrame = nextFrames.pageFrame
let inspectorFrame = nextFrames.inspectorFrame
@@ -1742,8 +1766,9 @@ final class WindowBrowserSlotView: NSView {
func pinHostedWebView(_ webView: WKWebView) {
guard webView.superview === self else { return }
+ let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView)
let needsPlainWebViewFrameReset =
- !Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) &&
+ !hasCompanionWKSubviews &&
Self.frameDiffersFromBounds(webView.frame, bounds: bounds)
let needsFrameHosting =
hostedWebView !== webView ||
@@ -1765,7 +1790,9 @@ final class WindowBrowserSlotView: NSView {
// WebKit-managed split frame when docked DevTools siblings are present.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
- webView.frame = bounds
+ if !hasCompanionWKSubviews {
+ webView.frame = bounds
+ }
needsLayout = true
layoutSubtreeIfNeeded()
}
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index 5c2d7cd8..fd8853a6 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -1775,6 +1775,10 @@ final class BrowserPanel: Panel, ObservableObject {
private let developerToolsRestoreRetryMaxAttempts: Int = 40
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
private var developerToolsDetachedOpenGraceDeadline: Date?
+ private var developerToolsTransitionTargetVisible: Bool?
+ private var pendingDeveloperToolsTransitionTargetVisible: Bool?
+ private var developerToolsTransitionSettleWorkItem: DispatchWorkItem?
+ private let developerToolsTransitionSettleDelay: TimeInterval = 0.15
private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol?
private var preferredAttachedDeveloperToolsWidth: CGFloat?
private var preferredAttachedDeveloperToolsWidthFraction: CGFloat?
@@ -2698,6 +2702,8 @@ final class BrowserPanel: Panel, ObservableObject {
deinit {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
+ developerToolsTransitionSettleWorkItem?.cancel()
+ developerToolsTransitionSettleWorkItem = nil
if let detachedDeveloperToolsWindowCloseObserver {
NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver)
}
@@ -3002,29 +3008,97 @@ extension BrowserPanel {
return false
}
+ private var isDeveloperToolsTransitionInFlight: Bool {
+ developerToolsTransitionSettleWorkItem != nil
+ }
+
+ private func effectiveDeveloperToolsVisibilityIntent() -> Bool {
+ if let pendingDeveloperToolsTransitionTargetVisible {
+ return pendingDeveloperToolsTransitionTargetVisible
+ }
+ if let developerToolsTransitionTargetVisible {
+ return developerToolsTransitionTargetVisible
+ }
+ return isDeveloperToolsVisible()
+ }
+
+ private func scheduleDeveloperToolsTransitionSettle(source: String) {
+ developerToolsTransitionSettleWorkItem?.cancel()
+ let workItem = DispatchWorkItem { [weak self] in
+ self?.developerToolsTransitionSettleWorkItem = nil
+ self?.finishDeveloperToolsTransition(source: source)
+ }
+ developerToolsTransitionSettleWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsTransitionSettleDelay, execute: workItem)
+ }
+
+ private func finishDeveloperToolsTransition(source: String) {
+ let pendingTargetVisible = pendingDeveloperToolsTransitionTargetVisible
+ pendingDeveloperToolsTransitionTargetVisible = nil
+ developerToolsTransitionTargetVisible = nil
+
+ guard let pendingTargetVisible else { return }
+ guard pendingTargetVisible != isDeveloperToolsVisible() else { return }
+ _ = performDeveloperToolsVisibilityTransition(to: pendingTargetVisible, source: "\(source).queued")
+ }
+
@discardableResult
- func toggleDeveloperTools() -> Bool {
+ private func enqueueDeveloperToolsVisibilityTransition(
+ to targetVisible: Bool,
+ source: String
+ ) -> Bool {
+ if isDeveloperToolsTransitionInFlight {
+ pendingDeveloperToolsTransitionTargetVisible = targetVisible
+ preferredDeveloperToolsVisible = targetVisible
+ if !targetVisible {
+ developerToolsDetachedOpenGraceDeadline = nil
+ forceDeveloperToolsRefreshOnNextAttach = false
+ cancelDeveloperToolsRestoreRetry()
+ }
#if DEBUG
- dlog(
- "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " +
- "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
- )
+ dlog(
+ "browser.devtools transition.queue panel=\(id.uuidString.prefix(5)) " +
+ "source=\(source) target=\(targetVisible ? 1 : 0) \(debugDeveloperToolsStateSummary())"
+ )
#endif
+ return true
+ }
+
+ return performDeveloperToolsVisibilityTransition(to: targetVisible, source: source)
+ }
+
+ @discardableResult
+ private func performDeveloperToolsVisibilityTransition(
+ to targetVisible: Bool,
+ source: String
+ ) -> Bool {
guard let inspector = webView.cmuxInspectorObject() else { return false }
+
let isVisibleSelector = NSSelectorFromString("isVisible")
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
- let targetVisible = !visible
+ preferredDeveloperToolsVisible = targetVisible
+ developerToolsTransitionTargetVisible = targetVisible
+
if targetVisible {
- _ = revealDeveloperTools(inspector)
+ if !visible {
+ _ = revealDeveloperTools(inspector)
+ } else {
+ developerToolsDetachedOpenGraceDeadline = nil
+ }
} else {
- syncDeveloperToolsPresentationPreferenceFromUI()
- guard concealDeveloperTools(inspector) else { return false }
+ if visible {
+ syncDeveloperToolsPresentationPreferenceFromUI()
+ guard concealDeveloperTools(inspector) else {
+ developerToolsTransitionTargetVisible = nil
+ return false
+ }
+ }
developerToolsDetachedOpenGraceDeadline = nil
}
- preferredDeveloperToolsVisible = targetVisible
+
if targetVisible {
- let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
- if visibleAfterToggle {
+ let visibleAfterTransition = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
+ if visibleAfterTransition {
syncDeveloperToolsPresentationPreferenceFromUI()
cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
@@ -3036,6 +3110,26 @@ extension BrowserPanel {
cancelDeveloperToolsRestoreRetry()
forceDeveloperToolsRefreshOnNextAttach = false
}
+
+ if visible != targetVisible {
+ scheduleDeveloperToolsTransitionSettle(source: source)
+ } else {
+ developerToolsTransitionTargetVisible = nil
+ }
+
+ return true
+ }
+
+ @discardableResult
+ func toggleDeveloperTools() -> Bool {
+#if DEBUG
+ dlog(
+ "browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " +
+ "\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
+ )
+#endif
+ let targetVisible = !effectiveDeveloperToolsVisibilityIntent()
+ let handled = enqueueDeveloperToolsVisibilityTransition(to: targetVisible, source: "toggle")
#if DEBUG
dlog(
"browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " +
@@ -3049,30 +3143,18 @@ extension BrowserPanel {
)
}
#endif
- return true
+ return handled
}
@discardableResult
func showDeveloperTools() -> Bool {
- guard let inspector = webView.cmuxInspectorObject() else { return false }
- let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
- if !visible {
- guard revealDeveloperTools(inspector) else { return false }
- }
- preferredDeveloperToolsVisible = true
- if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
- syncDeveloperToolsPresentationPreferenceFromUI()
- cancelDeveloperToolsRestoreRetry()
- scheduleDetachedDeveloperToolsWindowDismissal()
- } else {
- scheduleDeveloperToolsRestoreRetry()
- }
- return true
+ return enqueueDeveloperToolsVisibilityTransition(to: true, source: "show")
}
@discardableResult
func showDeveloperToolsConsole() -> Bool {
guard showDeveloperTools() else { return false }
+ guard !isDeveloperToolsTransitionInFlight else { return true }
guard let inspector = webView.cmuxInspectorObject() else { return true }
// WebKit private inspector API differs by OS; try known console selectors.
let consoleSelectors = [
@@ -3094,6 +3176,20 @@ extension BrowserPanel {
func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) {
guard let inspector = webView.cmuxInspectorObject() else { return }
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
+ if isDeveloperToolsTransitionInFlight {
+ let targetVisible = pendingDeveloperToolsTransitionTargetVisible ?? developerToolsTransitionTargetVisible ?? visible
+ preferredDeveloperToolsVisible = targetVisible
+ if targetVisible, visible {
+ developerToolsDetachedOpenGraceDeadline = nil
+ syncDeveloperToolsPresentationPreferenceFromUI()
+ cancelDeveloperToolsRestoreRetry()
+ } else if !targetVisible {
+ developerToolsDetachedOpenGraceDeadline = nil
+ forceDeveloperToolsRefreshOnNextAttach = false
+ cancelDeveloperToolsRestoreRetry()
+ }
+ return
+ }
if visible {
developerToolsDetachedOpenGraceDeadline = nil
syncDeveloperToolsPresentationPreferenceFromUI()
@@ -3115,6 +3211,7 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach = false
return
}
+ guard !isDeveloperToolsTransitionInFlight else { return }
guard let inspector = webView.cmuxInspectorObject() else {
scheduleDeveloperToolsRestoreRetry()
return
@@ -3180,17 +3277,7 @@ extension BrowserPanel {
@discardableResult
func hideDeveloperTools() -> Bool {
- guard let inspector = webView.cmuxInspectorObject() else { return false }
- let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
- if visible {
- syncDeveloperToolsPresentationPreferenceFromUI()
- guard concealDeveloperTools(inspector) else { return false }
- }
- preferredDeveloperToolsVisible = false
- developerToolsDetachedOpenGraceDeadline = nil
- forceDeveloperToolsRefreshOnNextAttach = false
- cancelDeveloperToolsRestoreRetry()
- return true
+ return enqueueDeveloperToolsVisibilityTransition(to: false, source: "hide")
}
/// During split/layout transitions SwiftUI can briefly mark the browser surface hidden
@@ -4056,7 +4143,9 @@ extension BrowserPanel {
let attached = webView.superview == nil ? 0 : 1
let inWindow = webView.window == nil ? 0 : 1
let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0
- return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)"
+ let transitionTarget = developerToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil"
+ let pendingTarget = pendingDeveloperToolsTransitionTargetVisible.map { $0 ? "1" : "0" } ?? "nil"
+ return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh) tx=\(transitionTarget) pending=\(pendingTarget)"
}
func debugDeveloperToolsGeometrySummary() -> String {
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index d11a67cf..9b950016 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3716,6 +3716,12 @@ struct WebViewRepresentable: NSViewRepresentable {
final class HostContainerView: NSView {
private final class HostedInspectorSideDockContainerView: NSView {
override var isOpaque: Bool { false }
+
+ override func resizeSubviews(withOldSize oldSize: NSSize) {
+ // Managed side-docked DevTools use explicit frame updates from the host.
+ // Letting AppKit autoresize the WK siblings here makes them snap back to
+ // stale widths while the divider drag or pane resize is in flight.
+ }
}
var onDidMoveToWindow: (() -> Void)?
@@ -3760,7 +3766,7 @@ struct WebViewRepresentable: NSViewRepresentable {
}
private static let hostedInspectorDividerHitExpansion: CGFloat = 10
- private static let minimumHostedInspectorWidth: CGFloat = 1
+ private static let minimumHostedInspectorWidth: CGFloat = 120
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
@@ -4116,6 +4122,21 @@ struct WebViewRepresentable: NSViewRepresentable {
layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate")
}
+ @discardableResult
+ func promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded() -> Bool {
+ guard !isHostedInspectorSideDockActive(),
+ let slotView = localInlineSlotView,
+ let hit = hostedInspectorDividerCandidateUsingKnownWebViews(in: slotView) else {
+ return false
+ }
+
+ // The inspector frontend sometimes reports its dock configuration a tick
+ // late after local-inline reattach. Promote the visible left/right split
+ // immediately so drag routing stays symmetric on both dock sides.
+ activateHostedInspectorSideDockIfNeeded(using: hit)
+ return isHostedInspectorSideDockActive()
+ }
+
private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) {
guard let slotView,
let pageView = hostedInspectorSideDockPageView,
@@ -4151,6 +4172,7 @@ struct WebViewRepresentable: NSViewRepresentable {
inspectorView: inspectorView,
dockSide: dockSide
),
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: reason
)
}
@@ -4236,11 +4258,15 @@ struct WebViewRepresentable: NSViewRepresentable {
override func layout() {
super.layout()
+ _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if let previousSize = lastHostedInspectorLayoutBoundsSize,
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
- if isHostedInspectorSideDockActive() {
- layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock.sameSize")
- } else if !isHostedInspectorDividerDragActive && !hasStoredHostedInspectorWidthPreference {
+ // Origin-only frame churn is common while the surrounding split layout
+ // settles. Reapplying the side-docked inspector at the same size fights
+ // WebKit's own dock layout and shows up as visible flicker.
+ if !isHostedInspectorSideDockActive() &&
+ !isHostedInspectorDividerDragActive &&
+ !hasStoredHostedInspectorWidthPreference {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize")
}
notifyGeometryChangedIfNeeded()
@@ -4264,9 +4290,6 @@ struct WebViewRepresentable: NSViewRepresentable {
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
- if isHostedInspectorSideDockActive() {
- layoutHostedInspectorSideDockIfNeeded(reason: "setFrameOrigin.sideDock")
- }
window?.invalidateCursorRects(for: self)
notifyGeometryChangedIfNeeded()
#if DEBUG
@@ -4276,9 +4299,6 @@ struct WebViewRepresentable: NSViewRepresentable {
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
- if isHostedInspectorSideDockActive() {
- layoutHostedInspectorSideDockIfNeeded(reason: "setFrameSize.sideDock")
- }
window?.invalidateCursorRects(for: self)
notifyGeometryChangedIfNeeded()
#if DEBUG
@@ -4419,6 +4439,7 @@ struct WebViewRepresentable: NSViewRepresentable {
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
),
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: "drag"
)
#if DEBUG
@@ -4698,6 +4719,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.hostedInspectorReapplyWorkItem = nil
+ _ = self.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if self.isHostedInspectorSideDockActive() {
self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason)
} else if !self.hasStoredHostedInspectorWidthPreference {
@@ -4766,13 +4788,19 @@ struct WebViewRepresentable: NSViewRepresentable {
}
let currentInspectorWidth = max(0, hit.inspectorView.frame.width)
guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return }
- _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
+ _ = applyHostedInspectorDividerWidth(
+ preferredWidth,
+ to: hit,
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
+ reason: reason
+ )
}
@discardableResult
private func applyHostedInspectorDividerWidth(
_ preferredWidth: CGFloat,
to hit: HostedInspectorDividerHit,
+ minimumInspectorWidth: CGFloat,
reason: String
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
let containerBounds = hit.containerView.bounds
@@ -4781,7 +4809,7 @@ struct WebViewRepresentable: NSViewRepresentable {
in: containerBounds,
pageFrame: hit.pageView.frame,
inspectorFrame: hit.inspectorView.frame,
- minimumInspectorWidth: 0
+ minimumInspectorWidth: minimumInspectorWidth
)
let pageFrame = nextFrames.pageFrame
let inspectorFrame = nextFrames.inspectorFrame
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 3db83f10..3ef37061 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -2493,6 +2493,10 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
return nil
}
+ private func waitForDeveloperToolsTransitions() {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.5))
+ }
+
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
let (panel, inspector) = makePanelWithInspector()
@@ -2574,6 +2578,37 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
}
+ func testRapidToggleCoalescesToFinalVisibleIntentWithoutExtraInspectorCalls() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 0)
+
+ waitForDeveloperToolsTransitions()
+
+ XCTAssertTrue(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 0)
+ }
+
+ func testRapidToggleQueuesHideAfterOpenTransitionSettles() {
+ let (panel, inspector) = makePanelWithInspector()
+
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertTrue(panel.toggleDeveloperTools())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 0)
+
+ waitForDeveloperToolsTransitions()
+
+ XCTAssertFalse(panel.isDeveloperToolsVisible())
+ XCTAssertEqual(inspector.showCount, 1)
+ XCTAssertEqual(inspector.closeCount, 1)
+ }
+
func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
let (panel, _) = makePanelWithInspector()
@@ -9282,6 +9317,45 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
XCTAssertGreaterThan(inspectorContainer.frame.minX, 0)
}
+ func testBrowserPanelHostClaimsHostedInspectorDividerAcrossFullHeight() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 20, width: 92, height: webViewRoot.bounds.height - 40))
+ let inspectorContainer = EdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 92, y: 20, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height - 40)
+ )
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
+ "The custom DevTools divider should remain draggable at the top edge of the browser pane"
+ )
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
+ "The custom DevTools divider should remain draggable at the bottom edge of the browser pane"
+ )
+ }
+
func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
@@ -9333,6 +9407,301 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
XCTAssertGreaterThan(inspectorContainer.frame.minX, 92)
}
+ func testBrowserPanelHostKeepsInspectorResizableAfterShrinkingToMinimumWidth() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let webViewRoot = NSView(frame: host.bounds)
+ webViewRoot.autoresizingMask = [.width, .height]
+ host.addSubview(webViewRoot)
+
+ let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
+ let inspectorContainer = EdgeTransparentWKInspectorProbeView(
+ frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
+ )
+ webViewRoot.addSubview(pageView)
+ webViewRoot.addSubview(inspectorContainer)
+ contentView.layoutSubtreeIfNeeded()
+
+ let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x + 220, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThanOrEqual(
+ inspectorContainer.frame.width,
+ 120,
+ "Shrinking the DevTools pane should clamp to a recoverable minimum width"
+ )
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: 4)) === host,
+ "After clamping, the DevTools divider should still be draggable near the top edge"
+ )
+ XCTAssertTrue(
+ host.hitTest(NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.maxY - 4)) === host,
+ "After clamping, the DevTools divider should still be draggable near the bottom edge"
+ )
+ }
+
+ func testBrowserPanelHostPromotesVisibleRightDockedInspectorIntoManagedSideDock() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height + 180))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
+ "A visible right-docked inspector should not wait on async dock-configuration JS before entering the managed side-dock path"
+ )
+ XCTAssertTrue(
+ pageView.superview === inspectorView.superview && pageView.superview !== slotView,
+ "Promotion should move both hosted inspector siblings into the managed side-dock container"
+ )
+ XCTAssertEqual(
+ pageView.frame.height,
+ host.bounds.height,
+ accuracy: 0.5,
+ "Promotion should normalize stale page heights to the host height so the page layer stops covering the divider"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.height,
+ host.bounds.height,
+ accuracy: 0.5,
+ "Promotion should normalize the inspector height to the host height"
+ )
+ }
+
+ func testBrowserPanelHostAllowsRightDockedInspectorToExpandLeftAfterPromotion() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded(),
+ "The managed side-dock path should be active before drag assertions run"
+ )
+
+ let initialPageWidth = pageView.frame.width
+ let initialInspectorWidth = inspectorView.frame.width
+ let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+
+ host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x - 40, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ XCTAssertGreaterThan(
+ inspectorView.frame.width,
+ initialInspectorWidth,
+ "Right-docked DevTools should expand when the divider is dragged left"
+ )
+ XCTAssertLessThan(
+ pageView.frame.width,
+ initialPageWidth,
+ "Expanding right-docked DevTools should shrink the page width"
+ )
+ }
+
+ func testBrowserPanelHostKeepsAutomaticRightDockedWidthAboveMinimumWhileShrinking() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 140, y: 0, width: 280, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 132, height: host.bounds.height))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 132, y: 0, width: slotView.bounds.width - 132, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
+
+ host.setPreferredHostedInspectorWidth(width: 80, widthFraction: nil)
+ host.setFrameSize(NSSize(width: 210, height: host.frame.height))
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertGreaterThanOrEqual(
+ inspectorView.frame.width,
+ 120,
+ "Automatic pane resize should honor the same minimum hosted inspector width as manual dragging"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.height,
+ host.bounds.height,
+ accuracy: 0.5,
+ "Automatic shrink should keep the inspector vertically normalized to the host height"
+ )
+ }
+
+ func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 92, height: host.bounds.height))
+ let inspectorView = WKWebView(
+ frame: NSRect(x: 92, y: 0, width: slotView.bounds.width - 92, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
+
+ let dividerPointInHost = NSPoint(x: inspectorView.frame.minX + 2, y: host.bounds.midY)
+ let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
+ host.mouseDown(with: makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window))
+ let drag = makeMouseEvent(
+ type: .leftMouseDragged,
+ location: NSPoint(x: dividerPointInWindow.x - 30, y: dividerPointInWindow.y),
+ window: window
+ )
+ host.mouseDragged(with: drag)
+ host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
+
+ guard let managedContainer = pageView.superview else {
+ XCTFail("Expected managed side-dock container")
+ return
+ }
+ let draggedPageFrame = pageView.frame
+ let draggedInspectorFrame = inspectorView.frame
+
+ managedContainer.setFrameSize(
+ NSSize(width: managedContainer.frame.width, height: managedContainer.frame.height + 24)
+ )
+
+ XCTAssertEqual(
+ pageView.frame.origin.x,
+ draggedPageFrame.origin.x,
+ accuracy: 0.5,
+ "Managed side-dock container should not autoresize the page back to a stale divider position"
+ )
+ XCTAssertEqual(
+ pageView.frame.width,
+ draggedPageFrame.width,
+ accuracy: 0.5,
+ "Managed side-dock container should preserve the dragged page width until the host explicitly reapplies layout"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.origin.x,
+ draggedInspectorFrame.origin.x,
+ accuracy: 0.5,
+ "Managed side-dock container should preserve the dragged inspector origin"
+ )
+ XCTAssertEqual(
+ inspectorView.frame.width,
+ draggedInspectorFrame.width,
+ accuracy: 0.5,
+ "Managed side-dock container should preserve the dragged inspector width"
+ )
+ }
+
func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
@@ -11445,6 +11814,33 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
}
+ func testPortalSlotPinPreservesSideDockedInspectorManagedWebViewFrameOnRehost() {
+ let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 160))
+ let webView = CmuxWebView(frame: NSRect(x: 0, y: 0, width: 132, height: 160), configuration: WKWebViewConfiguration())
+ let inspectorContainer = NSView(frame: NSRect(x: 132, y: 0, width: 108, height: 160))
+ let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
+ inspectorView.autoresizingMask = [.width, .height]
+ inspectorContainer.addSubview(inspectorView)
+ slot.addSubview(webView)
+ slot.addSubview(inspectorContainer)
+
+ webView.translatesAutoresizingMaskIntoConstraints = false
+ webView.autoresizingMask = []
+ slot.pinHostedWebView(webView)
+
+ XCTAssertEqual(
+ webView.frame.maxX,
+ inspectorContainer.frame.minX,
+ accuracy: 0.5,
+ "Rehosting a portal-managed browser should preserve the WebKit-owned side inspector split"
+ )
+ XCTAssertLessThan(
+ webView.frame.width,
+ slot.bounds.width,
+ "The page frame should stay narrower than the full slot while a side-docked inspector is present"
+ )
+ }
+
func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
From c52bd24e8538b3541686a2e72a03b759411684cc Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 22:52:29 -0700
Subject: [PATCH 08/29] Avoid browser portal hide lifecycle on tab switch
---
Sources/BrowserWindowPortal.swift | 25 ++++++++++++++++++++++++-
1 file changed, 24 insertions(+), 1 deletion(-)
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index f1a9830f..ac6d9bf5 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -7,6 +7,7 @@ import WebKit
private var cmuxWindowBrowserPortalKey: UInt8 = 0
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0
+private var cmuxBrowserPortalNeedsRenderingStateReattachKey: UInt8 = 0
#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
@@ -44,7 +45,23 @@ private extension NSResponder {
}
private extension WKWebView {
+ private var browserPortalNeedsRenderingStateReattach: Bool {
+ get {
+ (objc_getAssociatedObject(self, &cmuxBrowserPortalNeedsRenderingStateReattachKey) as? NSNumber)?
+ .boolValue ?? false
+ }
+ set {
+ objc_setAssociatedObject(
+ self,
+ &cmuxBrowserPortalNeedsRenderingStateReattachKey,
+ NSNumber(value: newValue),
+ .OBJC_ASSOCIATION_RETAIN_NONATOMIC
+ )
+ }
+ }
+
func browserPortalNotifyHidden(reason: String) {
+ browserPortalNeedsRenderingStateReattach = true
let firedSelectors = ["viewDidHide", "_exitInWindow"].filter {
browserPortalCallVoidIfAvailable($0)
}
@@ -59,7 +76,9 @@ private extension WKWebView {
}
func browserPortalReattachRenderingState(reason: String) {
+ guard browserPortalNeedsRenderingStateReattach else { return }
guard window != nil else { return }
+ browserPortalNeedsRenderingStateReattach = false
let firedSelectors = [
"viewDidUnhide",
@@ -2918,7 +2937,11 @@ final class WindowBrowserPortal: NSObject {
containerView.setPaneDropContext(nil)
containerView.setPortalDragDropZone(nil)
containerView.setDropZoneOverlay(zone: nil)
- if !containerView.isHidden, webView.superview === containerView {
+ // Tab/workspace visibility changes should hide the portal slot without forcing
+ // WebKit through `_exitInWindow`/`_enterInWindow`, which fires visibilitychange
+ // and can trigger page reloads. Reserve the full lifecycle notify for cases
+ // where the visible surface is actually leaving the window/render tree.
+ if entry.visibleInUI, !containerView.isHidden, webView.superview === containerView {
webView.browserPortalNotifyHidden(reason: reason)
}
containerView.isHidden = true
From 72e7a9de768cf8736730f216f64afb030ea83aea Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 22:56:39 -0700
Subject: [PATCH 09/29] Adapt attached devtools to narrow panes
---
Sources/Panels/BrowserPanelView.swift | 108 ++++++++++++++++++
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 53 +++++++++
2 files changed, 161 insertions(+)
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 9dcb7ff5..74888012 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3715,6 +3715,17 @@ struct WebViewRepresentable: NSViewRepresentable {
final class HostContainerView: NSView {
private final class HostedInspectorSideDockContainerView: NSView {
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ wantsLayer = true
+ layer?.masksToBounds = true
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ nil
+ }
+
override var isOpaque: Bool { false }
override func resizeSubviews(withOldSize oldSize: NSSize) {
@@ -3767,6 +3778,8 @@ struct WebViewRepresentable: NSViewRepresentable {
private static let hostedInspectorDividerHitExpansion: CGFloat = 10
private static let minimumHostedInspectorWidth: CGFloat = 120
+ private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240
+ private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
@@ -3780,6 +3793,7 @@ struct WebViewRepresentable: NSViewRepresentable {
private var isApplyingHostedInspectorLayout = false
private var hostedInspectorReapplyWorkItem: DispatchWorkItem?
private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem?
+ private var adaptiveBottomDockRequestCooldownDeadline: Date?
private var lastHostedInspectorLayoutBoundsSize: NSSize?
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
@@ -4177,6 +4191,64 @@ struct WebViewRepresentable: NSViewRepresentable {
)
}
+ func normalizeHostedInspectorLayoutIfNeeded(reason: String) {
+ if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") {
+ return
+ }
+ _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
+ if isHostedInspectorSideDockActive() {
+ layoutHostedInspectorSideDockIfNeeded(reason: reason)
+ } else if !hasStoredHostedInspectorWidthPreference {
+ captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason)
+ }
+ }
+
+ private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool {
+ let containerWidth = max(0, hit.containerView.bounds.width)
+ guard containerWidth > 1 else { return false }
+
+ let currentInspectorWidth = max(0, hit.inspectorView.frame.width)
+ let currentPageWidth = max(0, hit.pageView.frame.width)
+ let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth))
+ let effectivePageWidth = min(currentPageWidth, remainingPageWidth)
+
+ return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock
+ }
+
+ @discardableResult
+ private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool {
+ let now = Date()
+ if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now {
+ return true
+ }
+ guard let hostedInspectorFrontendWebView else { return false }
+
+ adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown)
+#if DEBUG
+ dlog(
+ "browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " +
+ "host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))"
+ )
+#endif
+ hostedInspectorFrontendWebView.evaluateJavaScript(
+ "typeof WI !== 'undefined' ? WI._dockBottom() : null"
+ ) { [weak self] _, _ in
+ self?.scheduleHostedInspectorDockConfigurationSync(
+ reason: "\(reason).adaptiveBottomDock"
+ )
+ }
+ return true
+ }
+
+ @discardableResult
+ private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool {
+ guard let hit = hostedInspectorDividerCandidate(),
+ shouldForceHostedInspectorBottomDock(using: hit) else {
+ return false
+ }
+ return requestAdaptiveHostedInspectorBottomDock(reason: reason)
+ }
+
fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) {
hostedInspectorDockConfigurationSyncWorkItem?.cancel()
guard hostedInspectorFrontendWebView != nil else { return }
@@ -4202,22 +4274,37 @@ struct WebViewRepresentable: NSViewRepresentable {
case "left":
hostedInspectorSideDockDockSide = .leading
if isHostedInspectorSideDockActive() {
+ if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") {
+ return
+ }
layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft")
} else if let slotView = localInlineSlotView,
let hit = hostedInspectorDividerCandidate(in: slotView),
hit.dockSide == .leading {
+ if shouldForceHostedInspectorBottomDock(using: hit) {
+ _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft")
+ return
+ }
activateHostedInspectorSideDockIfNeeded(using: hit)
}
case "right":
hostedInspectorSideDockDockSide = .trailing
if isHostedInspectorSideDockActive() {
+ if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") {
+ return
+ }
layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight")
} else if let slotView = localInlineSlotView,
let hit = hostedInspectorDividerCandidate(in: slotView),
hit.dockSide == .trailing {
+ if shouldForceHostedInspectorBottomDock(using: hit) {
+ _ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight")
+ return
+ }
activateHostedInspectorSideDockIfNeeded(using: hit)
}
default:
+ adaptiveBottomDockRequestCooldownDeadline = nil
if isHostedInspectorSideDockActive() {
deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView)
if dockConfiguration == "bottom" {
@@ -4259,6 +4346,13 @@ struct WebViewRepresentable: NSViewRepresentable {
override func layout() {
super.layout()
_ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
+ if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") {
+ notifyGeometryChangedIfNeeded()
+#if DEBUG
+ debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
+#endif
+ return
+ }
if let previousSize = lastHostedInspectorLayoutBoundsSize,
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
// Origin-only frame churn is common while the surrounding split layout
@@ -4830,6 +4924,19 @@ struct WebViewRepresentable: NSViewRepresentable {
CATransaction.commit()
isApplyingHostedInspectorLayout = false
+ hit.pageView.needsDisplay = true
+ hit.pageView.setNeedsDisplay(hit.pageView.bounds)
+ hit.inspectorView.needsDisplay = true
+ hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
+ hit.containerView.needsDisplay = true
+ hit.containerView.setNeedsDisplay(hit.containerView.bounds)
+ if let localInlineSlotView {
+ localInlineSlotView.needsDisplay = true
+ localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds)
+ }
+ needsDisplay = true
+ setNeedsDisplay(bounds)
+
let isLiveDrag = reason == "drag"
#if DEBUG
dlog(
@@ -5176,6 +5283,7 @@ struct WebViewRepresentable: NSViewRepresentable {
webView.layoutSubtreeIfNeeded()
slotView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()
+ host.normalizeHostedInspectorLayoutIfNeeded(reason: "localInline.update.immediate")
host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync")
DispatchQueue.main.async { [weak host, weak webView] in
guard let host, let webView else { return }
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 90560272..7dcecf5a 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -9418,6 +9418,18 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
}
}
+ private final class TrackingInspectorFrontendWebView: WKWebView {
+ private(set) var evaluatedJavaScript: [String] = []
+
+ override func evaluateJavaScript(
+ _ javaScriptString: String,
+ completionHandler: ((Any?, Error?) -> Void)? = nil
+ ) {
+ evaluatedJavaScript.append(javaScriptString)
+ completionHandler?(nil, nil)
+ }
+ }
+
private final class WKInspectorProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
@@ -9861,6 +9873,47 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
)
}
+ func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer { window.orderOut(nil) }
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 280, height: contentView.bounds.height))
+ host.autoresizingMask = [.minXMargin, .height]
+ contentView.addSubview(host)
+
+ let slotView = host.ensureLocalInlineSlotView()
+ let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))
+ let inspectorView = TrackingInspectorFrontendWebView(
+ frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height)
+ )
+ slotView.addSubview(pageView)
+ slotView.addSubview(inspectorView)
+ host.pinHostedWebView(pageView, in: slotView)
+ host.setHostedInspectorFrontendWebView(inspectorView)
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
+
+ host.setFrameSize(NSSize(width: 210, height: host.frame.height))
+ contentView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+
+ XCTAssertTrue(
+ inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }),
+ "Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout"
+ )
+ }
+
func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
From f843ce2706f74e0c98714263cea29ff0182b1ef1 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 23:04:28 -0700
Subject: [PATCH 10/29] Hide unsafe side dock controls for attached devtools
---
Sources/Panels/BrowserPanelView.swift | 109 ++++++++++++++++++
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 4 +
2 files changed, 113 insertions(+)
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 74888012..5c9c780a 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3794,6 +3794,8 @@ struct WebViewRepresentable: NSViewRepresentable {
private var hostedInspectorReapplyWorkItem: DispatchWorkItem?
private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem?
private var adaptiveBottomDockRequestCooldownDeadline: Date?
+ private var recordedHostedInspectorSideDockWidth: CGFloat?
+ private var lastHostedInspectorManualSideDockAllowed: Bool?
private var lastHostedInspectorLayoutBoundsSize: NSSize?
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
@@ -3832,6 +3834,103 @@ struct WebViewRepresentable: NSViewRepresentable {
preferredHostedInspectorWidthFraction = widthFraction
}
+ private func recordHostedInspectorSideDockWidth(_ width: CGFloat) {
+ guard width > 1 else { return }
+ recordedHostedInspectorSideDockWidth = max(Self.minimumHostedInspectorWidth, width)
+ }
+
+ private func shouldAllowHostedInspectorManualSideDock() -> Bool {
+ let containerWidth = max(0, bounds.width)
+ guard containerWidth > 1 else { return true }
+ let baselineWidth = max(
+ Self.minimumHostedInspectorWidth,
+ recordedHostedInspectorSideDockWidth ?? Self.minimumHostedInspectorWidth
+ )
+ return containerWidth - baselineWidth >= Self.minimumHostedInspectorPageWidthForSideDock
+ }
+
+ private func updateHostedInspectorDockControlAvailabilityIfNeeded(reason: String) {
+ guard let hostedInspectorFrontendWebView else {
+ lastHostedInspectorManualSideDockAllowed = nil
+ return
+ }
+
+ let sideDockAllowed = shouldAllowHostedInspectorManualSideDock()
+ guard lastHostedInspectorManualSideDockAllowed != sideDockAllowed else { return }
+ lastHostedInspectorManualSideDockAllowed = sideDockAllowed
+
+ let sideDockAllowedLiteral = sideDockAllowed ? "true" : "false"
+#if DEBUG
+ let recordedWidthDesc = recordedHostedInspectorSideDockWidth.map {
+ String(format: "%.1f", $0)
+ } ?? "nil"
+ dlog(
+ "browser.panel.hostedInspector stage=\(reason).dockControls " +
+ "host=\(Self.debugObjectID(self)) allowSideDock=\(sideDockAllowed ? 1 : 0) " +
+ "recordedWidth=\(recordedWidthDesc) bounds=\(Self.debugRect(bounds))"
+ )
+#endif
+ hostedInspectorFrontendWebView.evaluateJavaScript(
+ """
+ (() => {
+ if (typeof WI === "undefined")
+ return null;
+ const allowSideDock = \(sideDockAllowedLiteral);
+ if (!WI.__cmuxOriginalUpdateDockNavigationItems && typeof WI._updateDockNavigationItems === "function")
+ WI.__cmuxOriginalUpdateDockNavigationItems = WI._updateDockNavigationItems;
+ if (!WI.__cmuxOriginalDockLeft && typeof WI._dockLeft === "function")
+ WI.__cmuxOriginalDockLeft = WI._dockLeft;
+ if (!WI.__cmuxOriginalDockRight && typeof WI._dockRight === "function")
+ WI.__cmuxOriginalDockRight = WI._dockRight;
+ if (!WI.__cmuxOriginalTogglePreviousDockConfiguration && typeof WI._togglePreviousDockConfiguration === "function")
+ WI.__cmuxOriginalTogglePreviousDockConfiguration = WI._togglePreviousDockConfiguration;
+ function callOriginal(fn, event) {
+ return typeof fn === "function" ? fn.call(WI, event) : null;
+ }
+ function updateButton(button, hidden) {
+ if (!button)
+ return;
+ button.hidden = hidden;
+ if (button.element) {
+ button.element.style.display = hidden ? "none" : "";
+ button.element.style.pointerEvents = hidden ? "none" : "";
+ }
+ }
+ function enforceDockControls() {
+ const disallowSideDock = !WI.__cmuxAllowSideDock;
+ updateButton(WI._dockLeftTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Left);
+ updateButton(WI._dockRightTabBarButton, disallowSideDock || WI.dockConfiguration === WI.DockConfiguration.Right);
+ }
+ WI.__cmuxAllowSideDock = allowSideDock;
+ WI._dockLeft = function(event) {
+ if (!WI.__cmuxAllowSideDock)
+ return callOriginal(WI._dockBottom, event);
+ return callOriginal(WI.__cmuxOriginalDockLeft, event);
+ };
+ WI._dockRight = function(event) {
+ if (!WI.__cmuxAllowSideDock)
+ return callOriginal(WI._dockBottom, event);
+ return callOriginal(WI.__cmuxOriginalDockRight, event);
+ };
+ WI._togglePreviousDockConfiguration = function(event) {
+ const previousSideDock = WI._previousDockConfiguration === WI.DockConfiguration.Left || WI._previousDockConfiguration === WI.DockConfiguration.Right;
+ if (!WI.__cmuxAllowSideDock && previousSideDock)
+ return callOriginal(WI._dockBottom, event);
+ return callOriginal(WI.__cmuxOriginalTogglePreviousDockConfiguration, event);
+ };
+ WI._updateDockNavigationItems = function(...args) {
+ if (typeof WI.__cmuxOriginalUpdateDockNavigationItems === "function")
+ WI.__cmuxOriginalUpdateDockNavigationItems.apply(WI, args);
+ enforceDockControls();
+ };
+ WI._updateDockNavigationItems();
+ return WI.__cmuxAllowSideDock;
+ })();
+ """,
+ completionHandler: nil
+ )
+ }
+
func containsManagedLocalInlineContent(_ view: NSView) -> Bool {
if let localInlineSlotView,
view === localInlineSlotView || view.isDescendant(of: localInlineSlotView) {
@@ -3856,6 +3955,8 @@ struct WebViewRepresentable: NSViewRepresentable {
func setHostedInspectorFrontendWebView(_ webView: WKWebView?) {
hostedInspectorFrontendWebView = webView
+ lastHostedInspectorManualSideDockAllowed = nil
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView")
}
private var hasStoredHostedInspectorWidthPreference: Bool {
@@ -4224,6 +4325,7 @@ struct WebViewRepresentable: NSViewRepresentable {
guard let hostedInspectorFrontendWebView else { return false }
adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown)
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason)
#if DEBUG
dlog(
"browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " +
@@ -4246,6 +4348,7 @@ struct WebViewRepresentable: NSViewRepresentable {
shouldForceHostedInspectorBottomDock(using: hit) else {
return false
}
+ recordHostedInspectorSideDockWidth(hit.inspectorView.frame.width)
return requestAdaptiveHostedInspectorBottomDock(reason: reason)
}
@@ -4315,6 +4418,7 @@ struct WebViewRepresentable: NSViewRepresentable {
}
}
}
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "\(reason).dockConfiguration")
}
override func viewDidMoveToWindow() {
@@ -4347,6 +4451,7 @@ struct WebViewRepresentable: NSViewRepresentable {
super.layout()
_ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") {
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
@@ -4363,6 +4468,7 @@ struct WebViewRepresentable: NSViewRepresentable {
!hasStoredHostedInspectorWidthPreference {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize")
}
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
@@ -4375,6 +4481,7 @@ struct WebViewRepresentable: NSViewRepresentable {
} else if !hasStoredHostedInspectorWidthPreference {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout")
}
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")
scheduleHostedInspectorDockConfigurationSync(reason: "layout")
notifyGeometryChangedIfNeeded()
#if DEBUG
@@ -4845,6 +4952,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let inspectorWidth = max(0, hit.inspectorView.frame.width)
guard inspectorWidth > 1 else { return }
+ recordHostedInspectorSideDockWidth(inspectorWidth)
let currentFraction: CGFloat? = {
guard hit.containerView.bounds.width > 0 else { return nil }
return inspectorWidth / hit.containerView.bounds.width
@@ -4915,6 +5023,7 @@ struct WebViewRepresentable: NSViewRepresentable {
guard pageChanged || inspectorChanged else {
return (pageFrame, inspectorFrame)
}
+ recordHostedInspectorSideDockWidth(inspectorFrame.width)
isApplyingHostedInspectorLayout = true
CATransaction.begin()
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 7dcecf5a..33e028a7 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -9912,6 +9912,10 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }),
"Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout"
)
+ XCTAssertTrue(
+ inspectorView.evaluatedJavaScript.contains(where: { $0.contains("const allowSideDock = false;") }),
+ "Once a narrow pane proves it cannot safely side-dock DevTools, the inspector frontend should hide and disable left/right dock controls"
+ )
}
func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() {
From f429907d57329b0b125a4364b2738751bf6a8625 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 23:18:36 -0700
Subject: [PATCH 11/29] Match WKWebView test override signature on CI
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 33e028a7..3d5501b1 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -9421,9 +9421,9 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
private final class TrackingInspectorFrontendWebView: WKWebView {
private(set) var evaluatedJavaScript: [String] = []
- override func evaluateJavaScript(
+ @MainActor override func evaluateJavaScript(
_ javaScriptString: String,
- completionHandler: ((Any?, Error?) -> Void)? = nil
+ completionHandler: (@MainActor @Sendable (Any?, (any Error)?) -> Void)? = nil
) {
evaluatedJavaScript.append(javaScriptString)
completionHandler?(nil, nil)
From 26ca65b77d0f56897bfb58a7bfc1508e5fe47df4 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Thu, 12 Mar 2026 01:26:28 -0700
Subject: [PATCH 12/29] Release v0.62.0
---
CHANGELOG.md | 36 +++++++++++++++++++++++++--
GhosttyTabs.xcodeproj/project.pbxproj | 12 ++++-----
2 files changed, 40 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7662a1ac..abae3664 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
All notable changes to cmux are documented here.
-## [0.62.0] - 2026-03-07
+## [0.62.0] - 2026-03-12
### Added
- Markdown viewer panel with live file watching ([#883](https://github.com/manaflow-ai/cmux/pull/883))
@@ -30,6 +30,12 @@ All notable changes to cmux are documented here.
- External URL bypass rules for the embedded browser ([#768](https://github.com/manaflow-ai/cmux/pull/768))
- Telemetry opt-out setting ([#610](https://github.com/manaflow-ai/cmux/pull/610))
- Browser automation docs page ([#622](https://github.com/manaflow-ai/cmux/pull/622))
+- Vim mode indicator badge on terminal panes ([#1092](https://github.com/manaflow-ai/cmux/pull/1092))
+- Sidebar workspace color in CLI sidebar_state output ([#1101](https://github.com/manaflow-ai/cmux/pull/1101))
+- Prompt before closing window with Cmd+Ctrl+W ([#1219](https://github.com/manaflow-ai/cmux/pull/1219))
+- Jump to Latest button in notifications popover ([#1167](https://github.com/manaflow-ai/cmux/pull/1167))
+- Khmer localization ([#1198](https://github.com/manaflow-ai/cmux/pull/1198))
+- cmux claude-teams launcher ([#1179](https://github.com/manaflow-ai/cmux/pull/1179))
### Changed
- Command palette search is now async and decoupled from typing for reduced lag
@@ -41,6 +47,9 @@ All notable changes to cmux are documented here.
- Feedback recipient changed to `feedback@manaflow.com` ([#1007](https://github.com/manaflow-ai/cmux/pull/1007))
- Regenerated app icons from Icon Composer ([#1005](https://github.com/manaflow-ai/cmux/pull/1005))
- Moved update logs into the Debug menu ([#1008](https://github.com/manaflow-ai/cmux/pull/1008))
+- Updated Ghostty to v1.3.0 ([#1142](https://github.com/manaflow-ai/cmux/pull/1142))
+- Welcome screen colors adapted for light mode ([#1214](https://github.com/manaflow-ai/cmux/pull/1214))
+- Notification sound picker width constrained ([#1168](https://github.com/manaflow-ai/cmux/pull/1168))
### Fixed
- Frozen blank launch from session restore race condition ([#399](https://github.com/manaflow-ai/cmux/issues/399), [#565](https://github.com/manaflow-ai/cmux/pull/565))
@@ -75,14 +84,37 @@ All notable changes to cmux are documented here.
- Voice dictation text insertion ([#857](https://github.com/manaflow-ai/cmux/pull/857))
- Browser panel lifecycle after WebContent process termination ([#892](https://github.com/manaflow-ai/cmux/pull/892))
- Typing lag reduction by hiding invisible views from the accessibility tree ([#862](https://github.com/manaflow-ai/cmux/pull/862))
+- CJK font fallback preventing decorative font rendering for CJK characters ([#1017](https://github.com/manaflow-ai/cmux/pull/1017))
+- Inline VS Code serve-web token exposure via argv ([#1033](https://github.com/manaflow-ai/cmux/pull/1033))
+- Browser pane portal anchor sizing ([#1094](https://github.com/manaflow-ai/cmux/pull/1094))
+- Pinned workspace notification reordering ([#1116](https://github.com/manaflow-ai/cmux/pull/1116))
+- cmux --version memory blowup ([#1121](https://github.com/manaflow-ai/cmux/pull/1121))
+- Notification ring dismissal on direct terminal clicks ([#1126](https://github.com/manaflow-ai/cmux/pull/1126))
+- Browser portal visibility when terminal tab is active ([#1130](https://github.com/manaflow-ai/cmux/pull/1130))
+- Browser panes reloading when switching workspaces ([#1136](https://github.com/manaflow-ai/cmux/pull/1136))
+- Sidebar PR badge detection ([#1139](https://github.com/manaflow-ai/cmux/pull/1139))
+- Browser address bar disappearing during pane zoom ([#1145](https://github.com/manaflow-ai/cmux/pull/1145))
+- Ghost terminal surface focus after split close ([#1148](https://github.com/manaflow-ai/cmux/pull/1148))
+- Browser DevTools resize loop and layout stability ([#1170](https://github.com/manaflow-ai/cmux/pull/1170), [#1173](https://github.com/manaflow-ai/cmux/pull/1173), [#1189](https://github.com/manaflow-ai/cmux/pull/1189))
+- Typing lag from sidebar re-evaluation and hitTest overhead ([#1204](https://github.com/manaflow-ai/cmux/issues/1204))
+- Browser pane stale content after drag splits ([#1215](https://github.com/manaflow-ai/cmux/pull/1215))
+- Terminal drop overlay misplacement during drag hover ([#1213](https://github.com/manaflow-ai/cmux/pull/1213))
+- Hidden browser slot inspector focus crash ([#1211](https://github.com/manaflow-ai/cmux/pull/1211))
+- Browser devtools hide fallback ([#1220](https://github.com/manaflow-ai/cmux/pull/1220))
+- Browser portal refresh on geometry churn ([#1224](https://github.com/manaflow-ai/cmux/pull/1224))
+- Browser tab switch triggering unnecessary reload ([#1228](https://github.com/manaflow-ai/cmux/pull/1228))
+- Devtools side dock guard for attached devtools ([#1230](https://github.com/manaflow-ai/cmux/pull/1230))
-### Thanks to 21 contributors!
+### Thanks to 24 contributors!
+- [@0xble](https://github.com/0xble)
- [@afxjzs](https://github.com/afxjzs)
- [@AI-per](https://github.com/AI-per)
- [@atani](https://github.com/atani)
+- [@atmigtnca](https://github.com/atmigtnca)
- [@austinywang](https://github.com/austinywang)
- [@cheulyop](https://github.com/cheulyop)
- [@ConnorCallison](https://github.com/ConnorCallison)
+- [@gonzaloserrano](https://github.com/gonzaloserrano)
- [@harukitosa](https://github.com/harukitosa)
- [@homanp](https://github.com/homanp)
- [@JLeeChan](https://github.com/JLeeChan)
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index f86d33cd..a786a5c9 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -810,7 +810,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 74;
+ CURRENT_PROJECT_VERSION = 75;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@@ -849,7 +849,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 74;
+ CURRENT_PROJECT_VERSION = 75;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@@ -925,7 +925,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 74;
+ CURRENT_PROJECT_VERSION = 75;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.0;
@@ -942,7 +942,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 74;
+ CURRENT_PROJECT_VERSION = 75;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.0;
@@ -959,7 +959,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 74;
+ CURRENT_PROJECT_VERSION = 75;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.0;
@@ -978,7 +978,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 74;
+ CURRENT_PROJECT_VERSION = 75;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.0;
From 1160c72420e5866536d19f48fc68536c5d7ad514 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 01:47:44 -0700
Subject: [PATCH 13/29] Add cmd+backtick window cycle regression test
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 113 +++++++++++++++++-
1 file changed, 109 insertions(+), 4 deletions(-)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 3d5501b1..acc89ea1 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -111,6 +111,23 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
}
+ private final class WindowCyclingActionSpy: NSObject {
+ weak var firstWindow: NSWindow?
+ weak var secondWindow: NSWindow?
+ private(set) var invocationCount = 0
+
+ @objc func cycleWindow(_ sender: Any?) {
+ invocationCount += 1
+ guard let firstWindow, let secondWindow else { return }
+
+ if NSApp.keyWindow === firstWindow {
+ secondWindow.makeKeyAndOrderFront(nil)
+ } else {
+ firstWindow.makeKeyAndOrderFront(nil)
+ }
+ }
+ }
+
private final class FirstResponderView: NSView {
override var acceptsFirstResponder: Bool { true }
}
@@ -677,15 +694,98 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
XCTAssertTrue(window.makeFirstResponder(responder))
}
+
+ @MainActor
+ func testCmdBacktickMenuActionThatChangesKeyWindowOnlyRunsOnceWhenTerminalIsFirstResponder() {
+ _ = NSApplication.shared
+ AppDelegate.installWindowResponderSwizzlesForTesting()
+
+ let firstWindow = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ let secondWindow = NSWindow(
+ contentRect: NSRect(x: 40, y: 40, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+
+ let firstContainer = NSView(frame: firstWindow.contentRect(forFrameRect: firstWindow.frame))
+ let secondContainer = NSView(frame: secondWindow.contentRect(forFrameRect: secondWindow.frame))
+ firstWindow.contentView = firstContainer
+ secondWindow.contentView = secondContainer
+
+ let firstTerminal = GhosttyNSView(frame: firstContainer.bounds)
+ firstTerminal.autoresizingMask = [.width, .height]
+ firstContainer.addSubview(firstTerminal)
+
+ let secondTerminal = GhosttyNSView(frame: secondContainer.bounds)
+ secondTerminal.autoresizingMask = [.width, .height]
+ secondContainer.addSubview(secondTerminal)
+
+ let spy = WindowCyclingActionSpy()
+ spy.firstWindow = firstWindow
+ spy.secondWindow = secondWindow
+ installMenu(
+ target: spy,
+ action: #selector(WindowCyclingActionSpy.cycleWindow(_:)),
+ key: "`",
+ modifiers: [.command]
+ )
+
+ secondWindow.orderFront(nil)
+ firstWindow.makeKeyAndOrderFront(nil)
+ defer {
+ secondWindow.orderOut(nil)
+ firstWindow.orderOut(nil)
+ }
+
+ XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal))
+ XCTAssertTrue(NSApp.keyWindow === firstWindow)
+
+ guard let event = makeKeyDownEvent(
+ key: "`",
+ modifiers: [.command],
+ keyCode: 50,
+ windowNumber: firstWindow.windowNumber
+ ) else {
+ XCTFail("Failed to construct Cmd+` event")
+ return
+ }
+
+ XCTAssertTrue(firstWindow.performKeyEquivalent(with: event))
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+
+ XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action")
+ XCTAssertTrue(NSApp.keyWindow === secondWindow, "Cmd+` should leave the next window key")
+ }
+
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
+ installMenu(
+ target: spy,
+ action: #selector(ActionSpy.didInvoke(_:)),
+ key: key,
+ modifiers: modifiers
+ )
+ }
+
+ private func installMenu(
+ target: NSObject,
+ action: Selector,
+ key: String,
+ modifiers: NSEvent.ModifierFlags
+ ) {
let mainMenu = NSMenu()
let fileItem = NSMenuItem(title: "File", action: nil, keyEquivalent: "")
let fileMenu = NSMenu(title: "File")
- let item = NSMenuItem(title: "Test Item", action: #selector(ActionSpy.didInvoke(_:)), keyEquivalent: key)
+ let item = NSMenuItem(title: "Test Item", action: action, keyEquivalent: key)
item.keyEquivalentModifierMask = modifiers
- item.target = spy
+ item.target = target
fileMenu.addItem(item)
mainMenu.addItem(fileItem)
@@ -696,13 +796,18 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
NSApp.mainMenu = mainMenu
}
- private func makeKeyDownEvent(key: String, modifiers: NSEvent.ModifierFlags, keyCode: UInt16) -> NSEvent? {
+ private func makeKeyDownEvent(
+ key: String,
+ modifiers: NSEvent.ModifierFlags,
+ keyCode: UInt16,
+ windowNumber: Int = 0
+ ) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifiers,
timestamp: ProcessInfo.processInfo.systemUptime,
- windowNumber: 0,
+ windowNumber: windowNumber,
context: nil,
characters: key,
charactersIgnoringModifiers: key,
From 2db9bb4b8617ac96640c5b0b18204dbbe378fc68 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 01:53:59 -0700
Subject: [PATCH 14/29] Fix cmd+backtick window cycling
---
Sources/AppDelegate.swift | 17 ++++++++++++++++-
Sources/Panels/CmuxWebView.swift | 8 ++++++++
2 files changed, 24 insertions(+), 1 deletion(-)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 023ee5dd..4da5cd1d 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -1682,6 +1682,21 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
) != nil
}
+/// Let AppKit own native Cmd+` window cycling so key-window changes do not
+/// re-enter our direct-to-menu shortcut path.
+func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool {
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ guard flags.contains(.command) else { return false }
+
+ let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock])
+ if event.keyCode == 50,
+ normalizedFlags == [.command] || normalizedFlags == [.command, .shift] {
+ return false
+ }
+
+ return true
+}
+
func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? {
guard let responder else { return nil }
if let ghosttyView = responder as? GhosttyNSView {
@@ -11071,7 +11086,7 @@ private extension NSWindow {
// (which walks the SwiftUI content view hierarchy) and dispatch Command-key
// events directly to the main menu. This avoids the broken SwiftUI focus path.
if firstResponderGhosttyView != nil,
- event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
+ shouldRouteCommandEquivalentDirectlyToMainMenu(event),
let mainMenu = NSApp.mainMenu {
let consumedByMenu = mainMenu.performKeyEquivalent(with: event)
#if DEBUG
diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift
index f2947851..aaf751d9 100644
--- a/Sources/Panels/CmuxWebView.swift
+++ b/Sources/Panels/CmuxWebView.swift
@@ -143,6 +143,14 @@ final class CmuxWebView: WKWebView {
return result
}
+ if !shouldRouteCommandEquivalentDirectlyToMainMenu(event) {
+ let result = super.performKeyEquivalent(with: event)
+#if DEBUG
+ handled = result
+#endif
+ return result
+ }
+
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
#if DEBUG
From f50f70dc412e9f0faff67ec2a20ac593e6729cc7 Mon Sep 17 00:00:00 2001
From: tiffanysun1
Date: Thu, 12 Mar 2026 02:19:51 -0700
Subject: [PATCH 15/29] Run version memory guard in CI
---
.github/workflows/ci.yml | 12 ++++++++++++
.github/workflows/nightly.yml | 7 +++++++
.github/workflows/release.yml | 8 ++++++++
3 files changed, 27 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7315d8ed..bbaee7cb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -151,6 +151,18 @@ jobs:
fi
fi
+ - name: Run CLI version memory guard regression
+ run: |
+ set -euo pipefail
+
+ CLI_BIN="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmux" -print -quit)"
+ if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then
+ echo "cmux CLI binary not found in DerivedData" >&2
+ exit 1
+ fi
+
+ CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py
+
tests-depot:
# Never run Depot jobs for fork pull requests (avoid billing on external PRs).
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 5b3e21a9..5c46f0a3 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -192,6 +192,13 @@ jobs:
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
+ - name: Run CLI version memory guard regression
+ run: |
+ set -euo pipefail
+ CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
+ [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; }
+ CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py
+
- name: Check whether build commit is still current main HEAD
if: needs.decide.outputs.should_publish == 'true'
id: current_head
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ec935c63..6a58f07f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -134,6 +134,14 @@ jobs:
-clonedSourcePackagesDirPath .spm-cache \
CODE_SIGNING_ALLOWED=NO build
+ - name: Run CLI version memory guard regression
+ if: steps.guard_release_assets.outputs.skip_all != 'true'
+ run: |
+ set -euo pipefail
+ CLI_BINARY="build/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
+ [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; }
+ CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py
+
- name: Inject Sparkle keys into Info.plist
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
From e9556ba5f4528e2ccb2c56bb602cc9c27a1d498b Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 02:20:10 -0700
Subject: [PATCH 16/29] Prevent background terminal focus retries from
reordering windows
---
GhosttyTabs.xcodeproj/project.pbxproj | 8 +++-
Sources/GhosttyTerminalView.swift | 17 +++++++
...sttyEnsureFocusWindowActivationTests.swift | 46 +++++++++++++++++++
3 files changed, 69 insertions(+), 2 deletions(-)
create mode 100644 cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index a786a5c9..67318d7f 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -92,6 +92,7 @@
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
+ F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
@@ -235,8 +236,9 @@
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = ""; };
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = ""; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; };
- F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; };
- A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; };
+ F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; };
+ F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; };
+ A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; };
@@ -469,6 +471,7 @@
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
+ F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
);
@@ -707,6 +710,7 @@
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
+ F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
);
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 771cfa38..65e50eaa 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -5592,6 +5592,15 @@ private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView {
}
}
+func shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: TabManager?,
+ targetTabManager: TabManager,
+ keyWindow: NSWindow?,
+ mainWindow: NSWindow?
+) -> Bool {
+ activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil)
+}
+
final class GhosttySurfaceScrollView: NSView {
enum FlashStyle {
case standardFocus
@@ -7003,6 +7012,14 @@ final class GhosttySurfaceScrollView: NSView {
}
if !window.isKeyWindow {
+ guard shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: delegate.tabManager,
+ targetTabManager: tabManager,
+ keyWindow: NSApp.keyWindow,
+ mainWindow: NSApp.mainWindow
+ ) else {
+ return
+ }
window.makeKeyAndOrderFront(nil)
}
let result = window.makeFirstResponder(surfaceView)
diff --git a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
new file mode 100644
index 00000000..219aac46
--- /dev/null
+++ b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
@@ -0,0 +1,46 @@
+import XCTest
+import AppKit
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+@MainActor
+final class GhosttyEnsureFocusWindowActivationTests: XCTestCase {
+ func testAllowsActivationForActiveManager() {
+ let activeManager = TabManager()
+ let otherManager = TabManager()
+
+ XCTAssertTrue(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: activeManager,
+ targetTabManager: activeManager,
+ keyWindow: NSWindow(),
+ mainWindow: NSWindow()
+ )
+ )
+ XCTAssertFalse(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: activeManager,
+ targetTabManager: otherManager,
+ keyWindow: NSWindow(),
+ mainWindow: NSWindow()
+ )
+ )
+ }
+
+ func testAllowsActivationWhenAppHasNoKeyOrMainWindow() {
+ let targetManager = TabManager()
+
+ XCTAssertTrue(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: nil,
+ targetTabManager: targetManager,
+ keyWindow: nil,
+ mainWindow: nil
+ )
+ )
+ }
+}
From f41a83177856a011d409ebc1f2924458773a3e1c Mon Sep 17 00:00:00 2001
From: tiffanysun1
Date: Thu, 12 Mar 2026 02:26:52 -0700
Subject: [PATCH 17/29] Stop version lookup at root
---
CLI/cmux.swift | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/CLI/cmux.swift b/CLI/cmux.swift
index 6ddd7437..2ad2a8b9 100644
--- a/CLI/cmux.swift
+++ b/CLI/cmux.swift
@@ -8172,7 +8172,7 @@ struct CMUXCLI {
}
let fileManager = FileManager.default
- var current = executableURL.deletingLastPathComponent()
+ var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj")
@@ -8193,8 +8193,7 @@ struct CMUXCLI {
}
}
- let parent = current.deletingLastPathComponent()
- if parent.path == current.path {
+ guard let parent = parentSearchURL(for: current) else {
break
}
current = parent
@@ -8263,6 +8262,22 @@ struct CMUXCLI {
return String(normalized.prefix(12))
}
+ // Foundation can walk past "/" into "/.." when repeatedly deleting path
+ // components, so stop once the canonical root is reached.
+ private func parentSearchURL(for url: URL) -> URL? {
+ let standardized = url.standardizedFileURL
+ let path = standardized.path
+ guard !path.isEmpty, path != "/" else {
+ return nil
+ }
+
+ let parent = standardized.deletingLastPathComponent().standardizedFileURL
+ guard parent.path != path else {
+ return nil
+ }
+ return parent
+ }
+
private func candidateInfoPlistURLs() -> [URL] {
guard let executableURL = resolvedExecutableURL() else {
return []
@@ -8280,7 +8295,7 @@ struct CMUXCLI {
candidates.append(url)
}
- var current = executableURL.deletingLastPathComponent()
+ var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.pathExtension == "app" {
appendIfExisting(current.appendingPathComponent("Contents/Info.plist"))
@@ -8299,8 +8314,7 @@ struct CMUXCLI {
break
}
- let parent = current.deletingLastPathComponent()
- if parent.path == current.path {
+ guard let parent = parentSearchURL(for: current) else {
break
}
current = parent
From 9bd22d5d98afdf020a0c75d0075d558123fd7a54 Mon Sep 17 00:00:00 2001
From: tiffanysun1
Date: Thu, 12 Mar 2026 02:34:04 -0700
Subject: [PATCH 18/29] Pick newest CLI binary in CI
---
.github/workflows/ci.yml | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bbaee7cb..22933f48 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -155,7 +155,12 @@ jobs:
run: |
set -euo pipefail
- CLI_BIN="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmux" -print -quit)"
+ CLI_BIN="$(
+ find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmux" -exec stat -f '%m %N' {} \; \
+ | sort -nr \
+ | head -1 \
+ | cut -d' ' -f2-
+ )"
if [ -z "${CLI_BIN:-}" ] || [ ! -x "$CLI_BIN" ]; then
echo "cmux CLI binary not found in DerivedData" >&2
exit 1
From 85f9ad6ee99431454d22e656feea5d83c62b2d3a Mon Sep 17 00:00:00 2001
From: tiffanysun1
Date: Thu, 12 Mar 2026 02:35:23 -0700
Subject: [PATCH 19/29] Polish version guard follow-ups
---
CLI/cmux.swift | 4 ++--
tests/test_cli_version_memory_guard.py | 2 ++
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/CLI/cmux.swift b/CLI/cmux.swift
index 2ad2a8b9..cc3ee89e 100644
--- a/CLI/cmux.swift
+++ b/CLI/cmux.swift
@@ -8327,8 +8327,8 @@ struct CMUXCLI {
}
let searchRoots = [
- executableURL.deletingLastPathComponent(),
- executableURL.deletingLastPathComponent().deletingLastPathComponent()
+ executableURL.deletingLastPathComponent().standardizedFileURL,
+ executableURL.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL
]
for root in searchRoots {
guard let entries = fileManager.enumerator(
diff --git a/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py
index 0a1c5bd1..6252ea5e 100644
--- a/tests/test_cli_version_memory_guard.py
+++ b/tests/test_cli_version_memory_guard.py
@@ -83,6 +83,8 @@ def build_fixture(root: str, cli_path: str) -> str:
with open(os.path.join(contents_path, "Info.plist"), "wb") as handle:
plistlib.dump(info, handle)
+ # Regular files are enough here because the fallback scan keys off the
+ # ".app" suffix before it ever tries to inspect bundle contents.
for index in range(JUNK_APP_COUNT):
open(os.path.join(resources_path, f"junk-{index:05d}.app"), "wb").close()
From 8d5a1f611db99b06f7b9bc7a248d10e8d7c79167 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 02:46:30 -0700
Subject: [PATCH 20/29] Resync terminal portals after sidebar changes (#1253)
* Add regression test for portal ancestor shifts
* Resync terminal portals after sidebar changes
* Restore safeHelp view helper
* Fix portal geometry regression test harness
---
Sources/Backport.swift | 9 +++
Sources/ContentView.swift | 4 +
Sources/TerminalWindowPortal.swift | 14 +++-
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 74 +++++++++++++++++++
4 files changed, 100 insertions(+), 1 deletion(-)
diff --git a/Sources/Backport.swift b/Sources/Backport.swift
index d1bb5461..b6a1ec3b 100644
--- a/Sources/Backport.swift
+++ b/Sources/Backport.swift
@@ -7,6 +7,15 @@ struct Backport {
extension View {
var backport: Backport { Backport(content: self) }
+
+ @ViewBuilder
+ func safeHelp(_ text: String) -> some View {
+ if text.isEmpty {
+ self
+ } else {
+ self.help(text)
+ }
+ }
}
extension Scene {
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 3dfbd0c8..4f3c0725 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -2591,10 +2591,14 @@ struct ContentView: View {
if abs(sidebarState.persistedWidth - sanitized) > 0.5 {
sidebarState.persistedWidth = sanitized
}
+ // Sidebar width changes are pure SwiftUI layout updates, so portal-hosted
+ // terminals need an explicit post-layout geometry resync.
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
updateSidebarResizerBandState()
})
view = AnyView(view.onChange(of: sidebarState.isVisible) { _ in
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
updateSidebarResizerBandState()
})
diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift
index a0b890f0..b44fbffb 100644
--- a/Sources/TerminalWindowPortal.swift
+++ b/Sources/TerminalWindowPortal.swift
@@ -724,7 +724,7 @@ final class WindowTerminalPortal: NSObject {
return frameInContainer.width > 1 && frameInContainer.height > 1
}
- private func synchronizeAllEntriesFromExternalGeometryChange() {
+ fileprivate func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
synchronizeAllHostedViews(excluding: nil)
@@ -1635,6 +1635,7 @@ final class WindowTerminalPortal: NSObject {
enum TerminalWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
+ private static var hasPendingExternalGeometrySyncForAllWindows = false
#if DEBUG
private static var blockedBindCount: Int = 0
private static var blockedBindReasons: [String: Int] = [:]
@@ -1780,6 +1781,17 @@ enum TerminalWindowPortalRegistry {
portal.synchronizeHostedViewForAnchor(anchorView)
}
+ static func scheduleExternalGeometrySynchronizeForAllWindows() {
+ guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return }
+ Self.hasPendingExternalGeometrySyncForAllWindows = true
+ DispatchQueue.main.async {
+ Self.hasPendingExternalGeometrySyncForAllWindows = false
+ for portal in Self.portalsByWindowId.values {
+ portal.synchronizeAllEntriesFromExternalGeometryChange()
+ }
+ }
+ }
+
static func hideHostedView(_ hostedView: GhosttySurfaceScrollView) {
let hostedId = ObjectIdentifier(hostedView)
guard let windowId = hostedToWindowId[hostedId],
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 3d5501b1..902d085b 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -11861,6 +11861,80 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
portal.synchronizeHostedViewForAnchor(anchor)
XCTAssertFalse(hosted.isHidden, "Portal should unhide after geometry is usable")
}
+
+ func testScheduledExternalGeometrySyncRefreshesAncestorLayoutShift() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 700, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+ defer {
+ NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
+ window.orderOut(nil)
+ }
+
+ realizeWindowLayout(window)
+ guard let contentView = window.contentView else {
+ XCTFail("Expected content view")
+ return
+ }
+
+ let shiftedContainer = NSView(frame: NSRect(x: 120, y: 60, width: 220, height: 160))
+ contentView.addSubview(shiftedContainer)
+ let anchor = NSView(frame: NSRect(x: 24, y: 28, width: 72, height: 56))
+ shiftedContainer.addSubview(anchor)
+
+ let surface = TerminalSurface(
+ tabId: UUID(),
+ context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
+ configTemplate: nil,
+ workingDirectory: nil
+ )
+ let hosted = surface.hostedView
+ TerminalWindowPortalRegistry.bind(
+ hostedView: hosted,
+ to: anchor,
+ visibleInUI: true,
+ expectedSurfaceId: surface.id,
+ expectedGeneration: surface.portalBindingGeneration()
+ )
+ TerminalWindowPortalRegistry.synchronizeForAnchor(anchor)
+
+ let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
+ let originalWindowPoint = anchor.convert(anchorCenter, to: nil)
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "Initial hit-testing should resolve the portal-hosted terminal at its original window position"
+ )
+
+ shiftedContainer.frame.origin.x += 96
+ contentView.layoutSubtreeIfNeeded()
+ window.displayIfNeeded()
+
+ let shiftedWindowPoint = anchor.convert(anchorCenter, to: nil)
+ XCTAssertNotEqual(originalWindowPoint.x, shiftedWindowPoint.x, accuracy: 0.5)
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
+ "Ancestor-only layout shifts should leave the portal stale until an external geometry sync runs"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "Before the external geometry sync, hit-testing should still point at the stale portal location"
+ )
+
+ TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows()
+ RunLoop.current.run(until: Date().addingTimeInterval(0.05))
+
+ XCTAssertNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window),
+ "The stale portal position should be cleared after the scheduled external geometry sync"
+ )
+ XCTAssertNotNil(
+ TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window),
+ "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position"
+ )
+ }
}
@MainActor
From b7e7864ed47a3b58a9e2b547c2e9ce5c31e5cbd7 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 02:46:54 -0700
Subject: [PATCH 21/29] Tighten Cmd+` regression coverage
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 55 +++++++++++++++++--
...sttyEnsureFocusWindowActivationTests.swift | 18 +++++-
2 files changed, 68 insertions(+), 5 deletions(-)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index acc89ea1..ca6c6ecd 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -744,8 +744,6 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
XCTAssertTrue(firstWindow.makeFirstResponder(firstTerminal))
- XCTAssertTrue(NSApp.keyWindow === firstWindow)
-
guard let event = makeKeyDownEvent(
key: "`",
modifiers: [.command],
@@ -756,11 +754,60 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
return
}
- XCTAssertTrue(firstWindow.performKeyEquivalent(with: event))
+ NSApp.sendEvent(event)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action")
- XCTAssertTrue(NSApp.keyWindow === secondWindow, "Cmd+` should leave the next window key")
+ }
+
+ @MainActor
+ func testCmdBacktickDoesNotRouteDirectlyToMainMenuWhenWebViewIsFirstResponder() {
+ _ = NSApplication.shared
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
+ styleMask: [.titled, .closable],
+ backing: .buffered,
+ defer: false
+ )
+
+ let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ window.contentView = container
+
+ let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration())
+ webView.autoresizingMask = [.width, .height]
+ container.addSubview(webView)
+
+ let spy = ActionSpy()
+ installMenu(
+ target: spy,
+ action: #selector(ActionSpy.didInvoke(_:)),
+ key: "`",
+ modifiers: [.command]
+ )
+
+ window.makeKeyAndOrderFront(nil)
+ defer {
+ window.orderOut(nil)
+ }
+
+ XCTAssertTrue(window.makeFirstResponder(webView))
+ guard let event = makeKeyDownEvent(
+ key: "`",
+ modifiers: [.command],
+ keyCode: 50,
+ windowNumber: window.windowNumber
+ ) else {
+ XCTFail("Failed to construct Cmd+` event")
+ return
+ }
+
+ XCTAssertFalse(shouldRouteCommandEquivalentDirectlyToMainMenu(event))
+ _ = webView.performKeyEquivalent(with: event)
+ XCTAssertFalse(
+ spy.invoked,
+ "CmuxWebView should not route Cmd+` directly to the menu when WebKit is first responder"
+ )
}
private func installMenu(spy: ActionSpy, key: String, modifiers: NSEvent.ModifierFlags) {
diff --git a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
index 219aac46..e2718c9a 100644
--- a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
+++ b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
@@ -31,7 +31,7 @@ final class GhosttyEnsureFocusWindowActivationTests: XCTestCase {
)
}
- func testAllowsActivationWhenAppHasNoKeyOrMainWindow() {
+ func testAllowsActivationWhenAppHasNoKeyAndNoMainWindow() {
let targetManager = TabManager()
XCTAssertTrue(
@@ -42,5 +42,21 @@ final class GhosttyEnsureFocusWindowActivationTests: XCTestCase {
mainWindow: nil
)
)
+ XCTAssertFalse(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: nil,
+ targetTabManager: targetManager,
+ keyWindow: NSWindow(),
+ mainWindow: nil
+ )
+ )
+ XCTAssertFalse(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: nil,
+ targetTabManager: targetManager,
+ keyWindow: nil,
+ mainWindow: NSWindow()
+ )
+ )
}
}
From 64b37fc1218cb45f4d18a3189b833ce2530d62f3 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 03:19:35 -0700
Subject: [PATCH 22/29] Add Cmd+T (New tab) to welcome screen shortcuts (#1258)
---
CLI/cmux.swift | 1 +
1 file changed, 1 insertion(+)
diff --git a/CLI/cmux.swift b/CLI/cmux.swift
index cc3ee89e..8346d1ab 100644
--- a/CLI/cmux.swift
+++ b/CLI/cmux.swift
@@ -8074,6 +8074,7 @@ struct CMUXCLI {
\(bold)Shortcuts\(reset)
\(bold)\u{2318}N\(reset)\(subdued) New workspace\(reset)
+ \(bold)\u{2318}T\(reset)\(subdued) New tab\(reset)
\(bold)\u{2318}P\(reset)\(subdued) Go to workspace\(reset)
\(bold)\u{2318}D\(reset)\(subdued) Split right\(reset)
\(bold)\u{2318}\u{21E7}D\(reset)\(subdued) Split down\(reset)
From 459a0289c199f138be22a3bf0e11f89f9fdc758c Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 03:27:10 -0700
Subject: [PATCH 23/29] Fix titlebar shortcut hint clipping (#1259)
* Add regression test for titlebar shortcut hint clipping
* Fix titlebar shortcut hint clipping
---
Sources/Update/UpdateTitlebarAccessory.swift | 14 ++++++++++----
cmuxTests/UpdatePillReleaseVisibilityTests.swift | 15 +++++++++++++++
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift
index 1df2b75a..984df39c 100644
--- a/Sources/Update/UpdateTitlebarAccessory.swift
+++ b/Sources/Update/UpdateTitlebarAccessory.swift
@@ -193,6 +193,14 @@ struct ShortcutHintHorizontalPlanner {
}
}
+func titlebarShortcutHintHeight(for config: TitlebarControlsStyleConfig) -> CGFloat {
+ max(14, config.iconSize + 1)
+}
+
+func titlebarShortcutHintVerticalOffset(for config: TitlebarControlsStyleConfig) -> CGFloat {
+ max(0, floor(config.buttonSize - titlebarShortcutHintHeight(for: config)))
+}
+
struct TitlebarControlButton: View {
let config: TitlebarControlsStyleConfig
let action: () -> Void
@@ -240,7 +248,6 @@ struct TitlebarControlsView: View {
@StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor()
private let titlebarHintRightSafetyShift: CGFloat = 10
private let titlebarHintBaseXShift: CGFloat = -10
- private let titlebarHintBaseYShift: CGFloat = 1
private enum HintSlot: Int, CaseIterable {
case toggleSidebar
@@ -304,7 +311,7 @@ struct TitlebarControlsView: View {
}
private func titlebarHintVerticalBaseOffset(for config: TitlebarControlsStyleConfig) -> CGFloat {
- max(8, config.buttonSize * 0.4)
+ titlebarShortcutHintVerticalOffset(for: config)
}
@ViewBuilder
@@ -452,7 +459,6 @@ struct TitlebarControlsView: View {
) -> some View {
let yOffset = config.groupPadding.top
+ titlebarHintVerticalBaseOffset(for: config)
- + titlebarHintBaseYShift
+ ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)
ZStack(alignment: .topLeading) {
@@ -480,7 +486,7 @@ struct TitlebarControlsView: View {
.foregroundColor(.primary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
- .frame(minHeight: max(14, config.iconSize + 1))
+ .frame(minHeight: titlebarShortcutHintHeight(for: config))
.background(ShortcutHintPillBackground())
}
diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift
index 96826edf..1225c111 100644
--- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift
+++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift
@@ -166,6 +166,21 @@ final class TitlebarControlsSizingPolicyTests: XCTestCase {
)
XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: baseline, next: changed))
}
+
+ func testShortcutHintVerticalOffsetKeepsPillInsideButtonLane() {
+ for style in TitlebarControlsStyle.allCases {
+ let config = style.config
+ let hintHeight = titlebarShortcutHintHeight(for: config)
+ let verticalOffset = titlebarShortcutHintVerticalOffset(for: config)
+
+ XCTAssertGreaterThanOrEqual(verticalOffset, 0, "Expected non-negative hint offset for style \(style)")
+ XCTAssertLessThanOrEqual(
+ verticalOffset + hintHeight,
+ config.buttonSize,
+ "Expected hint pill to fit within the titlebar button lane for style \(style)"
+ )
+ }
+ }
}
final class TitlebarControlsHoverPolicyTests: XCTestCase {
From 9b529a128b3fe3419edc3f3b74f8c30e2e87fac7 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 03:39:03 -0700
Subject: [PATCH 24/29] test: cover Pure-style zsh prompt redraws
---
.../test_ghostty_zsh_pure_preprompt_redraw.py | 222 ++++++++++++++++++
1 file changed, 222 insertions(+)
create mode 100644 tests/test_ghostty_zsh_pure_preprompt_redraw.py
diff --git a/tests/test_ghostty_zsh_pure_preprompt_redraw.py b/tests/test_ghostty_zsh_pure_preprompt_redraw.py
new file mode 100644
index 00000000..76efa13f
--- /dev/null
+++ b/tests/test_ghostty_zsh_pure_preprompt_redraw.py
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""
+Regression: Ghostty's zsh integration must not leave stale Pure-style preprompt
+lines behind after an async redraw.
+
+Pure does not render its top path/git line as a static multiline PS1. Instead,
+it rewrites PROMPT with a special newline sequence and later calls
+`zle .reset-prompt` when async git info arrives. Plain zsh redraws that cleanly.
+The Ghostty integration currently leaves stale copies of the old top line behind.
+
+This test uses a minimal Pure-like prompt implementation as a control:
+- plain zsh must redraw without stale preprompt lines
+- Ghostty-integrated zsh must match that behavior
+"""
+
+from __future__ import annotations
+
+import os
+import pty
+import re
+import select
+import shutil
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+
+
+_MINIMAL_PURE_ZSHRC = r"""
+setopt promptsubst nopromptcr nopromptsp
+prompt_newline=$'\n%{\r%}'
+
+typeset -g CMUX_TOP='%F{4}%~%f'
+typeset -g CMUX_LAST_PROMPT=''
+typeset -gi CMUX_ASYNC_DONE=0
+typeset -g CMUX_ASYNC_FD=''
+
+cmux_render_prompt() {
+ local cleaned_ps1=$PROMPT
+ if [[ $PROMPT = *$prompt_newline* ]]; then
+ cleaned_ps1=${PROMPT##*${prompt_newline}}
+ fi
+
+ PROMPT="${CMUX_TOP}${prompt_newline}${cleaned_ps1:-%F{5}❯%f }"
+
+ local expanded_prompt="${(S%%)PROMPT}"
+ if [[ ${1:-} == precmd ]]; then
+ print
+ elif [[ $CMUX_LAST_PROMPT != $expanded_prompt ]]; then
+ zle && zle .reset-prompt
+ fi
+ typeset -g CMUX_LAST_PROMPT=$expanded_prompt
+}
+
+cmux_async_ready() {
+ emulate -L zsh
+ local fd="${1:-$CMUX_ASYNC_FD}"
+ if [[ -n $fd ]]; then
+ zle -F "$fd"
+ exec {fd}<&-
+ fi
+ CMUX_ASYNC_FD=''
+
+ (( CMUX_ASYNC_DONE )) && return
+ CMUX_ASYNC_DONE=1
+ CMUX_TOP='%F{4}%~%f %F{242}main%f%F{218}*%f %F{6}⇣⇡%f'
+ cmux_render_prompt async
+}
+
+precmd() {
+ CMUX_ASYNC_DONE=0
+ cmux_render_prompt precmd
+}
+
+cmux_line_init() {
+ if (( !CMUX_ASYNC_DONE )) && [[ -z $CMUX_ASYNC_FD ]]; then
+ exec {CMUX_ASYNC_FD}< <(
+ sleep 0.05
+ printf 'ready\n'
+ )
+ zle -F "$CMUX_ASYNC_FD" cmux_async_ready
+ fi
+}
+
+zle -N zle-line-init cmux_line_init
+PROMPT='%F{5}❯%f '
+""".lstrip()
+
+_ANSI_RE = re.compile(rb"\x1b\][^\x07]*\x07|\x1b\[[0-9;?]*[ -/]*[@-~]|\r")
+
+
+def _capture_session(*, use_ghostty: bool, wrapper_dir: Path, resources_dir: Path, workdir: Path) -> str:
+ base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_pure_preprompt_"))
+ try:
+ home = base / "home"
+ home.mkdir(parents=True, exist_ok=True)
+ (home / ".zshrc").write_text(_MINIMAL_PURE_ZSHRC, encoding="utf-8")
+
+ env = dict(os.environ)
+ env["HOME"] = str(home)
+ env["TERM"] = "xterm-256color"
+ env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
+ if use_ghostty:
+ env["ZDOTDIR"] = str(wrapper_dir)
+ env["GHOSTTY_ZSH_ZDOTDIR"] = str(home)
+ env["GHOSTTY_RESOURCES_DIR"] = str(resources_dir)
+ else:
+ env["ZDOTDIR"] = str(home)
+ env.pop("GHOSTTY_ZSH_ZDOTDIR", None)
+ env.pop("GHOSTTY_RESOURCES_DIR", None)
+
+ master, slave = pty.openpty()
+ proc = subprocess.Popen(
+ ["zsh", "-d", "-i"],
+ cwd=str(workdir),
+ stdin=slave,
+ stdout=slave,
+ stderr=slave,
+ env=env,
+ close_fds=True,
+ )
+ os.close(slave)
+
+ output = bytearray()
+ start = time.time()
+ phase = 0
+ try:
+ while time.time() - start < 4.5:
+ readable, _, _ = select.select([master], [], [], 0.2)
+ if master in readable:
+ try:
+ chunk = os.read(master, 4096)
+ except OSError:
+ break
+ if not chunk:
+ break
+ output.extend(chunk)
+
+ elapsed = time.time() - start
+ if phase == 0 and elapsed > 1.2:
+ os.write(master, b"\n")
+ phase = 1
+ elif phase == 1 and elapsed > 2.8:
+ os.write(master, b"exit\n")
+ phase = 2
+ finally:
+ try:
+ proc.wait(timeout=5)
+ finally:
+ os.close(master)
+
+ cleaned = _ANSI_RE.sub(b"", bytes(output)).decode("utf-8", errors="replace")
+ return cleaned
+ finally:
+ shutil.rmtree(base, ignore_errors=True)
+
+
+def _stale_preprompt_lines(cleaned: str, path_line: str, async_line: str) -> tuple[int, int]:
+ marker = cleaned.find(async_line)
+ if marker == -1:
+ return (-1, -1)
+
+ tail = cleaned[marker + len(async_line) :]
+ return (tail.count(path_line), tail.count(async_line))
+
+
+def main() -> int:
+ root = Path(__file__).resolve().parents[1]
+ wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh"
+ resources_dir = root / "ghostty" / "src"
+ workdir = root
+
+ if not (wrapper_dir / ".zshenv").exists():
+ print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}")
+ return 0
+ if shutil.which("zsh") is None:
+ print("SKIP: zsh not installed")
+ return 0
+
+ path_line = f"{workdir}\n"
+ async_line = f"{workdir} main* ⇣⇡"
+
+ plain = _capture_session(
+ use_ghostty=False,
+ wrapper_dir=wrapper_dir,
+ resources_dir=resources_dir,
+ workdir=workdir,
+ )
+ ghostty = _capture_session(
+ use_ghostty=True,
+ wrapper_dir=wrapper_dir,
+ resources_dir=resources_dir,
+ workdir=workdir,
+ )
+
+ plain_stale, plain_async = _stale_preprompt_lines(plain, path_line, async_line)
+ ghostty_stale, ghostty_async = _stale_preprompt_lines(ghostty, path_line, async_line)
+
+ if plain_stale < 0:
+ print("FAIL: plain zsh control never rendered the async preprompt line")
+ return 1
+ if ghostty_stale < 0:
+ print("FAIL: Ghostty zsh integration never rendered the async preprompt line")
+ return 1
+
+ if plain_stale != 0:
+ print(f"FAIL: plain zsh control left stale preprompt lines behind ({plain_stale})")
+ return 1
+
+ if ghostty_stale != plain_stale:
+ print(
+ "FAIL: Ghostty zsh integration left stale preprompt lines behind "
+ f"(ghostty={ghostty_stale}, plain={plain_stale}, async_renders={ghostty_async})"
+ )
+ return 1
+
+ print("PASS: Ghostty zsh integration redraws Pure-style preprompts without stale lines")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 3c58e8c5ca3714eeae1ca0fcee678f01505415f6 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 03:39:17 -0700
Subject: [PATCH 25/29] ghostty: fix Pure-style multiline prompt redraws
---
ghostty | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ghostty b/ghostty
index 8ade43ce..0cf55958 160000
--- a/ghostty
+++ b/ghostty
@@ -1 +1 @@
-Subproject commit 8ade43ce52cda470ffe07e963ab7722c38380792
+Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe
From 473b2a0d1c6f92cc197428bc3d011fb32071a024 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 03:44:06 -0700
Subject: [PATCH 26/29] docs: clarify fork head and test env setup
---
docs/ghostty-fork.md | 14 ++++++++++++--
tests/test_ghostty_zsh_pure_preprompt_redraw.py | 3 ++-
2 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md
index dd3d6dc5..36189e2d 100644
--- a/docs/ghostty-fork.md
+++ b/docs/ghostty-fork.md
@@ -71,7 +71,16 @@ section 3 copy-mode commit, even though the section 4 resize commits were applie
- Emits one `OSC 133;A` fresh-prompt mark for real prompt transitions.
- Uses `OSC 133;P` markers for prompt redraws so async zsh themes do not look like extra prompt lines.
-The fork branch HEAD is now the section 5 zsh redraw commit.
+### 6) zsh Pure-style multiline prompt redraws
+
+- Commit: `0cf559581` (zsh: fix Pure-style multiline prompt redraws)
+- Files:
+ - `src/shell-integration/zsh/ghostty-integration`
+- Summary:
+ - Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line.
+ - Places the continuation marker after Pure's hidden carriage return so async redraws do not leave stale preprompt lines behind.
+
+The fork branch HEAD is now the section 6 zsh redraw commit.
## Upstreamed fork changes
@@ -93,6 +102,7 @@ These files change frequently upstream; be careful when rebasing the fork:
- `src/shell-integration/zsh/ghostty-integration`
- Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the
- `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes.
+ `OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes, and preserve the special
+ handling for Pure-style `\n%{\r%}` prompt newlines.
If you resolve a conflict, update this doc with what changed.
diff --git a/tests/test_ghostty_zsh_pure_preprompt_redraw.py b/tests/test_ghostty_zsh_pure_preprompt_redraw.py
index 76efa13f..f59fe75e 100644
--- a/tests/test_ghostty_zsh_pure_preprompt_redraw.py
+++ b/tests/test_ghostty_zsh_pure_preprompt_redraw.py
@@ -99,7 +99,8 @@ def _capture_session(*, use_ghostty: bool, wrapper_dir: Path, resources_dir: Pat
env = dict(os.environ)
env["HOME"] = str(home)
env["TERM"] = "xterm-256color"
- env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
+ env.pop("GHOSTTY_SHELL_FEATURES", None)
+ env.pop("GHOSTTY_BIN_DIR", None)
if use_ghostty:
env["ZDOTDIR"] = str(wrapper_dir)
env["GHOSTTY_ZSH_ZDOTDIR"] = str(home)
From 4d0472360006c93dcacedb57848ba46ad4f09d6f Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 03:48:14 -0700
Subject: [PATCH 27/29] build: pin GhosttyKit checksum for prompt redraw fix
---
scripts/ghosttykit-checksums.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt
index 8ab36d3b..b3818784 100644
--- a/scripts/ghosttykit-checksums.txt
+++ b/scripts/ghosttykit-checksums.txt
@@ -3,3 +3,4 @@
# Format:
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
+0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
From 6258fbb48261aeb3efbe1b3c690a6a44969c108c Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 03:50:53 -0700
Subject: [PATCH 28/29] tests: resolve zsh paths in redraw regressions
---
docs/ghostty-fork.md | 5 +++--
...hostty_zsh_prompt_redraw_uses_prompt_start.py | 9 +++++----
tests/test_ghostty_zsh_pure_preprompt_redraw.py | 16 +++++++++++++---
3 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md
index 36189e2d..30ba29bf 100644
--- a/docs/ghostty-fork.md
+++ b/docs/ghostty-fork.md
@@ -45,8 +45,9 @@ Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 12, 20
### 4) macOS resize stale-frame mitigation
-Sections 3 and 4 are grouped by feature, not by commit order. The fork branch HEAD is the
-section 3 copy-mode commit, even though the section 4 resize commits were applied earlier.
+Sections 3 and 4 are grouped by feature, not by commit order. The section 4 resize commits were
+applied earlier than the section 3 copy-mode commit, but they are kept together here because they
+touch the same stale-frame mitigation path and tend to conflict in the same files during rebases.
- Commits:
- `769bbf7a9` (macos: reduce transient blank/scaled frames during resize)
diff --git a/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
index 7827265d..32ff0d64 100644
--- a/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
+++ b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
@@ -73,10 +73,10 @@ zle -N zle-line-init _cmux_redraw_line_init
)
-def _capture_session(env: dict[str, str]) -> bytes:
+def _capture_session(env: dict[str, str], zsh_path: str) -> bytes:
master, slave = pty.openpty()
proc = subprocess.Popen(
- ["zsh", "-d", "-i"],
+ [zsh_path, "-d", "-i"],
stdin=slave,
stdout=slave,
stderr=slave,
@@ -123,7 +123,8 @@ def main() -> int:
print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}")
return 0
- if shutil.which("zsh") is None:
+ zsh_path = shutil.which("zsh")
+ if zsh_path is None:
print("SKIP: zsh not installed")
return 0
@@ -141,7 +142,7 @@ def main() -> int:
env.pop("GHOSTTY_SHELL_FEATURES", None)
env.pop("GHOSTTY_BIN_DIR", None)
- output = _capture_session(env)
+ output = _capture_session(env, zsh_path)
marker = output.find(END_COMMAND)
if marker == -1:
diff --git a/tests/test_ghostty_zsh_pure_preprompt_redraw.py b/tests/test_ghostty_zsh_pure_preprompt_redraw.py
index f59fe75e..ab916fe5 100644
--- a/tests/test_ghostty_zsh_pure_preprompt_redraw.py
+++ b/tests/test_ghostty_zsh_pure_preprompt_redraw.py
@@ -89,7 +89,14 @@ PROMPT='%F{5}❯%f '
_ANSI_RE = re.compile(rb"\x1b\][^\x07]*\x07|\x1b\[[0-9;?]*[ -/]*[@-~]|\r")
-def _capture_session(*, use_ghostty: bool, wrapper_dir: Path, resources_dir: Path, workdir: Path) -> str:
+def _capture_session(
+ *,
+ use_ghostty: bool,
+ wrapper_dir: Path,
+ resources_dir: Path,
+ workdir: Path,
+ zsh_path: str,
+) -> str:
base = Path(tempfile.mkdtemp(prefix="cmux_ghostty_pure_preprompt_"))
try:
home = base / "home"
@@ -112,7 +119,7 @@ def _capture_session(*, use_ghostty: bool, wrapper_dir: Path, resources_dir: Pat
master, slave = pty.openpty()
proc = subprocess.Popen(
- ["zsh", "-d", "-i"],
+ [zsh_path, "-d", "-i"],
cwd=str(workdir),
stdin=slave,
stdout=slave,
@@ -174,7 +181,8 @@ def main() -> int:
if not (wrapper_dir / ".zshenv").exists():
print(f"SKIP: missing Ghostty zsh wrapper at {wrapper_dir}")
return 0
- if shutil.which("zsh") is None:
+ zsh_path = shutil.which("zsh")
+ if zsh_path is None:
print("SKIP: zsh not installed")
return 0
@@ -186,12 +194,14 @@ def main() -> int:
wrapper_dir=wrapper_dir,
resources_dir=resources_dir,
workdir=workdir,
+ zsh_path=zsh_path,
)
ghostty = _capture_session(
use_ghostty=True,
wrapper_dir=wrapper_dir,
resources_dir=resources_dir,
workdir=workdir,
+ zsh_path=zsh_path,
)
plain_stale, plain_async = _stale_preprompt_lines(plain, path_line, async_line)
From df065790d55903675d1dba026f62ac4ddeb67f49 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Thu, 12 Mar 2026 04:12:36 -0700
Subject: [PATCH 29/29] Sync translated READMEs with English version (#1261)
Add Khmer (README.km.md) to all 17 other translations' language
selectors. Fix README.km.md: add translation disclaimer, download
badge in Install section, translate feature table headings/descriptions
and browser section note from English to Khmer.
---
README.ar.md | 2 +-
README.bs.md | 2 +-
README.da.md | 2 +-
README.de.md | 2 +-
README.es.md | 2 +-
README.fr.md | 2 +-
README.it.md | 2 +-
README.ja.md | 2 +-
README.km.md | 24 +++++++++++++++---------
README.ko.md | 2 +-
README.no.md | 2 +-
README.pl.md | 2 +-
README.pt-BR.md | 2 +-
README.ru.md | 2 +-
README.th.md | 2 +-
README.tr.md | 2 +-
README.zh-CN.md | 2 +-
README.zh-TW.md | 2 +-
18 files changed, 32 insertions(+), 26 deletions(-)
diff --git a/README.ar.md b/README.ar.md
index 82a77bd0..86dddcd9 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.bs.md b/README.bs.md
index 603978dc..6782fa3a 100644
--- a/README.bs.md
+++ b/README.bs.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.da.md b/README.da.md
index 588d9c09..db36c1df 100644
--- a/README.da.md
+++ b/README.da.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.de.md b/README.de.md
index b04fd471..68bb81ff 100644
--- a/README.de.md
+++ b/README.de.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.es.md b/README.es.md
index 503d376a..1159c79e 100644
--- a/README.es.md
+++ b/README.es.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.fr.md b/README.fr.md
index 59c049b8..81cba423 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.it.md b/README.it.md
index a6546587..cfc97d8a 100644
--- a/README.it.md
+++ b/README.it.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.ja.md b/README.ja.md
index fabe91d4..9d3bdc50 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -10,7 +10,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
diff --git a/README.km.md b/README.km.md
index 65f245a3..f083816c 100644
--- a/README.km.md
+++ b/README.km.md
@@ -1,3 +1,5 @@
+> ការបកប្រែនេះត្រូវបានបង្កើតដោយ Claude។ ប្រសិនបើអ្នកមានការកែលម្អ សូមបង្កើត PR។
+
cmux
Terminal សម្រាប់ macOS ផ្អែកលើ Ghostty ដែលមាន tab បញ្ឈរ និងការជូនដំណឹងសម្រាប់ AI coding agents
@@ -29,8 +31,8 @@