Fix Pure prompt duplication in Ghostty zsh integration (#1316)

* Add Pure hidden-CR redraw regression

* Fix Pure hidden-CR prompt redraws

* Bundle Ghostty zsh integration in cmux
This commit is contained in:
Lawrence Chen 2026-03-13 02:39:12 -07:00 committed by GitHub
parent 1d6f55ce97
commit c69342a276
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 699 additions and 8 deletions

View file

@ -328,7 +328,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n";
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n";
};
/* End PBXShellScriptBuildPhase section */

View file

@ -34,8 +34,13 @@ fi
#
# We can't rely on GHOSTTY_ZSH_ZDOTDIR here because Ghostty's own zsh
# bootstrap unsets it before chaining into this cmux wrapper.
if [[ "${CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then
if [[ "${CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION:-0}" == "1" ]]; then
if [[ -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then
builtin typeset _cmux_ghostty="$CMUX_SHELL_INTEGRATION_DIR/ghostty-integration.zsh"
fi
if [[ ! -r "${_cmux_ghostty:-}" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then
builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration"
fi
[[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty"
fi

View file

@ -74,12 +74,15 @@ touch the same stale-frame mitigation path and tend to conflict in the same file
### 6) zsh Pure-style multiline prompt redraws
- Commit: `0cf559581` (zsh: fix Pure-style multiline prompt redraws)
- Commits:
- `0cf559581` (zsh: fix Pure-style multiline prompt redraws)
- `312c7b23a` (zsh: avoid extra Pure continuation markers)
- 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.
- Keeps redraw-safe prompt-start markers for async themes.
- Avoids inserting an explicit continuation marker after Pure's hidden carriage return, because Ghostty already tracks the newline as prompt continuation and the extra marker duplicates the preprompt row.
The fork branch HEAD is now the section 6 zsh redraw commit.
@ -103,7 +106,7 @@ These files change frequently upstream; be careful when rebasing the fork:
- `src/shell-integration/zsh/ghostty-integration`
- Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the
`OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes, and preserve the special
handling for Pure-style `\n%{\r%}` prompt newlines.
`OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}`
prompt newlines should not get an extra explicit continuation marker after the hidden CR.
If you resolve a conflict, update this doc with what changed.

@ -1 +1 @@
Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe
Subproject commit 312c7b23a7c8dc0704431940d76ba5dc32a46afb

View file

@ -4,3 +4,4 @@
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30

View file

@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: ./scripts/launch-tagged-automation.sh <tag> [options]
Options:
--mode <mode> Socket mode override. Default: automation
--shell-log <path> Set GHOSTTY_ZSH_INTEGRATION_LOG for shells in the tagged app.
--wait-socket <s> Wait for the tagged socket to appear. Default: 10
--env KEY=VALUE Extra environment variable to inject at launch. Repeatable.
-h, --help Show this help.
EOF
}
sanitize_bundle() {
local raw="$1"
local cleaned
cleaned="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/./g; s/^\\.+//; s/\\.+$//; s/\\.+/./g')"
if [[ -z "$cleaned" ]]; then
cleaned="agent"
fi
echo "$cleaned"
}
sanitize_path() {
local raw="$1"
local cleaned
cleaned="$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-+/-/g')"
if [[ -z "$cleaned" ]]; then
cleaned="agent"
fi
echo "$cleaned"
}
if [[ $# -lt 1 ]]; then
usage
exit 1
fi
TAG=""
MODE="automation"
SHELL_LOG=""
WAIT_SOCKET="10"
EXTRA_ENV=()
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
MODE="${2:-}"
if [[ -z "$MODE" ]]; then
echo "error: --mode requires a value" >&2
exit 1
fi
shift 2
;;
--env)
if [[ -z "${2:-}" ]]; then
echo "error: --env requires KEY=VALUE" >&2
exit 1
fi
EXTRA_ENV+=("${2}")
shift 2
;;
--shell-log)
SHELL_LOG="${2:-}"
if [[ -z "$SHELL_LOG" ]]; then
echo "error: --shell-log requires a path" >&2
exit 1
fi
shift 2
;;
--wait-socket)
WAIT_SOCKET="${2:-}"
if [[ -z "$WAIT_SOCKET" ]]; then
echo "error: --wait-socket requires seconds" >&2
exit 1
fi
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
if [[ -z "$TAG" ]]; then
TAG="$1"
shift
else
echo "error: unexpected argument $1" >&2
usage
exit 1
fi
;;
esac
done
if [[ -z "$TAG" ]]; then
echo "error: tag is required" >&2
usage
exit 1
fi
TAG_ID="$(sanitize_bundle "$TAG")"
TAG_SLUG="$(sanitize_path "$TAG")"
APP="$HOME/Library/Developer/Xcode/DerivedData/cmux-${TAG_SLUG}/Build/Products/Debug/cmux DEV ${TAG}.app"
BID="com.cmuxterm.app.debug.${TAG_ID}"
SOCK="/tmp/cmux-debug-${TAG_SLUG}.sock"
DSOCK="$HOME/Library/Application Support/cmux/cmuxd-dev-${TAG_SLUG}.sock"
LOG="/tmp/cmux-debug-${TAG_SLUG}.log"
if [[ ! -d "$APP" ]]; then
echo "error: tagged app not found at $APP" >&2
exit 1
fi
/usr/bin/osascript -e "tell application id \"${BID}\" to quit" >/dev/null 2>&1 || true
sleep 0.5
pkill -f "cmux DEV ${TAG}.app/Contents/MacOS/cmux DEV" || true
rm -f "$SOCK" "$DSOCK"
sleep 0.5
OPEN_ENV=(
env
-u CMUX_SOCKET_PATH
-u CMUX_SOCKET_MODE
-u CMUX_TAB_ID
-u CMUX_PANEL_ID
-u CMUX_SURFACE_ID
-u CMUX_WORKSPACE_ID
-u CMUXD_UNIX_PATH
-u CMUX_TAG
-u CMUX_PORT
-u CMUX_PORT_END
-u CMUX_PORT_RANGE
-u CMUX_DEBUG_LOG
-u CMUX_BUNDLE_ID
-u CMUX_SHELL_INTEGRATION
-u CMUX_SHELL_INTEGRATION_DIR
-u CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION
-u GHOSTTY_BIN_DIR
-u GHOSTTY_RESOURCES_DIR
-u GHOSTTY_SHELL_FEATURES
-u GIT_PAGER
-u GH_PAGER
-u TERMINFO
-u XDG_DATA_DIRS
"CMUX_SOCKET_MODE=${MODE}"
"CMUX_SOCKET_PATH=${SOCK}"
"CMUXD_UNIX_PATH=${DSOCK}"
"CMUX_DEBUG_LOG=${LOG}"
)
for kv in "${EXTRA_ENV[@]}"; do
OPEN_ENV+=("${kv}")
done
if [[ -n "$SHELL_LOG" ]]; then
OPEN_ENV+=("GHOSTTY_ZSH_INTEGRATION_LOG=${SHELL_LOG}")
fi
"${OPEN_ENV[@]}" open -g "$APP"
if [[ "$WAIT_SOCKET" != "0" ]]; then
deadline=$((SECONDS + WAIT_SOCKET))
while (( SECONDS < deadline )); do
if [[ -S "$SOCK" ]]; then
break
fi
sleep 0.1
done
fi
echo "app: $APP"
echo "bundle_id: $BID"
echo "socket: $SOCK"
echo "cmuxd_socket: $DSOCK"
echo "log: $LOG"
echo "mode: $MODE"
echo "socket_ready: $(if [[ -S "$SOCK" ]]; then echo yes; else echo no; fi)"
if [[ -n "$SHELL_LOG" ]]; then
echo "shell_log: $SHELL_LOG"
fi
if [[ "${#EXTRA_ENV[@]}" -gt 0 ]]; then
echo "extra_env:"
for kv in "${EXTRA_ENV[@]}"; do
echo " $kv"
done
fi

View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
Check whether the current focused terminal surface duplicates a Pure-style
preprompt line when Enter is pressed on an empty prompt.
Usage:
python3 scripts/probe-pure-prompt-duplication.py
Run this from a spare cmux pane. The script creates a temporary workspace,
probes the prompt there, and restores your original workspace afterwards.
"""
from __future__ import annotations
import argparse
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str((Path(__file__).resolve().parents[1] / "tests_v2")))
from cmux import cmux, cmuxError
def _is_prompt_line(line: str) -> bool:
stripped = line.strip()
return stripped.startswith("") or stripped.startswith(">") or stripped.startswith("$")
def _prompt_block(text: str) -> tuple[list[str], str]:
lines = text.splitlines()
while lines and not lines[-1].strip():
lines.pop()
prompt_idx = -1
for i in range(len(lines) - 1, -1, -1):
if _is_prompt_line(lines[i]):
prompt_idx = i
break
if prompt_idx == -1:
raise cmuxError(f"Could not find prompt line in surface text:\n{text}")
preprompt: list[str] = []
i = prompt_idx - 1
while i >= 0 and lines[i].strip():
preprompt.append(lines[i])
i -= 1
preprompt.reverse()
return preprompt, lines[prompt_idx]
def _duplicate_run_length(preprompt: list[str]) -> int:
if not preprompt:
return 0
last = preprompt[-1]
count = 1
for line in reversed(preprompt[:-1]):
if line != last:
break
count += 1
return count
def _read_text(client: cmux, workspace_id: str, surface_id: str) -> str:
payload = client._call(
"surface.read_text",
{
"workspace_id": workspace_id,
"surface_id": surface_id,
"scrollback": True,
"lines": 80,
},
) or {}
return str(payload.get("text") or "")
def _wait_for_prompt_text(
client: cmux,
workspace_id: str,
surface_id: str,
*,
timeout: float,
) -> tuple[str, list[str], str]:
start = time.time()
last_text = ""
last_error = ""
while time.time() - start < timeout:
last_text = _read_text(client, workspace_id, surface_id)
try:
preprompt, prompt = _prompt_block(last_text)
return last_text, preprompt, prompt
except Exception as exc:
last_error = str(exc)
time.sleep(0.2)
raise cmuxError(
"Timed out waiting for a prompt block "
f"(last_error={last_error!r}, surface_text={last_text!r})"
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--enters", type=int, default=3)
parser.add_argument("--delay", type=float, default=0.8)
parser.add_argument("--prompt-timeout", type=float, default=15.0)
parser.add_argument("--keep-workspace", action="store_true")
parser.add_argument(
"--socket",
default=os.environ.get("CMUX_SOCKET") or os.environ.get("CMUX_SOCKET_PATH") or "/tmp/cmux-debug.sock",
)
args = parser.parse_args()
with cmux(args.socket) as client:
current = client._call("workspace.current", {}) or {}
original_workspace_id = str(current.get("workspace_id") or "")
if not original_workspace_id:
raise cmuxError(f"workspace.current returned no workspace_id: {current}")
created = client._call("workspace.create", {}) or {}
workspace_id = str(created.get("workspace_id") or "")
if not workspace_id:
raise cmuxError(f"workspace.create returned no workspace_id: {created}")
client._call("workspace.select", {"workspace_id": workspace_id})
surface_id = ""
probe_text = ""
start = time.time()
while True:
try:
listed = client._call("surface.list", {"workspace_id": workspace_id}) or {}
surfaces = listed.get("surfaces") or []
if surfaces:
surface_id = str(surfaces[0].get("id") or "")
if surface_id:
baseline = _read_text(client, workspace_id, surface_id)
probe_text = baseline
break
raise cmuxError("surface not ready yet")
except Exception as exc:
probe_text = str(exc)
if time.time() - start > 10:
raise cmuxError(f"Timed out waiting for readable terminal surface: {probe_text}")
time.sleep(0.2)
try:
print(f"workspace={workspace_id}")
print(f"surface={surface_id}")
baseline, preprompt, prompt = _wait_for_prompt_text(
client,
workspace_id,
surface_id,
timeout=args.prompt_timeout,
)
baseline_run = _duplicate_run_length(preprompt)
print(f"baseline_prompt={prompt!r}")
print(f"baseline_preprompt={preprompt!r}")
print(f"baseline_duplicate_run={baseline_run}")
if baseline_run > 1:
print("FAIL: surface is already duplicated before probing")
print(baseline)
return 1
for step in range(1, args.enters + 1):
client._call(
"surface.send_text",
{
"workspace_id": workspace_id,
"surface_id": surface_id,
"text": "\n",
},
)
time.sleep(args.delay)
text, preprompt, prompt = _wait_for_prompt_text(
client,
workspace_id,
surface_id,
timeout=args.prompt_timeout,
)
duplicate_run = _duplicate_run_length(preprompt)
print(f"after_enter_{step}_prompt={prompt!r}")
print(f"after_enter_{step}_preprompt={preprompt!r}")
print(f"after_enter_{step}_duplicate_run={duplicate_run}")
if duplicate_run > 1:
print("FAIL: prompt duplication reproduced")
print(text)
return 1
print("PASS: empty Enter did not duplicate the current prompt block")
return 0
finally:
if not args.keep_workspace:
try:
client._call("workspace.close", {"workspace_id": workspace_id})
except Exception:
pass
client._call("workspace.select", {"workspace_id": original_workspace_id})
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Regression: Pure-style prompts that use `\n%{\r%}` must not get an explicit
OSC 133 continuation marker injected between the hidden carriage return and the
visible prompt line.
Ghostty already marks the next row as a prompt continuation when a newline
arrives while prompt mode is active. Injecting `OSC 133;P;k=s` after Pure's
hidden carriage return creates a second prompt-start boundary inside the same
logical prompt redraw, which matches the Theo/Prezto Pure duplication repro.
"""
from __future__ import annotations
import os
import pty
import select
import shutil
import subprocess
import tempfile
import time
from pathlib import Path
PROMPT_START = b"\x1b]133;P;k=i\x07"
PROMPT_CONTINUATION = b"\x1b]133;P;k=s\x07"
_PURE_HIDDEN_CR_ZSHRC = r"""
setopt prompt_percent 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'
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()
def _capture_session(env: dict[str, str], zsh_path: str, workdir: Path) -> bytes:
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)
return bytes(output)
def main() -> int:
root = Path(__file__).resolve().parents[1]
wrapper_dir = root / "ghostty" / "src" / "shell-integration" / "zsh"
resources_dir = root / "ghostty" / "src"
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_pure_hidden_cr_"))
try:
home = base / "home"
home.mkdir(parents=True, exist_ok=True)
(home / ".zshrc").write_text(_PURE_HIDDEN_CR_ZSHRC, encoding="utf-8")
env = dict(os.environ)
env["HOME"] = str(home)
env["TERM"] = "xterm-256color"
env["ZDOTDIR"] = str(wrapper_dir)
env["GHOSTTY_ZSH_ZDOTDIR"] = str(home)
env["GHOSTTY_RESOURCES_DIR"] = str(resources_dir)
env.pop("GHOSTTY_SHELL_FEATURES", None)
env.pop("GHOSTTY_BIN_DIR", None)
output = _capture_session(env, zsh_path, root)
prompt_start_count = output.count(PROMPT_START)
prompt_continuation_count = output.count(PROMPT_CONTINUATION)
if prompt_start_count < 2:
print(
"FAIL: expected Ghostty zsh integration to emit prompt-start markers "
f"for the Pure-style prompt, saw {prompt_start_count}"
)
return 1
if prompt_continuation_count != 0:
print(
"FAIL: hidden-CR Pure-style prompt emitted explicit continuation markers "
f"({prompt_continuation_count})"
)
return 1
print("PASS: Pure-style hidden-CR prompt redraws without explicit continuation markers")
return 0
finally:
shutil.rmtree(base, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Regression: the cmux zsh wrapper should prefer a bundled Ghostty zsh
integration file in CMUX_SHELL_INTEGRATION_DIR over the fallback integration
under GHOSTTY_RESOURCES_DIR.
Without this, tagged cmux builds can silently load Ghostty's installed app
integration instead of the version bundled with the build under test.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parents[1]
wrapper_dir = root / "Resources" / "shell-integration"
if not (wrapper_dir / ".zshenv").exists():
print(f"SKIP: missing wrapper .zshenv at {wrapper_dir}")
return 0
base = Path("/tmp") / f"cmux_bundled_ghostty_zsh_{os.getpid()}"
try:
shutil.rmtree(base, ignore_errors=True)
base.mkdir(parents=True, exist_ok=True)
home = base / "home"
orig = base / "orig-zdotdir"
bundled = base / "bundled-shell-integration"
fallback = base / "ghostty-resources"
marker = base / "marker.txt"
home.mkdir(parents=True, exist_ok=True)
orig.mkdir(parents=True, exist_ok=True)
bundled.mkdir(parents=True, exist_ok=True)
(fallback / "shell-integration" / "zsh").mkdir(parents=True, exist_ok=True)
for filename in (".zshenv", ".zprofile", ".zshrc"):
(orig / filename).write_text("", encoding="utf-8")
(bundled / "ghostty-integration.zsh").write_text(
'echo "bundled" >> "$CMUX_TEST_OUT"\n',
encoding="utf-8",
)
(fallback / "shell-integration" / "zsh" / "ghostty-integration").write_text(
'echo "fallback" >> "$CMUX_TEST_OUT"\n',
encoding="utf-8",
)
env = dict(os.environ)
env["HOME"] = str(home)
env["ZDOTDIR"] = str(wrapper_dir)
env["GHOSTTY_ZSH_ZDOTDIR"] = str(orig)
env["CMUX_SHELL_INTEGRATION_DIR"] = str(bundled)
env["GHOSTTY_RESOURCES_DIR"] = str(fallback)
env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
env["CMUX_SHELL_INTEGRATION"] = "0"
env["CMUX_TEST_OUT"] = str(marker)
result = subprocess.run(
["zsh", "-d", "-i", "-c", "true"],
env=env,
capture_output=True,
text=True,
timeout=8,
)
if result.returncode != 0:
print(f"FAIL: zsh exited non-zero rc={result.returncode}")
combined = ((result.stdout or "") + (result.stderr or "")).strip()
if combined:
print(combined)
return 1
if not marker.exists():
print("FAIL: no Ghostty integration marker was written")
return 1
entries = [
line.strip()
for line in marker.read_text(encoding="utf-8").splitlines()
if line.strip()
]
if entries != ["bundled"]:
print(f"FAIL: expected only bundled integration, saw {entries!r}")
return 1
print("PASS: wrapper prefers bundled ghostty-integration.zsh over fallback resources")
return 0
finally:
shutil.rmtree(base, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())