Skip to content

feat(diagnostics): enhance diagnostics display, dynamic virtual text like VS Code Error Lens #1628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

glaulher
Copy link

@glaulher glaulher commented Jul 4, 2025

Hi there! 👋

This PR introduces a small enhancement to the diagnostic experience in Neovim.

Instead of keeping the diagnostics always in virtual text or virtual lines, this new setup dynamically switches between the two based on the current mode:

Insert mode: diagnostics are shown inline using virtual text — similar to the behavior of the Error Lens extension in VS Code.

Normal mode: diagnostics switch to virtual lines below the affected code, offering a cleaner and more elegant overview.

This approach provides real-time feedback while coding and a less intrusive display when navigating the code. It's a feature that requires a plugin in editors like VS Code, but here it’s done natively with just a bit of Lua! 😄

I believe this improves the overall developer experience by making diagnostics both more immediate and more readable.

Let me know what you think — open to suggestions!

Thanks 🙏

Copy link
Contributor

@oriori1703 oriori1703 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would make a great addition :)

I left some comments for improvements

} or false,
}
end

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should call set_virtual_text at least once outside of the autocmds.
Without doing that you will have no diagnostics at all, until you switch to insert mode for the first time.

Suggested change
set_virtual_text(false)

init.lua Outdated
Comment on lines 631 to 665
-- Diagnostic configuration similar to VS Code's Error Lens.
-- In insert mode, diagnostics are displayed as inline virtual text.
-- In normal mode, diagnostics are shown as virtual lines below the affected lines.

local function set_virtual_text(enable)
local diagnostic_icons = {
[vim.diagnostic.severity.ERROR] = '󰅚 ',
[vim.diagnostic.severity.WARN] = '󰀪 ',
[vim.diagnostic.severity.INFO] = '󰋽 ',
[vim.diagnostic.severity.HINT] = '󰌶 ',
}

vim.diagnostic.config {
update_in_insert = true, -- error messages in insert mode
severity_sort = true,
float = { border = 'rounded', source = 'if_many' },
underline = { severity = vim.diagnostic.severity.ERROR },
signs = vim.g.have_nerd_font and {
text = diagnostic_icons,
} or {},

virtual_lines = not enable and {
format = function(diagnostic)
return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
end,
} or false,

virtual_text = enable and {
source = 'if_many',
spacing = 2,
format = function(diagnostic)
return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
end,
} or false,
}
end
Copy link
Contributor

@oriori1703 oriori1703 Jul 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the logic could be simplified by settings most of the config outside of the function.
i.e. something like this:

Suggested change
-- Diagnostic configuration similar to VS Code's Error Lens.
-- In insert mode, diagnostics are displayed as inline virtual text.
-- In normal mode, diagnostics are shown as virtual lines below the affected lines.
local function set_virtual_text(enable)
local diagnostic_icons = {
[vim.diagnostic.severity.ERROR] = '󰅚 ',
[vim.diagnostic.severity.WARN] = '󰀪 ',
[vim.diagnostic.severity.INFO] = '󰋽 ',
[vim.diagnostic.severity.HINT] = '󰌶 ',
}
vim.diagnostic.config {
update_in_insert = true, -- error messages in insert mode
severity_sort = true,
float = { border = 'rounded', source = 'if_many' },
underline = { severity = vim.diagnostic.severity.ERROR },
signs = vim.g.have_nerd_font and {
text = diagnostic_icons,
} or {},
virtual_lines = not enable and {
format = function(diagnostic)
return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
end,
} or false,
virtual_text = enable and {
source = 'if_many',
spacing = 2,
format = function(diagnostic)
return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
end,
} or false,
}
end
vim.diagnostic.config {
update_in_insert = true, -- error messages in insert mode
severity_sort = true,
float = { border = 'rounded', source = 'if_many' },
underline = { severity = vim.diagnostic.severity.ERROR },
signs = vim.g.have_nerd_font and {
text = {
[vim.diagnostic.severity.ERROR] = '󰅚 ',
[vim.diagnostic.severity.WARN] = '󰀪 ',
[vim.diagnostic.severity.INFO] = '󰋽 ',
[vim.diagnostic.severity.HINT] = '󰌶 ',
},
} or {},
}
-- Diagnostic configuration similar to VS Code's Error Lens.
-- In insert mode, diagnostics are displayed as inline virtual text.
-- In normal mode, diagnostics are shown as virtual lines below the affected lines.
---@param enable boolean
local function set_virtual_text(enable)
vim.diagnostic.config {
virtual_lines = not enable,
virtual_text = enable and {
source = 'if_many',
spacing = 2,
},
}
end

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in my example, I removed format as suggested by #1550

Copy link
Author

@glaulher glaulher Jul 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the feedback and suggestions! 🙌

