Claude-skill-registry emacs-transient
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.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/emacs-transient" ~/.claude/skills/majiayu000-claude-skill-registry-emacs-transient && rm -rf "$T"
skills/data/emacs-transient/SKILL.mdEmacs 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
menustransient-define-prefix - Creating
ortransient-define-suffix
commandstransient-define-infix - 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:
- Default arguments:value
- Man page for help:man-page
- Info manual section:info-manual
- Default transient behavior for suffixes:transient-suffix
- Allow/forbid non-suffix commands:transient-non-suffix
- When to refresh suffix state:refresh-suffixes
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 binding (can override menu binding):key
- Can be string or function returning string:description
- Control transient state (see below):transient
/:if
- Conditional visibility:if-not
/:inapt-if
- Show but disable:inapt-if-not
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
- Base class for all suffixestransient-suffix
- Base for all infixes (auto-stays transient)transient-infix
Infix Classes (derive from transient-infix)
For command-line arguments:
- Boolean flag (e.g.,transient-switch
)--verbose
- Argument with value (e.g.,transient-option
)--author=NAME
- Mutually exclusive optionstransient-switches
- File arguments (transient-files
separator)--
For variables:
- Base for variable infixestransient-variable
- Set Emacs Lisp variablestransient-lisp-variable
Display only:
- Display info (no command/key)transient-information
- Info aligned with descriptionstransient-information*
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:
- Stack items vertically (default)transient-column
- Arrange items horizontallytransient-row
- Contains column groups side-by-sidetransient-columns
- Contains subgroupstransient-subgroups
The :transient Slot (Controlling State)
Controls whether transient stays active after invoking a command.
For suffixes (default: exit transient):
ornil
- Exit transient (default for suffixes):transient nil
ort
- Stay transient:transient t
- Export args and stay:transient 'transient--do-call
- Return to parent prefix:transient 'transient--do-return
For infixes (default: stay transient):
- Infixes always use
by defaulttransient--do-stay - Rarely need to override
For sub-prefixes (nested transients):
- Exit all transients when sub-prefix exitsnil
- Return to parent when sub-prefix exitst
- Enable return behavior:transient 'transient--do-recurse
- Replace parent (no return):transient 'transient--do-replace
Common pre-commands:
- Exit and export argstransient--do-exit
- Stay, don't export argstransient--do-stay
- Stay and export argstransient--do-call
- Exit to parent prefixtransient--do-return
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 binding:key
- String or function returning string:description
- The command to invoke:command
Behavioral:
- Stay transient? (t/nil/pre-command):transient
- Visibility level (1-7, default 4):level
Conditional:
,:if
,:if-not
,:if-mode
- Visibility:if-derived
,:inapt-if
- Enable/disable:inapt-if-not
Display:
- Custom display format (:format%k%d
)%v
- Face for description:face
- Echo area/tooltip text:summary
Help:
- Custom help function:show-help
Important Infix Slots
All suffix slots, plus:
Argument-related:
- Long form (e.g.,:argument
)--verbose
- Short form (e.g.,:shortarg
)-v
- Infix class (switch/option/etc.):class
Reading values:
- Function to read value (PROMPT, INITIAL, HISTORY):reader
- Prompt string or function:prompt
- List of valid values:choices
- Always prompt (don't toggle for options):always-read
- Allow empty string:allow-empty
Multi-value:
-:multi-value
or'rest
for multiple values'repeat
Other:
- Function to set initial value:init-value
- Symbol for history (share across infixes):history-key
- Don't save with prefix value:unsavable
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
for boolean flagstransient-switch - Use
for value-taking argumentstransient-option - Use
for mutually exclusive optionstransient-switches
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
for longer explanations:summary
4. Handle state properly
- Use
for commands that should stay in menu:transient t - Use
predicates instead of manual state checking:if - Use
to show unavailable options:inapt-if
5. Organize logically
- Group related items together
- Use columns for parallel choices
- Put common actions on left, advanced on right
6. Share history
- Use
to share history between similar infixes:history-key
(transient-define-argument my-author () :argument "--author=" :history-key 'my-package-author-history)
7. Provide help
- Set
or:man-page
on prefix:info-manual - Use descriptive docstrings
- Implement custom
if needed:show-help
8. Test interactively
- Use
while transient is active to see helpC-h - Use
to experiment with levelsC-x l - Use
/C-x s
to test persistenceC-x C-s
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
on infixes:transient t - They automatically use
transient--do-stay
- Don't need
-
:if vs :inapt-if
- completely hide the suffix:if
- show but grayed out:inapt-if
-
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
in transient to see actual keysC-h - 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
buffer state*transient* - 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
on prefixfuncall-interactively
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:
opens the editor buffer- d
types into that buffer (it's now active)Full SPC description SPC text
commits and returns to transient menuC-c C-c- 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