Skip to content
Open
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ lisp/%.elc: lisp/%.el
$(EVIL_DIR):
git clone --depth 1 https://github.com/emacs-evil/evil.git "$@"

extensions/evil-ghostel/%.elc: extensions/evil-ghostel/%.el | $(EVIL_DIR)
# Depend on the core .elc files: `require' prefers a stale core .elc over
# the fresh .el, so a parallel build could otherwise compile the extension
# before a core function it uses exists in the loaded bytecode.
extensions/evil-ghostel/%.elc: extensions/evil-ghostel/%.el $(filter lisp/%.elc,$(ELC)) | $(EVIL_DIR)
$(EMACS) --batch $(EMACSFLAGS) -Q -L "$(EVIL_DIR)" -L lisp -L extensions/evil-ghostel \
--eval "(setq byte-compile-error-on-warn t)" -f batch-byte-compile $<

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ If you're an evil user you can install the [evil-ghostel](https://melpa.org/#/ev
:hook (ghostel-mode . evil-ghostel-mode))
```

Note: in alt-screen apps — vim, less, fullscreen TUIs like Claude Code
(`/tui fullscreen`) — ESC is by default sent to the app instead of switching
to normal state, so you stay in insert state and a leader key like `SPC` goes
to the terminal too (`M-x`, `C-x …`, `C-c …` still work). Toggle the routing
with `C-c C-r` (`evil-ghostel-toggle-send-escape`), or hit `C-c ESC` for a
one-shot switch to normal state; `evil-ghostel-escape` sets the default
routing. See [Evil-mode](https://dakra.github.io/ghostel/#evil-mode)
in the manual.

## Shell integration

Directory tracking and prompt navigation are on by default for local bash, zsh, fish or nushell sessions.
Expand Down
45 changes: 40 additions & 5 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -1403,7 +1403,8 @@ subdirectory inside the ghostel monorepo:
When =evil-ghostel-mode= is active:

- Ghostel starts in *insert state* (terminal input works normally).
- Pressing *ESC* enters normal state and snaps point to the terminal cursor.
- Pressing *ESC* enters normal state and snaps point to the terminal cursor
(outside alt-screen; see [[#evil-escape-routing][ESC routing]] below).
- Normal-mode navigation (=h=, =j=, =k=, =l=, =w=, =b=, =e=, =0=, =$=, ...) works as expected.
- *Insert/append* (=i=, =a=, =I=, =A=) sync the terminal cursor to point before entering
insert state.
Expand All @@ -1414,11 +1415,45 @@ When =evil-ghostel-mode= is active:
- *Paste* (=p=, =P=) pastes from the kill ring via bracketed paste.
- *Undo* (=u=) sends readline undo (=Ctrl+_=).
- Cursor shape follows evil state (block for normal, bar for insert).
- Alt-screen programs (vim, less, htop) are unaffected.
- Alt-screen programs (vim, less, htop) are unaffected: ESC goes to the app
(see below).

The initial state and the ESC behavior are configurable via
=evil-ghostel-initial-state= (default =insert=) and =evil-ghostel-escape= (default
=auto=).
The initial state is configurable via =evil-ghostel-initial-state= (default
=insert=).

*** ESC routing
:properties:
:custom_id: evil-escape-routing
:end:
#+cindex: escape routing

Where insert-state ESC goes is controlled by =evil-ghostel-escape=:

- =auto= (default) :: to the terminal while an alt-screen app runs (DECSET
1049: vim, less, htop, fullscreen TUIs like OpenCode); otherwise evil's
binding switches to normal state.
- =terminal= :: always to the terminal.
- =evil= :: always run evil's binding.

The =auto= default keeps ESC working inside vim and friends, but it also
means that while a fullscreen TUI such as OpenCode (=/tui fullscreen=)
runs, ESC does /not/ enter normal state — you stay in insert state, and a
leader key like =SPC= goes to the terminal too. Regular Emacs bindings
(=M-x=, =C-x ...=, =C-c ...=) keep working. The first time ESC is routed to
an alt-screen app this way, a one-time hint in the echo area points at the
toggle command.

=C-c C-r= (=evil-ghostel-toggle-send-escape=) toggles the routing for the
current buffer: from =auto= it switches to whichever mode differs from
auto's current effect (=evil= while an alt-screen app runs, =terminal=
otherwise), and from an explicit mode back to =auto=. Numeric prefixes
1/2/3 set =auto=/=terminal=/=evil= directly.

=C-c ESC= (=evil-force-normal-state=) switches to normal state once without
changing the routing — useful to scroll or copy while the TUI keeps ESC.
The binding is on the =<escape>= function key, which exists on GUI frames
and, on a tty, in terminals speaking the kitty keyboard protocol with
[[https://github.com/benjaminor/kkp][kkp.el]] enabled; on a legacy tty use =M-x evil-force-normal-state=.

** Compilation mode
:properties:
Expand Down
42 changes: 42 additions & 0 deletions extensions/evil-ghostel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# evil-ghostel

[Evil-mode](https://github.com/emacs-evil/evil) integration for the
[ghostel](https://github.com/dakra/ghostel) terminal emulator, modeled on
`evil-collection-vterm`.

Install from [MELPA](https://melpa.org/#/evil-ghostel):

```emacs-lisp
(use-package evil-ghostel
:after (ghostel evil)
:hook (ghostel-mode . evil-ghostel-mode))
```

When `evil-ghostel-mode` is active, ghostel buffers start in insert state,
ESC enters normal state, normal-state motions work over the rendered
terminal, and the editing operators (`d`, `c`, `x`, `r`, `p`, `u`, …) drive
the shell's line editor over the PTY. See the
[manual](https://dakra.github.io/ghostel/#evil-mode) for the full list.

## ESC in fullscreen apps

In alt-screen apps — vim, less, and fullscreen TUIs like Claude Code
(`/tui fullscreen`) — insert-state ESC is by default routed to the app
instead of switching to normal state, so the app keeps its ESC key. This
means you stay in insert state while such an app runs: a leader key like
`SPC` goes to the terminal too. Regular Emacs bindings (`M-x`, `C-x …`,
`C-c …`) still work.

To get ESC back for evil, toggle the per-buffer routing with `C-c C-r`
(`evil-ghostel-toggle-send-escape`): from `auto` it switches to whichever
mode differs from auto's current effect (`evil` while an alt-screen app
runs, `terminal` otherwise), and from an explicit mode back to `auto`;
numeric prefixes 1/2/3 set auto/terminal/evil directly. The default comes
from the `evil-ghostel-escape` option (`auto`, `terminal`, or `evil`).

To switch to normal state just once — without changing the routing — use
`C-c ESC` (`evil-force-normal-state`). This binding uses the `<escape>`
function key, so it works on GUI frames and, on a tty, in terminals
speaking the kitty keyboard protocol with
[kkp.el](https://github.com/benjaminor/kkp) enabled; `M-x
evil-force-normal-state` works everywhere.
80 changes: 55 additions & 25 deletions extensions/evil-ghostel/evil-ghostel.el
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
;; Outside semi-char input mode (`line' / `copy' / `emacs' / `char' modes,
;; or an alt-screen TUI) every command falls through to `evil-*'.
;;
;; In alt-screen apps (vim, less, fullscreen TUIs like Claude Code)
;; insert-state ESC is by default routed to the app instead of switching
;; to normal state, so the app keeps its ESC key. `C-c C-r'
;; (`evil-ghostel-toggle-send-escape') toggles the routing per buffer;
;; the `evil-ghostel-escape' option sets the default. `C-c <escape>'
;; switches to normal state once without changing the routing (GUI
;; frames; on a tty it needs the kitty keyboard protocol via kkp.el).
;;
;; Enable by adding to your init:
;;
;; (use-package evil-ghostel
Expand All @@ -38,7 +46,7 @@
(require 'evil)
(require 'ghostel)

(declare-function ghostel--mode-enabled "ghostel-module")
(declare-function ghostel--alt-screen-p "ghostel-module")

(defvar evil-ghostel-mode)

Expand All @@ -65,8 +73,8 @@ change immediately through `evil-set-initial-state'."
(defcustom evil-ghostel-escape 'auto
"Where insert-state ESC is routed in ghostel buffers.

`auto' - to the terminal in alt-screen mode (DECSET 1049: vim, less,
htop, …); otherwise evil's binding switches to normal state.
`auto' - to the terminal in alt-screen mode (vim, less, htop, …);
otherwise evil's binding switches to normal state.
`terminal' - always send ESC to the terminal.
`evil' - always run evil's binding.

Expand All @@ -93,7 +101,7 @@ True in `semi-char' input mode and outside alt-screen — the only state in
which `evil-ghostel-*' commands send PTY keys rather than running `evil-*'."
(and evil-ghostel-mode
ghostel--term
(not (ghostel--mode-enabled ghostel--term 1049))
(not (ghostel-alt-screen-p))
(eq ghostel--input-mode 'semi-char)))

(defun evil-ghostel--ctrl-passthrough-active-p ()
Expand Down Expand Up @@ -149,9 +157,9 @@ No window showing the buffer counts as following."
(defun evil-ghostel--around-redraw (orig-fn term &optional full force-sync)
"Apply Evil point/visual handling around `ghostel--redraw'.
ORIG-FN is the advised function (TERM, FULL, FORCE-SYNC). Skipped in
alt-screen (1049) and when the native renderer defers synchronized output."
alt-screen and when the native renderer defers synchronized output."
(if (and evil-ghostel-mode
(not (ghostel--mode-enabled term 1049)))
(not (ghostel--alt-screen-p term)))
(let* ((visual-p (eq evil-state 'visual))
;; Sampled pre-render (window check included): only then does
;; point on the cursor's row mean the user has not left the
Expand Down Expand Up @@ -214,7 +222,7 @@ evil state with point off the cursor, unless FORCE."
ORIG-FN is the advised setter (STYLE, VISIBLE); deferred to in alt-screen."
(if (and evil-ghostel-mode
ghostel--term
(not (ghostel--mode-enabled ghostel--term 1049)))
(not (ghostel-alt-screen-p)))
;; Evil owns the cursor now; end any terminal-driven blink that a
;; full-screen app left running before we exited the alt-screen.
(progn (ghostel--cursor-blink-stop)
Expand Down Expand Up @@ -808,8 +816,11 @@ rather than forward-delete in the shell."
Initialized from `evil-ghostel-escape' when the minor mode turns on.
Valid values: `auto', `terminal', `evil'.")

(defvar evil-ghostel--escape-hint-shown nil
"Non-nil after the one-time hint about ESC routing has been shown.")

(defconst evil-ghostel--escape-modes '(auto terminal evil)
"Cycle order for `evil-ghostel-toggle-send-escape'.")
"Prefix-argument order (1/2/3) for `evil-ghostel-toggle-send-escape'.")

(defun evil-ghostel--escape ()
"Dispatch insert-state ESC based on `evil-ghostel--escape-mode'.
Expand All @@ -821,37 +832,56 @@ keystroke is never dropped."
(let* ((mode evil-ghostel--escape-mode)
(to-terminal (or (eq mode 'terminal)
(and (eq mode 'auto)
ghostel--term
(ghostel--mode-enabled ghostel--term 1049)))))
(ghostel-alt-screen-p)))))
(if to-terminal
(progn
(ghostel--on-user-input)
(ghostel--send-encoded "escape" ""))
(ghostel--send-encoded "escape" "")
(when (and (eq mode 'auto) (not evil-ghostel--escape-hint-shown))
(setq evil-ghostel--escape-hint-shown t)
(message (substitute-command-keys
"ESC sent to the terminal app (alt-screen); \
\\[evil-ghostel-toggle-send-escape] routes it to evil instead"))))
(let ((cmd (lookup-key evil-insert-state-map (kbd "<escape>"))))
(call-interactively (if (commandp cmd) cmd #'evil-force-normal-state))))))

(defun evil-ghostel-toggle-send-escape (&optional arg)
"Cycle or set the ESC routing mode for the current buffer.
Without ARG, cycle `auto' → `terminal' → `evil'. With numeric prefix 1/2/3
set `auto'/`terminal'/`evil'; other prefixes signal a `user-error'. The
mode is buffer-local; see `evil-ghostel-escape' for the default."
"Toggle or set the ESC routing mode for the current buffer.
Without ARG, toggle: from `auto' switch to whichever mode differs from
auto's current effect (`evil' while an alt-screen app runs, `terminal'
otherwise); from an explicit mode switch back to `auto'. With numeric
prefix 1/2/3 set `auto'/`terminal'/`evil'; other prefixes signal a
`user-error'. The mode is buffer-local; see `evil-ghostel-escape' for
the default."
(interactive "P")
(let ((target
(if arg
(let ((n (prefix-numeric-value arg)))
(or (nth (1- n) evil-ghostel--escape-modes)
(user-error
"Invalid prefix %d; use 1 (auto), 2 (terminal), or 3 (evil)"
n)))
(let ((next (cdr (memq evil-ghostel--escape-mode
evil-ghostel--escape-modes))))
(or (car next) (car evil-ghostel--escape-modes))))))
(cond (arg
(let ((n (prefix-numeric-value arg)))
(or (nth (1- n) evil-ghostel--escape-modes)
(user-error
"Invalid prefix %d; use 1 (auto), 2 (terminal), or 3 (evil)"
n))))
((eq evil-ghostel--escape-mode 'auto)
(if (ghostel-alt-screen-p) 'evil 'terminal))
(t 'auto))))
(setq evil-ghostel--escape-mode target)
(message "evil-ghostel ESC mode: %s" target)))
(if (eq target 'auto)
(message "evil-ghostel ESC mode: auto (now → %s)"
(if (ghostel-alt-screen-p) 'terminal 'evil))
(message "evil-ghostel ESC mode: %s" target))))

(evil-define-key* 'insert evil-ghostel-mode-map
(kbd "<escape>") #'evil-ghostel--escape)

(define-key evil-ghostel-mode-map (kbd "C-c C-r")
#'evil-ghostel-toggle-send-escape)

;; The <escape> function-key event exists on GUI frames and on ttys with
;; the kitty keyboard protocol (kkp.el); legacy ttys never generate it,
;; so this binding cannot shadow the ESC-prefixed C-c M-... keys there.
(define-key evil-ghostel-mode-map (kbd "C-c <escape>")
#'evil-force-normal-state)


;; Minor mode

Expand Down
20 changes: 6 additions & 14 deletions lisp/ghostel-line-mode.el
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
(declare-function ghostel--ensure-ghostel-buffer "ghostel")
(declare-function ghostel--invalidate "ghostel")
(declare-function ghostel--leave-readonly-state "ghostel")
(declare-function ghostel--mode-enabled "ghostel-module")
(declare-function ghostel--mode-line-refresh "ghostel")
(declare-function ghostel--mode-line-tag-make "ghostel")
(declare-function ghostel--open-link "ghostel")
Expand All @@ -32,6 +31,7 @@
(declare-function ghostel--send-encoded "ghostel")
(declare-function ghostel--uri-at-pos "ghostel")
(declare-function ghostel--write-pty "ghostel-module")
(declare-function ghostel-alt-screen-p "ghostel")
(declare-function ghostel-beginning-of-input-or-line "ghostel")
(declare-function ghostel-input-start-point "ghostel")
(declare-function bash-completion-capf-nonexclusive "bash-completion")
Expand Down Expand Up @@ -230,14 +230,6 @@ read-only scrollback portion, mirroring `ghostel-line-mode-self-insert'."
(goto-char end)))
(insert "\n"))

(defun ghostel--line-mode-alt-screen-p ()
"Return non-nil if libghostty is on its alternate screen.
Checks DEC private modes 1049 and 1047 (the modern alt-screen
modes used by less, htop, vim, etc.)."
(and ghostel--term
(or (ghostel--mode-enabled ghostel--term 1049)
(ghostel--mode-enabled ghostel--term 1047))))

(defun ghostel--line-mode-prompt-on-screen-p ()
"Return non-nil when a real OSC 133 prompt char sits on the cursor's row.
Only a `ghostel-prompt' property counts (no regex or cursor fallback),
Expand Down Expand Up @@ -428,7 +420,7 @@ in line mode (the interactive entry validates these)."
(setq ghostel--char-mode-override-active nil)
;; A deliberate alt-screen entry must survive the next redraw's
;; auto-pause, and supersedes any armed sentinel.
(setq ghostel--line-mode-on-alt-screen (ghostel--line-mode-alt-screen-p))
(setq ghostel--line-mode-on-alt-screen (ghostel-alt-screen-p))
(setq ghostel--line-mode-paused nil)
(setq ghostel--input-mode 'line)
(use-local-map ghostel-line-mode-map)
Expand Down Expand Up @@ -487,12 +479,12 @@ when the TUI exits. A prefix arg FORCE forces immediate entry."
(message "Line mode: RET sends the whole line; C-c C-j to exit")
(user-error "Line mode could not locate the cursor or a prompt")))
;; Alt screen with a real prompt on the cursor row (inner shell) — enter.
((and (ghostel--line-mode-alt-screen-p)
((and (ghostel-alt-screen-p)
(ghostel--line-mode-prompt-on-screen-p)
(ghostel--line-mode-enter))
(message "Line mode: RET sends the whole line; C-c C-j to exit"))
;; Alt screen, no detectable prompt (raw TUI) — arm instead.
((ghostel--line-mode-alt-screen-p)
((ghostel-alt-screen-p)
(ghostel--line-mode-defer-entry))
;; Primary screen — normal entry.
((ghostel--line-mode-enter)
Expand Down Expand Up @@ -646,7 +638,7 @@ overwrites the buffer. A deliberate alt-screen entry
\(`ghostel--line-mode-on-alt-screen') is exempt; the flag clears
once the alt screen goes away so a later TUI pauses normally."
(when (eq ghostel--input-mode 'line)
(if (ghostel--line-mode-alt-screen-p)
(if (ghostel-alt-screen-p)
(unless ghostel--line-mode-on-alt-screen
(ghostel--line-mode-pause))
;; Back on the primary screen — re-arm the pause for the next TUI.
Expand All @@ -662,7 +654,7 @@ new prompt one or more redraws after libghostty leaves the alt
screen. Also drives the deferred startup entry when
`ghostel-initial-input-mode' is `line'."
(when (and ghostel--line-mode-paused
(not (ghostel--line-mode-alt-screen-p)))
(not (ghostel-alt-screen-p)))
(ghostel--line-mode-try-resume))
(ghostel--line-mode-maybe-enter-initial))

Expand Down
7 changes: 6 additions & 1 deletion lisp/ghostel.el
Original file line number Diff line number Diff line change
Expand Up @@ -1151,7 +1151,6 @@ is non-nil."
(unless (memq 'ghostel--emulation-alist emulation-mode-map-alists)
(push 'ghostel--emulation-alist emulation-mode-map-alists))



;;; Input mode predicates

Expand All @@ -1174,7 +1173,13 @@ across the rewrite."
"Non-nil when the terminal is frozen (copy mode)."
(eq ghostel--input-mode 'copy))

(defun ghostel-alt-screen-p ()
"Return non-nil when the terminal is on its alternate screen.
True while a fullscreen TUI (vim, less, htop, …) runs, regardless of
which DEC mode (47, 1047, or 1049) it used to get there."
(and ghostel--term (ghostel--alt-screen-p ghostel--term)))


;;; Keymap

(defun ghostel--define-terminal-keys (map &optional no-exceptions)
Expand Down
Loading
Loading