Skip to content

Commit b3094b7

Browse files
committed
Add OSC 51 elisp eval from shell
Allow shell scripts to call whitelisted Elisp functions via OSC 51 escape sequences (same protocol as vterm). Includes ghostel_cmd shell helper, ghostel-eval-cmds whitelist, and documentation.
1 parent 3c71314 commit b3094b7

8 files changed

Lines changed: 160 additions & 14 deletions

File tree

README.md

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,48 @@ Soft-wrapped newlines are automatically stripped from copied text.
141141
- Prompt navigation via OSC 133 — jump between prompts with `C-c C-n` / `C-c C-p`
142142
- Title tracking (buffer renamed from OSC 2)
143143
- OSC 8 hyperlinks — clickable URLs in terminal output (click or `RET` to open)
144+
- Elisp eval from shell via OSC 51 — call whitelisted Emacs functions from shell scripts
144145
- OSC 52 clipboard support (opt-in, for remote sessions)
145146
- `INSIDE_EMACS` and `EMACS_GHOSTEL_PATH` environment variables
146147

148+
### Calling Elisp from the Shell
149+
150+
Shell scripts running inside ghostel can call whitelisted Elisp functions
151+
via the `ghostel_cmd` helper (provided by the shell integration scripts):
152+
153+
```sh
154+
ghostel_cmd find-file "/path/to/file"
155+
ghostel_cmd message "Hello from the shell"
156+
```
157+
158+
This uses OSC 51 escape sequences (the same protocol as vterm). Only
159+
functions listed in `ghostel-eval-cmds` are allowed.
160+
161+
Default whitelisted commands:
162+
163+
`find-file`, `find-file-other-window`, `dired`, `dired-other-window`, `message`.
164+
165+
Add your own with:
166+
167+
```elisp
168+
(add-to-list 'ghostel-eval-cmds '("magit-status-setup-buffer" magit-status-setup-buffer))
169+
```
170+
171+
Example shell aliases (add to your `.bashrc` / `.zshrc`):
172+
173+
```sh
174+
if [[ "$INSIDE_EMACS" = 'ghostel' ]]; then
175+
# Open a file in Emacs from the terminal
176+
e() { ghostel_cmd find-file-other-window "$@"; }
177+
178+
# Open dired in another window
179+
dow() { ghostel_cmd dired-other-window "$@"; }
180+
181+
# Open magit for the current directory
182+
gst() { ghostel_cmd magit-status-setup-buffer "$(pwd)"; }
183+
fi
184+
```
185+
147186
### Color Palette
148187

149188
The 16 ANSI colors are defined as Emacs faces inheriting from `term-color-*`:
@@ -164,18 +203,19 @@ individual faces with `M-x customize-face`.
164203

165204
## Configuration
166205

167-
| Variable | Default | Description |
168-
|-------------------------------|---------------------|----------------------------------------|
169-
| `ghostel-shell` | `$SHELL` | Shell program to run |
170-
| `ghostel-shell-integration` | `t` | Auto-inject shell integration |
171-
| `ghostel-buffer-name` | `"*ghostel*"` | Default buffer name |
172-
| `ghostel-max-scrollback` | `10000` | Maximum scrollback lines |
173-
| `ghostel-timer-delay` | `0.033` | Redraw delay in seconds (~30fps) |
174-
| `ghostel-kill-buffer-on-exit` | `t` | Kill buffer when shell exits |
175-
| `ghostel-enable-osc52` | `nil` | Allow apps to set clipboard via OSC 52 |
176-
| `ghostel-prompt-reapply-on-redraw` | `nil` | Re-apply prompt markers after full redraws |
177-
| `ghostel-keymap-exceptions` | `("C-c" "C-x" ...)` | Keys passed through to Emacs |
178-
| `ghostel-exit-functions` | `nil` | Hook run when the shell process exits |
206+
| Variable | Default | Description |
207+
|------------------------------------|---------------------|--------------------------------------------|
208+
| `ghostel-shell` | `$SHELL` | Shell program to run |
209+
| `ghostel-shell-integration` | `t` | Auto-inject shell integration |
210+
| `ghostel-buffer-name` | `"*ghostel*"` | Default buffer name |
211+
| `ghostel-max-scrollback` | `10000` | Maximum scrollback lines |
212+
| `ghostel-timer-delay` | `0.033` | Redraw delay in seconds (~30fps) |
213+
| `ghostel-kill-buffer-on-exit` | `t` | Kill buffer when shell exits |
214+
| `ghostel-eval-cmds` | `(see above)` | Whitelisted functions for OSC 51 eval |
215+
| `ghostel-enable-osc52` | `nil` | Allow apps to set clipboard via OSC 52 |
216+
| `ghostel-prompt-reapply-on-redraw` | `nil` | Re-apply prompt markers after full redraws |
217+
| `ghostel-keymap-exceptions` | `("C-c" "C-x" ...)` | Keys passed through to Emacs |
218+
| `ghostel-exit-functions` | `nil` | Hook run when the shell process exits |
179219

