Skip to content

Commit 67c3130

Browse files
committed
fix(session-picker): support previews across picker backends
- Unify custom preview rendering behind a backend-neutral preview target so session previews work consistently with Telescope, fzf-lua, and Snacks. - Normalize picker plugin aliases such as fzf-lua, telescope.nvim, and snacks.nvim to their internal backend names, and add regression coverage for custom preview behavior across the picker integrations.
1 parent 7183002 commit 67c3130

8 files changed

Lines changed: 546 additions & 60 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ Install the plugin with your favorite package manager. See the [Configuration](#
116116
```lua
117117
-- Default configuration with all available options
118118
require('opencode').setup({
119-
preferred_picker = nil, -- 'telescope', 'fzf', 'mini.pick', 'snacks', 'select', if nil, it will use the best available picker. Note mini.pick does not support multiple selections
119+
preferred_picker = nil, -- 'telescope'/'telescope.nvim', 'fzf'/'fzf-lua', 'mini.pick', 'snacks'/'snacks.nvim', 'select', if nil, it will use the best available picker. Note mini.pick does not support multiple selections
120120
preferred_completion = nil, -- 'blink', 'nvim-cmp','vim_complete' if nil, it will use the best available completion
121121
default_global_keymaps = true, -- If false, disables all default global keymaps
122122
default_mode = 'build', -- 'build' or 'plan' or any custom configured. @see [OpenCode Agents](https://opencode.ai/docs/modes/)

lua/opencode/types.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@
356356
---@field fn? fun(args:string[]|nil):nil|Promise<any>|any
357357

358358
---@class OpencodeConfig
359-
---@field preferred_picker 'telescope' | 'fzf' | 'mini.pick' | 'snacks' | 'select' | nil
359+
---@field preferred_picker 'telescope' | 'telescope.nvim' | 'fzf' | 'fzf-lua' | 'mini.pick' | 'snacks' | 'snacks.nvim' | 'select' | nil
360360
---@field default_global_keymaps boolean
361361
---@field default_mode 'build' | 'plan' | string -- Default mode
362362
---@field default_system_prompt string | nil

lua/opencode/ui/base_picker.lua

Lines changed: 122 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ local Promise = require('opencode.promise')
1818
---@field width? number Optional width for the picker (defaults to config or current window width)
1919
---@field multi_selection? table<string, boolean> Actions that support multi-selection
2020
---@field preview? "file"|"custom"|"none"|false Preview mode: "file" for file preview, "custom" for custom preview via preview_fn, "none" or false to disable
21-
---@field preview_fn? fun(item: any, bufnr: integer): nil Custom preview function, called when preview = 'custom' and a selection changes
21+
---@field preview_fn? fun(item: any, target: PickerPreviewTarget): nil Custom preview function, called when preview = 'custom' and a selection changes
2222
---@field layout_opts? OpencodeUIPickerConfig
2323
---@field close? fun() Close the picker programmatically (set by the backend)
2424

25+
---@class PickerPreviewTarget
26+
---@field get_bufnr fun(self: PickerPreviewTarget): integer?
27+
---@field is_valid fun(self: PickerPreviewTarget): boolean
28+
---@field set_lines fun(self: PickerPreviewTarget, lines: string[]): nil
29+
---@field with_window fun(self: PickerPreviewTarget, fn: fun(): nil): nil
30+
2531
---@class TelescopeEntry
2632
---@field value any
2733
---@field display fun(entry: TelescopeEntry): string[]
@@ -58,6 +64,64 @@ local Promise = require('opencode.promise')
5864
local M = {}
5965
local picker = require('opencode.ui.picker')
6066

67+
---@param bufnr integer?
68+
---@return PickerPreviewTarget
69+
local function create_buffer_preview_target(bufnr)
70+
return {
71+
get_bufnr = function()
72+
return bufnr
73+
end,
74+
is_valid = function()
75+
return bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr)
76+
end,
77+
set_lines = function(_, lines)
78+
if bufnr == nil or not vim.api.nvim_buf_is_valid(bufnr) then
79+
return
80+
end
81+
local modifiable = vim.bo[bufnr].modifiable
82+
vim.bo[bufnr].modifiable = true
83+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
84+
vim.bo[bufnr].modifiable = modifiable
85+
end,
86+
with_window = function(_, fn)
87+
if bufnr == nil or not vim.api.nvim_buf_is_valid(bufnr) then
88+
return
89+
end
90+
local win = vim.fn.bufwinid(bufnr)
91+
if win ~= -1 then
92+
vim.api.nvim_win_call(win, fn)
93+
end
94+
end,
95+
}
96+
end
97+
98+
---@param ctx snacks.picker.preview.ctx
99+
---@return PickerPreviewTarget
100+
local function create_snacks_preview_target(ctx)
101+
return {
102+
get_bufnr = function()
103+
return ctx.buf
104+
end,
105+
is_valid = function()
106+
return ctx.buf ~= nil and vim.api.nvim_buf_is_valid(ctx.buf)
107+
end,
108+
set_lines = function(_, lines)
109+
if ctx.preview and ctx.preview.set_lines then
110+
ctx.preview:set_lines(lines)
111+
elseif ctx.buf and vim.api.nvim_buf_is_valid(ctx.buf) then
112+
create_buffer_preview_target(ctx.buf):set_lines(lines)
113+
end
114+
end,
115+
with_window = function(_, fn)
116+
if ctx.win and vim.api.nvim_win_is_valid(ctx.win) then
117+
vim.api.nvim_win_call(ctx.win, fn)
118+
return
119+
end
120+
create_buffer_preview_target(ctx.buf):with_window(fn)
121+
end,
122+
}
123+
end
124+
61125
---Build title with action legend
62126
---@param base_title string The base title
63127
---@param actions table<string, PickerAction> The available actions
@@ -156,7 +220,7 @@ local function telescope_ui(opts)
156220
if not entry then
157221
return
158222
end
159-
opts.preview_fn(entry.value, self.state.bufnr)
223+
opts.preview_fn(entry.value, create_buffer_preview_target(self.state.bufnr))
160224
end,
161225
})
162226
else
@@ -268,8 +332,35 @@ local function fzf_ui(opts)
268332
['--delimiter'] = '\x01', -- use SOH as delimiter (invisible char)
269333
},
270334
_headers = { 'actions' },
271-
-- Enable builtin previewer for file preview support
272-
previewer = opts.preview == 'file' and 'builtin' or nil,
335+
previewer = (function()
336+
if opts.preview == 'file' then
337+
return 'builtin'
338+
elseif opts.preview == 'custom' and opts.preview_fn then
339+
return {
340+
_ctor = function()
341+
local previewer = require('fzf-lua.previewer.builtin').buffer_or_file:extend()
342+
function previewer:populate_preview_buf(entry_str)
343+
if not self.win or not self.win:validate_preview() then
344+
return
345+
end
346+
local idx_str = entry_str:match('^(%d+)\x01')
347+
local idx = tonumber(idx_str)
348+
if not idx or not opts.items[idx] then
349+
return
350+
end
351+
-- Create scratch buffer, attach to preview window first
352+
-- so preview_fn can use bufwinid for window-local ops (folds)
353+
local buf = self:get_tmp_buffer()
354+
self:set_preview_buf(buf, true) -- min_winopts=true
355+
opts.preview_fn(opts.items[idx], create_buffer_preview_target(buf))
356+
end
357+
return previewer
358+
end,
359+
}
360+
else
361+
return nil
362+
end
363+
end)(),
273364
fn_fzf_index = function(line)
274365
-- Extract the numeric index prefix before the SOH delimiter
275366
local idx_str = line:match('^(%d+)\x01')
@@ -506,30 +597,36 @@ end
506597
local function snacks_picker_ui(opts)
507598
local Snacks = require('snacks')
508599

509-
local has_preview = opts.preview == 'file'
600+
local has_custom_preview = opts.preview == 'custom' and opts.preview_fn ~= nil
601+
local has_preview = opts.preview == 'file' or has_custom_preview
510602

511603
local title = type(opts.title) == 'function' and opts.title() or opts.title
512604
---@cast title string
513605

514606
local layout_opts = opts.layout_opts and opts.layout_opts.snacks_layout or nil
515607

516608
local selection_made = false
609+
local default_layout = {
610+
preset = has_custom_preview and 'default' or 'select',
611+
config = function(layout)
612+
local width = opts.width and (opts.width + 3) or nil -- extra space for snacks UI
613+
if not has_preview then
614+
layout.layout.width = width
615+
layout.layout.max_width = width
616+
layout.layout.min_width = width
617+
end
618+
end,
619+
}
620+
if opts.preview == 'file' then
621+
default_layout.preview = 'main'
622+
elseif not has_preview then
623+
default_layout.preview = false
624+
end
517625

518626
---@type snacks.picker.Config
519627
local snack_opts = {
520628
title = title,
521-
layout = layout_opts or {
522-
preview = has_preview and 'main' or false,
523-
preset = 'select',
524-
config = function(layout)
525-
local width = opts.width and (opts.width + 3) or nil -- extra space for snacks UI
526-
if not has_preview then
527-
layout.layout.width = width
528-
layout.layout.max_width = width
529-
layout.layout.min_width = width
530-
end
531-
end,
532-
},
629+
layout = layout_opts or default_layout,
533630
finder = function()
534631
return opts.items
535632
end,
@@ -576,9 +673,15 @@ local function snacks_picker_ui(opts)
576673
},
577674
}
578675

579-
-- Add file preview if enabled
580-
if has_preview then
676+
if opts.preview == 'file' then
581677
snack_opts.preview = 'file'
678+
elseif has_custom_preview then
679+
snack_opts.preview = function(ctx)
680+
if ctx.item then
681+
ctx.preview:reset()
682+
opts.preview_fn(ctx.item, create_snacks_preview_target(ctx))
683+
end
684+
end
582685
else
583686
snack_opts.preview = function()
584687
return false

lua/opencode/ui/picker.lua

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
local M = {}
22

3+
local picker_aliases = {
4+
['fzf-lua'] = 'fzf',
5+
['snacks.nvim'] = 'snacks',
6+
['telescope.nvim'] = 'telescope',
7+
}
8+
9+
local function normalize_picker_name(name)
10+
if name == 'select' then
11+
return nil
12+
end
13+
14+
return picker_aliases[name] or name
15+
end
16+
317
function M.get_best_picker()
418
local config = require('opencode.config')
519

620
local preferred_picker = config.preferred_picker
721
if preferred_picker and type(preferred_picker) == 'string' and preferred_picker ~= '' then
8-
if preferred_picker == 'select' then
9-
return nil
10-
end
11-
12-
return preferred_picker
22+
return normalize_picker_name(preferred_picker)
1323
end
1424

1525
if pcall(require, 'telescope') then

lua/opencode/ui/session_picker.lua

Lines changed: 53 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -172,36 +172,40 @@ local function format_messages(messages, omitted_count)
172172
end
173173

174174
--- Write formatted output to a preview buffer
175-
---@param bufnr integer
175+
---@param target PickerPreviewTarget
176176
---@param formatted { lines: string[], extmarks: table, fold_ranges: table }
177-
local function render_preview_buffer(bufnr, formatted)
178-
if not vim.api.nvim_buf_is_valid(bufnr) then
177+
local function render_preview_buffer(target, formatted)
178+
if not target:is_valid() then
179+
return
180+
end
181+
local bufnr = target:get_bufnr()
182+
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
179183
return
180184
end
181185

182186
local output_window = require('opencode.ui.output_window')
183187

184-
-- Set lines
185-
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, formatted.lines)
188+
target:set_lines(formatted.lines)
189+
bufnr = target:get_bufnr()
190+
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
191+
return
192+
end
186193

187194
-- Clear old extmarks then apply new ones
188195
pcall(vim.api.nvim_buf_clear_namespace, bufnr, output_window.namespace, 0, -1)
189196
output_window.apply_extmarks(bufnr, formatted.extmarks)
190197

191198
-- Apply folds (window-local operation)
192-
local win = vim.fn.bufwinid(bufnr)
193-
if win ~= -1 then
194-
vim.api.nvim_win_call(win, function()
195-
vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 })
196-
vim.cmd('silent! normal! zE') -- clear existing manual folds
197-
local line_count = vim.api.nvim_buf_line_count(bufnr)
198-
for _, range in ipairs(formatted.fold_ranges) do
199-
if range.from <= line_count and range.to <= line_count then
200-
vim.cmd(range.from .. ',' .. range.to .. 'fold')
201-
end
199+
target:with_window(function()
200+
vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 })
201+
vim.cmd('silent! normal! zE') -- clear existing manual folds
202+
local line_count = vim.api.nvim_buf_line_count(bufnr)
203+
for _, range in ipairs(formatted.fold_ranges) do
204+
if range.from <= line_count and range.to <= line_count then
205+
vim.cmd(range.from .. ',' .. range.to .. 'fold')
202206
end
203-
end)
204-
end
207+
end
208+
end)
205209
end
206210

207211
function M.pick(sessions, callback)
@@ -329,32 +333,47 @@ function M.pick(sessions, callback)
329333
width = config.ui.picker_width or 100,
330334
layout_opts = config.ui.picker,
331335
preview = 'custom',
332-
preview_fn = function(session, bufnr)
336+
---@param session table
337+
---@param target PickerPreviewTarget
338+
preview_fn = function(session, target)
333339
preview_seq = preview_seq + 1
334340
local current_seq = preview_seq
341+
target:set_lines({ 'Loading...' })
335342

336343
local state = require('opencode.state')
337-
local ok, messages = pcall(function()
338-
return state.api_client:list_messages(session.id, nil):wait()
344+
local ok, request = pcall(function()
345+
return state.api_client:list_messages(session.id, nil)
339346
end)
340-
341-
-- Check race: another selection happened while we were loading
342-
if current_seq ~= preview_seq then
343-
return
344-
end
345-
if not vim.api.nvim_buf_is_valid(bufnr) then
347+
if not ok or not request then
348+
target:set_lines({ 'No messages or failed to load' })
346349
return
347350
end
348351

349-
if not ok or not messages or #messages == 0 then
350-
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'No messages or failed to load' })
351-
return
352-
end
352+
request
353+
:and_then(function(messages)
354+
-- Check race: another selection happened while we were loading
355+
if current_seq ~= preview_seq then
356+
return
357+
end
358+
if not target:is_valid() then
359+
return
360+
end
361+
362+
if not messages or #messages == 0 then
363+
target:set_lines({ 'No messages or failed to load' })
364+
return
365+
end
353366

354-
messages = normalize_message_order(messages)
355-
local preview_msgs, omitted = filter_preview_messages(messages)
356-
local formatted = format_messages(preview_msgs, omitted)
357-
render_preview_buffer(bufnr, formatted)
367+
messages = normalize_message_order(messages)
368+
local preview_msgs, omitted = filter_preview_messages(messages)
369+
local formatted = format_messages(preview_msgs, omitted)
370+
render_preview_buffer(target, formatted)
371+
end)
372+
:catch(function()
373+
if current_seq == preview_seq and target:is_valid() then
374+
target:set_lines({ 'No messages or failed to load' })
375+
end
376+
end)
358377
end,
359378
})
360379
end

0 commit comments

Comments
 (0)