Skip to content

feat: Lua scripting in custom commands#415

Merged
idursun merged 8 commits intomainfrom
exp/lua-scripting
Dec 10, 2025
Merged

feat: Lua scripting in custom commands#415
idursun merged 8 commits intomainfrom
exp/lua-scripting

Conversation

@idursun
Copy link
Copy Markdown
Owner

@idursun idursun commented Dec 8, 2025

Warning

This is very much experimental.

This PR introduces the concept of intents, which is my attempt to decouple capabilities from key bindings. This way input layer (i.e. keyboard, mouse, macros) translate user inputs to intents and only intents update the models.

I have implemented this only for the revisions, revset and flash models for now. I have consolidated all navigation operations into a single Navigate intent which can take various arguments for different kind of navigations/jumps.

Additionally, I implemented a simple Lua script runner and a bridge for intents defined for the revisions, revset and flash models.

The following lua bridge defines the following functions:

  • revisions.current() → returns currently selected change ID (string) if a revision is selected.
  • revisions.checked() → returns a list of checked change IDs.
  • revisions.refresh({keep_selections?, selected_revision?}) → refreshes revisions
  • revisions.navigate({by?, page?, target?, to?, fallback?, ensureView?, allowStream?}) → moves selection; supports delta navigation, page jumps, explicit targets (parent, child, working, etc.), and optional view/stream flags.
  • revisions.start_squash({files?}) → begins an inline squash workflow for the given files.
  • revisions.start_rebase({source?, target?}) → starts a rebase with parsed source/target strings (branch, descendants, after, before, etc.).
  • revisions.open_details() → opens the revision details view.
  • revisions.start_inline_describe() → opens inline describe input for the selected revision.
  • revset.set(value) → sets the current revset string.
  • revset.reset() → resets revset to default.
  • revset.current() → returns the active revset string.
  • revset.default() → returns the default revset string.
  • jj_async({...} | args...) → runs a JJ command asynchronously; yields until completion.
  • jj({...} | args...) → runs a JJ command immediately; returns (output, nil) on success or (nil, err) on failure.
  • flash(message) → enqueues a transient UI flash message.
  • copy_to_clipboard(text) → copies text to the system clipboard; returns (true, nil) on success or (nil, err) on failure.

All functions are available under the jjui table (e.g., jjui.revisions.refresh) and also exported at the global Lua scope for convenience (e.g., revisions.refresh).

I haven't fully tested anything but so far the following custom commands seem to work fine:

  • Appends | ancestors(<change id of the current revisions>, 2) to the end of revset and bumps the number with each execution
[custom_commands.append_to_revset]
  key = ["+"]
  lua = '''
  local change_id = revisions.current()
  if not change_id then return end

  local current = revset.current()
  local bumped = false
  local updated = current:gsub("ancestors%(" .. change_id .. "%s*,%s*(%d+)%)", function(n)
    bumped = true
    return "ancestors(" .. change_id .. ", " .. (tonumber(n) + 1) .. ")"
  end, 1)

  if not bumped then
    updated = current .. "| ancestors(" .. change_id .. ", 2)"
  end

  revset.set(updated)
 '''
  • Inserts a new commit after the selected one and then starts inline describe on the new revision.
[custom_commands.new_then_describe]
  key = ["N"]
  lua = '''
  jj("new", "-A", revisions.current())
  revisions.refresh()

  local new_change_id = jj("log", "-r", "@", "-T", "change_id.shortest()", "--no-graph")
  revisions.navigate{to=new_change_id}
  revisions.start_inline_describe()
  '''
  • moves selection to main
[custom_commands.navigate_to_main]
  key = ["O"]
  lua = '''
  revisions.navigate{to=jj("log", "-r", "main", "-T", "change_id.shortest()", "--no-graph")}
  '''
  • a copy to clipboard example
[custom_commands.copy_to_clipboard]
  key = ["X"]
  lua = '''
  local selections = revisions.checked()
  if #selections == 0 then
    flash("none selected")
  end
  local content = table.concat(selections, ",")
  copy_to_clipboard(content)
  '''
  • a loop example
[custom_commands.loop]
  key = ["Y"]
  lua = '''
  local values = { "hello", "world" }
  for i = 1, #values do
    flash(values[i])
  end
  '''

@baggiiiie
Copy link
Copy Markdown
Collaborator

gosh this is looking great, amazing work!

Currently, every functionality is tied to a key bindings but ideally input layer (i.e. keyboard and mouse) should only map the input to an intent an intent (a.k.a action) so that we have better separation. 

This should make testing easier and potentially unlock lua scripting.
@idursun idursun marked this pull request as ready for review December 10, 2025 00:40
@idursun
Copy link
Copy Markdown
Owner Author

idursun commented Dec 10, 2025

I am going to merge this now as this is mostly refactoring work and Lua script is more like an addition to the existing behaviour without any breaking changes.

@idursun idursun merged commit 1d8437b into main Dec 10, 2025
3 checks passed
@idursun idursun deleted the exp/lua-scripting branch December 10, 2025 09:57
tmeijn pushed a commit to tmeijn/dotfiles that referenced this pull request Dec 12, 2025
This MR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [idursun/jjui](https://github.com/idursun/jjui) | patch | `v0.9.7` -> `v0.9.8` |

MR created with the help of [el-capitano/tools/renovate-bot](https://gitlab.com/el-capitano/tools/renovate-bot).

**Proposed changes to behavior should be submitted there as MRs.**

---

### Release Notes

<details>
<summary>idursun/jjui (idursun/jjui)</summary>

### [`v0.9.8`](https://github.com/idursun/jjui/releases/tag/v0.9.8)

[Compare Source](idursun/jjui@v0.9.7...v0.9.8)

#### Release Summary

This release includes experimental Lua scripting support for custom commands, several bug fixes, and UI improvements. The streaming command handler has been reworked to remove the 100ms delay incurred on every refresh (you should feel the difference), and issues with leader keys, parser colour handling, and preview panel focus have been resolved.

##### Key Highlights

**🚀 Major Features**

- **Lua Scripting in Custom Commands ([#&#8203;415](idursun/jjui#415: Experimental support for writing custom commands using Lua scripts.

Initial version includes API for revision navigation, JJ command execution, clipboard operations, revset manipulation and displaying flash messages.

These are the currently available functions but expect the list to grow and change with each release.

**Available Functions (v1)**:

- `revisions.current()` - Get currently selected change ID
- `revisions.checked()` - Get list of checked change IDs
- `revisions.refresh({keep_selections?, selected_revision?})` - Refresh revisions view
- `revisions.navigate({by?, page?, target?, to?, fallback?, ensureView?, allowStream?})` - Navigate revisions
- `revisions.start_squash({files?})` - Begin squash workflow
- `revisions.start_rebase({source?, target?})` - Start rebase operation
- `revisions.open_details()` - Open revision details view
- `revisions.start_inline_describe()` - Open inline describe editor
- `revset.set(value)` - Set custom revset
- `revset.reset()` - Reset to default revset
- `revset.current()` - Get active revset string
- `revset.default()` - Get default revset string
- `jj_async({...})` - Run JJ command asynchronously
- `jj({...})` - Run JJ command synchronously (returns output, err)
- `flash(message)` - Display flash message
- `copy_to_clipboard(text)` - Copy text to clipboard

Here are a couple of examples:

- Appends `| ancestors(<change id of the current revisions>, 2)` to the end of revset and bumps the number with each execution

```toml
[custom_commands.append_to_revset]
  key = ["+"]
  lua = '''
  local change_id = revisions.current()
  if not change_id then return end

  local current = revset.current()
  local bumped = false
  local updated = current:gsub("ancestors%(" .. change_id .. "%s*,%s*(%d+)%)", function(n)
    bumped = true
    return "ancestors(" .. change_id .. ", " .. (tonumber(n) + 1) .. ")"
  end, 1)

  if not bumped then
    updated = current .. "| ancestors(" .. change_id .. ", 2)"
  end

  revset.set(updated)
 '''
```

- Inserts a new commit after the selected one and then starts inline describe on the new revision.

```toml
[custom_commands.new_then_describe]
  key = ["N"]
  lua = '''
  jj("new", "-A", revisions.current())
  revisions.refresh()

  local new_change_id = jj("log", "-r", "@&#8203;", "-T", "change_id.shortest()", "--no-graph")
  revisions.navigate{to=new_change_id}
  revisions.start_inline_describe()
  '''
```

- Copy to clipboard example

```toml
[custom_commands.copy_to_clipboard]
  key = ["X"]
  lua = '''
  local selections = revisions.checked()
  if #selections == 0 then
    flash("none selected")
  end
  local content = table.concat(selections, ",")
  copy_to_clipboard(content)
  '''
```

**✨ Enhancements**

- **Key Sequences for Custom Commands ([#&#8203;420](idursun/jjui#420: Custom commands can now be triggered with multi-key sequences using `key_sequence` property. Also adds `desc` property for command descriptions. An overlay shows available sequences after pressing the first key.

  Example:

  ```toml
  [custom_commands.bookmark_list]
    key_sequence = ["w", "b", "l"]
    desc = "bookmarks list"
    lua = '''
    revset.set("bookmarks() | remote_bookmarks()")
    '''
  ```

- **Faster Refresh ([#&#8203;412](idursun/jjui#412: Improved streaming command handling, eliminating 100ms delay and making refreshes instant. Previously jjui would fail to launch or get stuck when jj emitted warning messages (e.g., deprecated config options like `git.push-new-bookmarks`).

- **Quick Search Highlighting ([#&#8203;414](idursun/jjui#414: Case-insensitive search with visual highlighting of all matches in the revisions view

- **Remember Unsaved Descriptions ([#&#8203;417](idursun/jjui#417: Descriptions are now preserved when you cancel, preventing accidental loss of work. Addresses the common frustration of accidentally hitting ESC and losing long commit messages with no way to recover them.

- **Squash Operation Toggle ([#&#8203;405](idursun/jjui#405: New `--use-destination-message` option for squash operations

**🐛 Bug Fixes**

- **Preview Panel Focus Issue ([#&#8203;390](idursun/jjui#390: Fixed preview panel showing full commit diff instead of selected file diff when terminal regains focus
- **EOF Error Handling ([#&#8203;418](idursun/jjui#418: Proper error messages when revset contains no revisions instead of getting stuck
- **Parser Color Agnostic ([#&#8203;413](idursun/jjui#413: Fixed parsing issues when users configure ChangeID/CommitID/IsDivergent with same colors.
- **Leader Key Timing ([#&#8203;416](idursun/jjui#416: Fixed leader key processing to prevent race conditions. Leader keys were completely non-functional in versions after v0.9.3 - the options would appear in the UI but do nothing when selected.

**🎨 UI/UX Improvements**

- Clear selected revisions with ESC key when not in editing/overlay/focused operations ([#&#8203;419](idursun/jjui#419))
- Better menu spacing for git and bookmarks
- Reduced preview debounce time back to 50ms for snappier response ([#&#8203;410](idursun/jjui#410)). The 200ms debounce made the UI feel sluggish when navigating between revisions.

**⚙️ Internal Improvements**

- Introduced intent-based architecture for better separation of concerns (only implemented for revisions, flash for now)
- Moved flash intents to dedicated package
- Simplified details view rendering
- Better configuration organisation

#### What's Changed

- operation: Add use destination message to squash operation by [@&#8203;woutersmeenk](https://github.com/woutersmeenk) in [#&#8203;405](idursun/jjui#405)
- Preview panel shows whole commit diff instead of selected file's diff when terminal regains focus by [@&#8203;abourget](https://github.com/abourget) in [#&#8203;390](idursun/jjui#390)
- fix(streamer): handle warning messages by [@&#8203;idursun](https://github.com/idursun) in [#&#8203;412](idursun/jjui#412)
- parser: stringify log/evolog prefixes to be color agnostic by [@&#8203;baggiiiie](https://github.com/baggiiiie) in [#&#8203;413](idursun/jjui#413)
- revisions: add highlight to QuickSearch, make search case insensitive by [@&#8203;baggiiiie](https://github.com/baggiiiie) in [#&#8203;414](idursun/jjui#414)
- feat: Lua scripting in custom commands by [@&#8203;idursun](https://github.com/idursun) in [#&#8203;415](idursun/jjui#415)
- revisions: handle EOF error for revset without revisions by [@&#8203;baggiiiie](https://github.com/baggiiiie) in [#&#8203;418](idursun/jjui#418)
- revisions: clear selected revisions on cancel by [@&#8203;baggiiiie](https://github.com/baggiiiie) in [#&#8203;419](idursun/jjui#419)
- feat: custom commands with sequence keys by [@&#8203;idursun](https://github.com/idursun) in [#&#8203;420](idursun/jjui#420)

#### New Contributors

- [@&#8203;woutersmeenk](https://github.com/woutersmeenk) made their first contribution in [#&#8203;405](idursun/jjui#405)
- [@&#8203;abourget](https://github.com/abourget) made their first contribution in [#&#8203;390](idursun/jjui#390)

**Full Changelog**: <idursun/jjui@v0.9.7...v0.9.8>

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever MR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this MR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this MR, check this box

---

This MR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi40Ny4wIiwidXBkYXRlZEluVmVyIjoiNDIuNDcuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsiUmVub3ZhdGUgQm90Il19-->
@abourget
Copy link
Copy Markdown
Contributor

Do you plan to expose some elements of the details view? Like the selected files (absolute, relative), the position of the preview pane.. I'd like to have something that jumps into my editor at the exact same location as the preview pane :)

@idursun
Copy link
Copy Markdown
Owner Author

idursun commented Dec 15, 2025

@abourget That's the plan. I will add those gradually.

@idursun idursun mentioned this pull request Jan 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants