Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,31 @@ string match -qr '^ghostel(,|$)' -- "$INSIDE_EMACS"; and source "$EMACS_GHOSTEL_
```
</details>

### 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
Expand Down
24 changes: 18 additions & 6 deletions etc/shell/ghostel.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Expand All @@ -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
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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"
}
26 changes: 20 additions & 6 deletions etc/shell/ghostel.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Expand All @@ -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
Expand Down Expand Up @@ -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
24 changes: 18 additions & 6 deletions etc/shell/ghostel.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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) ---
Expand All @@ -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)
Expand Down Expand Up @@ -152,5 +164,5 @@ ghostel_cmd() {
payload="$payload\"$arg\" "
shift
done
printf '\e]51;E%s\e\\' "$payload"
__ghostel_emit_osc "51;E$payload"
}
79 changes: 79 additions & 0 deletions test/ghostel-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
;; -----------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
Loading