--- @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 --- @type integer|nil buf = nil, -- The current channel to communciate with the terminal --- @type integer|nil chan = nil, --- The current job id for the terminal buffer --- @type integer|nil job_id = nil, -- The current command being displayed --- @type string|nil buf_cmd = nil, -- The floating buffer if any --- @type integer|nil floating_buf = nil, -- The floating channel to communciate with the terminal --- @type integer|nil floating_chan = nil, --- 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) end --- Close the current terminal buffer if it exists function M.close_floating_buffer() buffer.close(state.floating_buf) end --- Hide the current floating window function M.hide_floating_buffer() if not state.floating_buf then return elseif state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then vim.cmd("hide") 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 function M.run_floating(cmd, keymaps) -- Clean up previous state if invalid if state.floating_buf and not vim.api.nvim_buf_is_valid(state.floating_buf) then state.floating_buf = nil state.floating_chan = nil state.floating_job_id = nil end -- Stop any running job first if state.floating_job_id then vim.fn.jobstop(state.floating_job_id) state.floating_job_id = nil end -- Close previous channel if state.floating_chan then vim.fn.chanclose(state.floating_chan) state.floating_chan = nil end -- Wipe old buffer if it exists if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then vim.api.nvim_buf_delete(state.floating_buf, { force = true }) state.floating_buf = nil end -- Create new floating buffer local buf, win = buffer.create_float({ title = " JJ Diff ", title_pos = "center", enter = true, bufhidden = "hide", win_options = { wrap = true, number = false, relativenumber = false, cursorline = false, signcolumn = "no", }, on_exit = function(b) if state.floating_buf == b then state.floating_buf = nil end if state.floating_chan then vim.fn.chanclose(state.floating_chan) state.floating_chan = nil end if state.floating_job_id then vim.fn.jobstop(state.floating_job_id) state.floating_job_id = nil end end, }) state.floating_buf = buf -- Create new terminal channel local chan = vim.api.nvim_open_term(state.floating_buf, {}) if not chan or chan <= 0 then vim.notify("Failed to create terminal channel", vim.log.levels.ERROR) return end state.floating_chan = chan -- Move cursor to top before output arrives vim.api.nvim_win_set_cursor(win, { 1, 0 }) local jid = vim.fn.jobstart(cmd, { pty = true, width = vim.api.nvim_win_get_width(win), height = vim.api.nvim_win_get_height(win), env = { TERM = "xterm-256color", PAGER = "cat", DELTA_PAGER = "cat", COLORTERM = "truecolor", DFT_BACKGROUND = "light", }, on_stdout = function(_, data) if not state.floating_buf or not vim.api.nvim_buf_is_valid(state.floating_buf) then return end local output = table.concat(data, "\n") vim.api.nvim_chan_send(chan, output) end, on_exit = function(_, _) --[[ exit_code ]] vim.schedule(function() if state.floating_buf and vim.api.nvim_buf_is_valid(state.floating_buf) then buffer.set_modifiable(state.floating_buf, false) buffer.stop_insert(state.floating_buf) end end) end, }) if jid <= 0 then vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n") state.floating_chan = nil else state.floating_job_id = jid end -- Set keymaps only if they haven't been set for this buffer if not vim.b[state.floating_buf].jj_keymaps_set then local default_keymaps = { { modes = { "n", "v" }, lhs = "i", rhs = function() end }, { modes = { "n", "v" }, lhs = "c", rhs = function() end }, { modes = { "n", "v" }, lhs = "a", rhs = function() end }, { modes = { "n", "v" }, lhs = "u", rhs = function() end }, } -- Merge default keymaps with provided keymaps if keymaps and #keymaps > 0 then for _, km in ipairs(keymaps) do table.insert(default_keymaps, km) end end buffer.set_keymaps(state.floating_buf, default_keymaps) vim.b[state.floating_buf].jj_keymaps_set = true end end --- Run a command and show it's output in a terminal buffer --- 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 } end -- Clean up previous state if invalid if state.buf and not vim.api.nvim_buf_is_valid(state.buf) then state.buf = nil state.chan = nil state.job_id = nil state.buf_cmd = nil end -- Stop any running job first if state.job_id then vim.fn.jobstop(state.job_id) state.job_id = nil end -- Close previous channel if state.chan then vim.fn.chanclose(state.chan) state.chan = nil end -- Wipe old buffer if it exists if state.buf and vim.api.nvim_buf_is_valid(state.buf) then vim.api.nvim_buf_delete(state.buf, { force = true }) state.buf = nil end -- Create new terminal buffer state.buf = buffer.create({ split = "horizontal", size = math.floor(vim.o.lines / 2), on_exit = function(buf) if state.buf == buf then state.buf = nil end if state.chan then vim.fn.chanclose(state.chan) state.chan = nil end if state.job_id then vim.fn.jobstop(state.job_id) state.job_id = nil end state.buf_cmd = nil end, }) local win = vim.api.nvim_get_current_win() vim.bo[state.buf].bufhidden = "wipe" -- Create new terminal channel local chan = vim.api.nvim_open_term(state.buf, {}) if not chan or chan <= 0 then vim.notify("Failed to create terminal channel", vim.log.levels.ERROR) return end state.chan = chan -- Move cursor to top before output arrives -- vim.api.nvim_win_set_cursor(win, { 1, 0 }) -- If the command is a string split it into parts -- to store the subcommand later if #cmd == 1 then cmd = vim.split(cmd[1], "%s+") end local jid = vim.fn.jobstart(cmd, { pty = true, width = vim.api.nvim_win_get_width(win), height = vim.api.nvim_win_get_height(win), env = { TERM = "xterm-256color", PAGER = "cat", DELTA_PAGER = "cat", COLORTERM = "truecolor", DFT_BACKGROUND = "light", }, on_stdout = function(_, data) if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) or not state.chan then return end local output = table.concat(data, "\n") vim.api.nvim_chan_send(state.chan, output) end, on_exit = function(_, exit_code) vim.schedule(function() -- Check buffer still exists (it might have been closed) if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then return end -- 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, }) if jid <= 0 then vim.api.nvim_chan_send(chan, "Failed to start command: " .. cmd .. "\r\n") state.chan = nil else state.job_id = jid end -- Set keymaps only if they haven't been set for this buffer -- Set base keymaps only if they haven't been set for this buffer yet if not vim.b[state.buf].jj_keymaps_set then buffer.set_keymaps(state.buf, { -- Disable insert, command and append modes { modes = { "n", "v" }, lhs = "i", rhs = function() end }, { modes = { "n", "v" }, lhs = "c", rhs = function() end }, { modes = { "n", "v" }, lhs = "a", rhs = function() end }, { modes = { "n", "v" }, lhs = "u", rhs = function() end }, }) vim.b[state.buf].jj_keymaps_set = true end -- Remove command-specific keymaps from previous runs if vim.b[state.buf].jj_command_keymaps then buffer.remove_keymaps(state.buf, vim.b[state.buf].jj_command_keymaps) vim.b[state.buf].jj_command_keymaps = nil end -- Add command-specific keymaps for jj buffers local new_command_keymaps = {} -- Append the given keymaps -- Add a debug if keymaps and #keymaps > 0 then for _, km in ipairs(keymaps) do table.insert(new_command_keymaps, km) end end -- Status keymaps are already handled in cmd.lua via status_keymaps() -- No need to duplicate them here if #new_command_keymaps > 0 then buffer.set_keymaps(state.buf, new_command_keymaps) vim.b[state.buf].jj_command_keymaps = new_command_keymaps end vim.cmd("stopinsert") return state.buf end return M