Using GNU Emacs as a terminal emulator

This is something that has long been waiting on my machine and in my mind, but can’t easily be pressed into a project of its own. It is possible, with some tweaking, to use GNU Emacs as a powerful terminal emulator, effectively replacing software such as GNOME Terminal and GNU Screen at the same time. Additionally, it’ll give you something missing with alternative other pieces of software: Fully searchable and storable output buffers.

Basics

At the foundation of the concept lies a forsaken Emacs library called term. term, by itself, already provides a full-fledged terminal emulator. Just try invoking

M-x term

and it will give you a terminal emulating buffer, after querying for your preferred CLI shell. term by itself, however, is not very friendly: It has rough edges here and there, something you’ll likely realize after playing around with it for a while. Then, there’s also the ansi-term function pre-provided as part of term, which tries to be a little more clever with buffer names. But it all gets better by fetching and using multi-term by Andy Stewart and “ahei”, which deals with a number of problems in the default term implementation:

1. term.el just provides commands `term’ or `ansi-term’
for creating a terminal buffer.
And there is no special command to create or switch
between multiple terminal buffers quickly.

2. By default, the keystrokes of term.el conflict with global-mode keystrokes,
which makes it difficult for the user to integrate term.el with Emacs.

3. By default, executing *NIX command “exit” from term-mode,
it will leave an unused buffer.

4. term.el won’t quit running sub-process when you kill terminal buffer forcibly.

5. Haven’t a dedicated window for debug program.

And multi-term.el is enhanced with those features.

This little gem will make your life easier, and to get more out of it I use

