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:
parent
1d6f55ce97
commit
c69342a276
9 changed files with 699 additions and 8 deletions
|
|
@ -328,7 +328,7 @@
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,13 @@ fi
|
||||||
#
|
#
|
||||||
# We can't rely on GHOSTTY_ZSH_ZDOTDIR here because Ghostty's own zsh
|
# We can't rely on GHOSTTY_ZSH_ZDOTDIR here because Ghostty's own zsh
|
||||||
# bootstrap unsets it before chaining into this cmux wrapper.
|
# 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
|
||||||
builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration"
|
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"
|
[[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
### 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:
|
- Files:
|
||||||
- `src/shell-integration/zsh/ghostty-integration`
|
- `src/shell-integration/zsh/ghostty-integration`
|
||||||
- Summary:
|
- Summary:
|
||||||
- Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line.
|
- 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.
|
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`
|
- `src/shell-integration/zsh/ghostty-integration`
|
||||||
- Prompt marker handling is easy to regress when upstream adjusts zsh redraw behavior. Keep the
|
- 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
|
`OSC 133;A` vs `OSC 133;P` split intact for redraw-heavy themes. Pure-style `\n%{\r%}`
|
||||||
handling for Pure-style `\n%{\r%}` prompt newlines.
|
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.
|
If you resolve a conflict, update this doc with what changed.
|
||||||
|
|
|
||||||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
||||||
Subproject commit 0cf5595817794466e3a60abe6bf97f8494dedcfe
|
Subproject commit 312c7b23a7c8dc0704431940d76ba5dc32a46afb
|
||||||
|
|
@ -4,3 +4,4 @@
|
||||||
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
|
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
|
||||||
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
|
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
|
||||||
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
|
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
|
||||||
|
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
|
||||||
|
|
|
||||||
189
scripts/launch-tagged-automation.sh
Executable file
189
scripts/launch-tagged-automation.sh
Executable 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
|
||||||
207
scripts/probe-pure-prompt-duplication.py
Executable file
207
scripts/probe-pure-prompt-duplication.py
Executable 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())
|
||||||
|
|
@ -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())
|
||||||
98
tests/test_shell_zsh_prefers_bundled_ghostty_integration.py
Normal file
98
tests/test_shell_zsh_prefers_bundled_ghostty_integration.py
Normal 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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue