Skip to content

Instantly share code, notes, and snippets.

@OXY2DEV
Last active June 11, 2026 21:27
Show Gist options
  • Select an option

  • Save OXY2DEV/645c90df32095a8a397735d0be646452 to your computer and use it in GitHub Desktop.

Select an option

Save OXY2DEV/645c90df32095a8a397735d0be646452 to your computer and use it in GitHub Desktop.
A slightly fancier LSP hover for Neovim

✏️ Overview

Note

A more bleeding-edge version of this is available here.

A pretty simple custom LSP hover window that tries to solve the issues I face with the built-in one.

showcase_1 showcase_2

Note

This was designed with small screen size in mind!

❄️ How does this work?

This is done by replacing the TextDocument/hover handler with a cusom function.

See :h lsp-handlers & :h vim.lsp.handlers.hover() for more information.

📜 Features

  • Fancier LSP window(with custom footers & decorations).
  • Quadrant aware window. The LSP window can open on any of the quadrants around the cursor. Don't worry the border changes with the quadrant.
  • Per language server/hover provider configuration. Allows changing how the hover window looks based on the server name.
  • Minimum & maximum width/height. Allows clamping the hover window between a minimum & maximum width/height. No more flooding the entire screen with a single hover.
  • Wrapped text! No more needing to switch to the hover window just to see the message.
  • markview.nvim support for markdown preview support(For v25(dev branch at the moment) only)!

💻 Usage

Note

The lsp_hover.lua file must be on your $RUNTIMEPATH. Also, make sure you don't have another file with the same name.

require("lsp_hover").setup();

🔩 Configuration

