λ ryan.himmelwright.net

Emacs Programs as Standalone TUI Apps

A beautiful garden walkway
Sarah P. Duke Gardens, Durham NC (USA)

As I wrote about previously, I switched back to using Obsidian as my main notes and task management system. Everything has been great, but it creates a conflict. Emacs is no longer my “always opened” notes app… but I still love Emacs “applications” like mu4e and elfeed.

Despite my love for these applications, I’ve noticed myself using other tools like neomutt instead, just because it’s easier to open if not already in Emacs. For applications I equally like such as neomutt, this is fine. However, for others like elfeed, the alternatives have substantially different workflows that I don’t always want. Sometimes I simply want elfeed. In my current (or pop-up) terminal. Easily.

Solution

With that thought, I figured why not make the Emacs apps open and work like standalone TUI programs? I knew you could open Emacs in a CLI session with the -nw flag. Additionally, you can provide arguments to execute once Emacs opens.

So, I set off to cobble together some shell aliases.

Basics

Let’s start with simply opening elfeed in Emacs with the eval command:

emacsclient -nw -a "" --eval "(elfeed)"

This command runs emacsclient instead of straight Emacs. For those unfamiliar, you can run Emacs as a service in the background, and then connect to that server with emacsclient. This enables persistence of buffers, and automatic lunches when opening a new window. As for the flags:

Interactive Opening

Emacs in a terminal with a scratch buffer and a mini buffer to select an Internet radio station
The initial radio selection was messy.

The basic command worked great for simple application launches like elfeed or mu4e. However, I hit issues launching the radio app, which normally prompts the user to select which station/feed they want to listen to. This prompting behavior caused my simple command to throw an error:

>emacsclient -nw -a "" -e "(radio)"
>*ERROR*: Wrong number of arguments: radio, 0

To get around this, I just had to wrap my call with the call-interactively function:

 emacsclient -nw -a "" -e "(call-interactively 'radio)"

With that fix, it worked. Technically.

Cleanup

While the radio call worked, it was messy. It opened emacsclient with the prompt buffer already visible, but also displayed the main buffer with whatever nonsense was in my scratch file. On top of that, after selecting a station, it started playing but left me sitting in Emacs.

With some help from Claude (credit where due I guess), we improved the --eval prompt to open with a blank buffer, and then auto-close after the user selected the station.

Given the complexity added for all this conditional logic, we pulled most of it into an Elisp function (this goes in your init.el):

(defun my/cli-launch (fn &optional close-after)
  "Run FN interactively in a blank frame; close the frame after if CLOSE-AFTER."
  (let ((frame (selected-frame))
        (buf (generate-new-buffer " *cli-launch*")))
    (with-current-buffer buf
      (setq mode-line-format nil
            header-line-format nil))
    (switch-to-buffer buf)
    (delete-other-windows)
    (if close-after
        (unwind-protect
            (call-interactively fn)
          (delete-frame frame)
          (kill-buffer buf))
      (call-interactively fn)
      (kill-buffer buf))))

Now, the --eval just needs to make a function call and supply parameters instead of containing logic within the call:

emacsclient -nw -a "" -e "(my/cli-launch 'elfeed)"
emacsclient -nw -a "" -e "(my/cli-launch 'radio t)"

This function can wrap around my previous --eval calls to cleanly launch them. When t is provided as a second parameter, it will auto-close emacsclient after the program executes (for example, after a radio stream is launched as a background process).

Shell Aliases

With my commands all figured out, I just needed to add a few simple aliases to my shell configuration (zsh.nix):

# Emacs app launchers (open in the current terminal, starting the daemon if needed)
# my/cli-launch (init.el) shows a blank frame instead of whatever buffer was last active
eww = ''emacsclient -nw -a "" --eval "(my/cli-launch 'eww)"'';
mu4e = ''emacsclient -nw -a "" --eval "(my/cli-launch 'mu4e)"'';
elfeed = ''emacsclient -nw -a "" --eval "(my/cli-launch 'elfeed)"'';

# Radio: blank prompt-only frame, closes itself once a station is picked
eradio = ''emacsclient -nw -a "" --eval "(my/cli-launch 'radio t)"'';
eradio-stop = ''emacsclient -a "" --eval "(radio-stop)"'';

After a quick sudo nixos-rebuild switch --flake .#porygon, everything was working.

Conclusion

An Emacs window with a blank buffer and radio select in a message buffer
Emacs Radio selection, cleaned up.

So far it’s been great. I’ve been launching mu4e and especially elfeed all the time as if they were their own apps. I know this isn’t new and many have done it before. But, it’s one of those customizations that hackable systems (Emacs, CLI) allow you to do that I love so much. If you haven’t attempted to have your system fit like a glove in a while, give it a shot.

- Back to Post Index -