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

Notification rings

-Panes get a blue ring and tabs light up when coding agents need your attention +

រង្វង់ជូនដំណឹង (Notification rings)

+ផ្ទាំង (Panes) នឹងមានរង្វង់ពណ៌ខៀវ ហើយ tabs នឹងភ្លឺឡើង នៅពេល coding agents ត្រូវការការយកចិត្តទុកដាក់របស់អ្នក
Notification rings @@ -38,8 +40,8 @@ Panes get a blue ring and tabs light up when coding agents need your attention
-

Notification panel

-See all pending notifications in one place, jump to the most recent unread +

ផ្ទាំងជូនដំណឹង (Notification panel)

+មើលការជូនដំណឹងដែលកំពុងរង់ចាំទាំងអស់នៅកន្លែងតែមួយ លោតទៅកាន់សារមិនទាន់អានថ្មីបំផុត
Sidebar notification badge @@ -47,8 +49,8 @@ See all pending notifications in one place, jump to the most recent unread
-

In-app browser

-Split a browser alongside your terminal with a scriptable API ported from agent-browser +

កម្មវិធីរុករកក្នុងកម្មវិធី (In-app browser)

+បំបែកកម្មវិធីរុករកនៅក្បែរ terminal របស់អ្នកជាមួយ scriptable API ដែលបានយកចេញពី agent-browser
Built-in browser @@ -56,8 +58,8 @@ Split a browser alongside your terminal with a scriptable API ported from
-

Vertical + horizontal tabs

-Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically. +

Tab បញ្ឈរ + ផ្ដេក (Vertical + horizontal tabs)

+របារចំហៀងបង្ហាញ git branch, ស្ថានភាព/លេខ PR, ថតការងារ, port ដែលកំពុងស្តាប់ និងអត្ថបទជូនដំណឹងចុងក្រោយ។ បំបែកទាំងផ្ដេក និងបញ្ឈរ។
Vertical tabs and split panes @@ -74,6 +76,10 @@ Sidebar shows git branch, linked PR status/number, working directory, listening ### DMG (ត្រូវបានណែនាំ) + + ទាញយក cmux សម្រាប់ macOS + + បើកឯកសារ `.dmg` ហើយអូស cmux បញ្ចូលទៅក្នុងថត Applications របស់អ្នក។ cmux ធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិតាមរយៈ Sparkle ដូច្នេះអ្នកគ្រាន់តែទាញយកវាតែម្តងគត់។ ### Homebrew @@ -156,7 +162,7 @@ cmux គឺជាមូលដ្ឋានគ្រឹះ (primitive) មិន ### កម្មវិធីរុករក (Browser) -Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`. +ផ្លូវកាត់ឧបករណ៍អ្នកអភិវឌ្ឍន៍កម្មវិធីរុករក (Browser developer-tool shortcuts) ប្រើតាមលំនាំដើមរបស់ Safari ហើយអាចប្ដូរតាមបំណងបាននៅក្នុង `Settings → Keyboard Shortcuts`។ | ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) | |---|---| diff --git a/README.ko.md b/README.ko.md index 7f0406eb..9f36929b 100644 --- a/README.ko.md +++ b/README.ko.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.no.md b/README.no.md index 15605c94..fb7c211a 100644 --- a/README.no.md +++ b/README.no.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.pl.md b/README.pl.md index ba28fd2d..3408897d 100644 --- a/README.pl.md +++ b/README.pl.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.pt-BR.md b/README.pt-BR.md index bd79e450..f815f276 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.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.ru.md b/README.ru.md index 61d049d0..78769516 100644 --- a/README.ru.md +++ b/README.ru.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.th.md b/README.th.md index f77aea0b..d57fe8a8 100644 --- a/README.th.md +++ b/README.th.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.tr.md b/README.tr.md index d317b7e9..a69c4a29 100644 --- a/README.tr.md +++ b/README.tr.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.zh-CN.md b/README.zh-CN.md index d0435a4f..f376b5f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.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.zh-TW.md b/README.zh-TW.md index 7547e4ec..fee17fd4 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.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 | ភាសាខ្មែរ