180220
## Commands
181221

@@ -269,7 +309,7 @@ powering Neovim's built-in terminal.
269309
| Alternate screen | Yes | Yes |
270310
| Shell integration auto-inject | Yes | No |
271311
| Prompt navigation (OSC 133) | Yes | Yes |
272-
| Elisp eval from shell | No | Yes |
312+
| Elisp eval from shell | Yes | Yes |
273313
| Tramp-aware directory | No | Yes |
274314
| OSC 52 clipboard | Yes | Yes |
275315
| Copy mode | Yes | Yes |

etc/ghostel.bash

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,15 @@ __ghostel_original_prompt_command="${PROMPT_COMMAND:+$PROMPT_COMMAND}"
6060
PROMPT_COMMAND="__ghostel_wrapped_prompt_command"
6161

6262
trap '__ghostel_preexec' DEBUG
63+
64+
# Call an Emacs Elisp function from the shell.
65+
# Usage: ghostel_cmd FUNCTION [ARGS...]
66+
# The function must be in `ghostel-eval-cmds'.
67+
ghostel_cmd() {
68+
local payload=""
69+
while [ $# -gt 0 ]; do
70+
payload="$payload\"$(printf '%s' "$1" | sed -e 's|\\|\\\\|g' -e 's|"|\\"|g')\" "
71+
shift
72+
done
73+
printf '\e]51;E%s\e\\' "$payload"
74+
}

etc/ghostel.fish

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,16 @@ end
3636
function __ghostel_preexec --on-event fish_preexec
3737
printf '\e]133;C\e\\'
3838
end
39+
40+
# Call an Emacs Elisp function from the shell.
41+
# Usage: ghostel_cmd FUNCTION [ARGS...]
42+
# The function must be in `ghostel-eval-cmds'.
43+
function ghostel_cmd
44+
set -l payload ""
45+
for arg in $argv
46+
set arg (string replace -a '\\' '\\\\' -- $arg)
47+
set arg (string replace -a '"' '\\"' -- $arg)
48+
set payload "$payload\"$arg\" "
49+
end
50+
printf '\e]51;E%s\e\\' "$payload"
51+
end

etc/ghostel.zsh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,15 @@ __ghostel_preexec() {
3737

3838
precmd_functions=(__ghostel_save_status __ghostel_prompt_start __ghostel_osc7 "${precmd_functions[@]}" __ghostel_prompt_end)
3939
preexec_functions=(__ghostel_preexec "${preexec_functions[@]}")
40+
41+
# Call an Emacs Elisp function from the shell.
42+
# Usage: ghostel_cmd FUNCTION [ARGS...]
43+
# The function must be in `ghostel-eval-cmds'.
44+
ghostel_cmd() {
45+
local payload=""
46+
while (( $# )); do
47+
payload="$payload\"${1//\\/\\\\}\" "
48+
shift
49+
done
50+
printf '\e]51;E%s\e\\' "$payload"
51+
}

ghostel.el

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,18 @@ exit event string."
333333
:type 'hook
334334
:group 'ghostel)
335335

336+
(defcustom ghostel-eval-cmds '(("find-file" find-file)
337+
("find-file-other-window" find-file-other-window)
338+
("dired" dired)
339+
("dired-other-window" dired-other-window)
340+
("message" message))
341+
"Whitelisted Emacs functions callable from the terminal via OSC 51.
342+
Each entry is (NAME FUNCTION) where NAME is the string sent from
343+
the shell and FUNCTION is the Elisp function to invoke.
344+
All arguments are passed as strings."
345+
:type '(alist :key-type string :value-type function)
346+
:group 'ghostel)
347+
336348
(defcustom ghostel-enable-osc52 nil
337349
"Allow terminal applications to set the clipboard via OSC 52.
338350
When non-nil, programs running in the terminal can copy text to the
@@ -1296,6 +1308,20 @@ the last non-whitespace+whitespace boundary (e.g. after `$ ' or `# ')."
12961308

12971309
;;; Callbacks from native module
12981310

1311+
(defun ghostel--osc51-eval (str)
1312+
"Handle an OSC 51;E command from the terminal.
1313+
STR is the payload after the E sub-command.
1314+
Parses the command and arguments, looks up the command in
1315+
`ghostel-eval-cmds', and calls it if whitelisted."
1316+
(let* ((parts (split-string-and-unquote str))
1317+
(command (car parts))
1318+
(args (cdr parts))
1319+
(entry (assoc command ghostel-eval-cmds)))
1320+
(if entry
1321+
(apply (cadr entry) args)
1322+
(message "ghostel: unknown eval command %S (add to `ghostel-eval-cmds' to allow)"
1323+
command))))
1324+
12991325
(defun ghostel--osc52-handle (_selection base64-data)
13001326
"Handle an OSC 52 clipboard set request.
13011327
SELECTION is the target (e.g. \"c\" for clipboard).

src/emacs.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ pub const Sym = struct {
302302
@"ghostel--detect-urls": Value,
303303
@"ghostel--set-cursor-style": Value,
304304
@"ghostel--update-directory": Value,
305+
@"ghostel--osc51-eval": Value,
305306
@"ghostel--osc52-handle": Value,
306307
@"ghostel--osc133-marker": Value,
307308
@"ghostel--flush-output": Value,

src/module.zig

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ fn fnWriteInput(raw_env: ?*c.emacs_env, _: isize, args: [*c]c.emacs_value, _: ?*
176176

177177
// Scan for OSC sequences that libghostty-vt discards.
178178
extractAndSetPwd(term, raw);
179+
extractOsc51(env, raw);
179180
extractOsc52(env, raw);
180181
extractOsc133(env, raw);
181182

@@ -227,6 +228,23 @@ const OscScanner = struct {
227228
}
228229
};
229230

231+
/// Scan data for OSC 51;E elisp eval sequences.
232+
/// OSC 51 format: ESC ] 51 ; E <quoted-args> (ST | BEL)
233+
/// Passes the payload (after 'E') to ghostel--osc51-eval for dispatch.
234+
fn extractOsc51(env: emacs.Env, data: []const u8) void {
235+
var scanner = OscScanner{ .data = data, .prefix = "\x1b]51;" };
236+
while (scanner.next()) |match| {
237+
const payload = match.payload;
238+
if (payload.len < 2) continue;
239+
// Sub-command must be 'E'
240+
if (payload[0] != 'E') continue;
241+
_ = env.call1(
242+
emacs.sym.@"ghostel--osc51-eval",
243+
env.makeString(payload[1..]),
244+
);
245+
}
246+
}
247+
230248
/// Scan data for OSC 7 sequences and set the terminal PWD.
231249
/// OSC 7 format: ESC ] 7 ; <url> (ST | BEL)
232250
fn extractAndSetPwd(term: *Terminal, data: []const u8) void {

test/ghostel-test.el

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,28 @@
819819
(kill-buffer buf)
820820
(kill-buffer other))))))
821821

822+
;; -----------------------------------------------------------------------
823+
;; OSC 51 elisp eval
824+
;; -----------------------------------------------------------------------
825+
826+
(ert-deftest ghostel-test-osc51-eval ()
827+
"Test that OSC 51;E dispatches to whitelisted functions."
828+
(let* ((called-with nil)
829+
(ghostel-eval-cmds
830+
`(("test-fn" ,(lambda (&rest args) (setq called-with args))))))
831+
(ghostel--osc51-eval "\"test-fn\" \"hello\" \"world\"")
832+
(should (equal '("hello" "world") called-with))))
833+
834+
(ert-deftest ghostel-test-osc51-eval-unknown ()
835+
"Test that unknown OSC 51;E commands produce a message."
836+
(let ((ghostel-eval-cmds nil)
837+
(messages nil))
838+
(cl-letf (((symbol-function 'message)
839+
(lambda (fmt &rest args) (push (apply #'format fmt args) messages))))
840+
(ghostel--osc51-eval "\"unknown-fn\" \"arg\"")
841+
(should (car messages))
842+
(should (string-match-p "unknown eval command" (car messages))))))
843+
822844
;; -----------------------------------------------------------------------
823845
;; Runner
824846
;; -----------------------------------------------------------------------
@@ -831,7 +853,9 @@
831853
ghostel-test-update-directory
832854
ghostel-test-filter-soft-wraps
833855
ghostel-test-prompt-navigation
834-
ghostel-test-sync-theme)
856+
ghostel-test-sync-theme
857+
ghostel-test-osc51-eval
858+
ghostel-test-osc51-eval-unknown)
835859
"Tests that require only Elisp (no native module).")
836860

837861
(defun ghostel-test-run-elisp ()

0 commit comments

Comments
 (0)