require("lsp_hover").setup({
  ["^lua_ls"] = {
    border_hl = "Special"
  }
});
--- Slightly *fancier* LSP hover handler.
local lsp_hover = {};
---@class hover.opts
---
---@field border_hl? string Highlight group for the window borders.
---@field name_hl? string Highlight group for the `name`. Defaults to `border_hl`.
---@field name string
---
---@field min_width? integer
---@field max_width? integer
---
---@field min_height? integer
---@field max_height? integer
--- Configuration for lsp_hovers from different
--- servers.
---
---@type { default: hover.opts, [string]: hover.opts }
lsp_hover.config = {
default = {
border_hl = "@comment",
name = "󰗊 LSP/Hover",
min_width = 20,
max_width = math.floor(vim.o.columns * 0.75),
min_height = 1,
max_height = math.floor(vim.o.lines * 0.5)
},
["^lua_ls"] = {
name = " LuaLS",
border_hl = "@function"
}
};
--- Finds matching configuration.
--- NOTE: The output is the merge of the {config} and {default}.
---@param str string
---@return hover.opts
local match = function (str)
---+${lua}
local ignore = { "default" };
local config = lsp_hover.config.default or {};
---@type string[]
local keys = vim.tbl_keys(lsp_hover.config);
--- Sorting is nice in-case the same pattern can
--- match multiple servers.
table.sort(keys);
for _, k in ipairs(keys) do
if vim.list_contains(ignore, k) == false and string.match(str, k) then
return vim.tbl_extend("force", config, lsp_hover.config[k]);
end
end
return config;
---_
end
--- Get which quadrant to open the window on.
---
--- ```txt
--- top, left ↑ top, right
--- ← █ →
--- bottom, left ↓ bottom, right
--- ```
---@param w integer
---@param h integer
---@return [ "left" | "right" | "center", "top" | "bottom" | "center" ]
local function get_quadrant (w, h)
---+${lua}
---@type integer
local window = vim.api.nvim_get_current_win();
---@type [ integer, integer ]
local src_c = vim.api.nvim_win_get_cursor(window);
--- (Terminal) Screen position.
---@class screen.pos
---
---@field row integer Screen row.
---@field col integer First screen column.
---@field endcol integer Last screen column.
---
---@field curscol integer Cursor screen column.
local scr_p = vim.fn.screenpos(window, src_c[1], src_c[2]);
---@type integer, integer Vim's width & height.
local vW, vH = vim.o.columns, vim.o.lines - (vim.o.cmdheight or 0);
---@type "left" | "right", "top" | "bottom"
local x, y;
if scr_p.curscol - w <= 0 then
--- Not enough spaces on `left`.
if scr_p.curscol + w >= vW then
--- Not enough space on `right`.
return { "center", "center" };
else
--- Enough spaces on `right`.
x = "right";
end
else
--- Enough space on `left`.
x = "left";
end
if scr_p.row + h >= vH then
--- Not enough spaces on `top`.
if scr_p.row - h <= 0 then
--- Not enough spaces on `bottom`.
return { "center", "center" };
else
y = "top";
end
else
y = "bottom";
end
return { x, y }
---_
end
---@type integer? LSP hover buffer.
lsp_hover.buffer = nil;
---@type integer? LSP hover window.
lsp_hover.window = nil;
--- Initializes the hover buffer & window.
---@param config table
lsp_hover.__init = function (config)
---+${lua}
if not config then
return;
end
if not lsp_hover.buffer or vim.api.nvim_buf_is_valid(lsp_hover.buffer) then
pcall(vim.api.nvim_buf_delete, lsp_hover.buffer, { force = true });
lsp_hover.buffer = vim.api.nvim_create_buf(false, true);
vim.api.nvim_buf_set_keymap(lsp_hover.buffer, "n", "q", "", {
desc = "Closes LSP hover window",
callback = function ()
pcall(vim.api.nvim_win_close, lsp_hover.window, true);
lsp_hover.window = nil;
end
});
end
if not lsp_hover.window then
lsp_hover.window = vim.api.nvim_open_win(lsp_hover.buffer, false, config);
elseif vim.api.nvim_win_is_valid(lsp_hover.window) == false then
pcall(vim.api.nvim_win_close, lsp_hover.window, true);
lsp_hover.window = vim.api.nvim_open_win(lsp_hover.buffer, false, config);
else
vim.api.nvim_win_set_config(lsp_hover.window, config);
end
---_
end
--- Custom hover function.
---@param error table Error.
---@param result table Result of the hover.
---@param context table Context for this hover.
---@param _ table Hover config(we won't use this).
lsp_hover.hover = function (error, result, context, _)
---+${lua}
if error then
--- Emit error message.
vim.api.nvim_echo({
{ "  Lsp hover: ", "DiagnosticVirtualTextError" },
{ " " },
{ error.message, "Comment" }
}, true, {})
end
if vim.api.nvim_get_current_buf() ~= context.bufnr then
--- Buffer was changed before the request was
--- resolved.
return;
elseif not result or not result.contents then
--- No result.
vim.api.nvim_echo({
{ "  Lsp hover: ", "DiagnosticVirtualTextInfo" },
{ " " },
{ "No information available!", "Comment" }
}, true, {})
return;
end
---@type string | table
local content = result.contents;
---@type string[]
local lines = {};
local ft;
--[[
NOTE: LSP hover contents can be any of the followings,
1. Literal string.
2. A table(`{ kind = ..., value = ... }`).
3. A list(`{ kind = ..., value = ... }[]`).
]]
if type(content) == "string" then
lines = vim.split(content or "", "\n", { trimempty = true });
ft = "markdown";
elseif vim.islist(content) then
content = content[1];
lines = vim.split(content.value or "", "\n", { trimempty = true });
ft = content.kind;
else
lines = vim.split(content.value or "", "\n", { trimempty = true });
ft = content.kind;
end
---@type integer LSP client ID.
local client_id = context.client_id;
---@type { name: string } LSP client info.
local client = vim.lsp.get_client_by_id(client_id) or { name = "Unknown" };
---@type hover.opts
local config = match(client.name);
local w = config.min_width or 20;
local h = config.min_height or 1;
local max_height = config.max_height or 10;
local max_width = config.max_width or 60;
for _, line in ipairs(lines) do
if vim.fn.strdisplaywidth(line) >= max_width then
w = max_width;
break;
elseif vim.fn.strdisplaywidth(line) > w then
w = vim.fn.strdisplaywidth(line);
end
end
h = math.max(math.min(#lines, max_height), h);
--- Window configuration.
local win_conf = {
relative = "cursor",
row = 1, col = 0,
width = w, height = h,
style = "minimal",
footer = {
{ "╼ ", config.border_hl or "FloatBorder" },
{ config.name, config.name_hl or config.border_hl or "FloatBorder" },
{ " ╾", config.border_hl or "FloatBorder" },
},
footer_pos = "right"
};
--- Window borders.
local border = {
{ "╭", config.border_hl or "FloatBorder" },
{ "─", config.border_hl or "FloatBorder" },
{ "╮", config.border_hl or "FloatBorder" },
{ "│", config.border_hl or "FloatBorder" },
{ "╯", config.border_hl or "FloatBorder" },
{ "─", config.border_hl or "FloatBorder" },
{ "╰", config.border_hl or "FloatBorder" },
{ "│", config.border_hl or "FloatBorder" },
};
--- Which quadrant to open the window on.
---@type [ "left" | "right" | "center", "top" | "bottom" | "center" ]
local quad = get_quadrant(w + 2, h + 2);
if quad[1] == "left" then
win_conf.col = (w * -1) - 1;
elseif quad[1] == "right" then
win_conf.col = 0;
else
win_conf.relative = "editor";
win_conf.col = math.ceil((vim.o.columns - w) / 2);
end
if quad[2] == "top" then
win_conf.row = (h * -1) - 2;
if quad[1] == "left" then
border[5][1] = "┤";
else
border[7][1] = "├";
end
elseif quad[2] == "bottom" then
win_conf.row = 1;
if quad[1] == "left" then
border[3][1] = "┤";
else
border[1][1] = "├";
end
else
win_conf.relative = "editor";
win_conf.row = math.ceil((vim.o.lines - h) / 2);
end
win_conf.border = border;
lsp_hover.__init(win_conf);
vim.api.nvim_buf_set_lines(lsp_hover.buffer, 0, -1, false, lines);
vim.bo[lsp_hover.buffer].ft = ft;
vim.wo[lsp_hover.window].conceallevel = 3;
vim.wo[lsp_hover.window].concealcursor = "n";
vim.wo[lsp_hover.window].signcolumn = "no";
vim.wo[lsp_hover.window].wrap = true;
vim.wo[lsp_hover.window].linebreak = true;
if package.loaded["markview"] and package.loaded["markview"].render then
--- If markview is available use it to render stuff.
--- This is for `v25`.
require("markview").render(lsp_hover.buffer, { enable = true, hybrid_mode = false });
end
---_
end
--- Setup function.
---@param config { default: hover.opts, [string]: hover.opts } | nil
lsp_hover.setup = function (config)
---+${lua}
if config then
lsp_hover.config = vim.tbl_deep_extend("force", lsp_hover.config, config);
end
if vim.fn.has("nvim-0.11") == 1 then
vim.api.nvim_create_autocmd("LspAttach", {
callback = function (ev)
vim.api.nvim_buf_set_keymap(ev.buf, "n", "K", "", {
callback = function ()
local window = vim.api.nvim_get_current_win();
if lsp_hover.window and vim.api.nvim_win_is_valid(lsp_hover.window) then
vim.api.nvim_set_current_win(lsp_hover.window);
else
vim.lsp.buf_request(0, 'textDocument/hover', vim.lsp.util.make_position_params(window, "utf-8"), lsp_hover.hover);
end
end
});
end
});
end
--- TODO, maybe we should remove this.
--- Set-up the new provider.
vim.lsp.handlers["textDocument/hover"] = lsp_hover.hover;
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
callback = function (event)
if event.buf == lsp_hover.buffer then
--- Don't do anything if the current buffer
--- is the hover buffer.
return;
elseif lsp_hover.window and vim.api.nvim_win_is_valid(lsp_hover.window) then
pcall(vim.api.nvim_win_close, lsp_hover.window, true);
lsp_hover.window = nil;
end
end
});
---_
end
return lsp_hover;
@edte

edte commented Jan 24, 2025

Copy link
Copy Markdown

Hello, it not useful after config

image

my config

    {
        name = "hover",
        dir = "lsp.lsp_hover",
        virtual = true,
        config = function()
            require("lsp.lsp_hover").setup();
        end,
    },
image

@OXY2DEV

OXY2DEV commented Jan 24, 2025

Copy link
Copy Markdown
Author

Why not just add require("lsp.lsp_hover").setup() to the end of your init.lua?

There shouldn't be any gains from lazy loading.

Also dir expects a plugin directory. So, you probably need something like this,

📂 lsp
    📂 lsp_hover
        📂 lua
            📜 lsp_hover.lua

@midgramr

Copy link
Copy Markdown

Unfortunately no longer works in neovim 0.11 since we can't override the LSP handlers I believe

@OXY2DEV

OXY2DEV commented Apr 14, 2025

Copy link
Copy Markdown
Author

@N1v3x2 that's due to vim.lsp.buf.hover() no longer triggering global handlers(see :h news-breaking).

You can however manually trigger it(see the first example in :h vim.lsp.handlers).

@midgramr

midgramr commented Apr 14, 2025

Copy link
Copy Markdown

Sorry if this is a dumb question, but could you tell me what's wrong with this config? Double checked that the require path is right

require('custom.lsp.lsp_hover').setup()
vim.api.nvim_create_autocmd('LspAttach', {
  ---@diagnostic disable: unused-local
  callback = function(event)
    local client = assert(vim.lsp.get_client_by_id(event.data.client_id))
    client:request 'textDocument/hover'
  end,
})

@OXY2DEV

OXY2DEV commented Apr 14, 2025

Copy link
Copy Markdown
Author

As far as I can tell you are supposed to use set it as a keymap.

But I haven't managed to get it working for me.

I will update the gist after I figure out how to get it to work like before.

@OXY2DEV

OXY2DEV commented Apr 14, 2025

Copy link
Copy Markdown
Author

@N1v3x2 I have updated the gist. It should work properly now.

@midgramr

Copy link
Copy Markdown

@OXY2DEV How hard would it be to port this custom UI over to nvim-cmp? Love the way it looks, but wish it was consistent with my completions.

@OXY2DEV

OXY2DEV commented Apr 14, 2025

Copy link
Copy Markdown
Author

It's not very hard since all it's doing is just calling require("markview").render({...}).

You can use an autocmd(maybe check :h WinNew) to manually draw the preview. You can set ignore_buftypes = {}(see here) in markview's config which should automatically show previews.

You can also check nvim-cmp's repo and check if they have callbacks for this.

Note

I have tested this in blink.cmp and the description's are mostly plaintext, so you won't see any drastic changes(this is for lua_ls, other LSP's may have different behavior).

@edte

edte commented Apr 15, 2025

Copy link
Copy Markdown

@N1v3x2 I have updated the gist. It should work properly now.

hello, it report error Undefined global hover on 345 line number

@midgramr

midgramr commented Apr 15, 2025

Copy link
Copy Markdown

@edte it's a small typo; change it to lsp_hover.hover and it'll work

@OXY2DEV

OXY2DEV commented Apr 15, 2025

Copy link
Copy Markdown
Author

@N1v3x2 I have updated the gist. It should work properly now.

hello, it report error Undefined global hover on 345 line number

I have fixed the typo.

@azdanov

azdanov commented Aug 30, 2025

Copy link
Copy Markdown

Awesome work! Was looking how to make markview render on lsp hover and this is just so much better. Thanks for making this.

@azdanov

azdanov commented Aug 30, 2025

Copy link
Copy Markdown

Would you happen to know why it might display blank when using vtsls? It shows up nicely when not using the fancy hover. And similar for jsonls maybe others too. It works for gopls and rust-analyzer nicely.

image

@OXY2DEV

OXY2DEV commented Aug 30, 2025

Copy link
Copy Markdown
Author

@azdanov it should be fixed now.

The issue happened if an LSP returned a string as the hover information instead of a table.

Note

If you have multiple LSPs attached to the same buffer and all of them return a hover information, only the last one will be shown.
Some LSPs may be slower then other. So, you might notice the text changing.

@azdanov

azdanov commented Aug 30, 2025

Copy link
Copy Markdown

Thank you 🙏

@michaelfortunato

Copy link
Copy Markdown

Hi! Could this be extended to render code docs written in restructured text?

@OXY2DEV

OXY2DEV commented Aug 31, 2025

Copy link
Copy Markdown
Author

@michaelfortunato I am not aware of any tree-sitter parsers for restructured text.

So, at the moment no.

@michaelfortunato

michaelfortunato commented Aug 31, 2025

Copy link
Copy Markdown

@michaelfortunato I am not aware of any tree-sitter parsers for restructured text.

So, at the moment no.

Got it thanks!

@xsalman01

Copy link
Copy Markdown

The issue raised by @azdanov still happens when using typescript-tools. https://github.com/pmizio/typescript-tools.nvim

@OXY2DEV

OXY2DEV commented Oct 15, 2025

Copy link
Copy Markdown
Author

@xsalman01 It should be fixed now.

@xsalman01

Copy link
Copy Markdown

thanks

@rodhash

rodhash commented Nov 8, 2025

Copy link
Copy Markdown

Is this still working? For me it does nothing, no pretty LSP nor any error msg.

After saving the lsp_hover.lua file I tried calling like this (before and after "OXY2DEV/markview.nvim"):

require("lsp_hover").setup();

And tried this:

    {
        name = "hover",
        dir = "lsp.lsp_hover",
        virtual = true,
        config = function()
            require("lsp.lsp_hover").setup();
        end,
    },

"OXY2DEV/markview.nvim" is working tho.

@OXY2DEV

OXY2DEV commented Nov 8, 2025

Copy link
Copy Markdown
Author

@rodhash Does it normally show anything(without using this script)?

What LSP are you using? Has the LSP finished loading(this is relevant for LSPs such as rust-analyzer)?

@rodhash

rodhash commented Nov 8, 2025

Copy link
Copy Markdown

@rodhash Does it normally show anything(without using this script)?

What LSP are you using? Has the LSP finished loading(this is relevant for LSPs such as rust-analyzer)?

I tried on clangd LSP before (will test others), I didn't think of a race condition. I do use lazy loading so I’ll need to check on this.

@OXY2DEV

OXY2DEV commented Nov 8, 2025

Copy link
Copy Markdown
Author

I have just tested it and it is working on my end.

You are using K, right? Check if it's mapped to something else.

@tahirub

tahirub commented Mar 21, 2026

Copy link
Copy Markdown

Any one looking looking for simple solution: here is what i found after wasting alot of hours.

vim.keymap.set('n', 'K', function()
  vim.lsp.buf.hover({
    max_width = 80,
    max_height = 20,
    border = 'rounded',  -- or 'single', 'double', 'solid', 'shadow', none'
  })
end)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment