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 [custom_commands] or [keys] in your config, run:
jjui --config --migrateThis converts them automatically. Then you’re done.
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.
Automated Migration
Section titled “Automated Migration”Run the migration command to convert [custom_commands] and [keys] automatically:
jjui --config --migrateWhat it does:
- Creates
config.old.tomlas a backup (only on first run). - Converts each
[custom_commands]entry to an[[actions]]+[[bindings]]block. - Converts
[keys]entries to matching[[bindings]]blocks. - Removes the legacy sections from
config.toml.
Limitations of migration:
[leader]entries are removed but not converted — rewrite them asseqbindings manually (see below).- Commands with
show = "diff"are skipped with a warning — the output-in-diff-viewer feature is not supported in the new system.
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.
Before:
[keys]abandon = "x"After:
[[bindings]]action = "revisions.abandon"key = "x"scope = "revisions"Replacing [leader] (key sequences)
Section titled “Replacing [leader] (key sequences)”Leader key sequences become seq bindings.
Before:
[leader.gr]help = "Open revset"send = ["L"]After:
[[bindings]]action = "ui.open_revset"seq = ["g", "r"]scope = "revisions"desc = "open revset"The seq field takes an ordered array of keys. When the user presses the first key, jjui enters a pending state and waits for subsequent keys.
Replacing [custom_commands]
Section titled “Replacing [custom_commands]”Simple jj command
Section titled “Simple jj command”Before:
[custom_commands."show diff"]key = ["U"]args = ["diff", "-r", "$change_id", "--git"]After:
[[actions]]name = "show-diff"lua = '''jj_async("diff", "-r", context.change_id(), "--git")'''
[[bindings]]action = "show-diff"key = "U"scope = "revisions"desc = "show diff"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'''Lua API Reference
Section titled “Lua API Reference”All APIs are available as top-level globals and also under jjui.*.
Command helpers
Section titled “Command helpers”| Function | Description |
|---|---|
jj(...) | Run jj command immediately, returns (output, err) |
jj_async(...) | Run jj command asynchronously |
jj_interactive(...) | Run interactive jj command (like diffedit) |
exec_shell(command) | Run a shell command |
UI helpers
Section titled “UI helpers”| Function | Description |
|---|---|
flash("text") | Show a flash message |
flash({ text, error, sticky }) | Show a flash message with options |
copy_to_clipboard(text) | Copy text to clipboard, returns (ok, err) |
split_lines(text, keep_empty?) | Split a string by newlines into an array |
choose(options) | Show a selection menu, returns the chosen string or nil |
choose({ options, title }) | Show a selection menu with a title |
input({ title, prompt }) | Show a text input, returns the entered string or nil |
Context helpers
Section titled “Context helpers”Available as context.*:
| Function | Description |
|---|---|
context.change_id() | Currently selected change ID |
context.commit_id() | Currently selected commit ID |
context.file() | Currently selected file (in details view) |
context.operation_id() | Currently selected operation ID (in oplog) |
context.checked_files() | Array of checked file paths |
context.checked_change_ids() | Array of checked change IDs |
context.checked_commit_ids() | Array of checked commit IDs |
Revisions helpers
Section titled “Revisions helpers”Available as revisions.*:
| Function | Description |
|---|---|
revisions.current() | Current change ID |
revisions.checked() | Array of checked change IDs |
revisions.refresh({ keep_selections, selected_revision }) | Refresh the revision list |
revisions.navigate({ by, page, target, to, fallback, ensureView, allowStream }) | Move cursor |
revisions.start_squash({ files }) | Start squash operation |
revisions.start_rebase({ source, target }) | Start rebase operation |
revisions.open_details() | Open details view |
revisions.start_inline_describe() | Start inline describe |
Revset helpers
Section titled “Revset helpers”Available as revset.*:
| Function | Description |
|---|---|
revset.set(value) | Set the active revset |
revset.reset() | Reset to the default revset |
revset.current() | Return the current revset string |
revset.default() | Return the default revset string |
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.