Skip to main content

Taming eshell with shackle.el, popper & el-patch

A short while ago, I decided to adopt eshell into my daily work routine as a quick & easy-to-access shell from within my favourite editor.
But I couldn’t stand some of its default behaviour. Let’s talk about it!

Emacs window management

For quite some time now, I had been unhappy with Emacs’ default management of temporary/ephemeral buffers and windows.

Example: Something goes wrong, a *Backtrace* buffer pops up in some window, you close it with q and boom, your window setup is broken yet again.

That’s a very tame and relatable example, but there are some packages that have much more… intrusive behaviour.

Eshell, for example, just takes over whatever window you are currently in. Entirely.
That’s not very friendly, nor is it especially useful for a shell, which you most likely want:

  • In a separate window, to monitor certain running processes
  • In a hidden buffer, within a key-chord’s reach, to execute a quick command or 2

Looking into packages to manage this type of buffer and window more carefully lead me to the popper package.

Introducing Popper.el

Popper.el is an amazing package, with a fairly simple premise:

If it tends to pop up out of nowhere, treat it as a pop-up buffer.

These “ephemeral” buffers get a window of their own, whose visibility can be toggled on-and-off at your command.

Its setup is simple enough, and in my init.el I have the following configured:

(use-package popper
  :straight t
  :after shackle
  :bind (("C-`" . popper-toggle-latest)
         ("M-`" . popper-cycle)
         ("C-M-`" . popper-toggle-type))
  :config
  (setq popper-group-function #'popper-group-by-projectile
        popper-display-control nil
        popper-reference-buffers
        '(occur-mode
          grep-mode
          locate-mode
          embark-collect-mode
          deadgrep-mode
          "^\\*deadgrep"
          help-mode
          compilation-mode
          ("^\\*Compile-Log\\*$" . hide)
          backtrace-mode
          "^\\*Backtrace\\*"
          "^\\*eshell"
          ("^\\*Warnings\\*$" . hide)
          "^\\*Messages\\*$"
          "^\\*Apropos"
          "^\\*eldoc\\*"
          "^\\*TeX errors\\*"
          "^\\*ielm\\*"
          "^\\*TeX Help\\*"
          "\\*Shell Command Output\\*"
          ("\\*Async Shell Command\\*" . hide)
          "\\*Completions\\*"
          "[Oo]utput\\*$"
          "^magit*"))
  (popper-mode +1)
  (popper-echo-mode +1))

As you can tell from the highlighted lines, you do need to configure for yourself which buffers are treated as pop-ups.
But that’s a plus, since popper does not make any assumptions about what defines a pop-up buffer. That’s all up to you!

The special buffers now get a nice visible POP identifier in the leftmost corner of the modeline; it looks sort of like this:


As stated in the popper README itself, however, you will need additional configuration to manage the placement of these popups.

By default, it uses, well, the default window placement defined by the creator of the buffer, and if that’s non-existent, it pops up at the bottom of your active frame.

You can set a different default behaviour for popper by setting
(setq popper-display-control t) and defining your own placement function similar to how popper-select-popup-at-bottom is defined.

For more granular control over each window’s placement, they recommend shackle.el, and so that is what I ended up using.

Shackling your windows in-place

Shackle is very similar to popper in configuration, in that it is

  1. Very simple and straightforward, and
  2. You just specify a list of regexps, buffer names or modes to match

and it does its magic. Just see for yourself:

(use-package shackle
  :straight t
  :demand t
  :config
  (setq shackle-default-rule '(:select t)
        shackle-rules
        '(;; Below
          (compilation-mode
           :noselect t :align below :size 0.33)
          ("*Buffer List*"
           :select t :align below :size 0.33)
          ("*Async Shell Command*"
           :noselect t :align below :size 0.20)
          ("\\(?:[Oo]utput\\)\\*"
           :regexp t :noselect t :align below :size 0.33)
          ("\\*\\(?:Warnings\\|Compile-Log\\|Messages\\|Tex Help\\|TeX errors\\)\\*"
           :regexp t :noselect t :align below :size 0.33)
          (help-mode
           :select t :align below :size 0.33)
          ("*Backtrace*"
           :noselect t :align below :size 0.33)
          (magit-status-mode
           :select t :align below :size 0.66)
          ("magit-*"
           :regexp t :align below :size 0.33)
          ("^\\*deadgrep"
           :regexp t :select t :align below :size 0.33)
          ("^\\*eshell"
           :regexp t :select t :align below :size 0.20)
          ;; Right
          ("\\*Apropos"
           :regexp t :select t :align right :size 0.45)
          )
        )
  (shackle-mode +1))

With this combination in hand, I managed to tackle almost every issue I had with buffer- and window-placement.

Almost every single one…except for the main topic of this post: eshell.

The good, the bad, and the eshell

As is mentioned in the Internals section of the shackle.el README:


Emacs packages that neither use the display-buffer function directly nor indirectly won’t be influenced by shackle.

And this is problematic for us. If we look at the source code for eshell, we see the following:

(defun eshell (&optional arg)
  (interactive "P")
  (cl-assert eshell-buffer-name)
  (let ((buf (cond ((numberp arg)
                    (get-buffer-create (format "%s<%d>"
                                               eshell-buffer-name
                                               arg)))
                   (arg
                    (generate-new-buffer eshell-buffer-name))
                   (t
                    (get-buffer-create eshell-buffer-name)))))
    (cl-assert (and buf (buffer-live-p buf)))
    (pop-to-buffer-same-window buf)
    (unless (derived-mode-p 'eshell-mode)
      (eshell-mode))
    buf))

That highlighted line, (pop-to-buffer-same-window buf) is the bane of our existence at this point.
No matter what rules you add to display-buffer-alist, eshell won’t care. It will force its buffer into your current window, regardless of your demands.

So how do we fix this?
Surely we won’t wait for an upstream patch to be applied, especially since the Emacs 28.1 release notes state the following:

‘project-shell’ and ‘shell’ now use ‘pop-to-buffer-same-window’.
This is to keep the same behavior as Eshell.

That’s where el-patch comes in!

Patch, patch, patch to your heart’s content

Without going too much in-depth, el-patch is a wonderful package once you start wanting to hack on internal packages, or don’t want to fork an entire project for a minor code-change.

Its documentation is extensive, and you can find plenty examples of how to use it in the wild.

For this post, we’ll focus on the following functionalities:

  1. Use display-buffer to manage the eshell-buffer
  2. Allow specifying a custom buffer-name suffix

The latter is just to “namespace” our eshell buffers a bit more clearly than just eshell<1>, eshell<2>, and so on.

(use-package el-patch)

(eval-when-compile
  (require 'el-patch))

(use-package esh-mode
  :straight (:type built-in)
  :config/el-patch
  (defcustom eshell-buffer-name "*eshell*"
    :type 'string
    :group 'eshell)
  (defun eshell (&optional arg)
    (interactive "P")
    (cl-assert eshell-buffer-name)
    (let ((buf (cond ((numberp arg)
                      (get-buffer-create (format "%s<%d>"
                                                 eshell-buffer-name
                                                 arg)))
                     (arg
                      (generate-new-buffer (el-patch-swap
                                             eshell-buffer-name
                                             (format "%s[%s]"
                                                     eshell-buffer-name
                                                     arg))))
                     (t
                      (get-buffer-create eshell-buffer-name)))))
      (cl-assert (and buf (buffer-live-p buf)))
      (el-patch-swap (pop-to-buffer-same-window buf)
                     (display-buffer buf 'display-buffer-pop-up-window))
      (el-patch-wrap 1 0
        (with-current-buffer buf
          (unless (derived-mode-p 'eshell-mode)
            (eshell-mode))))
      buf))
  :config
  (defun eshell-here ()
    "Opens up a new shell in the directory associated with the
    current buffer's file. The eshell is renamed to match that
    directory to make multiple eshell windows easier."
    (interactive)
    (let* ((parent (if (buffer-file-name)
                       (file-name-directory (buffer-file-name))
                     default-directory))
           (name   (car (last (split-string parent "/" t)))))
      (eshell name)))
  (global-set-key (kbd "C-!") 'eshell-here))

Conclusion

There you go, we’ve el-patch’ed the original eshell command so that it conforms to the (display-buffer) requirement of shackle.el, and in the process added some minor QoL changes.

Now, whenever you are in a project, you can start up a fresh, “project-local” instance of eshell with C-!.

Whenever you want it out-of-sight, or back in-sight, you can just toggle it or cycle to it with popper’s shortcuts (C-` and M-`).

The end result, depending on your configuration settings for shackle.el, might look something like this: