From 1f086c6632fb748df3889fee309e61e490adee88 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Mon, 27 Apr 2026 12:00:06 +0200 Subject: [PATCH] Wrap shell-integration OSCs in tmux DCS-passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user runs ghostel's shell integration inside tmux (without control-mode integration), tmux strips OSC sequences before they reach ghostel. The shell emits OSC 7, but `default-directory' never updates, so `find-file' and friends don't follow the shell's cwd. Reported in issue #195. Fix: when `$TMUX' is set, the shell integration wraps every OSC in tmux's DCS-passthrough envelope (`\ePtmux;\e\\'). tmux unwraps one level and forwards the bare OSC to ghostel. Users opt in by adding `set -g allow-passthrough on' to `~/.tmux.conf' (tmux 3.3+); without that, tmux drops the wrapped sequence — same behavior as before, no regression. A single `__ghostel_emit_osc' helper in each of the bash/zsh/fish integration files takes the OSC body and emits either the bare `\e]\e\\' or the wrapped form, depending on `$TMUX'. The five existing emitters (`__ghostel_osc7', `__ghostel_prompt_start', `__ghostel_prompt_end', `__ghostel_preexec', `ghostel_cmd') route through it. Byte output is identical across all three shells; fish's helper needs extra escaping because fish's single-quote consumes one level of `\\' → `\' before printf sees it. Tests: new `ghostel-test-shell-osc-tmux-wrapping' (elisp suite) spawns each shell, sources the integration with and without `$TMUX', and asserts byte-exact output. README gains an "Inside tmux" subsection documenting the `allow-passthrough' requirement and the `INSIDE_EMACS'-propagation caveat. Refs #195 --- README.md | 25 +++++++++++++ etc/shell/ghostel.bash | 24 +++++++++---- etc/shell/ghostel.fish | 26 ++++++++++---- etc/shell/ghostel.zsh | 24 +++++++++---- test/ghostel-test.el | 79 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b5446375..f7f81442 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,31 @@ string match -qr '^ghostel(,|$)' -- "$INSIDE_EMACS"; and source "$EMACS_GHOSTEL_ ``` +### Inside tmux + +When `$TMUX` is set, the shell integration wraps every OSC sequence +(directory reports, prompt markers, `ghostel_cmd`) in tmux's +DCS-passthrough envelope so tmux forwards the body to ghostel rather +than swallowing it. Two things have to be true for this to work: + +**1. Enable passthrough in tmux** (tmux 3.3+): + +```tmux +set -g allow-passthrough on +``` + +Restart the tmux server (`tmux kill-server`) for the change to take +effect on existing sessions. + +**2. Source the integration manually from your shell rc.** +ghostel's auto-injection sets per-shell env vars (`ENV`, `ZDOTDIR`, +`XDG_DATA_DIRS`) that tmux does not propagate to pane shells, so the +integration silently fails to load inside a pane. The manual `source` +line from the [Manual shell integration](#shell-integration) section +above does work — `$INSIDE_EMACS` is propagated, so the gate fires +correctly inside tmux panes. Add the line for whichever shell tmux +launches in panes (often bash even if your outer shell is zsh). + ## Key Bindings ### Terminal mode diff --git a/etc/shell/ghostel.bash b/etc/shell/ghostel.bash index 2c6f6bf9..85e70eb0 100644 --- a/etc/shell/ghostel.bash +++ b/etc/shell/ghostel.bash @@ -19,9 +19,21 @@ # kernel echo input immediately. builtin command stty echo 2>/dev/null +# Emit an OSC sequence with the given body (the part between `\e]' and +# the ST `\e\\'). Inside tmux, wrap the OSC in DCS-passthrough so tmux +# forwards the body to the outer terminal — requires `set -g +# allow-passthrough on' in tmux.conf (tmux 3.3+). +__ghostel_emit_osc() { + if [[ -n "$TMUX" ]]; then + printf '\ePtmux;\e\e]%s\e\e\\\e\\' "$1" + else + printf '\e]%s\e\\' "$1" + fi +} + # Report working directory to the terminal via OSC 7 __ghostel_osc7() { - printf '\e]7;file://%s%s\e\\' "$HOSTNAME" "$PWD" + __ghostel_emit_osc "7;file://$HOSTNAME$PWD" } # --- Semantic prompt markers (OSC 133) --- @@ -30,14 +42,14 @@ __ghostel_osc7() { # D is skipped on the very first prompt (no previous command). __ghostel_prompt_start() { if [[ -n "$__ghostel_prompt_shown" ]]; then - printf '\e]133;D;%s\e\\' "$__ghostel_last_status" + __ghostel_emit_osc "133;D;$__ghostel_last_status" fi - printf '\e]133;A\e\\' + __ghostel_emit_osc "133;A" } # Emit "prompt end / command start" (B). __ghostel_prompt_end() { - printf '\e]133;B\e\\' + __ghostel_emit_osc "133;B" __ghostel_prompt_shown=1 } @@ -46,7 +58,7 @@ __ghostel_prompt_end() { __ghostel_in_prompt_command=0 __ghostel_preexec() { [[ "$__ghostel_in_prompt_command" = 1 ]] && return - printf '\e]133;C\e\\' + __ghostel_emit_osc "133;C" } __ghostel_wrapped_prompt_command() { @@ -197,5 +209,5 @@ ghostel_cmd() { payload="$payload\"$(printf '%s' "$1" | sed -e 's|\\|\\\\|g' -e 's|"|\\"|g')\" " shift done - printf '\e]51;E%s\e\\' "$payload" + __ghostel_emit_osc "51;E$payload" } diff --git a/etc/shell/ghostel.fish b/etc/shell/ghostel.fish index e6636727..3795baa4 100644 --- a/etc/shell/ghostel.fish +++ b/etc/shell/ghostel.fish @@ -14,9 +14,23 @@ # Idempotency guard — skip if already loaded (e.g. auto-injected). functions -q __ghostel_osc7; and return +# Emit an OSC sequence with the given body (the part between `\e]' and +# the ST `\e\\'). Inside tmux, wrap the OSC in DCS-passthrough so tmux +# forwards the body to the outer terminal — requires `set -g +# allow-passthrough on' in tmux.conf (tmux 3.3+). +function __ghostel_emit_osc + if set -q TMUX + # Fish single-quote eats `\\' (escape) so printf-format `\\' must + # be written `\\\\' here. `\e' passes through to printf as-is. + printf '\ePtmux;\e\e]%s\e\e\\\\\e\\\\' "$argv[1]" + else + printf '\e]%s\e\\\\' "$argv[1]" + end +end + # Report working directory to the terminal via OSC 7 function __ghostel_osc7 --on-event fish_prompt - printf '\e]7;file://%s%s\e\\' (hostname) "$PWD" + __ghostel_emit_osc "7;file://"(hostname)"$PWD" end # --- Semantic prompt markers (OSC 133) --- @@ -30,20 +44,20 @@ end # Emit "command finished" (D) + "prompt start" (A) before the prompt. function __ghostel_prompt_start --on-event fish_prompt if test "$__ghostel_prompt_shown" = 1 - printf '\e]133;D;%s\e\\' "$__ghostel_last_status" + __ghostel_emit_osc "133;D;$__ghostel_last_status" end - printf '\e]133;A\e\\' + __ghostel_emit_osc "133;A" end # Emit "prompt end / command start" (B) after the prompt. function __ghostel_prompt_end --on-event fish_prompt - printf '\e]133;B\e\\' + __ghostel_emit_osc "133;B" set -g __ghostel_prompt_shown 1 end # Emit "command output start" (C) before command runs. function __ghostel_preexec --on-event fish_preexec - printf '\e]133;C\e\\' + __ghostel_emit_osc "133;C" end # Outbound `ssh' wrapper. See etc/ghostel.bash for the full design @@ -152,5 +166,5 @@ function ghostel_cmd set arg (string replace -a '"' '\\"' -- $arg) set payload "$payload\"$arg\" " end - printf '\e]51;E%s\e\\' "$payload" + __ghostel_emit_osc "51;E$payload" end diff --git a/etc/shell/ghostel.zsh b/etc/shell/ghostel.zsh index 79fe3907..01c56de3 100644 --- a/etc/shell/ghostel.zsh +++ b/etc/shell/ghostel.zsh @@ -17,9 +17,21 @@ # Idempotency guard — skip if already loaded (e.g. auto-injected). (( $+functions[__ghostel_osc7] )) && return +# Emit an OSC sequence with the given body (the part between `\e]' and +# the ST `\e\\'). Inside tmux, wrap the OSC in DCS-passthrough so tmux +# forwards the body to the outer terminal — requires `set -g +# allow-passthrough on' in tmux.conf (tmux 3.3+). +__ghostel_emit_osc() { + if [[ -n "$TMUX" ]]; then + printf '\ePtmux;\e\e]%s\e\e\\\e\\' "$1" + else + printf '\e]%s\e\\' "$1" + fi +} + # Report working directory to the terminal via OSC 7 __ghostel_osc7() { - printf '\e]7;file://%s%s\e\\' "$HOST" "$PWD" + __ghostel_emit_osc "7;file://$HOST$PWD" } # --- Semantic prompt markers (OSC 133) --- @@ -31,20 +43,20 @@ __ghostel_save_status() { # Emit "command finished" (D) + "prompt start" (A). __ghostel_prompt_start() { if [[ -n "$__ghostel_prompt_shown" ]]; then - printf '\e]133;D;%s\e\\' "$__ghostel_last_status" + __ghostel_emit_osc "133;D;$__ghostel_last_status" fi - printf '\e]133;A\e\\' + __ghostel_emit_osc "133;A" } # Emit "prompt end / command start" (B). __ghostel_prompt_end() { - printf '\e]133;B\e\\' + __ghostel_emit_osc "133;B" __ghostel_prompt_shown=1 } # Emit "command output start" (C). __ghostel_preexec() { - printf '\e]133;C\e\\' + __ghostel_emit_osc "133;C" } precmd_functions=(__ghostel_save_status __ghostel_prompt_start __ghostel_osc7 "${precmd_functions[@]}" __ghostel_prompt_end) @@ -152,5 +164,5 @@ ghostel_cmd() { payload="$payload\"$arg\" " shift done - printf '\e]51;E%s\e\\' "$payload" + __ghostel_emit_osc "51;E$payload" } diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 991cb8dd..19198457 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -1181,6 +1181,84 @@ that collided with a fish-internal local variable, leaking (ghostel--update-directory file-url) (should (equal old ghostel--last-directory))))) ; dedup +;; ----------------------------------------------------------------------- +;; Test: shell integration wraps OSC in tmux DCS-passthrough when $TMUX +;; ----------------------------------------------------------------------- + +(defun ghostel-test--shell-osc-bytes (shell rel-script tmux-value) + "Source REL-SCRIPT in SHELL and capture bytes from `__ghostel_emit_osc'. +TMUX-VALUE is what to set $TMUX to (nil means unset). REL-SCRIPT is +relative to the package root. Returns raw stdout (after a sentinel) as +a unibyte string. + +Bash installs a DEBUG trap during sourcing that emits OSC 133;C before +the next simple command — including commands inside the script's own +body after the trap is installed. That contaminates stdout with one or +more 133;C OSCs we can't suppress before the trap fires. Workaround: +emit a sentinel via the same helper after sourcing, and return only the +bytes that follow the sentinel." + (let* ((root (file-name-directory (directory-file-name + (file-name-directory + (locate-library "ghostel"))))) + (script (expand-file-name rel-script root)) + (qscript (shell-quote-argument script)) + (qtmux (and tmux-value (shell-quote-argument tmux-value))) + (sentinel "===GHOSTEL-OSC-CAPTURE===") + (cmd (pcase shell + ((or "bash" "zsh") + (concat (if tmux-value + (format "export TMUX=%s; " qtmux) + "unset TMUX; ") + (format "source %s; " qscript) + ;; Set the integration's own DEBUG-trap escape + ;; hatch so the trap stops emitting 133;C, and + ;; emit a sentinel so the caller can slice off + ;; any 133;C bytes already emitted before this. + "__ghostel_in_prompt_command=1; " + (format "printf '%%s' %s; " (shell-quote-argument sentinel)) + "HOST=h HOSTNAME=h PWD=/p " + "__ghostel_emit_osc '7;file://h/p'")) + ("fish" + (concat (if tmux-value + (format "set -x TMUX %s; " qtmux) + "set -e TMUX; ") + (format "source %s; " qscript) + (format "printf '%%s' %s; " (shell-quote-argument sentinel)) + "__ghostel_emit_osc '7;file://h/p'")))) + (flag (pcase shell + ("bash" "--norc") + ("zsh" "-f") + ("fish" "--no-config")))) + (with-temp-buffer + (set-buffer-multibyte nil) + (let ((coding-system-for-read 'binary) + (coding-system-for-write 'binary)) + (let ((status (call-process shell nil (current-buffer) nil + flag "-c" cmd))) + (unless (zerop status) + (error "%s exited %d: %s" shell status (buffer-string))))) + (let* ((out (buffer-string)) + (idx (string-match (regexp-quote sentinel) out))) + (unless idx + (error "Sentinel %s not found in output: %S" sentinel out)) + (substring out (+ idx (length sentinel))))))) + +(ert-deftest ghostel-test-shell-osc-tmux-wrapping () + "Shell integration wraps OSCs in tmux DCS-passthrough when $TMUX is set. +Without $TMUX, emits a bare `\\e]…\\e\\\\' OSC. With $TMUX, wraps it as +`\\ePtmux;\\e\\e]…\\e\\e\\\\\\e\\\\' (inner ESCs doubled, outer DCS+ST)." + (let ((bare "\e]7;file://h/p\e\\") + (wrapped "\ePtmux;\e\e]7;file://h/p\e\e\\\e\\")) + (dolist (c '(("bash" "etc/shell/ghostel.bash") + ("zsh" "etc/shell/ghostel.zsh") + ("fish" "etc/shell/ghostel.fish"))) + (cl-destructuring-bind (shell script) c + (when (executable-find shell) + (should (equal bare + (ghostel-test--shell-osc-bytes shell script nil))) + (should (equal wrapped + (ghostel-test--shell-osc-bytes shell script "fake-tmux")))))))) + ;; ----------------------------------------------------------------------- ;; Test: cwd exposed via list-buffers-directory ;; ----------------------------------------------------------------------- @@ -7791,6 +7869,7 @@ while :; do sleep 0.1; done'\n") ghostel-test-send-event ghostel-test-raw-key-modified-specials ghostel-test-update-directory + ghostel-test-shell-osc-tmux-wrapping ghostel-test-list-buffers-directory ghostel-test-compile-view-list-buffers-directory ghostel-test-filter-soft-wraps