| name | emacs-transient |
| description | Expert guide for building Emacs transient menus (keyboard-driven UI like Magit). Use when implementing or debugging transient-define-prefix, transient-define-suffix, or transient-define-infix commands. |
Emacs Transient Expert
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.
When to Use This Skill
Invoke this skill when:
- Implementing new
transient-define-prefix menus
- Creating
transient-define-suffix or transient-define-infix commands
- Debugging transient menu layouts or behavior
- Understanding transient levels, groups, or conditional display
- Working on keyboard-driven UI similar to Magit
- Converting traditional Emacs commands to transient interfaces
Core Concepts
What is Transient?
Transient is a library for creating keyboard-driven, temporary menus
in Emacs. It's the foundation of Magit's interface and provides:
- Display of current state - Show active arguments and options
- Modal bindings - Temporary keymaps that disappear when done
- Contextual UI - Menus adapt based on state
- Persistence - Save and restore argument values across sessions
- History - Track previously used values
Key Terminology
- Prefix - The main transient command that opens the menu
- Suffix - Commands invoked from the transient (actions)
- Infix - Special suffixes that set arguments/options without
exiting
- Groups - Organizational units for layout (rows, columns,
sections)
- Levels (1-7) - Control visibility based on user expertise
(default: 4)
- Scope - Contextual value passed to suffixes (e.g., current
branch)
Three Core Macros
1. transient-define-prefix
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 state
2. transient-define-suffix
Defines 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 disable
3. transient-define-infix (or transient-define-argument)
Defines 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")
Infix/Suffix Classes
Suffix Classes
transient-suffix - Base class for all suffixes
transient-infix - Base for all infixes (auto-stays transient)
Infix Classes (derive from transient-infix)
For command-line arguments:
transient-switch - Boolean flag (e.g., --verbose)
transient-option - Argument with value (e.g., --author=NAME)
transient-switches - Mutually exclusive options
transient-files - File arguments (-- separator)
For variables:
transient-variable - Base for variable infixes
transient-lisp-variable - Set Emacs Lisp variables
Display only:
transient-information - Display info (no command/key)
transient-information* - Info aligned with descriptions
Suffix Specification Syntax
Three ways to specify suffixes:
1. Inline (shorthand)
("key" "description" command)
("-s" "switch" "--switch") ; Auto-creates transient-switch
("-o" "option" "--option=") ; Auto-creates transient-option
2. With keyword arguments
("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
3. Reference to separately defined command
(my-custom-suffix) ; Uses suffix's own key/desc
Group Specification Syntax
[{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 horizontally
transient-columns - Contains column groups side-by-side
transient-subgroups - Contains subgroups
The :transient Slot (Controlling State)
Controls 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 prefix
For infixes (default: stay transient):
- Infixes always use
transient--do-stay by default
- Rarely need to override
For sub-prefixes (nested transients):
nil - Exit all transients when sub-prefix exits
t - 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 args
transient--do-stay - Stay, don't export args
transient--do-call - Stay and export args
transient--do-return - Exit to parent prefix
Layout Patterns
Stacked Groups (Vertical)
(transient-define-prefix my-menu ()
["Top Group" ...]
["Bottom Group" ...])
Columns (Side-by-side)
(transient-define-prefix my-menu ()
[["Left Column" ...]
["Right Column" ...]])
Mixed (Stacked + Columns)
(transient-define-prefix my-menu ()
["Top Group (full width)" ...]
[["Left Column" ...]
["Right Column" ...]])
Grid Layout
(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)]])
Spacing
["Group"
"" ; Empty line
("k" "first" cmd)
("l" "second" cmd)
"" ; Another gap
("m" "third" cmd)]
Accessing Transient Values
In suffix commands
(transient-define-suffix my-suffix (args)
"Do something with ARGS."
(interactive (list (transient-args 'my-prefix)))
;; Now use args...
(message "Args: %S" args))
Getting specific argument values
(let* ((args (transient-args 'my-prefix))
(author (transient-arg-value "--author=" args))
(verbose-p (transient-arg-value "--verbose" args)))
;; Use values...
)
Using scope (contextual value)
(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)))
Conditional Display (Predicates)
Visibility predicates (if suffix should appear)
("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
Inapt predicates (show but grayed out)
("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
On groups
[:if magit-rebase-in-progress-p ; Whole group conditional
("a" "abort" magit-rebase-abort)
("c" "continue" magit-rebase-continue)]
Important Suffix Slots
Required/Common:
:key - Key binding
:description - String or function returning string
:command - The command to invoke
Behavioral:
: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/disable
Display:
:format - Custom display format (%k %d %v)
:face - Face for description
:summary - Echo area/tooltip text
Help:
:show-help - Custom help function
Important Infix Slots
All 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 string
Multi-value:
:multi-value - 'rest or 'repeat for multiple values
Other:
:init-value - Function to set initial value
:history-key - Symbol for history (share across infixes)
:unsavable - Don't save with prefix value
Levels (1-7)
Control visibility based on user preference:
- 1-3: Essential commands (always visible)
- 4: Default level
- 5-6: Advanced/rarely used
- 7: Experimental/debug
- 0: Never show (effectively disabled)
("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.
Dynamic Content
Dynamic descriptions
("k" my-cmd
:description (lambda ()
(format "Branch: %s" (magit-get-current-branch))))
Dynamic groups (:setup-children)
[: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 "."))))]
Information display
["Info"
(:info "Static information")
(:info (lambda () (format "Dynamic: %s" (current-time-string))))
(:info my-info-function :format " %d")] ; Custom format
Common Patterns from Magit
Context-aware suffixes
(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))))
...)
Shared argument groups
(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" ...])
Validation before execution
(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))
Best Practices
1. Use appropriate classes
- Use
transient-switch for boolean flags
- Use
transient-option for value-taking arguments
- Use
transient-switches for mutually exclusive options
2. Leverage levels effectively
- Put common operations at level 3-4
- Put advanced features at level 5-6
- Use level 7 for debug/experimental
3. Provide good descriptions
- Keep descriptions concise (fits in menu)
- Use dynamic descriptions to show state
- Use
:summary for longer explanations
4. Handle state properly
- Use
:transient t for commands that should stay in menu
- Use
:if predicates instead of manual state checking
- Use
:inapt-if to show unavailable options
5. Organize logically
- Group related items together
- Use columns for parallel choices
- Put common actions on left, advanced on right
6. Share history
- Use
:history-key to share history between similar infixes
(transient-define-argument my-author ()
:argument "--author="
:history-key 'my-package-author-history)
7. Provide help
- Set
:man-page or :info-manual on prefix
- Use descriptive docstrings
- Implement custom
:show-help if needed
8. Test interactively
- Use
C-h while transient is active to see help
- Use
C-x l to experiment with levels
- Use
C-x s / C-x C-s to test persistence
Common Gotchas
-
Don'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
- Don't need
:transient t on infixes
- They automatically use
transient--do-stay
-
:if vs :inapt-if
:if - completely hide the suffix
:inapt-if - show but grayed out
-
Accessing args in interactive
(interactive (list (transient-args 'my-prefix))) ; Correct
-
Sub-prefix returns
("s" "sub-menu" my-sub-prefix :transient t) ; Returns to parent
Complete Example
;; 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)]])
Reference Materials
For deeper understanding, refer to:
- transient-reference/transient/ - Core library documentation
- transient-reference/magit/ - Real-world usage in Magit
- transient-reference/forge/ - Additional patterns in Forge
- transient-reference/transient-showcase/ - Examples showcase
Testing Transient Menus
Why Test Transients?
Transient menus have complex state management involving:
- Infix commands that set state variables
- Suffix commands that read those states
- Pre/post-command hooks for state transitions
- Display refreshes and keybinding resolution
Testing only suffix commands (by mocking transient-args) skips
testing the entire UI layer.
Two Testing Approaches
1. Unit Testing (Mocked Args) - Fast but Limited
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:
- Fast (~0.5s per test)
- Simple to write
- Good for testing suffix logic
Cons:
- ❌ Doesn't test infix commands
- ❌ Doesn't test transient UI
- ❌ Doesn't test user workflow
- ❌ Won't catch binding errors
2. Integration Testing (execute-kbd-macro) - Slower but Complete
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:
- ✅ Tests infix commands
- ✅ Tests transient UI
- ✅ Tests real user workflow
- ✅ No mocking needed
- ✅ Works in batch mode (CI-friendly!)
- ✅ Catches binding errors, UI bugs
Cons:
- Slower (~2.3s per test, +0.65s overhead)
- More complex to write
- Need to know key sequences
Performance breakdown:
- Transient infrastructure: ~0.15s
- Multiple kbd macro calls: ~0.20s
- Minibuffer interactions: ~0.20s
- Display refreshes: ~0.10s
- Total overhead: ~0.65s per test
Critical Rule for execute-kbd-macro
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.
Testing Patterns
Pattern 1: Minimal workflow (title only)
(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...
))
Pattern 2: Full workflow (multiple fields)
(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
))
Pattern 3: Testing switches (toggle flags)
(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...
))
Pattern 4: Testing transient navigation
(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...
))
Helper Functions
(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
When to Use Each Approach
Use mocked approach when:
- Testing suffix logic in isolation
- Testing error handling
- Speed is critical (large test suite)
- Don't need to test UI interaction
Use execute-kbd-macro approach when:
- Testing end-to-end user workflows
- Validating infix commands work
- Testing transient state management
- Need confidence in the full UI
- Testing for a critical user path
Recommended strategy:
- Use mocked tests for most unit tests
- Use execute-kbd-macro for key integration tests
- At least one full UI test per transient menu
Example: Complete Test Suite
;;; 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...
))
Debugging Tips
When tests fail:
- Check key bindings - Use
C-h in transient to see actual keys
- Test interactively first - Run the transient manually
- Add debug messages:
(execute-kbd-macro (kbd "t Test RET"))
(message "After title: %S" (transient-args 'my-prefix))
- Check transient buffer - Look at
*transient* buffer state
- Use edebug - Step through suffix execution
Common issues:
- "Unbound suffix" - Wrong key binding in test
- "Wrong type argument" - Reader function got nil/wrong type
- Macro fails silently - Split key + input across macros
- Args not set - Forgot to call
funcall-interactively on prefix
Testing Multiline Editor Fields
Fields 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 buffer
Full SPC description SPC text types into that buffer (it's now active)
C-c C-c commits and returns to transient menu
- All happens in single macro execution, so context is preserved
What 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:
- ✅ Simple minibuffer prompts work (combine key + input + RET)
- ✅ Switches/toggles work (just the key)
- ✅ Multiline editor fields work (combine key + text + C-c C-c)
- ❌ Splitting any interaction across multiple macro calls fails
Comparison to Other Testing Approaches
with-simulated-input package:
- ❌ Adds complexity without clear benefits for transient testing
- ❌ Our pattern (mock transient-args + call-interactively) is simpler
- ✅ execute-kbd-macro works natively in batch mode
Casual project approach:
- Tests only binding structure (overrides commands with stubs)
- ❌ Doesn't test actual command execution
- ✅ Our approach tests REAL commands with REAL execution
Result: Our execute-kbd-macro approach gives superior coverage!
Version History
- v1.1.0 (2025-11-19): Added comprehensive testing section with
execute-kbd-macro patterns and performance analysis
- v1.0.0 (2025-11-06): Initial skill created from research of
transient, Magit, Forge, and transient-showcase repositories