diff --git a/CLAUDE.md b/CLAUDE.md index 6c90b811..66eaae69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,12 @@ After making code changes, always run the reload script to launch the Debug app: ./scripts/reload.sh ``` +After you're done with a fix, also reload with a tag so you can verify it in an isolated side-by-side app: + +```bash +./scripts/reload.sh --tag fix-zsh-autosuggestions +``` + After making code changes, always run the build: ```bash diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index e7b74ee5..f0228c51 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -204,7 +204,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\"\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\"\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\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\"\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\"\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 cmuxterm-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\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/.zlogin b/Resources/shell-integration/.zlogin index b6782440..ccd06471 100644 --- a/Resources/shell-integration/.zlogin +++ b/Resources/shell-integration/.zlogin @@ -1,16 +1,19 @@ -# cmuxterm ZDOTDIR wrapper — sources user's .zlogin -_cmux_wrapper_zdotdir="${ZDOTDIR:-}" -_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}" -if [ -f "$_cmux_real_zdotdir/.zlogin" ]; then - ZDOTDIR="$_cmux_real_zdotdir" - source "$_cmux_real_zdotdir/.zlogin" -fi +# vim:ft=zsh +# +# Compatibility shim: with the current integration model, cmuxterm restores +# ZDOTDIR in .zshenv so this file should never be reached. If it is, restore +# ZDOTDIR and behave like vanilla zsh by sourcing the user's .zlogin. -# Restore whatever ZDOTDIR was for the current shell. -if [ -n "$_cmux_wrapper_zdotdir" ]; then - ZDOTDIR="$_cmux_wrapper_zdotdir" +if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" + builtin unset GHOSTTY_ZSH_ZDOTDIR +elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$CMUX_ZSH_ZDOTDIR" + builtin unset CMUX_ZSH_ZDOTDIR else - unset ZDOTDIR + builtin unset ZDOTDIR fi -unset _cmux_real_zdotdir _cmux_wrapper_zdotdir +builtin typeset _cmux_file="${ZDOTDIR-$HOME}/.zlogin" +[[ ! -r "$_cmux_file" ]] || builtin source -- "$_cmux_file" +builtin unset _cmux_file diff --git a/Resources/shell-integration/.zprofile b/Resources/shell-integration/.zprofile index 510ed93b..135c5f38 100644 --- a/Resources/shell-integration/.zprofile +++ b/Resources/shell-integration/.zprofile @@ -1,16 +1,19 @@ -# cmuxterm ZDOTDIR wrapper — sources user's .zprofile -_cmux_wrapper_zdotdir="${ZDOTDIR:-}" -_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}" -if [ -f "$_cmux_real_zdotdir/.zprofile" ]; then - ZDOTDIR="$_cmux_real_zdotdir" - source "$_cmux_real_zdotdir/.zprofile" -fi +# vim:ft=zsh +# +# Compatibility shim: with the current integration model, cmuxterm restores +# ZDOTDIR in .zshenv so this file should never be reached. If it is, restore +# ZDOTDIR and behave like vanilla zsh by sourcing the user's .zprofile. -# Restore wrapper ZDOTDIR so zsh continues through our wrapper chain. -if [ -n "$_cmux_wrapper_zdotdir" ]; then - ZDOTDIR="$_cmux_wrapper_zdotdir" +if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" + builtin unset GHOSTTY_ZSH_ZDOTDIR +elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$CMUX_ZSH_ZDOTDIR" + builtin unset CMUX_ZSH_ZDOTDIR else - unset ZDOTDIR + builtin unset ZDOTDIR fi -unset _cmux_real_zdotdir _cmux_wrapper_zdotdir +builtin typeset _cmux_file="${ZDOTDIR-$HOME}/.zprofile" +[[ ! -r "$_cmux_file" ]] || builtin source -- "$_cmux_file" +builtin unset _cmux_file diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv index 71cc236c..b7306e37 100644 --- a/Resources/shell-integration/.zshenv +++ b/Resources/shell-integration/.zshenv @@ -1,28 +1,47 @@ -# cmuxterm ZDOTDIR wrapper — sources user's .zshenv +# vim:ft=zsh # -# zsh resolves startup files relative to $ZDOTDIR. We point $ZDOTDIR at this -# wrapper directory so zsh loads our wrappers, but we must preserve the user's -# semantics when sourcing their real files. In particular, many setups rely on -# $ZDOTDIR inside early startup files, so source with ZDOTDIR temporarily -# restored to the original value. -_cmux_wrapper_zdotdir="${ZDOTDIR:-}" -_cmux_real_zdotdir="${CMUX_ORIGINAL_ZDOTDIR:-$HOME}" -if [ -f "$_cmux_real_zdotdir/.zshenv" ]; then - ZDOTDIR="$_cmux_real_zdotdir" - source "$_cmux_real_zdotdir/.zshenv" +# cmuxterm ZDOTDIR bootstrap for zsh. +# +# GhosttyKit already uses a ZDOTDIR injection mechanism for zsh (setting ZDOTDIR +# to Ghostty's integration dir). cmuxterm also needs to run its integration, but +# we must restore the user's real ZDOTDIR immediately so that: +# - /etc/zshrc sets HISTFILE relative to the real ZDOTDIR/HOME (shared history) +# - zsh loads the user's real .zprofile/.zshrc normally (no wrapper recursion) +# +# We restore ZDOTDIR from (in priority order): +# - GHOSTTY_ZSH_ZDOTDIR (set by GhosttyKit when it overwrote ZDOTDIR) +# - CMUX_ZSH_ZDOTDIR (set by cmuxterm when it overwrote a user-provided ZDOTDIR) +# - unset (zsh treats unset ZDOTDIR as $HOME) + +if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" + builtin unset GHOSTTY_ZSH_ZDOTDIR +elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$CMUX_ZSH_ZDOTDIR" + builtin unset CMUX_ZSH_ZDOTDIR +else + builtin unset ZDOTDIR fi -# For interactive shells, keep the wrapper chain intact so zsh loads our -# .zprofile/.zshrc wrappers next. For non-interactive shells, leave ZDOTDIR -# pointing at the real directory to avoid surprising script semantics. -case $- in - *i*) - if [ -n "$_cmux_wrapper_zdotdir" ]; then - ZDOTDIR="$_cmux_wrapper_zdotdir" - else - unset ZDOTDIR +{ + # zsh treats unset ZDOTDIR as if it were HOME. We do the same. + builtin typeset _cmux_file="${ZDOTDIR-$HOME}/.zshenv" + [[ ! -r "$_cmux_file" ]] || builtin source -- "$_cmux_file" +} always { + if [[ -o interactive ]]; then + # We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's + # zsh integration if available. + if [[ -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then + builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration" + [[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty" fi - ;; -esac -unset _cmux_real_zdotdir _cmux_wrapper_zdotdir + # Load cmuxterm integration (unless disabled) + if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then + builtin typeset _cmux_integ="$CMUX_SHELL_INTEGRATION_DIR/cmux-zsh-integration.zsh" + [[ -r "$_cmux_integ" ]] && builtin source -- "$_cmux_integ" + fi + fi + + builtin unset _cmux_file _cmux_ghostty _cmux_integ +} diff --git a/Resources/shell-integration/.zshrc b/Resources/shell-integration/.zshrc index 7b335af6..9f3a379d 100644 --- a/Resources/shell-integration/.zshrc +++ b/Resources/shell-integration/.zshrc @@ -1,16 +1,19 @@ -# cmuxterm ZDOTDIR wrapper — restore ZDOTDIR, source user's .zshrc, then load integration +# vim:ft=zsh +# +# Compatibility shim: with the current integration model, cmuxterm restores +# ZDOTDIR in .zshenv so this file should never be reached. If it is, restore +# ZDOTDIR and behave like vanilla zsh by sourcing the user's .zshrc. -# Restore original ZDOTDIR so user configs and subsequent shells work normally -if [ -n "$CMUX_ORIGINAL_ZDOTDIR" ]; then - ZDOTDIR="$CMUX_ORIGINAL_ZDOTDIR" +if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR" + builtin unset GHOSTTY_ZSH_ZDOTDIR +elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then + builtin export ZDOTDIR="$CMUX_ZSH_ZDOTDIR" + builtin unset CMUX_ZSH_ZDOTDIR else - ZDOTDIR="$HOME" + builtin unset ZDOTDIR fi -# Source user's .zshrc -[ -f "$ZDOTDIR/.zshrc" ] && source "$ZDOTDIR/.zshrc" - -# Load cmux shell integration (unless disabled) -if [ "$CMUX_SHELL_INTEGRATION" != "0" ]; then - source "${CMUX_SHELL_INTEGRATION_DIR}/cmux-zsh-integration.zsh" -fi +builtin typeset _cmux_file="${ZDOTDIR-$HOME}/.zshrc" +[[ ! -r "$_cmux_file" ]] || builtin source -- "$_cmux_file" +builtin unset _cmux_file diff --git a/Resources/terminfo-overlay/67/ghostty b/Resources/terminfo-overlay/67/ghostty new file mode 100644 index 00000000..e07be24c Binary files /dev/null and b/Resources/terminfo-overlay/67/ghostty differ diff --git a/Resources/terminfo-overlay/78/xterm-ghostty b/Resources/terminfo-overlay/78/xterm-ghostty new file mode 100644 index 00000000..e07be24c Binary files /dev/null and b/Resources/terminfo-overlay/78/xterm-ghostty differ diff --git a/Resources/terminfo-overlay/README.md b/Resources/terminfo-overlay/README.md new file mode 100644 index 00000000..8efd8e26 --- /dev/null +++ b/Resources/terminfo-overlay/README.md @@ -0,0 +1,15 @@ +# cmuxterm terminfo overlay + +cmuxterm ships Ghostty's `xterm-ghostty` terminfo entry, but the embedded +renderer in cmuxterm has differed from Ghostty's app renderer in how it treats +the "bright" SGR 90-97/100-107 sequences. + +This overlay patches the terminfo capabilities so that `tput setaf 8` (and +similar "bright" colors) uses 256-color indexed sequences (`38;5;m` / +`48;5;m`) rather than SGR 90-97/100-107. This avoids relying on bright SGR +handling and fixes zsh-autosuggestions (default `fg=8`) visibility issues in +cmuxterm. + +The build phase `Copy Ghostty Resources` overlays this directory onto the app +bundle's `Contents/Resources/terminfo` after copying Ghostty's resources. + diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index da6d1ef3..56e7d8b2 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -173,7 +173,6 @@ class GhosttyApp { ghostty_config_load_default_files(config) ghostty_config_finalize(config) updateDefaultBackground(from: config) - // Create runtime config with callbacks var runtimeConfig = ghostty_runtime_config_s() runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque() @@ -735,6 +734,7 @@ class GhosttyApp { } } } + } // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) @@ -858,7 +858,25 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - var env: [String: String] = [:] + // IMPORTANT: + // Pass a complete, sane environment to the shell process. Some zsh + // features/plugins (including zsh-autosuggestions highlighting) rely on + // terminfo/`TERM` being correct, and those can break if we accidentally + // provide only a minimal env var set. + // + // Start from the app's environment (already adjusted in `cmuxApp`), + // then layer in any per-surface environment from Ghostty config, then + // apply cmuxterm-specific overrides below. + var env: [String: String] = ProcessInfo.processInfo.environment + // Don't leak NO_COLOR into child processes (we explicitly disable it for TUIs). + env.removeValue(forKey: "NO_COLOR") + // `ProcessInfo.processInfo.environment` should reflect `setenv`/`unsetenv`, + // but defensively re-read critical vars from libc to avoid stale snapshots. + for key in ["GHOSTTY_RESOURCES_DIR", "XDG_DATA_DIRS", "MANPATH", "TERMINFO", "TERM", "TERM_PROGRAM", "COLORTERM"] { + if let value = getenv(key) { + env[key] = String(cString: value) + } + } if surfaceConfig.env_var_count > 0, let existingEnv = surfaceConfig.env_vars { let count = Int(surfaceConfig.env_var_count) if count > 0 { @@ -872,6 +890,13 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + if (env["TERM"] ?? "").isEmpty { + env["TERM"] = "xterm-ghostty" + } + if (env["TERM_PROGRAM"] ?? "").isEmpty { + env["TERM_PROGRAM"] = "ghostty" + } + env["CMUX_PANEL_ID"] = id.uuidString env["CMUX_TAB_ID"] = tabId.uuidString env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() @@ -902,8 +927,29 @@ final class TerminalSurface: Identifiable, ObservableObject { ?? "/bin/zsh" let shellName = URL(fileURLWithPath: shell).lastPathComponent if shellName == "zsh" { - let originalZdotdir = env["ZDOTDIR"] ?? ProcessInfo.processInfo.environment["ZDOTDIR"] ?? "" - env["CMUX_ORIGINAL_ZDOTDIR"] = originalZdotdir + // GhosttyKit already injects zsh integration by setting ZDOTDIR to + // Ghostty's integration directory. We still need to set ZDOTDIR + // to our wrapper to load Resources/shell-integration/.zshenv, + // but we must not treat Ghostty's injected ZDOTDIR as the user's + // "real" ZDOTDIR (or zsh history will end up inside the app bundle). + // + // If the user explicitly set ZDOTDIR, preserve it in CMUX_ZSH_ZDOTDIR + // so our wrapper can restore it immediately. + let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) + ?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil) + + if let candidateZdotdir, !candidateZdotdir.isEmpty { + var isGhosttyInjected = false + if let ghosttyResources = env["GHOSTTY_RESOURCES_DIR"], !ghosttyResources.isEmpty { + let ghosttyZdotdir = URL(fileURLWithPath: ghosttyResources) + .appendingPathComponent("shell-integration/zsh").path + isGhosttyInjected = (candidateZdotdir == ghosttyZdotdir) + } + if !isGhosttyInjected { + env["CMUX_ZSH_ZDOTDIR"] = candidateZdotdir + } + } + env["ZDOTDIR"] = integrationDir } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 71373799..1c826fef 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -59,6 +59,8 @@ struct cmuxApp: App { let resourcesParent = resourcesURL.deletingLastPathComponent() let dataDir = resourcesParent.path let manDir = resourcesParent.appendingPathComponent("man").path + let bundledTerminfoDir = resourcesURL.appendingPathComponent("terminfo").path + let siblingTerminfoDir = resourcesParent.appendingPathComponent("terminfo").path appendEnvPathIfMissing( "XDG_DATA_DIRS", @@ -66,6 +68,18 @@ struct cmuxApp: App { defaultValue: "/usr/local/share:/usr/share" ) appendEnvPathIfMissing("MANPATH", path: manDir) + + // Ensure `TERM=xterm-ghostty` works even when launching from Finder + // (no shell to pre-seed TERMINFO). Prefer a terminfo directory next + // to the configured ghostty resources, but fall back to the sibling + // layout used by the Ghostty.app bundle. + if getenv("TERMINFO") == nil { + if fileManager.fileExists(atPath: bundledTerminfoDir) { + setenv("TERMINFO", bundledTerminfoDir, 1) + } else if fileManager.fileExists(atPath: siblingTerminfoDir) { + setenv("TERMINFO", siblingTerminfoDir, 1) + } + } } } diff --git a/tests/test_shell_histfile_ghostty_zdotdir_regression.py b/tests/test_shell_histfile_ghostty_zdotdir_regression.py new file mode 100644 index 00000000..b7ad546c --- /dev/null +++ b/tests/test_shell_histfile_ghostty_zdotdir_regression.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Regression: GhosttyKit already injects zsh shell integration by setting ZDOTDIR +to Ghostty's own integration directory (and optionally preserving a user-set +ZDOTDIR in GHOSTTY_ZSH_ZDOTDIR). + +cmuxterm also injects its own zsh integration by setting ZDOTDIR to +Resources/shell-integration. If cmuxterm incorrectly treats Ghostty's injected +ZDOTDIR as the "user" ZDOTDIR, zsh history will be isolated to the integration +directory rather than the user's HOME/ZDOTDIR, breaking cross-terminal history +and therefore zsh-autosuggestions. + +This test simulates that stacked injection scenario and asserts HISTFILE ends +up at $HOME/.zsh_history (not inside Ghostty's integration directory). +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + + +def _run_zsh_print_histfile(env: dict[str, str]) -> tuple[int, str]: + # A PTY is not required for this regression: we only need /etc/zshrc to run + # and set HISTFILE based on the restored ZDOTDIR/HOME. + result = subprocess.run( + ["zsh", "-ic", 'print -r -- "$HISTFILE"'], + env=env, + capture_output=True, + text=True, + timeout=8, + ) + return (result.returncode, (result.stdout or "") + (result.stderr or "")) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + cmux_wrapper_dir = root / "Resources" / "shell-integration" + ghostty_zsh_dir = root / "ghostty" / "src" / "shell-integration" / "zsh" + + if not (cmux_wrapper_dir / ".zshenv").exists(): + print(f"SKIP: missing cmux wrapper .zshenv at {cmux_wrapper_dir}") + return 0 + if not (ghostty_zsh_dir / ".zshenv").exists(): + print(f"SKIP: missing Ghostty zsh .zshenv at {ghostty_zsh_dir}") + return 0 + + base = Path("/tmp") / f"cmux_histfile_ghostty_stack_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + home = base / "home" + home.mkdir(parents=True, exist_ok=True) + + env = dict(os.environ) + env["HOME"] = str(home) + env.pop("HISTFILE", None) + # Keep this test focused and deterministic: don't run Ghostty's heavy zsh + # integration when executing under a PTY in CI/agent runs. + env.pop("GHOSTTY_RESOURCES_DIR", None) + env.pop("GHOSTTY_SHELL_FEATURES", None) + env.pop("GHOSTTY_BIN_DIR", None) + + # Simulate the buggy situation: cmuxterm stores Ghostty's injected ZDOTDIR + # as the "original" ZDOTDIR, then sets ZDOTDIR to its own wrapper. + env["CMUX_ORIGINAL_ZDOTDIR"] = str(ghostty_zsh_dir) + env["ZDOTDIR"] = str(cmux_wrapper_dir) + env["CMUX_SHELL_INTEGRATION"] = "0" + + rc, out = _run_zsh_print_histfile(env) + if rc != 0: + print(f"FAIL: zsh exited non-zero rc={rc}") + return 1 + + lines = [ln.strip() for ln in out.splitlines() if ln.strip()] + if not lines: + print("FAIL: no output captured from zsh") + return 1 + seen = lines[-1] + expected = str(home / ".zsh_history") + if seen != expected: + print(f"FAIL: HISTFILE={seen!r}, expected {expected!r}") + print(f" cmux_wrapper_dir={cmux_wrapper_dir}") + print(f" ghostty_zsh_dir={ghostty_zsh_dir}") + return 1 + + print("PASS: HISTFILE resolves to user home history (not Ghostty integration dir)") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_shell_zdotdir_user_override.py b/tests/test_shell_zdotdir_user_override.py new file mode 100644 index 00000000..ed7438f2 --- /dev/null +++ b/tests/test_shell_zdotdir_user_override.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" +Regression: if the user's .zshenv changes ZDOTDIR, then .zshrc should be sourced +from the updated ZDOTDIR (matching vanilla zsh semantics). + +Why this matters for cmuxterm: +- cmuxterm sets ZDOTDIR to the app wrapper directory so zsh loads wrapper + startup files. +- The wrapper .zshenv temporarily restores ZDOTDIR to the original directory + while sourcing the user's real .zshenv. +- Some users set ZDOTDIR in their .zshenv to point to a dotfiles directory that + contains their real .zshrc (and plugin setup like zsh-autosuggestions). + +If we clobber that user-chosen ZDOTDIR before sourcing .zshrc, interactive +startup behavior diverges from Ghostty/vanilla zsh and plugins may not load. +""" + +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_zdotdir_user_override_{os.getpid()}" + try: + shutil.rmtree(base, ignore_errors=True) + base.mkdir(parents=True, exist_ok=True) + + orig = base / "orig" + alt = base / "alt" + home = base / "home" + orig.mkdir(parents=True, exist_ok=True) + alt.mkdir(parents=True, exist_ok=True) + home.mkdir(parents=True, exist_ok=True) + + out_path = base / "sourced.txt" + + # User .zshenv that redirects ZDOTDIR to an alternate directory. + # This is a common pattern for dotfiles-managed setups. + (orig / ".zshenv").write_text( + f'export ZDOTDIR="{alt}"\n', + encoding="utf-8", + ) + + # Two competing .zshrc files to prove which directory was honored. + (orig / ".zshrc").write_text( + f'echo "orig" > "{out_path}"\n', + encoding="utf-8", + ) + (alt / ".zshrc").write_text( + f'echo "alt" > "{out_path}"\n', + encoding="utf-8", + ) + + env = dict(os.environ) + env["HOME"] = str(home) + env["ZDOTDIR"] = str(wrapper_dir) + env["CMUX_ZSH_ZDOTDIR"] = str(orig) + env["CMUX_SHELL_INTEGRATION"] = "0" + + # Interactive is required for .zshrc; -d disables global rc files for isolation. + result = subprocess.run( + ["zsh", "-d", "-i", "-c", "true"], + env=env, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + print("FAIL: zsh exited non-zero") + if result.stderr.strip(): + print(result.stderr.strip()) + return 1 + + if not out_path.exists(): + print("FAIL: no output file created (user .zshrc did not run?)") + return 1 + + seen = out_path.read_text(encoding="utf-8").strip() + if seen != "alt": + print(f"FAIL: expected .zshrc from alt ZDOTDIR, got {seen!r}") + print(f" orig={orig}") + print(f" alt={alt}") + return 1 + + print("PASS: .zshrc sourced from user-updated ZDOTDIR") + return 0 + finally: + shutil.rmtree(base, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_shell_zdotdir_wrapper.py b/tests/test_shell_zdotdir_wrapper.py index c899f55e..1675163d 100644 --- a/tests/test_shell_zdotdir_wrapper.py +++ b/tests/test_shell_zdotdir_wrapper.py @@ -47,7 +47,7 @@ def main() -> int: env = dict(os.environ) env["ZDOTDIR"] = str(wrapper_dir) - env["CMUX_ORIGINAL_ZDOTDIR"] = str(orig) + env["CMUX_ZSH_ZDOTDIR"] = str(orig) env["CMUX_ZDOTDIR_TEST_OUTPUT"] = str(seen_path) env["CMUX_SHELL_INTEGRATION"] = "0" diff --git a/tests/test_terminfo_bright_colors.py b/tests/test_terminfo_bright_colors.py new file mode 100644 index 00000000..91c5de53 --- /dev/null +++ b/tests/test_terminfo_bright_colors.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +""" +Regression: cmuxterm relies on Ghostty's xterm-ghostty terminfo entry. + +In cmuxterm (embedded GhosttyKit), "bright" SGR 90-97 can render incorrectly +for some palettes. zsh-autosuggestions defaults to `fg=8`, which historically +resolved to SGR 90 via terminfo `setaf`. + +cmuxterm ships a terminfo overlay that forces bright colors to use indexed +256-color sequences (`38;5;` / `48;5;`) instead of SGR 90-97/100-107. +This test ensures the overlay remains in place. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def run_tput(args: list[str], *, terminfo_dir: Path, term: str) -> bytes: + env = dict(os.environ) + env["TERMINFO"] = str(terminfo_dir) + env["TERM"] = term + return subprocess.check_output(["tput", *args], env=env) + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + overlay = root / "Resources" / "terminfo-overlay" + if not overlay.exists(): + print(f"FAIL: missing overlay dir: {overlay}") + return 1 + + term = "xterm-ghostty" + + setaf8 = run_tput(["setaf", "8"], terminfo_dir=overlay, term=term).hex() + setab8 = run_tput(["setab", "8"], terminfo_dir=overlay, term=term).hex() + setaf7 = run_tput(["setaf", "7"], terminfo_dir=overlay, term=term).hex() + setaf16 = run_tput(["setaf", "16"], terminfo_dir=overlay, term=term).hex() + + # Expect \e[38;5;8m and \e[48;5;8m for bright black. + exp_setaf8 = "1b5b33383b353b386d" + exp_setab8 = "1b5b34383b353b386d" + # Expect standard 8-color white for 7: \e[37m + exp_setaf7 = "1b5b33376d" + # Expect 256-color for 16: \e[38;5;16m + exp_setaf16 = "1b5b33383b353b31366d" + + if setaf8 != exp_setaf8: + print(f"FAIL: setaf 8 = {setaf8}, expected {exp_setaf8}") + return 1 + if setab8 != exp_setab8: + print(f"FAIL: setab 8 = {setab8}, expected {exp_setab8}") + return 1 + if setaf7 != exp_setaf7: + print(f"FAIL: setaf 7 = {setaf7}, expected {exp_setaf7}") + return 1 + if setaf16 != exp_setaf16: + print(f"FAIL: setaf 16 = {setaf16}, expected {exp_setaf16}") + return 1 + + print("PASS: terminfo overlay uses 256-color sequences for bright colors") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +