feat(terminal): add configurable cursor render delay for terminal buffers

Add cursor position restoration with configurable timing to handle terminal
     buffer rendering asynchronously. This fixes cursor column being reset to 0
     when refreshing jj log commands.

     Changes:
     - Add `terminal.cursor_render_delay` configuration option (default: 10ms)
     - Add buffer.get_cursor() and buffer.set_cursor() helpers in core/buffer
       * buffer.set_cursor() uses defer_fn with configurable delay for terminal buffers
       * Automatic position validation with clamping to buffer bounds
       * Line number clamped to [1, line_count]
       * Column clamped to [0, line_length] based on actual line content
       * Validation happens inside deferred callback to check against final rendered content
     - Extract clamp_cursor_position() helper to eliminate code duplication
     - Refactor terminal module to use new buffer cursor helpers
     - Store and restore cursor position when cycling through log commands
     - Update README with terminal configuration section and example usage

     The delay is necessary because nvim_open_term() + nvim_chan_send() have
     asynchronous rendering in the terminal emulator layer. Setting the cursor
     before rendering completes results in the column being reset to 0. Users
     experiencing issues can increase the delay value if needed.
This commit is contained in:
NicolasGB
2025-11-25 09:10:12 +01:00
committed by Nicolas GB
parent 0cd1f554a6
commit 7329e89a85
6 changed files with 146 additions and 12 deletions
+50 -5
View File
@@ -1,8 +1,17 @@
--- @class jj.ui.terminal
local M = {}
--- Terminal configuration
--- @class jj.ui.terminal.opts
--- @field cursor_render_delay integer The delay in ms when cursor rerendering the terminal state (default: 10ms). If you're loosing the column of the cursor try adding more delay. I currently did not find a better way to do so due to async handling of the ouptut in the terminal
local buffer = require("jj.core.buffer")
--- @type jj.ui.terminal.opts
local opts = {
cursor_render_delay = 10,
}
--- @class jj.ui.terminal.state
local state = {
-- The current terminal buffer for jj commands
@@ -27,11 +36,20 @@ local state = {
--- The floating job id for the terminal buffer
--- @type integer|nil
floating_job_id = nil,
-- Cursor position
cursor_restore_pos = nil,
}
-- Re-export
M.state = state
--- Setup function to configure terminal options
--- @param user_opts jj.ui.terminal.opts Configuration options
function M.setup(user_opts)
opts = vim.tbl_deep_extend("force", opts, user_opts or {})
end
--- Close the current terminal buffer if it exists
function M.close_terminal_buffer()
buffer.close(state.buf)
@@ -51,6 +69,27 @@ function M.hide_floating_buffer()
end
end
--- Store the current cursor position, the terminal will restore it on the next render
function M.store_cursor_position()
if state.buf then
state.cursor_restore_pos = buffer.get_cursor(state.buf)
end
end
--- Restore the stored cursor position
function M.restore_cursor_position()
if not state.cursor_restore_pos or not state.buf then
return
end
buffer.set_cursor(
state.buf,
state.cursor_restore_pos,
{ delay = opts.cursor_render_delay and opts.cursor_render_delay or 10 }
)
state.cursor_restore_pos = nil
end
--- Run the command in a floating window
--- @param cmd string The command to run in the floating window
--- @param keymaps jj.core.buffer.keymap[]|nil Additional keymaps to set for this floating buffer
@@ -178,6 +217,7 @@ end
--- If a previous command already existed it smartly reuses the buffer cleaning the previous output
--- @param cmd string|string[] The command to run in the terminal buffer
--- @param keymaps jj.core.buffer.keymap[]|nil Additional keymaps to set for this command buffer
--- @return integer|nil buf The buffer handle, or nil on failure
function M.run(cmd, keymaps)
if type(cmd) == "string" then
cmd = { cmd }
@@ -241,7 +281,7 @@ function M.run(cmd, keymaps)
state.chan = chan
-- Move cursor to top before output arrives
vim.api.nvim_win_set_cursor(win, { 1, 0 })
-- vim.api.nvim_win_set_cursor(win, { 1, 0 })
-- If the command is a string split it into parts
-- to store the subcommand later
@@ -269,13 +309,17 @@ function M.run(cmd, keymaps)
end,
on_exit = function(_, exit_code)
vim.schedule(function()
-- Make the buffer not modifiable
buffer.set_modifiable(state.buf, false)
buffer.stop_insert(state.buf)
-- Store the subcommand on successful exit
if exit_code == 0 then
state.buf_cmd = cmd[2] or nil
end
-- Make the buffer not modifiable
buffer.set_modifiable(state.buf, false)
buffer.stop_insert(state.buf)
-- Restore cursor position after buffer is ready
if state.cursor_restore_pos then
M.restore_cursor_position()
end
end)
end,
})
@@ -326,7 +370,8 @@ function M.run(cmd, keymaps)
end
vim.cmd("stopinsert")
return state.buf
end
return M