From 7329e89a85eca4849c79957525e9ab71b5ee8873 Mon Sep 17 00:00:00 2001 From: NicolasGB Date: Mon, 24 Nov 2025 09:21:37 +0100 Subject: [PATCH] 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. --- README.md | 11 ++++++ lua/jj/cmd/describe.lua | 3 -- lua/jj/cmd/log.lua | 11 ++++-- lua/jj/core/buffer.lua | 74 ++++++++++++++++++++++++++++++++++++++++- lua/jj/init.lua | 4 +++ lua/jj/ui/terminal.lua | 55 +++++++++++++++++++++++++++--- 6 files changed, 146 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dcaa855..ff31554 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,14 @@ The plugin also provides `:Jdiff`, `:Jvdiff`, and `:Jhdiff` commands for diffing renamed = { fg = "#d29922", ctermfg = "Yellow" }, -- Renamed files }, + -- Configure terminal behavior + terminal = { + -- Cursor render delay in milliseconds (default: 10) + -- If cursor column is being reset to 0 when refreshing commands, try increasing this value + -- This delay allows the terminal emulator to complete rendering before restoring cursor position + cursor_render_delay = 10, + }, + -- Configure cmd module (describe editor, keymaps) cmd = { -- Configure describe editor @@ -305,6 +313,9 @@ jj.diff.hsplit({ rev = "@-2" }) -- Horizontal split against @-2 config = function() local jj = require("jj") jj.setup({ + terminal = { + cursor_render_delay = 10, -- Adjust if cursor position isn't restoring correctly + }, cmd = { describe = { editor = { diff --git a/lua/jj/cmd/describe.lua b/lua/jj/cmd/describe.lua index e908d32..4f26fbf 100644 --- a/lua/jj/cmd/describe.lua +++ b/lua/jj/cmd/describe.lua @@ -124,10 +124,7 @@ function M.describe(description, revset, opts) end, function() if open_log_on_close then vim.schedule(function() - vim.o.lazyredraw = true cmd.log({}) - vim.o.lazyredraw = false - vim.cmd("redraw!") end) end end, describe_editor_keymaps()) diff --git a/lua/jj/cmd/log.lua b/lua/jj/cmd/log.lua index 9d31e41..02f7373 100644 --- a/lua/jj/cmd/log.lua +++ b/lua/jj/cmd/log.lua @@ -15,7 +15,7 @@ local terminal = require("jj.ui.terminal") --- @field raw_flags? string ---@type jj.cmd.log_opts -local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20, raw_flats = nil } +local default_log_opts = { summary = false, reversed = false, no_graph = false, limit = 20, raw_flags = nil } --- Jujutsu log --- @param opts? jj.cmd.log_opts Optional command options @@ -24,12 +24,17 @@ function M.log(opts) return end + -- If a log was already being displayed before this command we will want to maintain the cursor position + if terminal.state.buf_cmd == "log" then + terminal.store_cursor_position() + end + local jj_cmd = "jj log" local merged_opts = vim.tbl_extend("force", default_log_opts, opts or {}) -- If a raw has been given simply execute it as is - if merged_opts.raw then - return terminal.run(string.format("%s %s", jj_cmd, merged_opts.raw), M.log_keymaps()) + if merged_opts.raw_flags then + return terminal.run(string.format("%s %s", jj_cmd, merged_opts.raw_flags), M.log_keymaps()) end for key, value in pairs(merged_opts) do diff --git a/lua/jj/core/buffer.lua b/lua/jj/core/buffer.lua index e06eeaf..59b1dec 100644 --- a/lua/jj/core/buffer.lua +++ b/lua/jj/core/buffer.lua @@ -297,4 +297,76 @@ function M.start_insert(buf) vim.cmd("startinsert") end -return M \ No newline at end of file +--- Get cursor position for a buffer +--- @param buf number Buffer handle +--- @return number[]|nil Cursor position as {line, col} or nil if buffer not visible +function M.get_cursor(buf) + if not vim.api.nvim_buf_is_valid(buf) then + return nil + end + + local winid = vim.fn.bufwinid(buf) + if winid == -1 then + return nil + end + + return vim.api.nvim_win_get_cursor(winid) +end + +--- Clamp cursor position to valid buffer bounds +--- @param buf number Buffer handle +--- @param pos number[] Cursor position as {line, col} +--- @return number[] Clamped position as {line, col} +local function clamp_cursor_position(buf, pos) + -- Validate and clamp position to buffer bounds + local line_count = vim.api.nvim_buf_line_count(buf) + local target_line = math.max(1, math.min(pos[1], line_count)) + + -- Get the actual line content to validate column + local line_content = vim.api.nvim_buf_get_lines(buf, target_line - 1, target_line, false)[1] or "" + local max_col = #line_content + local target_col = math.max(0, math.min(pos[2], max_col)) + + return { target_line, target_col } +end + +--- Set cursor position for a buffer +--- Automatically validates and clamps position to buffer bounds: +--- - Line number is clamped to [1, line_count] +--- - Column is clamped to [0, line_length] based on actual line content +--- For terminal buffers, uses defer_fn to allow terminal rendering to stabilize +--- @param buf number Buffer handle +--- @param pos number[] Cursor position as {line, col} (1-indexed line, 0-indexed column) +--- @param opts? {delay?: number} Optional delay in ms for terminal buffers (default: 10) +function M.set_cursor(buf, pos, opts) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + + opts = opts or {} + local delay = opts.delay or 10 + + local winid = vim.fn.bufwinid(buf) + if winid == -1 then + return + end + + -- For terminal buffers, delay to allow rendering to complete + -- Must validate position INSIDE the deferred function for terminal buffers + -- because the buffer content may not be stable yet + if vim.bo[buf].buftype == "terminal" or delay > 0 then + vim.defer_fn(function() + if not vim.api.nvim_buf_is_valid(buf) or not vim.api.nvim_win_is_valid(winid) then + return + end + + local clamped_pos = clamp_cursor_position(buf, pos) + vim.api.nvim_win_set_cursor(winid, clamped_pos) + end, delay) + else + local clamped_pos = clamp_cursor_position(buf, pos) + vim.api.nvim_win_set_cursor(winid, clamped_pos) + end +end + +return M diff --git a/lua/jj/init.lua b/lua/jj/init.lua index f7f88d8..bf7e9fe 100644 --- a/lua/jj/init.lua +++ b/lua/jj/init.lua @@ -2,12 +2,15 @@ local M = {} local cmd = require("jj.cmd") local picker = require("jj.picker") local editor = require("jj.ui.editor") +local terminal = require("jj.ui.terminal") --- Jujutsu plugin configuration --- @class jj.Config --- @field cmd? jj.cmd.opts Options for command module --- @field picker? jj.picker.config Options for picker module +--- @field terminal? jj.ui.terminal.opts Options for the terminal --- @field highlights? jj.ui.editor.highlights Highlight configuration for describe buffer + M.config = { -- Default configuration --- @type jj.picker.config @@ -27,6 +30,7 @@ function M.setup(opts) picker.setup(opts and opts.picker or {}) editor.setup({ highlights = M.config.highlights }) cmd.setup(opts and opts.cmd or {}) + terminal.setup(opts and opts.terminal or {}) cmd.register_command() end diff --git a/lua/jj/ui/terminal.lua b/lua/jj/ui/terminal.lua index 2573775..6ae896d 100644 --- a/lua/jj/ui/terminal.lua +++ b/lua/jj/ui/terminal.lua @@ -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 -