I've updated the code to reflect the recommendations:

  • I've kept the severity icons for both virtual_text and virtual_lines, as I believe it improves the visual clarity and overall diagnostic experience — especially when switching between modes.
  • I've added a call to set_virtual_text(false) right after defining the function. While I didn’t observe any issues during testing without it, I agree it's a good addition for consistency and to prevent potential edge cases.
  • I also set underline = true instead of restricting it to only errors. I found that underlining all severities helps quickly locate the affected lines, improving usability during review and debugging.

If there's still interest in merging this feature, I’d be happy to re-submit the PR with these improvements applied.

Thanks again for the thoughtful review!

 -- Diagnostic Config
      -- See :help vim.diagnostic.Opts
      local diagnostic_icons = {
        [vim.diagnostic.severity.ERROR] = '󰅚 ',
        [vim.diagnostic.severity.WARN] = '󰀪 ',
        [vim.diagnostic.severity.INFO] = '󰋽 ',
        [vim.diagnostic.severity.HINT] = '󰌶 ',
      }

      vim.diagnostic.config {
        update_in_insert = true,
        severity_sort = true,
        float = { border = 'rounded', source = 'if_many' },
        underline = true, -- { severity = vim.diagnostic.severity.ERROR }
        signs = vim.g.have_nerd_font and { text = diagnostic_icons } or {},
      }

      -- Diagnostic configuration similar to VS Code's Error Lens.
      -- In insert mode, diagnostics are displayed as inline virtual text.
      -- In normal mode, diagnostics are shown as virtual lines below the affected lines.
      ---@param enable boolean
      local function set_virtual_text(enable)
        vim.diagnostic.config {
          virtual_lines = not enable and {
            format = function(diagnostic)
              return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
            end,
          } or false,
          virtual_text = enable and {
            source = 'if_many',
            spacing = 2,
            format = function(diagnostic)
              return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
            end,
          } or false,
        }
      end

      set_virtual_text(false)

      vim.api.nvim_create_autocmd('InsertEnter', {
        callback = function()
          set_virtual_text(true)
        end,
      })

      vim.api.nvim_create_autocmd('InsertLeave', {
        callback = function()
          set_virtual_text(false)
        end,
      })



Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's still interest in merging this feature, I’d be happy to re-submit the PR with these improvements applied.

