Expert guide for building Emacs transient menus (keyboard-driven UI like Magit)...
This skill provides comprehensive guidance for building transient-based interfaces in Emacs Lisp, based on the official transient library and real-world implementations in Magit, Forge, and transient-showcase.
Invoke this skill when:
transient-define-prefix menustransient-define-suffix or transient-define-infix commandsTransient is a library for creating keyboard-driven, temporary menus in Emacs. It's the foundation of Magit's interface and provides:
Defines the main transient menu.
(transient-define-prefix my-menu ()
"Description of what this menu does."
:man-page "git-commit" ; Optional: link to man page
:info-manual "(magit)Committing" ; Optional: link to info manual
:value '("--verbose") ; Optional: default arguments
;; Groups define layout
["Arguments" ; Group header
("-v" "Verbose" "--verbose") ; Switch (toggle)
("-a" "Author" "--author=" ; Option (takes value)
:prompt "Author: ")
(my-custom-infix)] ; Reference to defined infix
[["Actions" ; Nested groups = columns
("c" "Commit" my-commit-cmd)
("a" "Amend" my-amend-cmd)]
["Other"
("q" "Quit" transient-quit)]])
Key slots:
:value - Default arguments:man-page - Man page for help:info-manual - Info manual section:transient-suffix - Default transient behavior for suffixes:transient-non-suffix - Allow/forbid non-suffix commands:refresh-suffixes - When to refresh suffix stateDefines action commands (suffixes).
(transient-define-suffix my-commit-cmd (args)
"Create a commit with ARGS."
:description "Commit staged changes" ; Optional: override in menu
:transient t ; Stay transient after calling
(interactive (list (transient-args 'my-menu)))
(apply #'my-run-git "commit" args))
Key slots:
:key - Key binding (can override menu binding):description - Can be string or function returning string:transient - Control transient state (see below):if / :if-not - Conditional visibility:inapt-if / :inapt-if-not - Show but disableDefines argument commands (infixes).
(transient-define-argument my-author-arg ()
:description "Set author"
:class 'transient-option ; Option class (takes value)
:shortarg "-a" ; Short form
:argument "--author=" ; Long form
:reader #'my-read-author) ; Custom reader function
(transient-define-infix my-verbose-switch ()
:description "Verbose output"
:class 'transient-switch ; Switch class (boolean)
:argument "--verbose")
transient-suffix - Base class for all suffixestransient-infix - Base for all infixes (auto-stays transient)For command-line arguments:
transient-switch - Boolean flag (e.g., --verbose)transient-option - Argument with value (e.g., --author=NAME)transient-switches - Mutually exclusive optionstransient-files - File arguments (-- separator)For variables:
transient-variable - Base for variable infixestransient-lisp-variable - Set Emacs Lisp variablesDisplay only:
transient-information - Display info (no command/key)transient-information* - Info aligned with descriptionsThree ways to specify suffixes:
("key" "description" command)
("-s" "switch" "--switch") ; Auto-creates transient-switch
("-o" "option" "--option=") ; Auto-creates transient-option
("key" "description" command
:transient t ; Stay transient
:if (lambda () (my-condition))) ; Conditional
("-a" "author" "--author="
:prompt "Author name: "
:reader #'my-custom-reader
:always-read t) ; Always prompt, don't toggle
(my-custom-suffix) ; Uses suffix's own key/desc
[{LEVEL} {DESCRIPTION} {KEYWORD VALUE}... ELEMENT...]
Common patterns:
["Group Title" ; Simple group with title
("k" "desc" cmd)]
[:description "Dynamic" ; Dynamic description
:description (lambda () (format "Time: %s" (current-time-string)))
("k" "desc" cmd)]
[:if some-predicate ; Conditional group
("k" "desc" cmd)]
[:class transient-row ; Explicit layout class
("k" "desc" cmd)]
[["Column 1" ; Nested groups = columns
("a" "cmd a" cmd-a)]
["Column 2"
("b" "cmd b" cmd-b)]]
Group classes:
transient-column - Stack items vertically (default)transient-row - Arrange items horizontallytransient-columns - Contains column groups side-by-sidetransient-subgroups - Contains subgroupsControls whether transient stays active after invoking a command.
For suffixes (default: exit transient):
nil or :transient nil - Exit transient (default for suffixes)t or :transient t - Stay transient:transient 'transient--do-call - Export args and stay:transient 'transient--do-return - Return to parent prefixFor infixes (default: stay transient):
transient--do-stay by defaultFor sub-prefixes (nested transients):
nil - Exit all transients when sub-prefix exitst - Return to parent when sub-prefix exits:transient 'transient--do-recurse - Enable return behavior:transient 'transient--do-replace - Replace parent (no return)Common pre-commands:
transient--do-exit - Exit and export argstransient--do-stay - Stay, don't export argstransient--do-call - Stay and export argstransient--do-return - Exit to parent prefix(transient-define-prefix my-menu ()
["Top Group" ...]
["Bottom Group" ...])
(transient-define-prefix my-menu ()
[["Left Column" ...]
["Right Column" ...]])
(transient-define-prefix my-menu ()
["Top Group (full width)" ...]
[["Left Column" ...]
["Right Column" ...]])
(transient-define-prefix my-menu ()
[:description "The Grid"
["Left Column"
("tl" "top-left" cmd)
("bl" "bottom-left" cmd)]
["Right Column"
("tr" "top-right" cmd)
("br" "bottom-right" cmd)]])
["Group"
"" ; Empty line
("k" "first" cmd)
("l" "second" cmd)
"" ; Another gap
("m" "third" cmd)]
(transient-define-suffix my-suffix (args)
"Do something with ARGS."
(interactive (list (transient-args 'my-prefix)))
;; Now use args...
(message "Args: %S" args))
(let* ((args (transient-args 'my-prefix))
(author (transient-arg-value "--author=" args))
(verbose-p (transient-arg-value "--verbose" args)))
;; Use values...
)
(transient-define-prefix my-menu (scope)
"Menu with scope."
["Actions"
("a" "Action" my-action)]
(interactive "P") ; Can take prefix arg as scope
(transient-setup 'my-menu nil nil :scope scope))
(transient-define-suffix my-action ()
(interactive)
(let ((scope (transient-scope)))
(message "Scope: %S" scope)))
("k" "desc" cmd
:if (lambda () (file-exists-p "Makefile"))) ; Show if true
("k" "desc" cmd
:if-not some-mode ; Show if not in mode
:if-non-nil some-variable ; Show if var non-nil
:if-mode 'emacs-lisp-mode ; Show in mode
:if-derived 'prog-mode) ; Show if derived
("k" "desc" cmd
:inapt-if (lambda () (not (magit-anything-staged-p)))) ; Gray if true
("k" "desc" cmd
:inapt-if-not some-function ; Gray if false
:inapt-if-nil some-variable) ; Gray if var nil
[:if magit-rebase-in-progress-p ; Whole group conditional
("a" "abort" magit-rebase-abort)
("c" "continue" magit-rebase-continue)]
Required/Common:
:key - Key binding:description - String or function returning string:command - The command to invokeBehavioral:
:transient - Stay transient? (t/nil/pre-command):level - Visibility level (1-7, default 4)Conditional:
:if, :if-not, :if-mode, :if-derived - Visibility:inapt-if, :inapt-if-not - Enable/disableDisplay:
:format - Custom display format (%k %d %v):face - Face for description:summary - Echo area/tooltip textHelp:
:show-help - Custom help functionAll suffix slots, plus:
Argument-related:
:argument - Long form (e.g., --verbose):shortarg - Short form (e.g., -v):class - Infix class (switch/option/etc.)Reading values:
:reader - Function to read value (PROMPT, INITIAL, HISTORY):prompt - Prompt string or function:choices - List of valid values:always-read - Always prompt (don't toggle for options):allow-empty - Allow empty stringMulti-value:
:multi-value - 'rest or 'repeat for multiple valuesOther:
:init-value - Function to set initial value:history-key - Symbol for history (share across infixes):unsavable - Don't save with prefix valueControl visibility based on user preference:
("k" "advanced" cmd :level 6) ; Only show at level 6+
["Arguments" :level 5 ; Whole group at level 5
("-v" "verbose" "--verbose")]
Users can change levels interactively with C-x l.
("k" my-cmd
:description (lambda ()
(format "Branch: %s" (magit-get-current-branch))))
[:class transient-column
:setup-children
(lambda (_)
(transient-parse-suffixes
'my-prefix
(mapcar (lambda (file)
(list (substring file 0 1) file
(lambda () (interactive) (find-file file))))
(directory-files "."))))]
["Info"
(:info "Static information")
(:info (lambda () (format "Dynamic: %s" (current-time-string))))
(:info my-info-function :format " %d")] ; Custom format
(transient-define-suffix my-cmd (args)
(interactive
(if (derived-mode-p 'my-list-mode)
(list (my-get-args-from-buffer))
(list (transient-args 'my-prefix))))
...)
(transient-define-group my-common-args ()
["Common Arguments"
("-v" "Verbose" "--verbose")
("-q" "Quiet" "--quiet")])
(transient-define-prefix my-menu-1 ()
[my-common-args] ; Include by reference
["Actions" ...])
(transient-define-prefix my-menu-2 ()
[my-common-args] ; Reuse in another menu
["Other Actions" ...])
(transient-define-suffix my-create (title desc)
(interactive
(list (read-string "Title: ")
(read-string "Description: ")))
(when (string-empty-p title)
(user-error "Title cannot be empty"))
(my-create-thing title desc))
transient-switch for boolean flagstransient-option for value-taking argumentstransient-switches for mutually exclusive options:summary for longer explanations:transient t for commands that should stay in menu:if predicates instead of manual state checking:inapt-if to show unavailable options:history-key to share history between similar infixes(transient-define-argument my-author ()
:argument "--author="
:history-key 'my-package-author-history)
:man-page or :info-manual on prefix:show-help if neededC-h while transient is active to see helpC-x l to experiment with levelsC-x s / C-x C-s to test persistenceDon't quote in transient definitions - The macro handles it
;; WRONG:
["Group" '("k" "desc" 'cmd)]
;; RIGHT:
["Group" ("k" "desc" cmd)]
Use :transient t for iterative commands
("n" "next" my-next :transient t) ; Can press 'n' repeatedly
Remember infixes stay transient by default
:transient t on infixestransient--do-stay:if vs :inapt-if
:if - completely hide the suffix:inapt-if - show but grayed outAccessing args in interactive
(interactive (list (transient-args 'my-prefix))) ; Correct
Sub-prefix returns
("s" "sub-menu" my-sub-prefix :transient t) ; Returns to parent
;; Custom argument
(transient-define-argument my-pkg:--author ()
:description "Override author"
:class 'transient-option
:shortarg "-a"
:argument "--author="
:reader #'my-read-author)
;; Suffix that stays transient
(transient-define-suffix my-pkg-preview ()
"Preview current settings."
:transient t
(interactive)
(message "Args: %S" (transient-args 'my-pkg-create)))
;; Main suffix
(transient-define-suffix my-pkg-execute (args)
"Execute with ARGS."
(interactive (list (transient-args 'my-pkg-create)))
(apply #'my-pkg-run args))
;; Main menu
(transient-define-prefix my-pkg-create ()
"Create something with options."
:man-page "my-tool"
:value '("--verbose")
["Arguments"
("-v" "Verbose" "--verbose")
("-q" "Quiet" "--quiet")
("-n" "Dry run" "--dry-run" :level 5)
(my-pkg:--author)]
[["Actions"
("p" "Preview" my-pkg-preview)
("c" "Create" my-pkg-execute)]
["Other"
("q" "Quit" transient-quit)]])
For deeper understanding, refer to:
Transient menus have complex state management involving:
Testing only suffix commands (by mocking transient-args) skips
testing the entire UI layer.
Test suffix commands directly by mocking transient-args:
(defmacro my-test-with-transient-args (prefix args &rest body)
"Execute BODY with transient-args mocked for PREFIX to return ARGS."
(declare (indent 2))
`(cl-letf (((symbol-function 'transient-args)
(lambda (p)
(when (eq p ,prefix)
,args))))
,@body))
(ert-deftest my-test-suffix-execution ()
"Test suffix command with mocked args."
(my-test-with-transient-args 'my-prefix
'("--title=Test" "--priority=1")
(let ((result (call-interactively #'my-suffix-execute)))
(should (string-match-p "Created" result)))))
Pros:
Cons:
Test the full user interaction with keyboard macros:
(ert-deftest my-test-full-ui-interaction ()
"Test complete user workflow through transient UI."
:tags '(:integration :ui)
(my-test-with-project () ; Setup test environment
;; Invoke the transient menu
(funcall-interactively #'my-prefix)
;; Set title via infix (key + input in ONE macro!)
(execute-kbd-macro (kbd "t Test SPC Title RET"))
;; Set priority via infix
(execute-kbd-macro (kbd "- p 1 RET"))
;; Execute the suffix
(execute-kbd-macro (kbd "x"))
;; Verify results
(let ((result (my-get-created-item)))
(should (equal (plist-get result :title) "Test Title"))
(should (equal (plist-get result :priority) 1)))))
Pros:
Cons:
Performance breakdown:
You MUST combine transient key + input in a SINGLE kbd macro:
;; ✅ CORRECT - Key + input in one macro
(execute-kbd-macro (kbd "t Bug SPC Title RET"))
;; ❌ WRONG - Splitting key and input
(execute-kbd-macro (kbd "t")) ; Opens minibuffer
(execute-kbd-macro (kbd "Bug Title RET")) ; Fails! Tries to invoke
; transient keys B, u, g
Why? When you split the macro, the second call doesn't go to the minibuffer - it's interpreted as more transient commands.
(ert-deftest my-test-minimal-workflow ()
"Test simplest possible workflow."
(my-test-setup ()
(funcall-interactively #'my-create)
(execute-kbd-macro (kbd "t Minimal SPC Test RET"))
(execute-kbd-macro (kbd "x"))
;; Verify...
))
(ert-deftest my-test-full-workflow ()
"Test complete workflow with all fields."
(my-test-setup ()
(funcall-interactively #'my-create)
;; Set all fields
(execute-kbd-macro (kbd "t Full SPC Test RET"))
(execute-kbd-macro (kbd "- t feature RET"))
(execute-kbd-macro (kbd "- p 2 RET"))
(execute-kbd-macro (kbd "- a john@example.com RET"))
;; Execute
(execute-kbd-macro (kbd "x"))
;; Verify all fields were set correctly
))
(ert-deftest my-test-switches ()
"Test boolean switch toggling."
(my-test-setup ()
(funcall-interactively #'my-prefix)
;; Toggle switch on
(execute-kbd-macro (kbd "- v")) ; --verbose
;; Toggle switch off
(execute-kbd-macro (kbd "- v"))
;; Toggle back on
(execute-kbd-macro (kbd "- v"))
(execute-kbd-macro (kbd "x"))
;; Verify switch state...
))
(ert-deftest my-test-navigation ()
"Test moving between transient levels."
(my-test-setup ()
(funcall-interactively #'my-prefix)
;; Change transient level to show advanced options
(execute-kbd-macro (kbd "C-x l 6 RET"))
;; Now advanced options should be visible
(execute-kbd-macro (kbd "- n")) ; Advanced option
(execute-kbd-macro (kbd "x"))
;; Verify...
))
(defun my-test-kbd-do (keys)
"Execute keyboard macro from KEYS list.
KEYS is a list of key sequence strings that will be joined
and executed as a keyboard macro."
(execute-kbd-macro (kbd (string-join keys " "))))
;; Usage:
(my-test-kbd-do '("t" "Title" "RET")) ; Cleaner than raw kbd
Use mocked approach when:
Use execute-kbd-macro approach when:
Recommended strategy:
;;; Unit Tests (Mocked - Fast)
(ert-deftest my-create-test-parse-args ()
"Test argument parsing."
;; Fast unit test, no UI
)
(ert-deftest my-create-test-validation ()
"Test validation logic."
(my-test-with-transient-args 'my-create
'("--title=") ; Empty title
(should-error (call-interactively #'my-create-execute)
:type 'user-error)))
;;; Integration Tests (Full UI - Comprehensive)
(ert-deftest my-create-test-full-ui-basic ()
"Test basic creation workflow through UI."
:tags '(:integration :ui)
(my-test-setup ()
(funcall-interactively #'my-create)
(execute-kbd-macro (kbd "t Basic SPC Test RET"))
(execute-kbd-macro (kbd "x"))
;; Verify creation...
))
(ert-deftest my-create-test-full-ui-all-fields ()
"Test creation with all fields through UI."
:tags '(:integration :ui :slow)
(my-test-setup ()
(funcall-interactively #'my-create)
(execute-kbd-macro (kbd "t Full SPC Test RET"))
(execute-kbd-macro (kbd "- t feature RET"))
(execute-kbd-macro (kbd "- p 2 RET"))
(execute-kbd-macro (kbd "x"))
;; Verify all fields...
))
When tests fail:
C-h in transient to see actual keys(execute-kbd-macro (kbd "t Test RET"))
(message "After title: %S" (transient-args 'my-prefix))
*transient* buffer stateCommon issues:
funcall-interactively on prefixFields that open dedicated editor buffers for multiline input (description,
notes, comments) DO work with execute-kbd-macro using the same principle
as simple fields: combine everything in a SINGLE macro.
Pattern:
(funcall-interactively #'my-create)
;; ✅ CORRECT - Combine infix + text + commit in ONE macro
(execute-kbd-macro (kbd "- d Full SPC description SPC text C-c C-c"))
(execute-kbd-macro (kbd "- A Acceptance SPC criteria C-c C-c"))
Why this works:
- d opens the editor bufferFull SPC description SPC text types into that buffer (it's now active)C-c C-c commits and returns to transient menuWhat DOESN'T work (split across macros):
;; ❌ WRONG - Split into separate macros
(execute-kbd-macro (kbd "- d")) ; Opens editor
(execute-kbd-macro (kbd "My description")) ; Fails! Wrong context
(execute-kbd-macro (kbd "C-c C-c")) ; Fails! Wrong context
When you split into multiple execute-kbd-macro calls, each call starts from
the current buffer context, which may not be the editor buffer that was opened.
Summary:
with-simulated-input package:
Casual project approach:
Result: Our execute-kbd-macro approach gives superior coverage!