A smart, posframe-based terminal toggler for Emacs.
popterm merges the best paradigms of modern Emacs terminal management into a
single package. It combines centered child-frame display, the named-instance
multiplexing of toggle-term.el, and the TRAMP-aware scope-locking of
vterm-toggle.
Whether you use vterm, ghostel, eat, shell, or eshell, popterm
provides a context-aware pop-up terminal experience that respects your window
layouts, asynchronous compositor events, and background processes.
- Multiple Display Backends: Choose between a centered
posframechild-frame, a bottomwindowsplit, or afullscreentakeover. - Agnostic Terminal Support: Seamlessly supports
vterm,ghostel,eat,shell, andeshellout of the box. No hard dependencies requiredβit auto-detects what you have installed. - Smart Auto-CD & TRAMP Awareness: Automatically changes the terminal directory to match the buffer you toggled it from. It correctly parses TRAMP remote paths so SSH commands execute safely on the remote host without local prefix mangling.
- Named Instances & Scope Control: Run multiple terminal instances (e.g.,
βbuildβ, βserverβ, βreplβ) and restrict their cycling scope to the current
project, the currentframe, or globally. - Solid State Management:
- Safely overrides
display-buffer-alistto prevent terminal initialization artifacts (like 0Γ0 PTY grids). - Reliably injects keybindings through
vtermβs character-mode input traps viavterm-keymap-exceptions.
- Safely overrides
popterm requires Emacs 29.1+ and the posframe package.
(use-package popterm
:bind (("C-`" . popterm-toggle)
("C-~" . popterm-toggle-cd)
([f9] . popterm-window-toggle))
:config
(setq popterm-backend 'vterm ; or 'ghostel, 'eat, 'shell, 'eshell
popterm-display-method 'posframe ; or 'window, 'fullscreen
popterm-scope 'project ; or 'frame, 'dedicated, nil
popterm-auto-cd t)
(popterm-global-mode 1))(use-package popterm
:straight (popterm :type git :host github :repo "CsBigDataHub/popterm.el")
:bind (("C-`" . popterm-toggle)
("C-~" . popterm-toggle-cd)
([f9] . popterm-window-toggle))
:config
(setq popterm-backend 'vterm ; or 'ghostel, 'eat, 'shell, 'eshell
popterm-display-method 'posframe ; or 'window, 'fullscreen
popterm-scope 'project ; or 'frame, 'dedicated, nil
popterm-auto-cd t)
(popterm-global-mode 1))Clone the repository and add the directory to your load-path:
(add-to-list 'load-path "~/.emacs.d/site-lisp/popterm/")
(require 'popterm)| Package | Required? | Purpose |
|---|---|---|
posframe | Required | Child-frame rendering |
vterm | Optional | vterm backend |
ghostel | Optional | Ghostel backend (requires its module) |
eat | Optional | eat backend |
project | Built-in | Project-scoped buffer resolution |
tramp | Built-in | Remote path stripping for auto-cd |
| Command | Default Binding | Description |
|---|---|---|
popterm-toggle | C-` | Toggle the default terminal popup |
popterm-toggle-cd | C-~ | Toggle and cd to the originating bufferβs directory |
popterm-toggle-named | C-c t n | Toggle a named instance, e.g. build, server, repl |
popterm-window-toggle | f9 | Force a bottom window split regardless of display method |
popterm-posframe-toggle | β | Force posframe regardless of display method |
popterm-find | C-c t f | completing-read picker over all active popterm buffers |
popterm-vterm | β | Toggle a vterm popup regardless of popterm-backend |
popterm-ghostel | β | Toggle a Ghostel popup regardless of popterm-backend |
popterm-eat | β | Toggle an eat popup regardless of popterm-backend |
popterm-shell | β | Toggle a shell popup regardless of popterm-backend |
popterm-eshell | β | Toggle an eshell popup regardless of popterm-backend |
When inside any popterm buffer, a minor mode activates navigation bindings.
For vterm, Popterm also installs buffer-local passthrough exceptions so those
keys reach Emacs instead of the terminal process (see
How the vterm Keymap Trap Is Solved):
| Binding | Command | Description |
|---|---|---|
C-<next> | popterm-next | Cycle forward through terminals in the current scope |
C-<prior> | popterm-prev | Cycle backward |
C-q | popterm-return | Hide and return to the originating source buffer |
popterm supports running multiple isolated terminal instances simultaneously.
Buffers are named *popterm-BACKEND[NAME]*:
;; Open or toggle a terminal named "build"
(popterm-toggle "build")
;; Bind named instances to dedicated keys
(global-set-key (kbd "C-c t b") (lambda () (interactive) (popterm-toggle "build")))
(global-set-key (kbd "C-c t s") (lambda () (interactive) (popterm-toggle "server")))
(global-set-key (kbd "C-c t r") (lambda () (interactive) (popterm-toggle "repl")))Inside the terminal, use C-<next> and C-<prior> to cycle between all
instances in your current scope.
popterm is fully customizable via M-x customize-group RET popterm RET or
via setq in your init file.
;; Centered child frame (default, requires posframe)
(setq popterm-display-method 'posframe)
;; Bottom split window
(setq popterm-display-method 'window)
;; Full-frame takeover
(setq popterm-display-method 'fullscreen)(setq popterm-backend 'vterm) ; 'vterm | 'ghostel | 'eat | 'shell | 'eshell(setq popterm-posframe-width-ratio 0.62) ; fraction of frame width
(setq popterm-posframe-height-ratio 0.62) ; fraction of frame height
(setq popterm-posframe-min-width 100) ; minimum column width
(setq popterm-posframe-border-width 3) ; border in pixels
;; Use a custom poshandler for a different position, e.g. bottom-center:
(setq popterm-posframe-poshandler #'posframe-poshandler-frame-bottom-center)(setq popterm-window-height-ratio 0.30) ; fraction of frame height
(setq popterm-window-side 'below) ; 'below | 'above | 'left | 'right;; Reuse any open popterm buffer (default)
(setq popterm-scope nil)
;; Only reuse buffers associated with the current project
(setq popterm-scope 'project)
;; Only reuse buffers not visible in another frame
(setq popterm-scope 'frame)
;; Always use exactly one buffer per backend (ignores named instances)
(setq popterm-scope 'dedicated);; Always cd to the source buffer's directory on every toggle
(setq popterm-auto-cd t)
;; Allow popterm-toggle-cd to create a new terminal when none exists in scope
(setq popterm-cd-auto-create-buffer t)On slow Wayland compositors the async focus-transfer window may exceed the
default 0.35 second guard. Increase popterm-posframe-focus-delay if the
posframe occasionally re-hides itself immediately after opening:
(setq popterm-posframe-focus-delay 0.5)vterm (and eat) intercept keystrokes at the process-filter level, before
Emacsβs minor-mode keymap lookup ever runs. popterm solves this by registering
its navigation keys from vterm-mode-hook, so each vterm buffer gets the
exceptions added buffer-locally when popterm-global-mode is enabled:
(add-hook 'vterm-mode-hook #'popterm--vterm-setup)This instructs libvterm to hand the key back to Emacs rather than forwarding it to the underlying PTY.
posframe ships with an idle-timer daemon (posframe-hidehandler-daemon) that
fires every 0.5 s and calls each posframeβs :hidehandler function. When the
handler returns non-nil, posframe hides that child frame automatically.
popterm provides popterm--posframe-hidehandler, modelled after Centaur Emacsβs
shell-pop-posframe-hidehandler, which only returns non-nil (hide) when ALL of
the following conditions are met:
- The focus-delay inhibit guard (
popterm--inhibit-hidehandler) has expired. - The posframe is still live and visible.
- The minibuffer is NOT active β guards standard minibuffer completion (M-x, etc.).
- The
selected-frameis NOT a child frame β guards childframe completion UIs such asvertico-posframe,corfu-posframe, orcompany-posframe. - The
selected-frameis neither the popterm frame nor its parent β the parent check ensures the posframe stays visible while the user is in their normal workspace frame.
The inhibit timer (popterm-posframe-focus-delay seconds, stored in
popterm--focus-timer) prevents the Wayland/pgtk async focus race: on these
compositors, focus events can arrive before select-frame-set-input-focus
completes, causing the hidehandler to fire prematurely on rapid toggle.
When the hidehandler does decide to hide, popterm also clears its own
posframe session state (focus guard, display-buffer guard, theme refresh watch,
and timers) before the child frame is dismissed. This prevents stale guards
from lingering after an idle-timer hide and avoids accidentally re-showing the
terminal on a later theme change.
vterm and eat call pop-to-buffer internally, which would normally disrupt
the active window layout. popterm overrides display-buffer-alist dynamically
during buffer creation to force display-buffer-no-window, keeping the current
layout intact while still allowing PTY-backed terminals to query correct frame
dimensions for their initial grid size:
(let ((display-buffer-alist
'((".*" (display-buffer-no-window) (allow-no-window . t)))))
(save-current-buffer
;; terminal constructor called here
))This is preferable to save-window-excursion, which hides window dimensions and
can produce a 0Γ0 initial PTY grid.
ghostel uses a different public entry point: it switches to the new terminal
buffer and derives its default name internally. popterm wraps that command in
save-window-excursion to preserve the userβs layout, then renames the created
buffer into Poptermβs canonical *popterm-ghostel* or
*popterm-ghostel[NAME]* naming scheme.
When popterm-return is called, popterm distinguishes between display modes
to avoid a βfriendly-fireβ bury-buffer trap:
- Fullscreen: The terminal is still the
current-bufferwhen hide runs, sobury-buffercorrectly targets the terminal. - Posframe/Window:
popterm--hidehas already transferred focus back to the parent frame before the return logic runs. Callingbury-bufferhere would accidentally bury the userβs code file.poptermemits a message instead and leaves the workspace untouched.
You are likely on Wayland/pgtk. Increase the focus delay guard:
(setq popterm-posframe-focus-delay 0.5)Ensure vterm-keymap-exceptions contains the keys. popterm registers them
automatically from vterm-mode-hook when popterm-global-mode is enabled.
If you do not use popterm-global-mode, add the hook manually:
(add-hook 'vterm-mode-hook #'popterm--vterm-setup)This occurs if eat-buffer-name is not respected by your version of eat.
Check that your eat package is up to date (NonGNU ELPA or Codeberg master
branch). popterm binds eat-buffer-name before calling (eat) and fetches
the buffer by name afterward β both steps require a current eat.
Ensure the ghostel package itself is installed and that its native module is
available. Running M-x ghostel directly should succeed before debugging
Popterm integration. If Ghostel prompts to download or compile its module, let
that complete first; Popterm calls Ghostelβs public entry point and does not
bypass Ghostelβs own module checks.
popterm strips the TRAMP remote prefix from the path before sending the cd
command, so the command is valid on the remote host. Verify with:
(popterm-cd-string (current-buffer)) ; should return "cd /remote/local/path"If the path still contains the TRAMP prefix, your TRAMP connection may not
have a localname component. File a bug with the output of the above.
popterm does not persist buffer state across Emacs restarts. Named instance
buffers must be re-created each session. Use popterm-find (M-x popterm-find)
to survey all active instances at any time.
This behavior is related to a known upstream posframe issue: posframe#155.
This is now handled natively by popterm--posframe-hidehandler. The hidehandler
combines the focus-delay timer, parent/child-frame checks, and session-state
cleanup to prevent spurious hides when a completion child frame
(vertico-posframe, corfu-posframe, etc.) opens while popterm is visible:
(active-minibuffer-window)β guards standard minibuffer completion.(frame-parameter (selected-frame) 'parent-frame)β guards all child-frame UIs.(memq (selected-frame) (list popterm--frame parent))β keeps the posframe visible while the workspace frame or the popterm frame itself is focused.- Internal cleanup on daemon hide β removes stale guards and timers so the posframe does not resurrect unexpectedly on later theme changes.
No user-side workaround is needed. If you had previously installed an advice
override for popterm--after-focus-change, you can safely remove it β that
function no longer exists.
| Feature | popterm | vterm-toggle | toggle-term |
|---|---|---|---|
| Posframe child-frame display | β | β | β |
| Window split display | β | β | β |
| vterm backend | β | β | β |
| Ghostel backend | β | β | β |
| eat backend | β | β | β |
| shell / eshell backend | β | β | β |
| Named instances | β | β | β |
| completing-read picker | β | β | β |
| Project scope | β | β | β |
| Frame scope | β | β | β |
| Auto-cd to source buffer | β | β | β |
| TRAMP-aware auto-cd | β | β | β |
- PGTK emacs builds have a child frame focus issue
please see #1 and Emacs bug#64625
I recommand using
lucid,x11or--with-x-toolkit=gtk3emacs builds on linux
popterm includes a comprehensive test suite using ERT (Emacs Lisp Regression Testing).
Load the test file and run all tests:
(load-file "popterm-tests.el")
(ert-run-tests-interactively "^popterm-")Or use the convenience function:
M-x popterm-run-all-testsFor CI/CD integration or batch testing:
# Run all tests
emacs -batch -l ert -l popterm.el -l popterm-tests.el -f ert-run-tests-batch-and-exit
# Run specific test
emacs -batch -l ert -l popterm.el -l popterm-tests.el \
--eval "(ert-run-tests-batch-and-exit 'popterm-test-buffer-name)";; Run only performance tests
(ert-run-tests-interactively '(tag :performance))
;; Run a specific test
M-x ert RET popterm-test-buffer-name RETThe test suite covers:
- Basic API: Buffer name generation, mode mapping, backend wrapper dispatch
- Ghostel Integration: Buffer creation/renaming and Ghostel auto-~cd~ behavior
- Directory & Path Handling: Local/remote paths, TRAMP awareness, cd command generation
- Scope & Filtering: Project scope, frame scope, multi-frame visibility, buffer list filtering
- Guard Installation: Idempotent installation/removal of display-buffer, focus, and theme guards
- Timer Management: Timer cleanup, leak prevention
- Closure Capture: Frame capture in async callbacks
- Session State: Active display-method tracking, forced posframe/window sessions, daemon-hide cleanup
- Code Quality: Byte-compilation, frame capture race condition fix, timer cleanup in hide, vterm-setup docstring
- Integration: Display-buffer-guard predicate, posframe visibility checks, theme-refresh behavior
- Performance: Buffer list filtering with 50+ buffers
All tests should pass. The exact number of tests and runtime will change as the suite evolves, so treat the batch output as informational rather than fixed.
Bug reports, feature requests, and pull requests are welcome.
Before submitting a patch, please ensure:
- The file byte-compiles cleanly:
M-x emacs-lisp-byte-compile checkdocpasses:M-x checkdoc- All tests pass:
M-x popterm-run-all-tests - All public functions have docstrings that uppercase argument names (e.g.,
NAME,BACKEND) - New
defcustomentries have a complete standalone first-sentence docstring - Messages begin with a capital letter:
"Popterm: ..." - New features include corresponding test coverage in
popterm-tests.el
This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version.
popterm stands on the shoulders of:
- seagle0128/.emacs.d β posframe shell-pop pattern and child-frame geometry
- toggle-term.el β multi-backend architecture and named instance model
- vterm-toggle β auto-cd, TRAMP path handling, and scope-locking design
- posframe β the child-frame rendering library that makes this possible