Skip to content

Commit c6c8c78

Browse files
authored
feat(session-picker): add full message preview with formatter rendering (#398)
1 parent c46defe commit c6c8c78

11 files changed

Lines changed: 761 additions & 37 deletions

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/api_client.lua

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,16 @@ end
292292
--- List messages for a session
293293
--- @param id string Session ID (required)
294294
--- @param directory string|nil Directory path
295+
--- @param opts? { limit?: number } Optional query parameters
295296
--- @return Promise<OpencodeMessage[]>
296-
function OpencodeApiClient:list_messages(id, directory)
297-
return self:_call('/session/' .. id .. '/message', 'GET', nil, { directory = directory })
297+
function OpencodeApiClient:list_messages(id, directory, opts)
298+
local query = { directory = directory }
299+
if opts then
300+
for k, v in pairs(opts) do
301+
query[k] = v
302+
end
303+
end
304+
return self:_call('/session/' .. id .. '/message', 'GET', nil, query)
298305
end
299306

300307
--- Create and send a new message to a session

lua/opencode/session.lua

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,14 @@ end)
9494

9595
---Get messages for a session
9696
---@param session Session
97+
---@param opts? { limit?: number } Optional query parameters (e.g. limit)
9798
---@return Promise<OpencodeMessage[]>
98-
function M.get_messages(session)
99+
function M.get_messages(session, opts)
99100
if not session then
100101
return Promise.new():resolve(nil)
101102
end
102103

103-
return state.api_client:list_messages(session.id)
104+
return state.api_client:list_messages(session.id, nil, opts)
104105
end
105106

106107
---Get snapshot IDs from a message's parts

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: 138 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ local Promise = require('opencode.promise')
1717
---@field title string|fun(): string The picker title
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
20-
---@field preview? "file"|"none"|false Preview mode: "file" for file preview, "none" or false to disable
20+
---@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, target: PickerPreviewTarget): nil Custom preview function, called when preview = 'custom' and a selection changes
2122
---@field layout_opts? OpencodeUIPickerConfig
2223
---@field close? fun() Close the picker programmatically (set by the backend)
2324

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+
2431
---@class TelescopeEntry
2532
---@field value any
2633
---@field display fun(entry: TelescopeEntry): string[]
@@ -57,6 +64,64 @@ local Promise = require('opencode.promise')
5764
local M = {}
5865
local picker = require('opencode.ui.picker')
5966

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+
60125
---Build title with action legend
61126
---@param base_title string The base title
62127
---@param actions table<string, PickerAction> The available actions
@@ -146,7 +211,22 @@ local function telescope_ui(opts)
146211
prompt_title = opts.title,
147212
finder = finders.new_table({ results = opts.items, entry_maker = make_entry }),
148213
sorter = conf.generic_sorter({}),
149-
previewer = opts.preview == 'file' and require('telescope.previewers').vim_buffer_vimgrep.new({}) or nil,
214+
previewer = (function()
215+
if opts.preview == 'file' then
216+
return require('telescope.previewers').vim_buffer_vimgrep.new({})
217+
elseif opts.preview == 'custom' and opts.preview_fn then
218+
return require('telescope.previewers').new_buffer_previewer({
219+
define_preview = function(self, entry)
220+
if not entry then
221+
return
222+
end
223+
opts.preview_fn(entry.value, create_buffer_preview_target(self.state.bufnr))
224+
end,
225+
})
226+
else
227+
return nil
228+
end
229+
end)(),
150230
layout_config = opts.width and {
151231
width = opts.width + 7, -- extra space for telescope UI
152232
} or nil,
@@ -252,8 +332,35 @@ local function fzf_ui(opts)
252332
['--delimiter'] = '\x01', -- use SOH as delimiter (invisible char)
253333
},
254334
_headers = { 'actions' },
255-
-- Enable builtin previewer for file preview support
256-
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)(),
257364
fn_fzf_index = function(line)
258365
-- Extract the numeric index prefix before the SOH delimiter
259366
local idx_str = line:match('^(%d+)\x01')
@@ -490,30 +597,36 @@ end
490597
local function snacks_picker_ui(opts)
491598
local Snacks = require('snacks')
492599

493-
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
494602

495603
local title = type(opts.title) == 'function' and opts.title() or opts.title
496604
---@cast title string
497605

498606
local layout_opts = opts.layout_opts and opts.layout_opts.snacks_layout or nil
499607

500608
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
501625

502626
---@type snacks.picker.Config
503627
local snack_opts = {
504628
title = title,
505-
layout = layout_opts or {
506-
preview = has_preview and 'main' or false,
507-
preset = 'select',
508-
config = function(layout)
509-
local width = opts.width and (opts.width + 3) or nil -- extra space for snacks UI
510-
if not has_preview then
511-
layout.layout.width = width
512-
layout.layout.max_width = width
513-
layout.layout.min_width = width
514-
end
515-
end,
516-
},
629+
layout = layout_opts or default_layout,
517630
finder = function()
518631
return opts.items
519632
end,
@@ -560,9 +673,15 @@ local function snacks_picker_ui(opts)
560673
},
561674
}
562675

563-
-- Add file preview if enabled
564-
if has_preview then
676+
if opts.preview == 'file' then
565677
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
566685
else
567686
snack_opts.preview = function()
568687
return false

lua/opencode/ui/output_window.lua

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -482,22 +482,20 @@ function M.clear_extmarks(start_line, end_line, clear_all)
482482
pcall(vim.api.nvim_buf_clear_namespace, windows.output_buf, clear_all and -1 or M.namespace, start_line, end_line)
483483
end
484484

485-
---Apply extmarks to the output buffer
485+
---Apply extmarks to any buffer (reusable for preview buffers)
486+
---@param bufnr integer Target buffer
486487
---@param extmarks table<number, OutputExtmark[]> Extmarks indexed by line
487488
---@param line_offset? integer Line offset to apply to extmarks, defaults to 0
488-
function M.set_extmarks(extmarks, line_offset)
489+
function M.apply_extmarks(bufnr, extmarks, line_offset)
489490
if not extmarks or type(extmarks) ~= 'table' then
490491
return
491492
end
492-
local windows = state.windows
493-
if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then
493+
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then
494494
return
495495
end
496496

497497
line_offset = line_offset or 0
498498

499-
local output_buf = windows.output_buf
500-
501499
local line_indices = vim.tbl_keys(extmarks)
502500
table.sort(line_indices)
503501

@@ -525,11 +523,26 @@ function M.set_extmarks(extmarks, line_offset)
525523
end
526524
end
527525
---@cast m vim.api.keyset.set_extmark
528-
pcall(vim.api.nvim_buf_set_extmark, output_buf, M.namespace, target_line, start_col or 0, m)
526+
pcall(vim.api.nvim_buf_set_extmark, bufnr, M.namespace, target_line, start_col or 0, m)
529527
end
530528
end
531529
end
532530

531+
---Apply extmarks to the output buffer
532+
---@param extmarks table<number, OutputExtmark[]> Extmarks indexed by line
533+
---@param line_offset? integer Line offset to apply to extmarks, defaults to 0
534+
function M.set_extmarks(extmarks, line_offset)
535+
if not extmarks or type(extmarks) ~= 'table' then
536+
return
537+
end
538+
local windows = state.windows
539+
if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then
540+
return
541+
end
542+
543+
M.apply_extmarks(windows.output_buf, extmarks, line_offset)
544+
end
545+
533546
---@param start_line integer
534547
---@param end_line integer
535548
function M.highlight_changed_lines(start_line, end_line)

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

0 commit comments

Comments
 (0)