Go for it.
I think it would be a great addition for kickstart (or at least for my config, if the maintainers don't agree with me :)

	Added functionality to mimic VSCode's Error Lens behavior:
	- Displays diagnostics as virtual text with severity icons during insert mode.
	- Switches to virtual lines with formatted messages in normal mode for better readability.

	Improves real-time feedback while coding and enhances the visual presentation of diagnostics.
Copy link
Contributor

@oriori1703 oriori1703 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 😄

@Jacobus-afk
Copy link

Just a heads-up.. exiting insert mode with Ctrl-C doesn't change back to virtual lines

exiting with esc does however

        Added functionality to mimic VSCode's Error Lens behavior:
        - Displays diagnostics as virtual text with severity icons during insert mode.
        - Switches to virtual lines with formatted messages in normal mode for better readability.
        - Replaced `InsertLeave` with `ModeChanged` (`i:*`) to ensure diagnostics update correctly even when exiting insert>

        This refactor improves code readability, follows single-responsibility principles, and makes it easier to maintain >
        Improves real-time feedback while coding and enhances the visual presentation of diagnostics.
@glaulher
Copy link
Author

Just a heads-up.. exiting insert mode with Ctrl-C doesn't change back to virtual lines

exiting with esc does however

Thanks, done.

@oriori1703
Copy link
Contributor

After using it for a bit I find the transition between normal and insert mode a bit distracting, because all of the line positions change.

Maybe this fits better as a toggle instead?
Or maybe it's just me? does anyone else have an opinion?

@dotfrag
Copy link

dotfrag commented Jul 18, 2025

After using it for a bit I find the transition between normal and insert mode a bit distracting, because all of the line positions change.

Maybe this fits better as a toggle instead? Or maybe it's just me? does anyone else have an opinion?

Same here. At first it looked cool. Now it's just annoying. Especially working with new code about 3-5 lines. You are destined to have diagnostic warnings/errors when writing a new block of code. And with vim, since we tend to enter and exit insert mode often, this is annoying. I have turned it off in my config for now. Maybe a toggle would be more useful.

@Jacobus-afk
Copy link

yip.. also.. i tried removing the diagnostic messages and only keeping the icons too, but it still felt distracting imo

it also disappears anyways as soon as you start typing in insert mode

@glaulher
Copy link
Author

After using it for a bit I find the transition between normal and insert mode a bit distracting, because all of the line positions change.

Maybe this fits better as a toggle instead? Or maybe it's just me? does anyone else have an opinion?

Thanks for the feedback! I agree that the transition effect can feel a bit disruptive, especially with the line shifting caused by virtual lines.

If you'd prefer a subtler experience, a good option is to disable the virtual lines entirely and rely on underlines plus floating windows for on-demand detail. For example, you can comment out the virtual_lines block and just use:

 -- Diagnostic Config
      -- See :help vim.diagnostic.Opts
      local diagnostic_icons = {
        [vim.diagnostic.severity.ERROR] = '󰅚 ',
        [vim.diagnostic.severity.WARN] = '󰀪 ',
        [vim.diagnostic.severity.INFO] = '󰋽 ',
        [vim.diagnostic.severity.HINT] = '󰌶 ',
      }

      vim.diagnostic.config {
        update_in_insert = true,
        severity_sort = true,
        float = { border = 'rounded', source = 'if_many' },
        underline = true, -- { severity = vim.diagnostic.severity.ERROR }
        signs = vim.g.have_nerd_font and { text = diagnostic_icons } or {},
      }

      -- Diagnostic configuration similar to VS Code's Error Lens.
      -- In insert mode, diagnostics are displayed as inline virtual text.
      -- In normal mode, diagnostics are shown as virtual lines below the affected lines.
      ---@param enable boolean
      local function set_virtual_text(enable)
        vim.diagnostic.config {
          -- virtual_lines = not enable and {
          --   format = function(diagnostic)
          --     return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
          --   end,
          -- } or false,
          virtual_text = enable and {
            source = 'if_many',
            spacing = 2,
            format = function(diagnostic)
              return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
            end,
          } or false,
        }
      end

      set_virtual_text(false)

      vim.api.nvim_create_autocmd('InsertEnter', {
        callback = function()
          set_virtual_text(true)
        end,
      })

      vim.api.nvim_create_autocmd('ModeChanged', {
        pattern = 'i:*',
        callback = function()
          set_virtual_text(false)
        end,
      })

      vim.keymap.set('n', '<leader>df', function()
        vim.diagnostic.open_float(nil, { border = 'rounded', source = 'if_many' })
      end, { desc = 'Show Diagnostics in Floating Window' })

      vim.keymap.set('n', '<leader>dc', function()
        for _, win in pairs(vim.api.nvim_list_wins()) do
          if vim.api.nvim_win_get_config(win).relative ~= '' then
            vim.api.nvim_win_close(win, false)
          end
        end
      end, { desc = 'Close floating diagnostics window' })

You can then use a keymap like df to open a floating diagnostic window when needed, keeping the UI minimal during regular editing.

image image

@glaulher
Copy link
Author

An alternative approach would be to automatically open a floating window when the cursor is over a diagnostic message, no need to press a key. You could still use a key binding to focus the window, and press q to close it when you're done.
It's a good option, while I personally like the virtual lines, I understand that when there are many diagnostics, they can visually clutter the screen.

     -- Diagnostic Config
      -- See :help vim.diagnostic.Opts
      local diagnostic_icons = {
        [vim.diagnostic.severity.ERROR] = '󰅚 ',
        [vim.diagnostic.severity.WARN] = '󰀪 ',
        [vim.diagnostic.severity.INFO] = '󰋽 ',
        [vim.diagnostic.severity.HINT] = '󰌶 ',
      }

      vim.diagnostic.config {
        update_in_insert = true,
        severity_sort = true,    
        underline = true, -- { severity = vim.diagnostic.severity.ERROR }
        signs = vim.g.have_nerd_font and { text = diagnostic_icons } or {},
      }

      -- Diagnostic configuration similar to VS Code's Error Lens.
      -- In insert mode, diagnostics are displayed as inline virtual text.
      -- In normal mode, diagnostics are shown as virtual lines below the affected lines.
      ---@param enable boolean
      local function set_virtual_text(enable)
        vim.diagnostic.config {  
          virtual_text = enable and {
            source = 'if_many',
            spacing = 2,
            format = function(diagnostic)
              return (diagnostic_icons[diagnostic.severity] or '') .. diagnostic.message
            end,
          } or false,
        }
      end

      set_virtual_text(false)

      vim.api.nvim_create_autocmd('InsertEnter', {
        callback = function()
          set_virtual_text(true)
        end,
      })

      vim.api.nvim_create_autocmd('ModeChanged', {
        pattern = 'i:*',
        callback = function()
          set_virtual_text(false)
        end,
      })

      vim.api.nvim_create_autocmd('CursorHold', {
        callback = function()
          vim.diagnostic.open_float(nil, { focusable = false, source = 'if_many', border = 'rounded' })
        end,
      })

      vim.keymap.set('n', '<leader>df', function()
        vim.diagnostic.open_float(nil, { border = 'rounded', source = 'if_many' })
      end, { desc = 'Enter Diagnostics in Floating Window' })

@dotfrag
Copy link

dotfrag commented Jul 19, 2025

Thank you for the all variations, the code is interesting and educational in itself, regardless of its functionality :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants