-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 ដែលកំពុងស្តាប់ និងអត្ថបទជូនដំណឹងចុងក្រោយ។ បំបែកទាំងផ្ដេក និងបញ្ឈរ។
|
@@ -74,6 +76,10 @@ Sidebar shows git branch, linked PR status/number, working directory, listening
### DMG (ត្រូវបានណែនាំ)
+
+
+
+
បើកឯកសារ `.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 | ភាសាខ្មែរ
diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash
index 643fc841..ab4b6e2c 100644
--- a/Resources/shell-integration/cmux-bash-integration.bash
+++ b/Resources/shell-integration/cmux-bash-integration.bash
@@ -44,10 +44,10 @@ _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_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
@@ -115,6 +115,182 @@ _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_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 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
+
+ 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 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 "$gh_output" ]]; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+
+ IFS=$'\t' read -r number state url <<< "$gh_output"
+ 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_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=""
+ 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
+ _cmux_kill_process_tree "$probe_pid" TERM
+ sleep 0.2
+ if kill -0 "$probe_pid" >/dev/null 2>&1; then
+ _cmux_kill_process_tree "$probe_pid" KILL
+ 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
+ _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
+}
+
+_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_run_pr_probe_with_timeout "$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_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
@@ -135,16 +311,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 +344,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,56 +381,35 @@ _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
+ 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
+ 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
+ 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
# Ports: lightweight kick to the app's batched scanner every ~10s.
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
_cmux_ports_kick
fi
-
}
_cmux_install_prompt_command() {
diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh
index ee2047a7..45a99aaf 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
@@ -237,6 +236,177 @@ _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_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 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
+
+ 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 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 "$gh_output" ]]; then
+ _cmux_clear_pr_for_panel
+ return 0
+ fi
+
+ local IFS=$'\t'
+ read -r number state url <<< "$gh_output"
+ 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_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=""
+ 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
+ _cmux_kill_process_tree "$probe_pid" TERM
+ sleep 0.2
+ if kill -0 "$probe_pid" >/dev/null 2>&1; then
+ _cmux_kill_process_tree "$probe_pid" KILL
+ 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
+ _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
+}
+
+_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_run_pr_probe_with_timeout "$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
@@ -299,6 +469,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
}
@@ -342,17 +513,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
@@ -368,6 +528,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.
@@ -381,6 +542,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
@@ -427,63 +589,30 @@ _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
+ 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
- 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
+ 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
# Ports: lightweight kick to the app's batched scanner.
@@ -520,6 +649,7 @@ _cmux_fix_path() {
_cmux_zshexit() {
_cmux_stop_git_head_watch
+ _cmux_stop_pr_poll_loop
}
autoload -Uz add-zsh-hook
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/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/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index f1a9830f..07393dbe 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",
@@ -126,13 +145,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 +185,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 +608,7 @@ final class WindowBrowserHostView: NSView {
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
),
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: "drag"
)
updateDividerCursor(
@@ -946,7 +983,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 +997,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 +1006,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
@@ -1772,8 +1815,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 ||
@@ -1795,7 +1839,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()
}
@@ -2918,7 +2964,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
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 64e72a7f..de60f501 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -2633,10 +2633,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/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index a9bbb0a9..5b4db687 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -5640,6 +5640,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
@@ -7054,6 +7063,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/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index 623d575d..b2927a8a 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -1798,6 +1798,10 @@ final class BrowserPanel: Panel, ObservableObject {
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
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?
@@ -2814,6 +2818,8 @@ final class BrowserPanel: Panel, ObservableObject {
deinit {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
+ developerToolsTransitionSettleWorkItem?.cancel()
+ developerToolsTransitionSettleWorkItem = nil
if let detachedDeveloperToolsWindowCloseObserver {
NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver)
}
@@ -3232,29 +3238,97 @@ extension BrowserPanel {
return !(inspector.cmuxCallBool(selector: isVisibleSelector) ?? 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()
@@ -3266,6 +3340,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) " +
@@ -3279,30 +3373,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 = [
@@ -3324,6 +3406,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()
@@ -3345,6 +3441,7 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach = false
return
}
+ guard !isDeveloperToolsTransitionInFlight else { return }
guard let inspector = webView.cmuxInspectorObject() else {
scheduleDeveloperToolsRestoreRetry()
return
@@ -3410,17 +3507,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
@@ -4296,7 +4383,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 7122edc7..5c9c780a 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3715,7 +3715,24 @@ 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) {
+ // 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 +3777,9 @@ struct WebViewRepresentable: NSViewRepresentable {
}
private static let hostedInspectorDividerHitExpansion: CGFloat = 10
- private static let minimumHostedInspectorWidth: CGFloat = 1
+ 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?
@@ -3774,6 +3793,9 @@ struct WebViewRepresentable: NSViewRepresentable {
private var isApplyingHostedInspectorLayout = false
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)?
@@ -3812,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) {
@@ -3836,6 +3955,8 @@ struct WebViewRepresentable: NSViewRepresentable {
func setHostedInspectorFrontendWebView(_ webView: WKWebView?) {
hostedInspectorFrontendWebView = webView
+ lastHostedInspectorManualSideDockAllowed = nil
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "setHostedInspectorFrontendWebView")
}
private var hasStoredHostedInspectorWidthPreference: Bool {
@@ -4116,6 +4237,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,10 +4287,71 @@ struct WebViewRepresentable: NSViewRepresentable {
inspectorView: inspectorView,
dockSide: dockSide
),
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: reason
)
}
+ 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)
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: reason)
+#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
+ }
+ recordHostedInspectorSideDockWidth(hit.inspectorView.frame.width)
+ return requestAdaptiveHostedInspectorBottomDock(reason: reason)
+ }
+
fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) {
hostedInspectorDockConfigurationSyncWorkItem?.cancel()
guard hostedInspectorFrontendWebView != nil else { return }
@@ -4180,22 +4377,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" {
@@ -4206,6 +4418,7 @@ struct WebViewRepresentable: NSViewRepresentable {
}
}
}
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "\(reason).dockConfiguration")
}
override func viewDidMoveToWindow() {
@@ -4236,13 +4449,26 @@ struct WebViewRepresentable: NSViewRepresentable {
override func layout() {
super.layout()
+ _ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
+ if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") {
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")
+ notifyGeometryChangedIfNeeded()
+#if DEBUG
+ debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
+#endif
+ return
+ }
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")
}
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout.sameSize")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
@@ -4255,6 +4481,7 @@ struct WebViewRepresentable: NSViewRepresentable {
} else if !hasStoredHostedInspectorWidthPreference {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout")
}
+ updateHostedInspectorDockControlAvailabilityIfNeeded(reason: "host.layout")
scheduleHostedInspectorDockConfigurationSync(reason: "layout")
notifyGeometryChangedIfNeeded()
#if DEBUG
@@ -4264,9 +4491,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 +4500,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 +4640,7 @@ struct WebViewRepresentable: NSViewRepresentable {
inspectorView: dragState.inspectorView,
dockSide: dragState.dockSide
),
+ minimumInspectorWidth: Self.minimumHostedInspectorWidth,
reason: "drag"
)
#if DEBUG
@@ -4698,6 +4920,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 {
@@ -4729,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
@@ -4766,13 +4990,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 +5011,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
@@ -4793,6 +5023,7 @@ struct WebViewRepresentable: NSViewRepresentable {
guard pageChanged || inspectorChanged else {
return (pageFrame, inspectorFrame)
}
+ recordHostedInspectorSideDockWidth(inspectorFrame.width)
isApplyingHostedInspectorLayout = true
CATransaction.begin()
@@ -4802,6 +5033,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(
@@ -5148,6 +5392,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/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
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/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/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index ecfaf772..bbe59232 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,145 @@ 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))
+ guard let event = makeKeyDownEvent(
+ key: "`",
+ modifiers: [.command],
+ keyCode: 50,
+ windowNumber: firstWindow.windowNumber
+ ) else {
+ XCTFail("Failed to construct Cmd+` event")
+ return
+ }
+
+ NSApp.sendEvent(event)
+ RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
+
+ XCTAssertEqual(spy.invocationCount, 1, "Cmd+` should only trigger one window-cycle action")
+ }
+
+ @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) {
+ 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 +843,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,
@@ -2606,6 +2758,10 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
return nil
}
+ private func waitForDeveloperToolsTransitions() {
+ RunLoop.current.run(until: Date().addingTimeInterval(0.5))
+ }
+
private func findWindowBrowserSlotView(in root: NSView) -> WindowBrowserSlotView? {
if let slot = root as? WindowBrowserSlotView {
return slot
@@ -2699,6 +2855,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 testToggleDeveloperToolsFallsBackToCloseWhenHideDoesNotConcealInspector() {
let (panel, inspector) = makePanelWithInspector(hideBehavior: .noEffect)
@@ -9451,6 +9638,18 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
}
}
+ private final class TrackingInspectorFrontendWebView: WKWebView {
+ private(set) var evaluatedJavaScript: [String] = []
+
+ @MainActor override func evaluateJavaScript(
+ _ javaScriptString: String,
+ completionHandler: (@MainActor @Sendable (Any?, (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
@@ -9588,6 +9787,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),
@@ -9639,6 +9877,346 @@ 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 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"
+ )
+ 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() {
+ 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),
@@ -11503,6 +12081,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
@@ -11793,6 +12445,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),
diff --git a/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
new file mode 100644
index 00000000..e2718c9a
--- /dev/null
+++ b/cmuxTests/GhosttyEnsureFocusWindowActivationTests.swift
@@ -0,0 +1,62 @@
+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 testAllowsActivationWhenAppHasNoKeyAndNoMainWindow() {
+ let targetManager = TabManager()
+
+ XCTAssertTrue(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: nil,
+ targetTabManager: targetManager,
+ keyWindow: nil,
+ mainWindow: nil
+ )
+ )
+ XCTAssertFalse(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: nil,
+ targetTabManager: targetManager,
+ keyWindow: NSWindow(),
+ mainWindow: nil
+ )
+ )
+ XCTAssertFalse(
+ shouldAllowEnsureFocusWindowActivation(
+ activeTabManager: nil,
+ targetTabManager: targetManager,
+ keyWindow: nil,
+ mainWindow: NSWindow()
+ )
+ )
+ }
+}
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 {
diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md
index c98a76c3..30ba29bf 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
@@ -45,8 +45,9 @@ Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 202
### 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)
@@ -62,6 +63,26 @@ 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.
+
+### 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
### cursor-click-to-move respects OSC 133 click-to-move
@@ -80,4 +101,9 @@ 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, 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/ghostty b/ghostty
index c47010b8..0cf55958 160000
--- a/ghostty
+++ b/ghostty
@@ -1 +1 @@
-Subproject commit c47010b80cd9ae6d1ab744c120f011a465521ea3
+Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe
diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt
index 522da07a..986b55d2 100644
--- a/scripts/ghosttykit-checksums.txt
+++ b/scripts/ghosttykit-checksums.txt
@@ -4,3 +4,4 @@
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df
+0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
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()
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..32ff0d64
--- /dev/null
+++ b/tests/test_ghostty_zsh_prompt_redraw_uses_prompt_start.py
@@ -0,0 +1,175 @@
+#!/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], zsh_path: str) -> bytes:
+ master, slave = pty.openpty()
+ proc = subprocess.Popen(
+ [zsh_path, "-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
+
+ zsh_path = shutil.which("zsh")
+ if zsh_path 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, zsh_path)
+
+ 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())
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..ab916fe5
--- /dev/null
+++ b/tests/test_ghostty_zsh_pure_preprompt_redraw.py
@@ -0,0 +1,233 @@
+#!/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,
+ zsh_path: str,
+) -> 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.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)
+ 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_path, "-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
+ zsh_path = shutil.which("zsh")
+ if zsh_path 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,
+ 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)
+ 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())
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..973e98dd
--- /dev/null
+++ b/tests/test_issue_1138_sidebar_pr_polling.py
@@ -0,0 +1,397 @@
+#!/usr/bin/env python3
+"""
+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
+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
+
+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
+ 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
+ if [ -n "$branch" ]; then
+ printf '%s\\n' "$branch"
+ fi
+ 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
+ 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:?}"
+
+ printf '%s\\n' "$*" >> "$args_log"
+
+ count=0
+ if [ -f "$count_file" ]; then
+ count="$(cat "$count_file")"
+ fi
+ count=$((count + 1))
+ printf '%s\\n' "$count" > "$count_file"
+
+ if [ "$1" != "pr" ] || [ "$2" != "view" ]; then
+ printf 'unexpected gh args: %s\\n' "$*" >&2
+ exit 9
+ fi
+
+ 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
+ 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
+ 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
+ if [ -n "$pid_file" ]; then
+ printf '%s\\n' "$$" > "$pid_file"
+ fi
+ 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, 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'
+ '_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"; }}
+ _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"; }}
+ _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 _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 _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"
+ repo_git = repo / ".git"
+ socket_path = base / "cmux.sock"
+ 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)
+ repo_git.mkdir(parents=True, exist_ok=True)
+ 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())
+
+ 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)
+ 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"
+
+ with BoundUnixSocket(socket_path):
+ result = subprocess.run(
+ [shell, *shell_args, _shell_command(shell, scenario)],
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=12,
+ )
+
+ combined_output = (result.stdout or "") + (result.stderr or "")
+ if result.returncode != 0:
+ return (result.returncode, combined_output)
+
+ 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
+
+ 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))
+
+ 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}")
+ 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")
+
+ 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))
+ 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")
+
+
+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"),
+ ]
+ scenarios = [
+ "prompt_helper_idle",
+ "transient_same_context",
+ "branch_switch_clear",
+ "timeout_recovery",
+ ]
+
+ 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
+ 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:")
+ for failure in failures:
+ print(failure)
+ return 1
+
+ print("PASS: shell integrations poll PR state robustly across transient failures, branch changes, and timeouts")
+ return 0
+ finally:
+ shutil.rmtree(base, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
|