
Figure 1: Emacs shell-mode integrations. dired/dirvish for navigation, woman for manuals, and orderless for narrowing completions.
A while back I replaced alacritty with shell-mode for (almost) everything.
Emacs has a way of slowly expropriating most of your (other) operating system. Note taking, emails, file management, IRC, vim keybindings, you name it. You start to live inside Emacs. Everything is just text. Once you get used to it, life outside is not so nice anymore. Which makes it weird that after all these years I never got used to a terminal (the most text based thing ever) based workflow in Emacs. I just kept on using alacritty.
I think it took so much time because I tried to bend Emacs shells to my existing terminal, rather than make them fit Emacs. My shell does not feel like my old terminal anymore. It feels better!
I use terminals less and less though. As you get comfortable with Emacs, you start replacing things with Emacs alternatives. dired, proced, mu4e, etc.
Still, old habits die hard. It’s nice to have a shell sometimes.
So why shells in Emacs?
I’m pretty happy with my zsh and tmux setup. Vim keybindings, clipboard integration - even over ssh. It’s not bad. But it’s not Emacs. As I move more and more workflows into Emacs I’ve come to expect things like orderless, corfu and embark (amazing completions and selection actions) regardless of what I do, and this has no equivalent in Linux-land. Being able to use the same interface for absolutely everything is such a joy. Same colors, fonts, everything. Pretty pretty preeeetty good.
So while my Emacs shell experience was lacking, I knew that with some tlc and time it would most likely be better (by law of Markov chains Emacs; this has so far been the case for every other thing Emacs has gobbled up).
A few things I wanted:
- Immediate access to the shell I want. My tmux setup for this is pretty good. I don’t want to spend time browsing for shell buffers
- zsh, so I can share history easily. A good fall back, and I still use an external terminal for long running processes and remote sessions
- A shared
.zshrcconfig which disables things likefzf,viand pagers when you start it from Emacs - Auto-complete that rivals fzf
- Perfect
evil-modecompatibility - A regular Emacs buffer!
I want to stress that I intentionally do not want to use things like fzf or pagers like less or bat in my shell (Emacs is a superior pager) if I can avoid it because that means I am no longer using my unified Emacs interface (but crutches for an inferior experience 😉). No shade on fzf or anything - great tools, I use them still in a regular shell. But I want my Emacs shell to be a regular Emacs buffer, so I can grep the buffer using consult-line, get history completions using consult-history etc.
Shell alternatives
Emacs has a bunch of shells. More than I list here. But as far as I know these are the biggest players:
eshell. A shell made in lisp. Sounds like heaven for an emacsyan1, but it is slow, especially over TRAMP (every command is an ssh command), and since it is not a real terminal lots of things will not work herevterm. It is its own thing (you have to be able to compile libvterm), so it doesn’t integrate into Emacs as well as the others do. Has some troubles with display artifacts. It is the fastest Emacs terminal. Not for me as at that point I might as well just use an external terminal (which will not have any rendering issues)eat. This is interesting indeed. It is fast, it supports most TUI things. Unfortunately I couldn’t really get it to work withevil-mode,completion-modeetc in ways that was enjoyable. Seems to be abandonedshell-mode. I thought this would be the last thing I’d like as it’s.. Slower. Not compatible with ncurses (TUI) programs. Turns out it’s great. Since it’s just a regular Emacs buffer, you can use everything you normally use in Emacs!
So despite the limitations I ended up with shell. It integrates so well with Emacs it is just perfect. That is it slower than vterm doesn’t really matter much as you don’t do things like cat huge files anymore, you open them in a read-only buffer in Emacs instead (which is much better anyway). And I’ve basically stopped using terminal/ncurses based apps (replaced with Emacs alternatives) so it does not matter that support for those are sub par.
Note that even if things like tmux will work it will probably not be a great experience, as Emacs will shadow tmux keys. Think of it like running tmux inside screen. Also, most interactive programs, like lazygit, will not work as that is incompatible with the simple input/output model shell-mode operates in.
A pleasant TRAMP surprise
This ended up being surprisingly ergonomic for remote and sudo things. If you open a file over TRAMP, and then open a shell from that buffer, you open the shell automatically there. So say you open some remote caddy config file, and you call tramp-revert-buffer-with-sudo so you can save it, and maybe now you want to reload caddy, well when you fire up the shell from that buffer you have the same access as the file you are viewing. Just type systemctl caddy reload. Done.
With an external shell, you would open a terminal, ssh to the system, change the directory, possibly elevate your user, change a file, reload the webserver. This is caveman level people! 🤓
You don’t get this kind of integration and DX with an external shell. And you get the normal bookmarks support as well, for quick access to anywhere.
So. Shell it is!
Pimp the shell out of it
To begin, we make zsh know we are from Emacs, and disable external pagers if we are:
if [[ "$INSIDE_EMACS" == *"comint"* ]]; then
export YAY_PAGER=""
export PAGER=""
export GIT_PAGER=""
# don't pop up a menu after a second request for completion - use emacs for it
unsetopt AUTO_MENU
fi
There is no reason why a simple shell-mode should be simple, so lets enable comint-mode for all shells. This makes shell super cool because it means that things like links are now actionable (this is what compile-mode uses). Think error messages with a link to a file; you can just open it from there focused on the reported line number. Also set up completions:
(defun bergheim/setup-shell ()
"Custom configurations for shell mode."
(setq comint-input-ring-file-name "~/.histfile")
(comint-read-input-ring 'silent)
;; stop duplicate input from appearing
(setq-local comint-process-echoes t)
(compilation-shell-minor-mode 1)
(completion-preview-mode 1)
;; Don't add space after file completions (helps with directory traversal)
(setq comint-completion-addsuffix nil)
;; Better file completion settings
(setq comint-completion-autolist t)
(setq comint-completion-fignore nil)
(setq comint-use-prompt-regexp t)
;; Improve history handling
(setq comint-input-autoexpand t)
(setq comint-completion-recexact nil)
;; Ensure we can complete from history
(setq-local completion-at-point-functions
(list #'comint-completion-at-point
#'comint-filename-completion
#'cape-file
#'cape-history
#'cape-dabbrev)))
(add-hook 'shell-mode-hook #'bergheim/setup-shell)
cape-dabbrev makes it so that I can complete words present in all my buffers, and it makes writing commands such a breeze.
Ctrl-r should use consult-history instead of fzf. Note that I use general.el for my keybindings:
;; don't send C-r to the shell, call our own history function
"C-r" (lambda ()
(interactive)
(goto-char (point-max))
(comint-kill-input)
(consult-history))
Then we need a few convenience things. This is evil-mode specific, but if point is on the actual command line, I want RET in normal mode to send the command without going into insert mode:
"RET" (lambda ()
(interactive)
(when (comint-after-pmark-p)
(comint-send-input)))
I want to summon a shell quickly. multishell turns out to be a great helper for this. It’s a package designed to help organize shell-mode buffers. By default I want it to open in the current directory:
(use-package multishell
:general
(bergheim/global-menu-keys
"att" '((lambda () (interactive) (multishell-pop-to-shell nil (expand-file-name default-directory))) :which-key "shell")
"atT" '((lambda () (interactive) (multishell-pop-to-shell '(4))) :which-key "new shell"))
:config
;; don't ask for history confirmation on quit
(remove-hook 'kill-buffer-query-functions #'multishell-kill-buffer-query-function))
And then another shortcut:
"C-M-<return>" '((lambda ()
(interactive)
(multishell-pop-to-shell nil (expand-file-name default-directory)))
:which-key "shell")
I use
C-M-RETbecause in my window manager (i3/sway) I useWIN+RETto open emacsclient andS-WIN-RETto open a terminal. Aligning your shortcuts is highly beneficial!
Although I rarely use ncurses based applications anymore, having rudimentary support for it is still nice. coterm adds support for some basic TUI programs like top:
(use-package coterm
:after shell
:config
(coterm-mode 1))
(mistty is a newer package for this that I have not tried, but it tries to fix some of these things)
Because I run projects using compile-mode I only need one shell per project (if any).
compile-modemakes it sound like it is made for things likemake buildcommands that has a start and end. At least it did for me. But it is for everything - like for instance yournpm run devcommand that never ends
So I want to be able to open my project shell easily. That’s easy indeed - just bind project-shell. Lets also add a few other convenience shortcuts. If you call project-shell outside of a project, your project list will open, and you boot your shell into there. Pretty handy.
A few useful keybindings:
SPC bt- Switch between buffer terminals
SPC at- Open menu with all terminals (eat, eshell, etc)
SPC att- Open default terminal (shell in current directory)
SPC atT- Run multishell (start shell in any location)
I use
tfor terminal instead ofsfor shell as I feel t is more fitting and that leavessopen for the search mnemonic
This is how I switch between all shells:
(defun bergheim/switch-to-shell ()
"Switch to an active shell buffer using completion with directory info."
(interactive)
(if-let ((shell-buffers (seq-filter (lambda (buf)
(with-current-buffer buf
(derived-mode-p 'shell-mode 'eshell-mode 'term-mode 'vterm-mode)))
(buffer-list))))
(let* ((candidates (mapcar (lambda (buf)
(cons (format "%s (%s)"
(buffer-name buf)
(with-current-buffer buf
(abbreviate-file-name default-directory)))
buf))
shell-buffers))
(choice (completing-read "Switch to shell: " candidates nil t)))
(switch-to-buffer (alist-get choice candidates nil nil #'string=)))
(message "No active shell buffers")))
Also, I hate confirmations. If I want to quit my terminal let me quit my terminal:
;; don't ask for history confirmation on quit
(remove-hook 'kill-buffer-query-functions #'multishell-kill-buffer-query-function)
(setq comint-check-proc nil)
(setq confirm-kill-processes nil)
(advice-add 'shell-mode :after
(lambda ()
(remove-hook 'kill-buffer-query-functions
'comint-kill-buffer-query-function t)))
tl;dr
You can now:
- quickly jump to and between shells
- one-command open project or location based shells
- open shells remotely when you use TRAMP
- use some TUI apps if you want (using coterm)
All the code for this is available in my dotfiles.
Current issues [1/5]:
Of course, everything is not perfect:
- if you ssh to a remote computer, your history will still be your local one. I’ve spent a bit of time trying to get shell-mode to recognize if you are remote, and then make your
C-rcommand pass through. I’ve also experimented with sending the remote history back to you, but this got crazy fast. At the moment my solution to that is to just use vterm or an external terminal emulator - similarly, completions won’t work because
default-directorywill not be updated and correct when you are on a remote system - tmux support sux0rs. I have made some things that allow you to list and connect to a window in tmux. So Emacs selects the windows, not tmux. It works, and it’s kinda cool to have all the windows and panes in a
completing-readbuffer, but usage is not great - most things that are not strictly line based (print stuff ⮫ get input) will break in various ways. Even vterm will make something like lazygit a worse experience
- shell is slow, but I know this, it’s fine, it rarely is an issue for me
That’s it. For local things it works great.
Thank you to Rahul Juliato (LionyxML) for giving me helpful feedback.
I was also let known of a great shell article written by Mickey Petersen, the Mastering Emacs author. It is probably what you should have read instead :p
-
According to my searches you heard this here first! ↩︎