(when (require 'multi-term nil t)
  (global-set-key (kbd "<f5>") 'multi-term)
  (global-set-key (kbd "<C-next>") 'multi-term-next)
  (global-set-key (kbd "<C-prior>") 'multi-term-prev)
  (setq multi-term-buffer-name "term"
        multi-term-program "/bin/zsh"))

in my .emacs. By setting multi-term-program to my favored shell, zsh, I’m not queried anymore each time I try to open a new terminal emulation. The F5 key would now open new terminal buffers, and the familiar Ctrl-PageUp/Down key combinations would cycle between existing open terminal buffers.

Thanks to multi-term’s cleverness, the default-directory variable is honored by default, which means that opening new terminal buffers will always have them started at the directory of the currently open file, if applicable.

Keybindings

Now, many popular key combinations still get captured by the default keymaps in place for multi-term. Let’s put some new keybindings in place that handle this:

(when (require 'term nil t) ; only if term can be loaded..
  (setq term-bind-key-alist
        (list (cons "C-c C-c" 'term-interrupt-subjob)
              (cons "C-p" 'previous-line)
              (cons "C-n" 'next-line)
              (cons "M-f" 'term-send-forward-word)
              (cons "M-b" 'term-send-backward-word)
              (cons "C-c C-j" 'term-line-mode)
              (cons "C-c C-k" 'term-char-mode)
              (cons "M-DEL" 'term-send-backward-kill-word)
              (cons "M-d" 'term-send-forward-kill-word)
              (cons "<C-left>" 'term-send-backward-word)
              (cons "<C-right>" 'term-send-forward-word)
              (cons "C-r" 'term-send-reverse-search-history)
              (cons "M-p" 'term-send-raw-meta)
              (cons "M-y" 'term-send-raw-meta)
              (cons "C-y" 'term-send-raw))))

Most of the bindings should be self-explanatory. What’s interesting is term-line-mode and term-char-mode: By default, term operates by sending each keystroke to the shell, unless otherwise defined in the active keymap. This prevents us from easily navigating or searching the buffer. By switching to term-line-mode however, which is intended to send input only after return is pressed, we can e.g. backward-search with Ctrl-r as usual, copy text, etc. and switch back to term-char-mode for normal operation when done.

Shell interop

If you were previously using emacsclient to open and edit files in your existing Emacs instance in server mode, you will wonder how that works out with Emacs being the terminal emulator itself. As it turns out, Emacs is not very good for running its own terminal frames inside itself so we need to find something different.

When digging through term.el, you will eventually find the following comment preceding the function term-handle-ansi-terminal-messages:

Function that handles term messages: code by rms (and you can see the difference ;-) -mm

Hmm, some leftover code by Richard Stallman, founder of the GNU project and GNU Emacs himself? As it turns out, the code in question will scan output for special byte sequences similar to the ones used by xterm terminal emulator to e.g. change its title. It would consume the sequence as well as the information passed to it and store it in three different variables,

  • term-ansi-at-dir
  • term-ansi-at-host
  • term-ansi-at-user

and use these, in turn, for default-directory and the ange-ftp library. This is, however, problematic as FTP (file transfer protocol) is not in wide use anymore and the generated values for default-directory will default to FTP operations even if the currently operated host is the local one running Emacs.

But we can use the existing function, hijack its implementation and use it to add custom commands as well as make it handle localhost correctly and remote hosts via SSH! To cut a long story short, add the following to your .emacs:

(when (require 'term nil t)
  (defun term-handle-ansi-terminal-messages (message)
    (while (string-match "\eAnSiT.+\n" message)
      ;; Extract the command code and the argument.
      (let* ((start (match-beginning 0))
             (command-code (aref message (+ start 6)))
             (argument
              (save-match-data
                (substring message
                           (+ start 8)
                           (string-match "\r?\n" message
                                         (+ start 8))))))
        ;; Delete this command from MESSAGE.
        (setq message (replace-match "" t t message))

        (cond ((= command-code ?c)
               (setq term-ansi-at-dir argument))
              ((= command-code ?h)
               (setq term-ansi-at-host argument))
              ((= command-code ?u)
               (setq term-ansi-at-user argument))
              ((= command-code ?e)
               (save-excursion
                 (find-file-other-window argument)))
              ((= command-code ?x)
               (save-excursion
                 (find-file argument))))))

    (when (and term-ansi-at-host term-ansi-at-dir term-ansi-at-user)
      (setq buffer-file-name
            (format "%s@%s:%s" term-ansi-at-user term-ansi-at-host term-ansi-at-dir))
      (set-buffer-modified-p nil)
        (setq default-directory (if (string= term-ansi-at-host (system-name))
                                    (concatenate 'string term-ansi-at-dir "/")
                                  (format "/%s@%s:%s/" term-ansi-at-user term-ansi-at-host term-ansi-at-dir))))
    message)

Now, term will play nicely with local shell sessions. But how do we teach term about our current user, host and working directory? The implementation varies with your shell of choice. For zsh, I use:

prompt_eterm_precmd () {
  case $TERM in
    xterm*)
      print -Pn "\e]0;%n@%m:%~ (%l)\a"
      ;;
    eterm-color*)
      print -P "\eAnSiTh %m"
      print -P "\eAnSiTu %n"
      print -P "\eAnSiTc %~"
      ;;
  esac
}

in my custom theme that I set using

autoload -U promptinit && promptinit && \
  prompt $([ ${TERM}X = "eterm-colorX" ] && echo eterm || echo e-user)

in my .zshrc. Similarly, for GNU Bash the following will work if set from .bashrc:

# are we an interactive shell?
if [ "$PS1" ]; then
  case $TERM in
    eterm-color*)
      if [ -n "$SSH_CONNECTION" ]
      then
        _HOST=$(echo -n $SSH_CONNECTION | cut -d\  -f3)
      else
        _HOST=$HOSTNAME
      fi

      PROMPT_COMMAND='echo -ne "\033AnSiTh ${_HOST}\n\033AnSiTu ${USER}\n\033AnSiTc ${PWD/#$HOME/~}\n"'
      ;;
    xterm*)
      PROMPT_COMMAND='echo -ne "\033]0;${USER}@${HOSTNAME%%.*}:${PWD/#$HOME/~}"; echo -ne "\007"'
      ;;
    screen)
      PROMPT_COMMAND='echo -ne "\033_${USER}@${HOSTNAME%%.*}:${PWD/#$HOME/~}"; echo -ne "\033\\"'
      ;;
    *)
      [ -e /etc/sysconfig/bash-prompt-default ] && PROMPT_COMMAND=/etc/sysconfig/bash-prompt-default
      ;;
  esac
fi

What’s now left to do is make file opening work again, the Emacs bit are already in place with the term-handle-ansi-terminal-messages implementation from above. For zsh, add to .zshrc:

if [ "${TERM}x" = "eterm-colorx" ]
then
  alias e='print -P "\eAnSiTe"'
  alias x='print -P "\eAnSiTx"'
else
  alias e='emacsclient -n -t -a nano'
fi

And for Bash, add to .bashrc:

if [ "${TERM}x" = "eterm-colorx" ]
then
  alias e='echo -ne "\033AnSiTe"'
  alias x='echo -ne "\033AnSiTx"'
else
  alias e='emacsclient -n -t -a nano'
fi

FWIW, you might not comply with my default fallback choice of GNU nano as Editor, just adapt to your personal needs.

From now on however, new sessions in Emacs term should be capable of making Emacs open files appointed to by e filename for a different window and x filename to make a file open in a new buffer in the same window the terminal emulator is running in. Furthermore, remote hosts with the same Bash / zsh RC configuration in place should support find-file with correctly built SSH default paths out of the box.

Conclusion

Even if with a lot of tampering involved, graphical mode Emacs can be turned into a powerful replacement for terminal emulators and GNU Screen at the same time. By utilizing Emacs buffers for shell output, new possibilities for context-switching free working arise (as well as horrifying security nightmares). The possibility to store and search output buffers, however, is unparalleled in power and essentially gives you similar possibilities to that of a typical in-Emacs Lisp REPL.