Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.


TL;DR

If you have [custom_commands] or [keys] in your config, run:

jjui --config --migrate

This 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

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.


Automated Migration

Run the migration command to convert [custom_commands] and [keys] automatically:

jjui --config --migrate

What it does:

  1. Creates config.old.toml as a backup (only on first run).
  2. Converts each [custom_commands] entry to an [[actions]] + [[bindings]] block.
  3. Converts [keys] entries to matching [[bindings]] blocks.
  4. Removes the legacy sections from config.toml.

Limitations of migration:

  • [leader] entries are removed but not converted — rewrite them as seq bindings 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

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

[[bindings]]
action = "copy-diff"
key = "Y"
scope = "revisions"
desc = "copy diff to clipboard"

Binding a built-in action

[[bindings]]
action = "ui.open_revset"
key = "L"
scope = "revisions"
desc = "revset"

Binding fields

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

Scope reference

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.


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)

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]

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

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

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)

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

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

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

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

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


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

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

Lua API Reference

All APIs are available as top-level globals and also under jjui.*.

Command helpers

FunctionDescription
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

FunctionDescription
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

Available as context.*:

FunctionDescription
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

Available as revisions.*:

FunctionDescription
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

Available as revset.*:

FunctionDescription
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

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.