Files
jj.nvim/lua/jj/ui/terminal.lua
T
NicolasGB 7827458568 fix(terminal): clamp tooltip dimensions to available screen space
Prevent tooltip from overflowing when the cursor is near the bottom or
right edge of the screen by clamping width and height to the remaining
space.
2026-02-11 13:54:41 +01:00

745 lines
21 KiB
Lua

--- @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,
-- The current tooltip buffer
--- @type integer|nil
tooltip_buf = nil,
-- The tooltip window
--- @type integer|nil
tooltip_win = nil,
-- The tooltip channel to communicate with the terminal
--- @type integer|nil
tooltip_chan = nil,
-- The tooltip_job_id
--- @type integer|nil
tooltip_job_id = nil,
-- The tooltip autocmd that auto closes it
--- @type integer|nil
tooltip_close_autocmd = 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
--- Help for terminal buffer
function M.keymap_help()
-- get the normal mode keys defined for the current buffer
local keys = vim.api.nvim_buf_get_keymap(0, "n")
-- sort the keys table by desc
table.sort(keys, function(a, b)
return (a.desc or "") < (b.desc or "")
end)
-- Figure out the width of the longest key for later formatting
-- Ignore keys for entries without a desc
local max_key_width = 0
for _, mapping in ipairs(keys) do
local desc = mapping["desc"]
if desc ~= nil then
local key = mapping["lhs"]
local key_width = vim.api.nvim_strwidth(key)
if key_width > max_key_width then
max_key_width = key_width
end
end
end
-- create a buffer and floating window to show the key mappings
local buf, win = buffer.create_float({
title = " Key mappings ",
title_pos = "left",
enter = true,
bufhidden = "hide",
win_options = {
wrap = true,
number = false,
relativenumber = false,
cursorline = false,
signcolumn = "no",
},
})
-- helper function to pad a given key with spaces if necessary such that
-- its size will match max_key_width calculated above.
local function space_pad(key)
local key_width = vim.api.nvim_strwidth(key)
local delta = max_key_width - key_width
if delta > 0 then
return key .. string.rep(" ", delta)
end
return key
end
-- create formated entries for items with a non-nil desc
local lines = {}
for _, entry in ipairs(keys) do
if entry["desc"] ~= nil then
local line = string.format(" %s %s", space_pad(entry["lhs"]), entry["desc"])
table.insert(lines, line)
end
end
-- add some helper text at the end
table.insert(lines, "")
table.insert(lines, ' Use "q" or ESC to close this window')
-- add the formatted lines to the buffer
vim.api.nvim_buf_set_lines(buf, 3, -1, false, lines)
-- Use q or ESC to close the buffer
vim.keymap.set("n", "q", "<cmd>close<CR>", { buffer = buf })
vim.keymap.set("n", "<Esc>", "<cmd>close<CR>", { buffer = buf })
end
--- Close the current terminal buffer if it exists
function M.close_terminal_buffer()
-- If the tooltip is showing that's what we want to close
if state.tooltip_buf then
M.close_tooltip()
return
end
-- Otherwise close the buffer
buffer.close(state.buf)
state.buf_cmd = nil
state.chan = nil
state.job_id = nil
end
--- Close the current terminal buffer if it exists
function M.close_floating_buffer()
buffer.close(state.floating_buf)
state.floating_chan = nil
state.floating_job_id = nil
state.floating_buf = nil
end
--- Close the current tooltip buffer if it exists
function M.close_tooltip()
if not state.tooltip_buf then
return
end
buffer.close(state.tooltip_buf)
if state.tooltip_close_autocmd then
pcall(vim.api.nvim_del_autocmd, state.tooltip_close_autocmd)
end
state.tooltip_chan = nil
state.tooltip_job_id = nil
state.tooltip_buf = nil
state.tooltip_win = nil
state.tooltip_close_autocmd = nil
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
--- Check whether the log buffer is currently active
--- @return boolean
function M.is_log_buffer_open()
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return false
end
return state.buf_cmd == "log"
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 = "<S-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
-- Remove prompt keymaps
buffer.remove_keymaps(state.floating_buf, {
{ modes = { "n", "v" }, lhs = "[[", rhs = function() end },
{ modes = { "n", "v" }, lhs = "]]", rhs = function() 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
-- 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, {
{ modes = { "n" }, lhs = "g?", rhs = M.keymap_help },
-- 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 prompt keymaps
buffer.remove_keymaps(state.buf, {
{ modes = { "n", "v" }, lhs = "[[", rhs = function() end },
{ modes = { "n", "v" }, lhs = "]]", rhs = function() 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
--- Replace keymaps for normal terminal
--- @param keymaps jj.core.buffer.keymap[] New keymaps to set
function M.replace_terminal_keymaps(keymaps)
if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then
return
end
-- Remove previous keymaps if any
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
-- Set new keymaps
if keymaps and #keymaps > 0 then
buffer.set_keymaps(state.buf, keymaps)
vim.b[state.buf].jj_command_keymaps = keymaps
end
end
--- @class jj.ui.terminal.tooltip_opts
--- @field title? string Tooltip title
--- @field enter? boolean Whether to enter the tooltip window (default: false)
--- @field keymaps? jj.core.buffer.keymap[] Keymaps to set on the tooltip buffer
--- @field on_exit? fun(buf: number) Callback when tooltip is closed
--- @field width? number Tooltip width (default: 80% of columns)
--- @field height? number Tooltip height (default: 80% of lines)
--- Run a command in a PTY-based tooltip window
--- @param cmd string The command to run
--- @param tool_opts? jj.ui.terminal.tooltip_opts Tooltip options
--- @return number|nil buf Buffer handle, or nil on failure
--- @return number|nil win Window handle, or nil on failure
function M.run_tooltip(cmd, tool_opts)
tool_opts = tool_opts or {}
-- Clean up previous state if invalid
if state.tooltip_buf and not vim.api.nvim_buf_is_valid(state.tooltip_buf) then
state.tooltip_buf = nil
state.tooltip_win = nil
state.tooltip_chan = nil
state.tooltip_job_id = nil
end
-- Stop any running job first
if state.tooltip_job_id then
vim.fn.jobstop(state.tooltip_job_id)
state.tooltip_job_id = nil
end
-- Close previous channel
if state.tooltip_chan then
vim.fn.chanclose(state.tooltip_chan)
state.tooltip_chan = nil
end
-- Wipe old buffer if it exists
if state.tooltip_buf and vim.api.nvim_buf_is_valid(state.tooltip_buf) then
vim.api.nvim_buf_delete(state.tooltip_buf, { force = true })
state.tooltip_buf = nil
end
local height = tool_opts.height
if height then
local cursor_screen_row = vim.fn.screenrow()
local available = vim.o.lines - cursor_screen_row - 2
height = math.min(height, math.max(available, 1))
end
local width = tool_opts.width
if width then
local cursor_screen_col = vim.fn.screencol()
local available = vim.o.columns - cursor_screen_col - 2
width = math.min(width, math.max(available, 1))
end
state.tooltip_buf, state.tooltip_win = buffer.create_float({
title = tool_opts.title or " JJ ",
title_pos = "center",
enter = tool_opts.enter or false,
bufhidden = "wipe",
relative = "cursor",
row = 1,
col = 0,
width = width,
height = height,
win_options = {
wrap = true,
number = false,
relativenumber = false,
cursorline = false,
signcolumn = "no",
},
})
state.tooltip_chan = vim.api.nvim_open_term(state.tooltip_buf, {})
if not state.tooltip_chan or state.tooltip_chan <= 0 then
vim.notify("Failed to create terminal channel", vim.log.levels.ERROR)
vim.api.nvim_buf_delete(state.tooltip_buf, { force = true })
return nil, nil
end
state.tooltip_job_id = vim.fn.jobstart(cmd, {
pty = true,
width = vim.api.nvim_win_get_width(state.tooltip_win),
height = vim.api.nvim_win_get_height(state.tooltip_win),
env = {
TERM = "xterm-256color",
PAGER = "cat",
DELTA_PAGER = "cat",
COLORTERM = "truecolor",
},
on_stdout = function(_, data)
if not state.tooltip_buf or not vim.api.nvim_buf_is_valid(state.tooltip_buf) then
return
end
local output = table.concat(data, "\n")
vim.api.nvim_chan_send(state.tooltip_chan, output)
end,
on_exit = function()
vim.schedule(function()
if state.tooltip_buf and vim.api.nvim_buf_is_valid(state.tooltip_buf) then
buffer.set_modifiable(state.tooltip_buf, false)
buffer.stop_insert(state.tooltip_buf)
end
end)
end,
})
if state.tooltip_job_id <= 0 then
vim.api.nvim_chan_send(state.tooltip_chan, "Failed to start command: " .. cmd .. "\r\n")
return state.tooltip_buf, state.tooltip_win
end
vim.api.nvim_create_autocmd({ "BufWipeout", "BufDelete" }, {
buffer = state.tooltip_buf,
once = true,
callback = function()
if state.tooltip_chan then
pcall(vim.fn.chanclose, state.tooltip_chan)
end
if state.tooltip_job_id then
pcall(vim.fn.jobstop, state.tooltip_job_id)
end
-- Clean the state when closing
state.tooltip_buf = nil
state.tooltip_win = nil
state.tooltip_chan = nil
state.tooltip_job_id = nil
end,
})
vim.keymap.set("n", "<Esc>", function()
M.close_tooltip()
-- Focus the log window before restoring cursor position
if state.buf then
local log_win = vim.fn.bufwinid(state.buf)
if log_win ~= -1 then
vim.api.nvim_set_current_win(log_win)
end
end
M.restore_cursor_position()
end, { buffer = state.tooltip_buf, silent = true })
vim.keymap.set("n", "q", function()
M.close_tooltip()
-- Focus the log window before restoring cursor position
if state.buf then
local log_win = vim.fn.bufwinid(state.buf)
if log_win ~= -1 then
vim.api.nvim_set_current_win(log_win)
end
end
M.restore_cursor_position()
end, { buffer = state.tooltip_buf, silent = true })
-- Close when cursor moves in other windows (unless suppress flag is set)
state.tooltip_close_autocmd = vim.api.nvim_create_autocmd("CursorMoved", {
callback = function()
if
state.tooltip_buf
and vim.api.nvim_buf_is_valid(state.tooltip_buf)
and vim.api.nvim_win_is_valid(state.tooltip_win)
and vim.api.nvim_get_current_win() ~= state.tooltip_win
then
-- Don't close if the keep open flag is set (e.g., when opening floating diff from tooltip)
if vim.b[state.tooltip_buf].jj_keep_open then
return
end
M.close_tooltip()
end
end,
})
if tool_opts.keymaps and #tool_opts.keymaps > 0 then
buffer.set_keymaps(state.tooltip_buf, tool_opts.keymaps)
end
return state.tooltip_buf, state.tooltip_win
end
--- Whether or not to keep the tooltip open instead of autoclosing
--- @param keep_open boolean
function M.keep_tooltip_open(keep_open)
vim.b[state.tooltip_buf].jj_keep_open = keep_open
end
--- Replace keymaps for floating terminal
--- @param keymaps jj.core.buffer.keymap[] New keymaps to set
function M.replace_floating_keymaps(keymaps)
if not state.floating_buf or not vim.api.nvim_buf_is_valid(state.floating_buf) then
return
end
-- Remove previous keymaps if any
if vim.b[state.floating_buf].jj_command_keymaps then
buffer.remove_keymaps(state.floating_buf, vim.b[state.floating_buf].jj_command_keymaps)
vim.b[state.floating_buf].jj_command_keymaps = nil
end
-- Set new keymaps
if keymaps and #keymaps > 0 then
buffer.set_keymaps(state.floating_buf, keymaps)
vim.b[state.floating_buf].jj_command_keymaps = keymaps
end
end
return M