Migrating to v0.10 (current trunk)
v0.10 replaces the legacy keybinding system with a unified actions + bindings architecture. All keyboard input now flows through a single pipeline:
KeyMsg → Dispatcher → Binding → Action → Intent → Model
This guide covers what changed and how to update your configuration.
If you have [keys], look up the old key name in the mapping table to find the new action and scope, then add a [[bindings]] block for each one. Sub-sections like [keys.rebase] map to their corresponding scope (e.g. revisions.rebase) — see Replacing [keys] below.
If you have [custom_commands], convert each entry to [[actions]] + [[bindings]] — see Replacing [custom_commands] below.
If you have [leader] sequences, rewrite them as seq bindings:
[[bindings]]action = "ui.open_revset"seq = ["g", "r"]scope = "revisions"If you want to add new custom actions, use [[actions]] + [[bindings]] in config.toml, or config.lua for scripting:
[[actions]]name = "my-action"lua = 'flash("hello")'
[[bindings]]action = "my-action"key = "H"scope = "revisions"Read on for the full reference.
What Was Removed
Section titled “What Was Removed”| Old | Replacement |
|---|---|
[custom_commands] | [[actions]] + [[bindings]] |
[leader] + leader key sequences | seq = [...] in [[bindings]] |
[keys] key overrides | [[bindings]] with matching scope |
Startup warnings: If your config still contains [custom_commands] or [leader], jjui prints a warning to stderr but continues. [keys] is silently ignored. None of them crash the app, but they have no effect.
Actions and Bindings
Section titled “Actions and Bindings”Defining a custom action
Section titled “Defining a custom action”Custom actions run Lua scripts. They are defined in [[actions]] blocks:
[[actions]]name = "copy-diff"lua = '''local diff = jj("diff", "-r", context.change_id(), "--git")copy_to_clipboard(diff)'''Required fields: name (string), lua (string).
Binding a custom action to a key
Section titled “Binding a custom action to a key”[[bindings]]action = "copy-diff"key = "Y"scope = "revisions"desc = "copy diff to clipboard"Binding a built-in action
Section titled “Binding a built-in action”[[bindings]]action = "ui.open_revset"key = "L"scope = "revisions"desc = "revset"Binding fields
Section titled “Binding fields”| Field | Required | Description |
|---|---|---|
action | yes | Action ID (built-in or custom) |
scope | yes | Where the binding is active |
key | one of key/seq | Key or array of keys |
seq | one of key/seq | Multi-key sequence (min 2 keys) |
desc | no | Label shown in the status bar help |
args | no | Arguments passed to built-in actions |
key and seq accept a single string or an array:
key = "r"key = ["up", "k"] # both keys trigger the same actionScope reference
Section titled “Scope reference”The full list of scopes and available built-in actions is in:
internal/config/default/bindings.tomlCommon scopes: revisions, revisions.rebase, revisions.squash, revisions.details, oplog, ui, diff.
Replacing [keys] (key rebinding)
Section titled “Replacing [keys] (key rebinding)”The old [keys] table mapped action names to keys. Replace each entry with a [[bindings]] block referencing the equivalent built-in action and scope.
Before:
[keys]abandon = "x"After:
[[bindings]]action = "revisions.open_abandon"key = "x"scope = "revisions"Top-level [keys] mapping
Section titled “Top-level [keys] mapping”Old [keys] name | New action | Scope |
|---|---|---|
up | revisions.move_up | revisions |
down | revisions.move_down | revisions |
scroll_up | revisions.page_up | revisions |
scroll_down | revisions.page_down | revisions |
jump_to_parent | revisions.jump_to_parent | revisions |
jump_to_children | revisions.jump_to_children | revisions |
jump_to_working_copy | revisions.jump_to_working_copy | revisions |
apply | revisions.open_inline_describe | revisions |
cancel | ui.cancel | ui |
toggle_select | revisions.toggle_select | revisions |
new | revisions.new | revisions |
commit | revisions.commit | revisions |
refresh | revisions.refresh | revisions |
abandon | revisions.open_abandon | revisions |
diff | revisions.diff | revisions |
quit | ui.quit | ui |
expand_status | ui.expand_status | ui |
describe | revisions.describe | revisions |
edit | revisions.edit | revisions |
force_edit | revisions.force_edit | revisions |
diffedit | revisions.diff_edit | revisions |
absorb | revisions.absorb | revisions |
split | revisions.split | revisions |
split_parallel | revisions.split_parallel | revisions |
undo | ui.open_undo | revisions |
redo | ui.open_redo | revisions |
revset | revset.edit | revisions |
exec_jj | ui.exec_jj | revisions |
exec_shell | ui.exec_shell | revisions |
ace_jump | revisions.ace_jump | revisions |
quick_search | ui.quick_search | revisions |
quick_search_cycle | revisions.quick_search_next | revisions.quick_search |
quick_search_cycle_back | revisions.quick_search_prev | revisions.quick_search |
suspend | ui.suspend | ui |
set_parents | revisions.open_set_parents | revisions |
custom_commands | (removed) | — |
leader | (removed — use seq in [[bindings]]) | — |
Sub-section [keys.xxx] mapping
Section titled “Sub-section [keys.xxx] mapping”Each [keys.xxx] section maps to a corresponding scope. The mode key (the one that opens the view) moves to the parent scope.
| Old section | New scope | mode key → new action in revisions scope |
|---|---|---|
[keys.rebase] | revisions.rebase | revisions.open_rebase |
[keys.revert] | revisions.revert | revisions.open_revert |
[keys.duplicate] | revisions.duplicate | revisions.open_duplicate |
[keys.squash] | revisions.squash | revisions.open_squash |
[keys.details] | revisions.details | revisions.open_details |
[keys.evolog] | revisions.evolog | revisions.open_evolog |
[keys.preview] | ui.preview | ui.preview_toggle |
[keys.bookmark] | bookmarks | ui.open_bookmarks |
[keys.inline_describe] | revisions.inline_describe | revisions.open_inline_describe |
[keys.git] | git | ui.open_git |
[keys.oplog] | oplog | ui.open_oplog |
[keys.file_search] | file_search | ui.file_search_toggle |
[keys.diff_view] | diff | revisions.diff |
Within each sub-scope, the key names map directly to action names in that scope. For example:
# Old[keys.rebase]source = ["s"]after = ["a"]
# New[[bindings]]action = "revisions.rebase.set_source"key = "s"scope = "revisions.rebase"args.source = "source"
[[bindings]]action = "revisions.rebase.set_target"key = "a"scope = "revisions.rebase"args.target = "after"For the complete list of built-in actions per scope, see internal/config/default/bindings.toml in the source.
Replacing [leader] (key sequences)
Section titled “Replacing [leader] (key sequences)”Leader key sequences become seq bindings. The leader key itself (\ by default) becomes the first element of seq.
Simple send → built-in action
Section titled “Simple send → built-in action”When a leader entry just fires a single key that maps to a built-in, bind the built-in directly with seq:
Before:
[leader.gr]help = "Open revset"send = ["L"]After:
[[bindings]]action = "revset.edit"seq = ["g", "r"]scope = "revisions"desc = "open revset"send with : commands → Lua action
Section titled “send with : commands → Lua action”Leader entries that used send = [":", "jj-command $change_id", "enter"] to run jj commands become Lua actions. Replace $change_id with context.change_id() and call jj() directly:
Before:
[leader.na]help = "New After"send = [":", "new -A $change_id", "enter"]After:
[[actions]]name = "new after"lua = ''' jj("new", "-A", context.change_id()) revisions.refresh()'''
[[bindings]]action = "new after"seq = ["\\", "n", "a"]scope = "revisions"desc = "New After"The backslash "\\" in seq is the literal \ key — the old leader key. You can use any key as the sequence prefix; it does not need to be \.
The seq field takes an ordered array of keys. When the user presses the first key, jjui enters a pending state and waits for the remaining keys in the sequence.
Replacing [custom_commands]
Section titled “Replacing [custom_commands]”Simple jj command
Section titled “Simple jj command”Before:
[custom_commands."new after"]key = ["N"]args = ["new", "--after", "$change_id"]After:
[[actions]]name = "new-after"lua = '''jj_async("new", "--after", context.change_id())revisions.refresh()'''
[[bindings]]action = "new-after"key = "N"scope = "revisions"desc = "new after"Interactive command
Section titled “Interactive command”Before:
[custom_commands."resolve vscode"]key = ["R"]args = ["resolve", "--tool", "vscode"]show = "interactive"After:
[[actions]]name = "resolve-vscode"lua = '''jj_interactive("resolve", "--tool", "vscode")'''
[[bindings]]action = "resolve-vscode"key = "R"scope = "revisions"desc = "resolve in vscode"Revset command
Section titled “Revset command”Before:
[custom_commands."show descendants"]key = ["M"]revset = "::$change_id"After:
[[actions]]name = "show-descendants"lua = '''revset.set("::" .. context.change_id())'''
[[bindings]]action = "show-descendants"key = "M"scope = "revisions"desc = "show descendants"Lua custom command (unchanged)
Section titled “Lua custom command (unchanged)”Lua scripts in [custom_commands] migrate directly — the same Lua API is available.
Before:
[custom_commands."set-revset"]key = ["+"]lua = '''revset.set("bookmarks()")'''After:
[[actions]]name = "set-revset"lua = '''revset.set("bookmarks()")'''
[[bindings]]action = "set-revset"key = "+"scope = "revisions"desc = "set revset"config.lua — Programmatic Setup
Section titled “config.lua — Programmatic Setup”As an alternative to TOML, you can register actions and bindings from Lua in config.lua. Both files live in the same directory as config.toml:
~/.config/jjui/config.toml~/.config/jjui/config.lua
At startup, jjui loads config.lua and calls setup(config) if it is defined. The config parameter exposes two helpers:
config.action(name, fn, opts?)
Section titled “config.action(name, fn, opts?)”Registers a Lua action, optionally with an inline binding.
function setup(config) config.action("copy-diff", function() local diff = jj("diff", "-r", context.change_id(), "--git") copy_to_clipboard(diff) end, { key = "Y", scope = "revisions", desc = "copy diff", })endopts fields:
| Field | Description |
|---|---|
key | Key or array of keys (mutually exclusive with seq) |
seq | Sequence of keys (mutually exclusive with key) |
scope | Required when key or seq is set |
desc | Optional description for the status bar |
config.bind({...})
Section titled “config.bind({...})”Adds a binding for an action defined elsewhere (TOML or another config.action call).
function setup(config) config.bind({ action = "ui.open_revset", key = "R", scope = "revisions", desc = "revset", })endNote:
argsis not supported inconfig.bind. Use built-in action bindings with args inconfig.tomlinstead.
Plugins via require
Section titled “Plugins via require”config.lua can load modules from the config directory using require. The search paths are:
<config_dir>/?.lua
~/.config/jjui/plugins/my_plugin.lua:
local M = {}
function M.setup(config) config.action("my-action", function() flash("hello from plugin") end, { key = "H", scope = "revisions", desc = "my action", })end
return M~/.config/jjui/config.lua:
local my_plugin = require("plugins.my_plugin")
function setup(config) my_plugin.setup(config)endSharing Lua Helpers Between TOML and config.lua
Section titled “Sharing Lua Helpers Between TOML and config.lua”config.lua and [[actions]].lua scripts run in the same Lua VM. Global functions defined in config.lua are available to TOML action scripts.
config.lua:
function format_diff(change_id) local out, err = jj("diff", "-r", change_id, "--git") if err then return nil, err end return out, nilendconfig.toml:
[[actions]]name = "copy-diff"lua = '''local diff, err = format_diff(context.change_id())if err then flash({ text = err, error = true })else copy_to_clipboard(diff)end'''Changed Lua API
Section titled “Changed Lua API”Three revisions.start_* functions were renamed to revisions.open_*, and their return value was removed. Previously they returned a boolean indicating whether the operation was accepted or dismissed. Now they return nothing — use wait_close() or wait_refresh() to sequence work after them.
| Old | New |
|---|---|
revisions.start_rebase() | revisions.open_rebase() |
revisions.start_squash() | revisions.open_squash() |
revisions.start_inline_describe() | revisions.open_inline_describe() |
Before:
if revisions.start_inline_describe() then -- user acceptedendAfter:
revisions.open_inline_describe()if wait_close() then -- user acceptedendwait_close() returns true if the user accepted the operation, false if they dismissed it. Use wait_refresh() instead if you only need to wait for the UI to finish updating before continuing.
Lua API Reference
Section titled “Lua API Reference”For the full Lua API reference, see Lua Scripting.
Alternate Base Bindings
Section titled “Alternate Base Bindings”bindings_profile lets you replace the built-in default bindings entirely. Set it to a path (relative to config dir or absolute) pointing to a bindings TOML file:
bindings_profile = "vim_bindings.toml"Your [[bindings]] overlays are then applied on top of the profile instead of the built-in defaults. Use :builtin to explicitly restore the built-in defaults.