Currently, jjui relies on tea.KeyMsg handling scattered across individual models. Each model has its own key matching logic in Update(), which means:
- Adding or changing a keybinding requires modifying Go code
- Users cannot customise bindings without forking the project
- Key handling is duplicated and inconsistent across models
- There's no single place to see what keys do what (e.g. command palette)
Over time we've added several customisation mechanisms — [custom_commands] for user-defined shell/Lua commands, [leader] for multi-key sequences, and [keys] for basic rebinding. These work but they're separate systems with different config formats, different capabilities, and different limitations. This new system would unify all of them into a single [[actions]] + [[bindings]] model.
I've been introducing intents as a way to decouple input from behaviour. Instead of models reacting to raw key presses, they respond to typed intent messages like Navigate{Delta: -1} or Apply{Force: true}. This is already partially done — most models now handle intents alongside legacy KeyMsg matching.
The next step is to close the loop: introduce a central dispatcher that converts key presses into actions, resolves those actions into intents, and dispatches them to the appropriate model. This would make keybindings fully user-configurable via a TOML config file.
Proposal
The current [keys] config section is a flat key-to-function map. I'd replace it with two separate concepts: actions (e.g. named operations like "revisions.move_up", "rebase.apply", "rebase.set_target") and bindings (mappings from keys to actions, scoped to specific UI contexts). Since bindings can carry arguments, the same action can be bound to multiple keys with different behaviour. For example, the rebase.apply action takes a force argument — we could bind it to enter with force = false and to alt+enter with force = true.
Each binding would live in a scope, so the same key can do different things depending on the state of the app. For example, j would move down in the revision list when in the revisions scope, but move the selected file down when the revisions.details scope is active. A central dispatcher would resolve key presses against the current scope and fire the matching action.
The bulk of the migration work is getting models to stop handling tea.KeyMsg directly and only respond to intents. The tea.KeyMsg case in Update() methods should be removed everywhere except true text-input widgets.
I am thinking of using code generation for creating the giant action-to-intent switch. We can annotate intent types with directives and use code generation to produce the giant action to intent resolution function. This keeps the intent definitions as the single source of truth. The generator would parse annotations on intent structs, type-check field assignments, and emit a ResolveIntent(owner, action, args) function.
These are roughly what I think the config would look like, not set in stone.
Default bindings
[[bindings]]
action = "move_up"
key = ["k", "up"]
scope = "revisions"
[[bindings]]
action = "open_git"
seq = ["g", "p"]
scope = "revisions"
[[bindings]]
action = "rebase.apply"
key = "enter"
scope = "revisions.rebase"
[[bindings]]
action = "rebase.apply"
key = "alt+enter"
scope = "revisions.rebase"
args = { force = true }
User-defined Lua action:
[[actions]]
name = "my_workflow"
desc = "Run my custom workflow"
lua = """
jj("bookmark", "set", "-r", "@", "wip")
jj("git", "push", "--bookmark", "wip")
"""
[[bindings]]
action = "my_workflow"
seq = ["w", "p"]
scope = "revisions"
Currently, jjui relies on
tea.KeyMsghandling scattered across individual models. Each model has its own key matching logic inUpdate(), which means:Over time we've added several customisation mechanisms —
[custom_commands]for user-defined shell/Lua commands,[leader]for multi-key sequences, and[keys]for basic rebinding. These work but they're separate systems with different config formats, different capabilities, and different limitations. This new system would unify all of them into a single[[actions]]+[[bindings]]model.I've been introducing intents as a way to decouple input from behaviour. Instead of models reacting to raw key presses, they respond to typed intent messages like
Navigate{Delta: -1}orApply{Force: true}. This is already partially done — most models now handle intents alongside legacyKeyMsgmatching.The next step is to close the loop: introduce a central dispatcher that converts key presses into actions, resolves those actions into intents, and dispatches them to the appropriate model. This would make keybindings fully user-configurable via a TOML config file.
Proposal
The current
[keys]config section is a flat key-to-function map. I'd replace it with two separate concepts: actions (e.g. named operations like"revisions.move_up","rebase.apply","rebase.set_target") and bindings (mappings from keys to actions, scoped to specific UI contexts). Since bindings can carry arguments, the same action can be bound to multiple keys with different behaviour. For example, therebase.applyaction takes aforceargument — we could bind it toenterwithforce = falseand toalt+enterwithforce = true.Each binding would live in a scope, so the same key can do different things depending on the state of the app. For example, j would move down in the revision list when in the revisions scope, but move the selected file down when the revisions.details scope is active. A central dispatcher would resolve key presses against the current scope and fire the matching action.
The bulk of the migration work is getting models to stop handling
tea.KeyMsgdirectly and only respond to intents. Thetea.KeyMsgcase inUpdate()methods should be removed everywhere except true text-input widgets.I am thinking of using code generation for creating the giant action-to-intent switch. We can annotate intent types with directives and use code generation to produce the giant action to intent resolution function. This keeps the intent definitions as the single source of truth. The generator would parse annotations on intent structs, type-check field assignments, and emit a
ResolveIntent(owner, action, args)function.These are roughly what I think the config would look like, not set in stone.
Default bindings
User-defined Lua action: