Skip to content

Commit 7c7f6e3

Browse files
authored
feat: Add optional Neovim diagnostics to context (#15)
1 parent 1922e69 commit 7c7f6e3

4 files changed

Lines changed: 279 additions & 1 deletion

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ It's funny that all AI plugins for Neovim are quite complex to interact with, li
1212

1313
## Features
1414

15-
- **Context aware**: Sends your current buffer, cwd, and selection as context.
15+
- **Context aware**: Sends your current buffer, cwd, selection, and optional diagnostics as context.
1616
- **Unsaved-buffer aware**: Tells pi to treat the sent Neovim buffer content as the source of truth, even if the on-disk file is stale.
1717
- **Simple configuration**: Just set your preferred AI model.
1818
- **Gets out of your way**: You ask it. It does it. Done.
@@ -69,6 +69,9 @@ require("pi").setup({
6969
selection = {
7070
surrounding_lines = 40,
7171
},
72+
diagnostics = {
73+
enabled = false,
74+
},
7275
},
7376
skills = true,
7477
extensions = true,
@@ -86,6 +89,7 @@ require("pi").setup({
8689
| `context.max_bytes` | `24000` | Maximum size in bytes for sent context before trimming. |
8790
| `context.ask.surrounding_lines` | `80` | Number of lines before and after the current cursor line to include for `:PiAsk`. |
8891
| `context.selection.surrounding_lines` | `40` | Number of lines before and after the current visual selection to include for `:PiAskSelection`. |
92+
| `context.diagnostics.enabled` | `false` | Includes Neovim diagnostics in the sent context. `:PiAsk` sends all buffer diagnostics; `:PiAskSelection` sends only diagnostics overlapping the selected lines. |
8993
| `skills` | `true` | Whether pi discovers and loads skills. Set to `false` to pass `--no-skills`. |
9094
| `extensions` | `true` | Whether pi discovers and loads extensions. Set to `false` to pass `--no-extensions`. |
9195

@@ -142,6 +146,7 @@ vim.keymap.set("v", "<leader>ai", ":PiAskSelection<CR>", { desc = "Ask pi (selec
142146
- Uses `nvim-notify` for status updates when available; otherwise falls back to a small floating status window.
143147
- Reloads changed loaded buffers on success so pi's on-disk edits are reflected in Neovim.
144148
- Treats sent buffer/selection context as newer than disk, so unsaved Neovim changes are the source of truth for the agent.
149+
- Optionally includes Neovim diagnostics from LSPs/linters via `vim.diagnostic`.
145150
- Trims oversized context for speed instead of always sending the full file.
146151

147152

lua/pi/config.lua

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
--- Configuration helpers for pi.nvim.
12
local M = {}
23

34
local VALID_THINKING_LEVELS = {
@@ -24,19 +25,27 @@ M.defaults = {
2425
selection = {
2526
surrounding_lines = 40,
2627
},
28+
diagnostics = {
29+
enabled = false,
30+
},
2731
},
2832
skills = true,
2933
extensions = true,
3034
}
3135

3236
local values = vim.deepcopy(M.defaults)
3337

38+
--- Validates a positive numeric configuration value.
39+
--- @param name string Human-readable config key name.
40+
--- @param value number Value to validate.
3441
local function validate_number(name, value)
3542
if type(value) ~= "number" or value < 1 then
3643
error(string.format("pi.nvim: %s must be a positive number", name))
3744
end
3845
end
3946

47+
--- Validates user-provided configuration overrides.
48+
--- @param opts table User configuration passed to `setup`.
4049
function M.validate(opts)
4150
if opts.binary ~= nil and not (type(opts.binary) == "string" or type(opts.binary) == "table") then
4251
error("pi.nvim: binary must be a string or list of strings")
@@ -74,6 +83,14 @@ function M.validate(opts)
7483
validate_number("context.selection.surrounding_lines", context.selection.surrounding_lines)
7584
end
7685
end
86+
if context.diagnostics ~= nil then
87+
if type(context.diagnostics) ~= "table" then
88+
error("pi.nvim: context.diagnostics must be a table")
89+
end
90+
if context.diagnostics.enabled ~= nil and type(context.diagnostics.enabled) ~= "boolean" then
91+
error("pi.nvim: context.diagnostics.enabled must be a boolean")
92+
end
93+
end
7794
end
7895
if opts.skills ~= nil and type(opts.skills) ~= "boolean" then
7996
error("pi.nvim: skills must be a boolean")
@@ -97,13 +114,18 @@ function M.validate(opts)
97114
end
98115
end
99116

117+
--- Merges user options with defaults and stores the effective config.
118+
--- @param opts? table User configuration overrides.
119+
--- @return table values Effective configuration.
100120
function M.setup(opts)
101121
opts = opts or {}
102122
M.validate(opts)
103123
values = vim.tbl_deep_extend("force", vim.deepcopy(M.defaults), opts)
104124
return values
105125
end
106126

127+
--- Returns the currently active configuration.
128+
--- @return table values Effective configuration.
107129
function M.get()
108130
return values
109131
end

lua/pi/context.lua

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
--- Context builders for pi.nvim prompts.
12
local M = {}
23

34
local SYSTEM_PROMPT = [[You are running inside the pi.nvim Neovim plugin. The user has sent a request and will not be able to reply back. You must complete the task immediately without asking any questions or requesting clarification. Take action now and do what was asked.
@@ -8,6 +9,9 @@ local BUFFER_SOURCE_OF_TRUTH_NOTE = [[NOTE: The context below comes from the cur
89

910
local EMPTY_FILE_NOTE = [[NOTE: This file is currently empty. Please create or populate it directly by applying the necessary edits so pi.nvim can write the file.]]
1011

12+
--- Returns whether a buffer contains only empty or whitespace-only lines.
13+
--- @param bufnr integer Buffer handle.
14+
--- @return boolean is_empty Whether the buffer has meaningful content.
1115
local function buffer_is_empty(bufnr)
1216
local line_count = vim.api.nvim_buf_line_count(bufnr)
1317
if line_count == 0 then
@@ -22,6 +26,9 @@ local function buffer_is_empty(bufnr)
2226
return true
2327
end
2428

29+
--- Returns whether a buffer maps to a real file on disk.
30+
--- @param bufnr integer Buffer handle.
31+
--- @return boolean is_file_backed Whether the buffer is file-backed.
2532
function M.buffer_is_file_backed(bufnr)
2633
if vim.bo[bufnr].buftype ~= "" then
2734
return false
@@ -30,6 +37,8 @@ function M.buffer_is_file_backed(bufnr)
3037
return filename ~= nil and filename ~= ""
3138
end
3239

40+
--- Returns the current visual selection as a 1-based inclusive line range.
41+
--- @return table|nil range Selection range with `start` and `end` keys.
3342
function M.get_visual_selection_range()
3443
local start_pos = vim.fn.getpos("'<")
3544
local end_pos = vim.fn.getpos("'>")
@@ -44,6 +53,10 @@ function M.get_visual_selection_range()
4453
return { start = start_line, ["end"] = end_line }
4554
end
4655

56+
--- Builds the prompt label shown in `vim.ui.input`.
57+
--- @param bufnr integer Buffer handle.
58+
--- @param selection_range table|nil Optional selected line range.
59+
--- @return string label Input prompt label.
4760
function M.format_prompt_label(bufnr, selection_range)
4861
local components = {}
4962
local filename = vim.api.nvim_buf_get_name(bufnr)
@@ -59,31 +72,144 @@ function M.format_prompt_label(bufnr, selection_range)
5972
return string.format("ask pi (%s): ", table.concat(components, ":"))
6073
end
6174

75+
--- Returns a buffer's filetype, defaulting to plain text.
76+
--- @param bufnr integer Buffer handle.
77+
--- @return string filetype Buffer filetype or `text`.
6278
local function filetype_for(bufnr)
6379
return vim.bo[bufnr].filetype ~= "" and vim.bo[bufnr].filetype or "text"
6480
end
6581

82+
--- Extracts nearby lines around a 1-based center line.
83+
--- @param lines string[] All buffer lines.
84+
--- @param center_line integer 1-based center line.
85+
--- @param surrounding_lines integer Number of surrounding lines to include on each side.
86+
--- @return string[] slice Selected lines.
87+
--- @return integer start_line First included line number.
88+
--- @return integer end_line Last included line number.
6689
local function slice_lines_around(lines, center_line, surrounding_lines)
6790
local start_line = math.max(1, center_line - surrounding_lines)
6891
local end_line = math.min(#lines, center_line + surrounding_lines)
6992
return vim.list_slice(lines, start_line, end_line), start_line, end_line
7093
end
7194

95+
--- Truncates text to a byte limit.
96+
--- @param text string Text to trim.
97+
--- @param max_bytes integer Maximum byte length.
98+
--- @return string content Trimmed or original text.
99+
--- @return boolean did_trim Whether truncation happened.
72100
local function truncate_to_bytes(text, max_bytes)
73101
if #text <= max_bytes then
74102
return text, false
75103
end
76104
return text:sub(1, max_bytes), true
77105
end
78106

107+
--- Wraps text inside a labeled fenced code block.
108+
--- @param label string Section label.
109+
--- @param text string Section content.
110+
--- @return string block Formatted block.
79111
local function content_block(label, text)
80112
return string.format("%s:\n```\n%s\n```", label, text)
81113
end
82114

115+
--- Converts a diagnostic severity enum into a stable uppercase label.
116+
--- @param severity integer|nil Diagnostic severity.
117+
--- @return string label Severity label.
118+
local function diagnostic_severity_label(severity)
119+
local labels = {
120+
[vim.diagnostic.severity.ERROR] = "ERROR",
121+
[vim.diagnostic.severity.WARN] = "WARN",
122+
[vim.diagnostic.severity.INFO] = "INFO",
123+
[vim.diagnostic.severity.HINT] = "HINT",
124+
}
125+
return labels[severity] or "UNKNOWN"
126+
end
127+
128+
--- Returns whether a diagnostic overlaps an inclusive line range.
129+
--- @param diagnostic table Diagnostic item from `vim.diagnostic.get`.
130+
--- @param start_line integer Inclusive start line.
131+
--- @param end_line integer Inclusive end line.
132+
--- @return boolean overlaps Whether the diagnostic intersects the range.
133+
local function diagnostic_overlaps_range(diagnostic, start_line, end_line)
134+
local diagnostic_start = (diagnostic.lnum or 0) + 1
135+
local diagnostic_end = (diagnostic.end_lnum or diagnostic.lnum or 0) + 1
136+
return diagnostic_start <= end_line and diagnostic_end >= start_line
137+
end
138+
139+
--- Formats a diagnostic as a human-readable bullet list item.
140+
--- @param diagnostic table Diagnostic item from `vim.diagnostic.get`.
141+
--- @return string line Rendered diagnostic line.
142+
local function format_diagnostic(diagnostic)
143+
local line = (diagnostic.lnum or 0) + 1
144+
local col = (diagnostic.col or 0) + 1
145+
local details = { diagnostic_severity_label(diagnostic.severity) }
146+
147+
if diagnostic.source and diagnostic.source ~= "" then
148+
details[#details + 1] = diagnostic.source
149+
end
150+
151+
return string.format("- line %d:%d: %s [%s]", line, col, diagnostic.message, table.concat(details, ", "))
152+
end
153+
154+
--- Builds an optional diagnostics block for the current buffer or selection.
155+
--- @param bufnr integer Buffer handle.
156+
--- @param config table Active pi.nvim configuration.
157+
--- @param opts? table Optional range filter.
158+
--- @return string|nil block Formatted diagnostics block.
159+
--- @return string|nil note Optional trimming note.
160+
local function diagnostics_block(bufnr, config, opts)
161+
if not config.context.diagnostics or not config.context.diagnostics.enabled then
162+
return nil
163+
end
164+
165+
opts = opts or {}
166+
local diagnostics = vim.diagnostic.get(bufnr)
167+
if not diagnostics or vim.tbl_isempty(diagnostics) then
168+
return nil
169+
end
170+
171+
if opts.range then
172+
diagnostics = vim.tbl_filter(function(diagnostic)
173+
return diagnostic_overlaps_range(diagnostic, opts.range.start, opts.range["end"])
174+
end, diagnostics)
175+
if vim.tbl_isempty(diagnostics) then
176+
return nil
177+
end
178+
end
179+
180+
table.sort(diagnostics, function(a, b)
181+
if a.lnum ~= b.lnum then
182+
return a.lnum < b.lnum
183+
end
184+
return (a.col or 0) < (b.col or 0)
185+
end)
186+
187+
local formatted = {}
188+
for _, diagnostic in ipairs(diagnostics) do
189+
formatted[#formatted + 1] = format_diagnostic(diagnostic)
190+
end
191+
192+
local content, did_trim_bytes = truncate_to_bytes(table.concat(formatted, "\n"), config.context.max_bytes)
193+
local label = opts.range and "Diagnostics in selection" or "Diagnostics"
194+
local note = nil
195+
196+
if did_trim_bytes then
197+
note = string.format("NOTE: Diagnostics were trimmed for speed (max_bytes=%d).", config.context.max_bytes)
198+
end
199+
200+
return content_block(label, content), note
201+
end
202+
203+
--- Returns the system prompt appended to pi invocations.
204+
--- @return string prompt Internal system prompt.
83205
function M.get_system_prompt()
84206
return SYSTEM_PROMPT
85207
end
86208

209+
--- Builds prompt context for `:PiAsk` around the current cursor line.
210+
--- @param bufnr integer Buffer handle.
211+
--- @param config table Active pi.nvim configuration.
212+
--- @return string context Prompt context payload.
87213
function M.get_buffer_context(bufnr, config)
88214
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
89215
local cursor_line = vim.api.nvim_win_get_cursor(0)[1]
@@ -108,13 +234,25 @@ function M.get_buffer_context(bufnr, config)
108234
)
109235
end
110236

237+
local diagnostics, diagnostics_note = diagnostics_block(bufnr, config)
238+
if diagnostics then
239+
parts[#parts + 1] = diagnostics
240+
end
241+
if diagnostics_note then
242+
parts[#parts + 1] = diagnostics_note
243+
end
244+
111245
if buffer_is_empty(bufnr) then
112246
parts[#parts + 1] = EMPTY_FILE_NOTE
113247
end
114248

115249
return table.concat(parts, "\n\n")
116250
end
117251

252+
--- Builds prompt context for `:PiAskSelection`.
253+
--- @param bufnr integer Buffer handle.
254+
--- @param config table Active pi.nvim configuration.
255+
--- @return string context Prompt context payload.
118256
function M.get_visual_context(bufnr, config)
119257
local filename = vim.api.nvim_buf_get_name(bufnr)
120258
local all_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
@@ -145,6 +283,14 @@ function M.get_visual_context(bufnr, config)
145283
)
146284
end
147285

286+
local diagnostics, diagnostics_note = diagnostics_block(bufnr, config, { range = selection_range })
287+
if diagnostics then
288+
parts[#parts + 1] = diagnostics
289+
end
290+
if diagnostics_note then
291+
parts[#parts + 1] = diagnostics_note
292+
end
293+
148294
if buffer_is_empty(bufnr) then
149295
parts[#parts + 1] = EMPTY_FILE_NOTE
150296
end

0 commit comments

Comments
 (0)