From c69342a2760c7613448c28638bf9d790e8fbed5c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:39:12 -0700 Subject: [PATCH] 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 --- GhosttyTabs.xcodeproj/project.pbxproj | 2 +- Resources/shell-integration/.zshenv | 9 +- docs/ghostty-fork.md | 11 +- ghostty | 2 +- scripts/ghosttykit-checksums.txt | 1 + scripts/launch-tagged-automation.sh | 189 ++++++++++++++++ scripts/probe-pure-prompt-duplication.py | 207 ++++++++++++++++++ ...ure_hidden_cr_omits_continuation_marker.py | 188 ++++++++++++++++ ...zsh_prefers_bundled_ghostty_integration.py | 98 +++++++++ 9 files changed, 699 insertions(+), 8 deletions(-) create mode 100755 scripts/launch-tagged-automation.sh create mode 100755 scripts/probe-pure-prompt-duplication.py create mode 100644 tests/test_ghostty_zsh_pure_hidden_cr_omits_continuation_marker.py create mode 100644 tests/test_shell_zsh_prefers_bundled_ghostty_integration.py diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index f39108b8..a37d6096 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -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 */ diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv index 74241671..45462ffe 100644 --- a/Resources/shell-integration/.zshenv +++ b/Resources/shell-integration/.zshenv @@ -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 - builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration" + 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 diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index 30ba29bf..d85ca46b 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -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. diff --git a/ghostty b/ghostty index 0cf55958..312c7b23 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe +Subproject commit 312c7b23a7c8dc0704431940d76ba5dc32a46afb diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index b3818784..c7c101ac 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -4,3 +4,4 @@ 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de +312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30 diff --git a/scripts/launch-tagged-automation.sh b/scripts/launch-tagged-automation.sh new file mode 100755 index 00000000..72c39663 --- /dev/null +++ b/scripts/launch-tagged-automation.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: ./scripts/launch-tagged-automation.sh [options] + +Options: + --mode Socket mode override. Default: automation + --shell-log Set GHOSTTY_ZSH_INTEGRATION_LOG for shells in the tagged app. + --wait-socket 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 diff --git a/scripts/probe-pure-prompt-duplication.py b/scripts/probe-pure-prompt-duplication.py new file mode 100755 index 00000000..a6692128 --- /dev/null +++ b/scripts/probe-pure-prompt-duplication.py @@ -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()) diff --git a/tests/test_ghostty_zsh_pure_hidden_cr_omits_continuation_marker.py b/tests/test_ghostty_zsh_pure_hidden_cr_omits_continuation_marker.py new file mode 100644 index 00000000..56681a1c --- /dev/null +++ b/tests/test_ghostty_zsh_pure_hidden_cr_omits_continuation_marker.py @@ -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()) diff --git a/tests/test_shell_zsh_prefers_bundled_ghostty_integration.py b/tests/test_shell_zsh_prefers_bundled_ghostty_integration.py new file mode 100644 index 00000000..62e9745d --- /dev/null +++ b/tests/test_shell_zsh_prefers_bundled_ghostty_integration.py @@ -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())