Skip to content

Commit 9a59aaf

Browse files
Danilo Verde RibeiroDanilo Verde Ribeiro
authored andcommitted
feat(context): add recent_buffers synthetic context (MRU buffers with optional symbols)
1 parent cf1c5fd commit 9a59aaf

4 files changed

Lines changed: 261 additions & 1 deletion

File tree

lua/opencode/config.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ M.defaults = {
147147
selection = {
148148
enabled = true,
149149
},
150+
recent_buffers = {
151+
enabled = true,
152+
symbols_only = false,
153+
max = 5,
154+
},
150155
},
151156
debug = {
152157
enabled = false,

lua/opencode/context.lua

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,16 @@ function M.format_message(prompt, opts)
308308

309309
local parts = { { type = 'text', text = prompt } }
310310

311+
-- recent_buffers synthetic context
312+
if config.context and config.context.recent_buffers and config.context.recent_buffers.enabled then
313+
local ok, recent = pcall(M.get_recent_buffers, prompt, config.context.recent_buffers)
314+
if ok and recent and #recent > 0 then
315+
for _, rb in ipairs(recent) do
316+
table.insert(parts, rb)
317+
end
318+
end
319+
end
320+
311321
for _, path in ipairs(context.mentioned_files or {}) do
312322
table.insert(parts, format_file_part(path, prompt))
313323
end
@@ -416,4 +426,162 @@ function M.extract_legacy_tag(tag, text)
416426
return nil
417427
end
418428

429+
---@param buf number
430+
---@return boolean
431+
local function is_valid_buffer(buf)
432+
if not vim.api.nvim_buf_is_valid(buf) then
433+
return false
434+
end
435+
if vim.bo[buf].buftype ~= '' then
436+
return false
437+
end
438+
if not vim.bo[buf].modifiable then
439+
return false
440+
end
441+
return true
442+
end
443+
444+
---@param client table
445+
local function client_supports_symbols(client)
446+
if not client or not client.server_capabilities then
447+
return false
448+
end
449+
local caps = client.server_capabilities
450+
return caps.documentSymbolProvider == true or (type(caps.documentSymbolProvider) == 'table')
451+
end
452+
453+
---@param bufnr number
454+
---@return table[]|nil
455+
local function fetch_document_symbols(bufnr)
456+
local params = { textDocument = vim.lsp.util.make_text_document_params(bufnr) }
457+
local results = {}
458+
local clients = vim.lsp.get_active_clients({ bufnr = bufnr })
459+
local any = false
460+
for _, client in ipairs(clients) do
461+
if client_supports_symbols(client) then
462+
any = true
463+
local ok, resp = pcall(function()
464+
return client.request_sync('textDocument/documentSymbol', params, 500, bufnr)
465+
end)
466+
if ok and resp and resp.result then
467+
if vim.tbl_islist(resp.result) then
468+
vim.list_extend(results, resp.result)
469+
else
470+
table.insert(results, resp.result)
471+
end
472+
end
473+
end
474+
end
475+
if not any or #results == 0 then
476+
return nil
477+
end
478+
return results
479+
end
480+
481+
local function flatten_symbols(symbols, acc, parent)
482+
acc = acc or {}
483+
if not symbols then
484+
return acc
485+
end
486+
for _, s in ipairs(symbols) do
487+
local name = s.name or '<anonymous>'
488+
local kind = s.kind or 0
489+
table.insert(acc, { name = name, kind = kind, parent = parent })
490+
if s.children then
491+
flatten_symbols(s.children, acc, name)
492+
end
493+
end
494+
return acc
495+
end
496+
497+
---@param prompt string
498+
---@param opts { enabled: boolean, symbols_only: boolean, max: number }
499+
---@return OpencodeMessagePart[]|nil
500+
function M.get_recent_buffers(prompt, opts)
501+
if not opts or not opts.enabled then
502+
return nil
503+
end
504+
505+
local bufs = vim.api.nvim_list_bufs()
506+
local recent = {}
507+
508+
-- Collect candidate buffers (MRU ordering approximation by number)
509+
for _, b in ipairs(bufs) do
510+
if is_valid_buffer(b) then
511+
local line_count = vim.api.nvim_buf_line_count(b)
512+
if line_count > 100 then
513+
local clients = vim.lsp.get_active_clients({ bufnr = b })
514+
if #clients > 0 then
515+
table.insert(recent, { bufnr = b, line_count = line_count })
516+
end
517+
end
518+
end
519+
end
520+
521+
if #recent == 0 then
522+
return nil
523+
end
524+
525+
table.sort(recent, function(a, b)
526+
return a.bufnr > b.bufnr -- crude MRU heuristic
527+
end)
528+
529+
local max_items = math.max(1, opts.max or 5)
530+
local parts = {}
531+
for i = 1, math.min(#recent, max_items) do
532+
local b = recent[i].bufnr
533+
local path = vim.api.nvim_buf_get_name(b)
534+
local rel_path = vim.fn.fnamemodify(path, ':~:.')
535+
local mention = '@' .. rel_path
536+
local pos = prompt and prompt:find(mention)
537+
pos = pos and pos - 1 or 0
538+
539+
local symbol_list
540+
if opts.symbols_only then
541+
local symbols = fetch_document_symbols(b)
542+
if symbols then
543+
local flat = flatten_symbols(symbols)
544+
local names = {}
545+
for _, s in ipairs(flat) do
546+
table.insert(names, s.name)
547+
end
548+
symbol_list = names
549+
end
550+
-- Guarantee a symbols array exists (empty if none found) for a stable contract
551+
if not symbol_list then
552+
symbol_list = {}
553+
end
554+
end
555+
556+
local content
557+
if not opts.symbols_only then
558+
local first_lines = vim.api.nvim_buf_get_lines(b, 0, math.min(200, vim.api.nvim_buf_line_count(b)), false)
559+
content = table.concat(first_lines, '\n')
560+
end
561+
562+
local data = {
563+
context_type = 'recent-buffer',
564+
path = path,
565+
relative = rel_path,
566+
line_count = recent[i].line_count,
567+
symbols = symbol_list,
568+
preview = content and ('```\n' .. content .. '\n```') or nil,
569+
}
570+
571+
local part = {
572+
type = 'text',
573+
text = vim.json.encode(data),
574+
synthetic = true,
575+
source = {
576+
path = path,
577+
type = 'file',
578+
text = { start = pos, value = mention, ['end'] = pos + #mention - 1 },
579+
},
580+
}
581+
table.insert(parts, part)
582+
end
583+
584+
return parts
585+
end
586+
419587
return M

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
---@field diagnostics { info: boolean, warning: boolean, error: boolean }
114114
---@field current_file { enabled: boolean }
115115
---@field selection { enabled: boolean }
116+
---@field recent_buffers { enabled: boolean, symbols_only: boolean, max: number }
116117

117118
---@class OpencodeDebugConfig
118119
---@field enabled boolean

tests/unit/context_spec.lua

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('extract_from_opencode_message', function()
1010
{
1111
type = 'text',
1212
synthetic = true,
13-
text = vim.json.encode({ context_type = 'selection', content = 'print(42)' }),
13+
text = vim.json.encode({ context_type = 'selection', content = 'print(42)' }),
1414
},
1515
{ type = 'file', filename = '/tmp/foo.lua' },
1616
},
@@ -139,3 +139,89 @@ describe('add_file/add_selection/add_subagent', function()
139139
assert.same({ 'agentX' }, context.context.mentioned_subagents)
140140
end)
141141
end)
142+
143+
describe('recent_buffers', function()
144+
local config = require('opencode.config').get()
145+
local original_get_active_clients
146+
147+
local function create_large_buffer(name, lines)
148+
local buf = vim.api.nvim_create_buf(true, false)
149+
vim.api.nvim_buf_set_name(buf, name)
150+
local content = {}
151+
for i = 1, lines do
152+
content[i] = 'line ' .. i
153+
end
154+
vim.api.nvim_buf_set_lines(buf, 0, -1, false, content)
155+
return buf
156+
end
157+
158+
before_each(function()
159+
config.context.recent_buffers.enabled = true
160+
config.context.recent_buffers.symbols_only = false
161+
config.context.recent_buffers.max = 5
162+
state.last_sent_context = nil
163+
original_get_active_clients = vim.lsp.get_active_clients
164+
local fake_client = {
165+
server_capabilities = { documentSymbolProvider = true },
166+
request_sync = function(_, method, params, timeout, bufnr)
167+
if method == 'textDocument/documentSymbol' then
168+
return { result = { { name = 'FuncA' }, { name = 'FuncB', children = { { name = 'Inner' } } } } }
169+
end
170+
return { result = {} }
171+
end,
172+
}
173+
vim.lsp.get_active_clients = function(_) return { fake_client } end
174+
local unique_name = 'recent_buffer_' .. tostring(math.random(100000)) .. '.lua'
175+
create_large_buffer(unique_name, 150)
176+
end)
177+
178+
after_each(function()
179+
if original_get_active_clients then
180+
vim.lsp.get_active_clients = original_get_active_clients
181+
end
182+
end)
183+
184+
local function find_recent_buffer_parts(parts)
185+
local found = {}
186+
for i = 2, #parts do
187+
local p = parts[i]
188+
if p.type == 'text' and p.synthetic and p.text then
189+
local ok, decoded = pcall(vim.json.decode, p.text)
190+
if ok and decoded and decoded.context_type == 'recent-buffer' then
191+
table.insert(found, decoded)
192+
end
193+
end
194+
end
195+
return found
196+
end
197+
198+
it('does not include recent buffers when disabled', function()
199+
config.context.recent_buffers.enabled = false
200+
local parts = context.format_message('prompt')
201+
local rb = find_recent_buffer_parts(parts)
202+
assert.equal(0, #rb)
203+
end)
204+
205+
it('includes recent buffer preview when enabled and symbols_only = false', function()
206+
config.context.recent_buffers.enabled = true
207+
config.context.recent_buffers.symbols_only = false
208+
local parts = context.format_message('prompt')
209+
local rb = find_recent_buffer_parts(parts)
210+
assert.is_true(#rb >= 1)
211+
assert.is_not_nil(rb[1].preview)
212+
assert.is_nil(rb[1].symbols)
213+
assert.is_truthy(rb[1].line_count > 100)
214+
end)
215+
216+
it('includes symbols list when symbols_only = true', function()
217+
config.context.recent_buffers.enabled = true
218+
config.context.recent_buffers.symbols_only = true
219+
local parts = context.format_message('prompt')
220+
local rb = find_recent_buffer_parts(parts)
221+
assert.is_true(#rb >= 1)
222+
assert.is_nil(rb[1].preview)
223+
assert.is_not_nil(rb[1].symbols)
224+
-- symbols list should exist (may be empty depending on LSP behavior in tests)
225+
assert.is_not_nil(rb[1].symbols)
226+
end)
227+
end)

0 commit comments

Comments
 (0)