Skip to content

Commit 5546b97

Browse files
committed
Forward xterm-paste events to the inferior shell
TTY Emacs running inside a host terminal that does bracketed paste delivers clipboard pastes as a single `(xterm-paste TEXT)` event. The default `xterm-paste' binding inserts TEXT into the current buffer, which is useless in a ghostel buffer: the renderer owns the buffer and wipes any plain-inserted text on the next redraw, so the shell never sees the paste. Bind `[xterm-paste]' in `ghostel-mode-map' (and `ghostel-copy-mode-map') to a new `ghostel-xterm-paste' handler that pulls the text out of the event and forwards it to the subprocess via `ghostel--paste-text', the same bracketed-paste path used by `ghostel-yank' and `ghostel-paste'. When `xterm-store-paste-on-kill-ring' is non-nil (stock default), also push the text onto the kill ring for parity with `xterm-paste'. If copy-mode is active, exit it first so the paste reaches the prompt. Covered by seven ERT tests under the existing paste/yank section: happy path forwards payload to `ghostel--paste-text'; wrong event type raises `user-error'; nil payload is a no-op; kill-ring is populated/skipped per `xterm-store-paste-on-kill-ring'; copy-mode is exited before forwarding; and [xterm-paste] is bound in both keymaps. Fixes #172.
1 parent c42451e commit 5546b97

3 files changed

Lines changed: 142 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ All notable changes to this project will be documented in this file.
3131
a sticky `ghostel--buffer-identity` set at creation time, and lookup
3232
matches on identity rather than current buffer name
3333
([#168](https://github.com/dakra/ghostel/issues/168)).
34+
- Bind `[xterm-paste]` to a ghostel-aware handler so clipboard pastes
35+
delivered by the host terminal (TTY Emacs with bracketed paste) reach
36+
the inferior shell instead of being inserted into the renderer-owned
37+
buffer and wiped on the next redraw
38+
([#172](https://github.com/dakra/ghostel/issues/172)).
3439

3540
## [0.17.0] — 2026-04-21
3641

lisp/ghostel.el

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,10 @@ is non-nil.")
10531053
(define-key map (kbd "<XF86Paste>") #'ghostel-yank)
10541054
(define-key map (kbd "<XF86Copy>") #'kill-ring-save)
10551055
(define-key map (kbd "M-y") #'ghostel-yank-pop)
1056+
;; Bracketed paste from the host terminal (TTY Emacs): forward the
1057+
;; paste payload to the subprocess instead of letting the default
1058+
;; `xterm-paste' insert it into the (renderer-owned) buffer.
1059+
(define-key map [xterm-paste] #'ghostel-xterm-paste)
10561060
;; Terminal control via C-c prefix (pass through to Emacs, then handled here)
10571061
(define-key map (kbd "C-c C-c") #'ghostel-send-C-c)
10581062
(define-key map (kbd "C-c C-z") #'ghostel-send-C-z)
@@ -1445,6 +1449,26 @@ pastes the selected entry into the terminal."
14451449
kill-ring nil t)))
14461450
(ghostel--paste-text text))))
14471451

1452+
(defun ghostel-xterm-paste (event)
1453+
"Forward an xterm-paste EVENT to the terminal via bracketed paste.
1454+
The default `xterm-paste' command inserts into the current buffer,
1455+
which is wrong for ghostel: the terminal renderer owns the buffer
1456+
and wipes the inserted text on the next redraw, so the shell
1457+
never sees it. This handler extracts the pasted text from EVENT
1458+
and pushes it to the subprocess through `ghostel--paste-text'
1459+
instead. When `xterm-store-paste-on-kill-ring' is non-nil (the
1460+
stock default), the text is also pushed onto the kill ring for
1461+
parity with `xterm-paste'."
1462+
(interactive "e")
1463+
(unless (eq (car-safe event) 'xterm-paste)
1464+
(error "This command must be bound to an xterm-paste event"))
1465+
(when ghostel--copy-mode-active
1466+
(ghostel-copy-mode-exit))
1467+
(when-let* ((text (nth 1 event)))
1468+
(when (bound-and-true-p xterm-store-paste-on-kill-ring)
1469+
(kill-new text))
1470+
(ghostel--paste-text text)))
1471+
14481472

14491473
;;; Drag and drop
14501474

@@ -1622,10 +1646,11 @@ Return non-nil if the event was forwarded (mouse tracking is active)."
16221646
;; Prompt navigation works in copy mode too
16231647
(define-key map (kbd "C-c M-n") #'ghostel-next-prompt)
16241648
(define-key map (kbd "C-c M-p") #'ghostel-previous-prompt)
1625-
(define-key map (kbd "M->") #'ghostel-copy-mode-end-of-buffer)
1626-
(define-key map (kbd "C-e") #'ghostel-copy-mode-end-of-line)
1627-
(define-key map (kbd "C-l") #'ghostel-copy-mode-recenter)
1628-
(define-key map (kbd "C-c C-l") #'ghostel-copy-mode-exit-and-clear)
1649+
(define-key map (kbd "M->") #'ghostel-copy-mode-end-of-buffer)
1650+
(define-key map (kbd "C-e") #'ghostel-copy-mode-end-of-line)
1651+
(define-key map (kbd "C-l") #'ghostel-copy-mode-recenter)
1652+
(define-key map (kbd "C-c C-l") #'ghostel-copy-mode-exit-and-clear)
1653+
(define-key map [xterm-paste] #'ghostel-xterm-paste)
16291654
map)
16301655
"Keymap for `ghostel-copy-mode'.
16311656
Standard Emacs navigation works.

test/ghostel-test.el

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6022,6 +6022,106 @@ but `this-command-keys-vector' retains the ESC prefix."
60226022
(ghostel-yank-pop)
60236023
(should (equal (car pasted) "alpha")))))
60246024

6025+
;; -----------------------------------------------------------------------
6026+
;; Test: ghostel-xterm-paste
6027+
;; -----------------------------------------------------------------------
6028+
6029+
;; Declared here so tests can let-bind it without byte-compile warnings
6030+
;; when xterm.el hasn't been loaded in the batch environment.
6031+
(defvar xterm-store-paste-on-kill-ring)
6032+
6033+
(ert-deftest ghostel-test-xterm-paste-forwards-to-paste-text ()
6034+
"`ghostel-xterm-paste' forwards the event payload via `ghostel--paste-text'."
6035+
(let ((pasted nil)
6036+
(ghostel--copy-mode-active nil)
6037+
(xterm-store-paste-on-kill-ring nil))
6038+
(cl-letf (((symbol-function 'ghostel--paste-text)
6039+
(lambda (text) (push text pasted))))
6040+
(ghostel-xterm-paste '(xterm-paste "hello world"))
6041+
(should (equal pasted '("hello world"))))))
6042+
6043+
(ert-deftest ghostel-test-xterm-paste-rejects-wrong-event ()
6044+
"`ghostel-xterm-paste' signals when the event isn't an xterm-paste."
6045+
(let ((ghostel--copy-mode-active nil))
6046+
(should-error (ghostel-xterm-paste '(mouse-1 "oops")))))
6047+
6048+
(ert-deftest ghostel-test-xterm-paste-no-text-is-noop ()
6049+
"`ghostel-xterm-paste' with a nil payload does not forward or touch the kill ring."
6050+
(let ((called nil)
6051+
(kill-ring '("preexisting"))
6052+
(kill-ring-yank-pointer nil)
6053+
(ghostel--copy-mode-active nil)
6054+
(xterm-store-paste-on-kill-ring t))
6055+
(cl-letf (((symbol-function 'ghostel--paste-text)
6056+
(lambda (_text) (setq called t))))
6057+
(ghostel-xterm-paste '(xterm-paste nil))
6058+
(should-not called)
6059+
(should (equal kill-ring '("preexisting"))))))
6060+
6061+
(ert-deftest ghostel-test-xterm-paste-stores-on-kill-ring ()
6062+
"When `xterm-store-paste-on-kill-ring' is non-nil, push the paste onto the kill ring."
6063+
(let ((pasted nil)
6064+
(kill-ring nil)
6065+
(kill-ring-yank-pointer nil)
6066+
(ghostel--copy-mode-active nil)
6067+
(xterm-store-paste-on-kill-ring t))
6068+
(cl-letf (((symbol-function 'ghostel--paste-text)
6069+
(lambda (text) (push text pasted))))
6070+
(ghostel-xterm-paste '(xterm-paste "clip"))
6071+
(should (equal pasted '("clip")))
6072+
(should (equal (car kill-ring) "clip")))))
6073+
6074+
(ert-deftest ghostel-test-xterm-paste-skips-kill-ring-when-disabled ()
6075+
"When `xterm-store-paste-on-kill-ring' is nil, the kill ring is untouched."
6076+
(let ((pasted nil)
6077+
(kill-ring '("preexisting"))
6078+
(kill-ring-yank-pointer nil)
6079+
(ghostel--copy-mode-active nil)
6080+
(xterm-store-paste-on-kill-ring nil))
6081+
(cl-letf (((symbol-function 'ghostel--paste-text)
6082+
(lambda (text) (push text pasted))))
6083+
(ghostel-xterm-paste '(xterm-paste "clip"))
6084+
(should (equal pasted '("clip")))
6085+
(should (equal kill-ring '("preexisting"))))))
6086+
6087+
(ert-deftest ghostel-test-xterm-paste-exits-copy-mode ()
6088+
"`ghostel-xterm-paste' exits copy mode before forwarding."
6089+
(let ((pasted nil)
6090+
(exit-called nil)
6091+
(ghostel--copy-mode-active t)
6092+
(xterm-store-paste-on-kill-ring nil))
6093+
(cl-letf (((symbol-function 'ghostel--paste-text)
6094+
(lambda (text) (push text pasted)))
6095+
((symbol-function 'ghostel-copy-mode-exit)
6096+
(lambda () (setq exit-called t))))
6097+
(ghostel-xterm-paste '(xterm-paste "payload"))
6098+
(should exit-called)
6099+
(should (equal pasted '("payload"))))))
6100+
6101+
(ert-deftest ghostel-test-xterm-paste-bound-in-keymaps ()
6102+
"`ghostel-xterm-paste' is bound to the [xterm-paste] event in both keymaps."
6103+
(should (eq (lookup-key ghostel-mode-map [xterm-paste])
6104+
#'ghostel-xterm-paste))
6105+
(should (eq (lookup-key ghostel-copy-mode-map [xterm-paste])
6106+
#'ghostel-xterm-paste)))
6107+
6108+
(ert-deftest ghostel-test-xterm-paste-copy-mode-and-kill-ring ()
6109+
"All three side effects (exit copy mode, `kill-ring', forward) fire together."
6110+
(let ((pasted nil)
6111+
(exit-called nil)
6112+
(kill-ring nil)
6113+
(kill-ring-yank-pointer nil)
6114+
(ghostel--copy-mode-active t)
6115+
(xterm-store-paste-on-kill-ring t))
6116+
(cl-letf (((symbol-function 'ghostel--paste-text)
6117+
(lambda (text) (push text pasted)))
6118+
((symbol-function 'ghostel-copy-mode-exit)
6119+
(lambda () (setq exit-called t))))
6120+
(ghostel-xterm-paste '(xterm-paste "combo"))
6121+
(should exit-called)
6122+
(should (equal pasted '("combo")))
6123+
(should (equal (car kill-ring) "combo")))))
6124+
60256125
;; -----------------------------------------------------------------------
60266126
;; Test: ghostel-copy-mode-recenter
60276127
;; -----------------------------------------------------------------------
@@ -7036,6 +7136,14 @@ while :; do sleep 0.1; done'\n")
70367136
ghostel-test-send-event-tty-esc-prefix
70377137
ghostel-test-yank-pop-after-yank
70387138
ghostel-test-yank-pop-no-preceding-yank
7139+
ghostel-test-xterm-paste-forwards-to-paste-text
7140+
ghostel-test-xterm-paste-rejects-wrong-event
7141+
ghostel-test-xterm-paste-no-text-is-noop
7142+
ghostel-test-xterm-paste-stores-on-kill-ring
7143+
ghostel-test-xterm-paste-skips-kill-ring-when-disabled
7144+
ghostel-test-xterm-paste-exits-copy-mode
7145+
ghostel-test-xterm-paste-bound-in-keymaps
7146+
ghostel-test-xterm-paste-copy-mode-and-kill-ring
70397147
ghostel-test-copy-mode-recenter
70407148
ghostel-test-send-next-key-control-x
70417149
ghostel-test-send-next-key-control-h

0 commit comments

Comments
 (0)