Skip to content

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.


OldReplacement
[custom_commands][[actions]] + [[bindings]]
[leader] + leader key sequencesseq = [...] 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.


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).

[[bindings]]
action = "copy-diff"
key = "Y"
scope = "revisions"
desc = "copy diff to clipboard"
[[bindings]]
action = "ui.open_revset"
key = "L"
scope = "revisions"
desc = "revset"
FieldRequiredDescription
actionyesAction ID (built-in or custom)
scopeyesWhere the binding is active
keyone of key/seqKey or array of keys
seqone of key/seqMulti-key sequence (min 2 keys)
descnoLabel shown in the status bar help
argsnoArguments 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 action

The full list of scopes and available built-in actions is in:

internal/config/default/bindings.toml

Common scopes: revisions, revisions.rebase, revisions.squash, revisions.details, oplog, ui, diff.


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"
Old [keys] nameNew actionScope
uprevisions.move_uprevisions
downrevisions.move_downrevisions
scroll_uprevisions.page_uprevisions
scroll_downrevisions.page_downrevisions
jump_to_parentrevisions.jump_to_parentrevisions
jump_to_childrenrevisions.jump_to_childrenrevisions
jump_to_working_copyrevisions.jump_to_working_copyrevisions
applyrevisions.open_inline_describerevisions
cancelui.cancelui
toggle_selectrevisions.toggle_selectrevisions
newrevisions.newrevisions
commitrevisions.commitrevisions
refreshrevisions.refreshrevisions
abandonrevisions.open_abandonrevisions
diffrevisions.diffrevisions
quitui.quitui
expand_statusui.expand_statusui
describerevisions.describerevisions
editrevisions.editrevisions
force_editrevisions.force_editrevisions
diffeditrevisions.diff_editrevisions
absorbrevisions.absorbrevisions
splitrevisions.splitrevisions
split_parallelrevisions.split_parallelrevisions
undoui.open_undorevisions
redoui.open_redorevisions
revsetrevset.editrevisions
exec_jjui.exec_jjrevisions
exec_shellui.exec_shellrevisions
ace_jumprevisions.ace_jumprevisions
quick_searchui.quick_searchrevisions
quick_search_cyclerevisions.quick_search_nextrevisions.quick_search
quick_search_cycle_backrevisions.quick_search_prevrevisions.quick_search
suspendui.suspendui
set_parentsrevisions.open_set_parentsrevisions
custom_commands(removed)
leader(removed — use seq in [[bindings]])

Each [keys.xxx] section maps to a corresponding scope. The mode key (the one that opens the view) moves to the parent scope.

Old sectionNew scopemode key → new action in revisions scope
[keys.rebase]revisions.rebaserevisions.open_rebase
[keys.revert]revisions.revertrevisions.open_revert
[keys.duplicate]revisions.duplicaterevisions.open_duplicate
[keys.squash]revisions.squashrevisions.open_squash
[keys.details]revisions.detailsrevisions.open_details
[keys.evolog]revisions.evologrevisions.open_evolog
[keys.preview]ui.previewui.preview_toggle
[keys.bookmark]bookmarksui.open_bookmarks
[keys.inline_describe]revisions.inline_describerevisions.open_inline_describe
[keys.git]gitui.open_git
[keys.oplog]oplogui.open_oplog
[keys.file_search]file_searchui.file_search_toggle
[keys.diff_view]diffrevisions.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.


Leader key sequences become seq bindings. The leader key itself (\ by default) becomes the first element of seq.

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"

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.


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"

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"

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 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"

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:

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",
})
end

opts fields:

FieldDescription
keyKey or array of keys (mutually exclusive with seq)
seqSequence of keys (mutually exclusive with key)
scopeRequired when key or seq is set
descOptional description for the status bar

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",
})
end

Note: args is not supported in config.bind. Use built-in action bindings with args in config.toml instead.


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)
end

Sharing 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, nil
end

config.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
'''

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.

OldNew
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 accepted
end

After:

revisions.open_inline_describe()
if wait_close() then
-- user accepted
end

wait_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.


For the full Lua API reference, see Lua Scripting.


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.

Contribute Community