diff --git a/README.md b/README.md index 06a2c6e4..bf5cb1e8 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Install the plugin with your favorite package manager. See the [Configuration](# ```lua -- Default configuration with all available options require('opencode').setup({ - 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 + 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 preferred_completion = nil, -- 'blink', 'nvim-cmp','vim_complete' if nil, it will use the best available completion default_global_keymaps = true, -- If false, disables all default global keymaps default_mode = 'build', -- 'build' or 'plan' or any custom configured. @see [OpenCode Agents](https://opencode.ai/docs/modes/) diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 02079146..2a7b5afa 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -292,9 +292,16 @@ end --- List messages for a session --- @param id string Session ID (required) --- @param directory string|nil Directory path +--- @param opts? { limit?: number } Optional query parameters --- @return Promise -function OpencodeApiClient:list_messages(id, directory) - return self:_call('/session/' .. id .. '/message', 'GET', nil, { directory = directory }) +function OpencodeApiClient:list_messages(id, directory, opts) + local query = { directory = directory } + if opts then + for k, v in pairs(opts) do + query[k] = v + end + end + return self:_call('/session/' .. id .. '/message', 'GET', nil, query) end --- Create and send a new message to a session diff --git a/lua/opencode/session.lua b/lua/opencode/session.lua index 5f920d1e..00270cfe 100644 --- a/lua/opencode/session.lua +++ b/lua/opencode/session.lua @@ -94,13 +94,14 @@ end) ---Get messages for a session ---@param session Session +---@param opts? { limit?: number } Optional query parameters (e.g. limit) ---@return Promise -function M.get_messages(session) +function M.get_messages(session, opts) if not session then return Promise.new():resolve(nil) end - return state.api_client:list_messages(session.id) + return state.api_client:list_messages(session.id, nil, opts) end ---Get snapshot IDs from a message's parts diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 426b6f2a..1b93cae8 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -356,7 +356,7 @@ ---@field fn? fun(args:string[]|nil):nil|Promise|any ---@class OpencodeConfig ----@field preferred_picker 'telescope' | 'fzf' | 'mini.pick' | 'snacks' | 'select' | nil +---@field preferred_picker 'telescope' | 'telescope.nvim' | 'fzf' | 'fzf-lua' | 'mini.pick' | 'snacks' | 'snacks.nvim' | 'select' | nil ---@field default_global_keymaps boolean ---@field default_mode 'build' | 'plan' | string -- Default mode ---@field default_system_prompt string | nil diff --git a/lua/opencode/ui/base_picker.lua b/lua/opencode/ui/base_picker.lua index b924022f..bda35b20 100644 --- a/lua/opencode/ui/base_picker.lua +++ b/lua/opencode/ui/base_picker.lua @@ -17,10 +17,17 @@ local Promise = require('opencode.promise') ---@field title string|fun(): string The picker title ---@field width? number Optional width for the picker (defaults to config or current window width) ---@field multi_selection? table Actions that support multi-selection ----@field preview? "file"|"none"|false Preview mode: "file" for file preview, "none" or false to disable +---@field preview? "file"|"custom"|"none"|false Preview mode: "file" for file preview, "custom" for custom preview via preview_fn, "none" or false to disable +---@field preview_fn? fun(item: any, target: PickerPreviewTarget): nil Custom preview function, called when preview = 'custom' and a selection changes ---@field layout_opts? OpencodeUIPickerConfig ---@field close? fun() Close the picker programmatically (set by the backend) +---@class PickerPreviewTarget +---@field get_bufnr fun(self: PickerPreviewTarget): integer? +---@field is_valid fun(self: PickerPreviewTarget): boolean +---@field set_lines fun(self: PickerPreviewTarget, lines: string[]): nil +---@field with_window fun(self: PickerPreviewTarget, fn: fun(): nil): nil + ---@class TelescopeEntry ---@field value any ---@field display fun(entry: TelescopeEntry): string[] @@ -57,6 +64,64 @@ local Promise = require('opencode.promise') local M = {} local picker = require('opencode.ui.picker') +---@param bufnr integer? +---@return PickerPreviewTarget +local function create_buffer_preview_target(bufnr) + return { + get_bufnr = function() + return bufnr + end, + is_valid = function() + return bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) + end, + set_lines = function(_, lines) + if bufnr == nil or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local modifiable = vim.bo[bufnr].modifiable + vim.bo[bufnr].modifiable = true + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.bo[bufnr].modifiable = modifiable + end, + with_window = function(_, fn) + if bufnr == nil or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + local win = vim.fn.bufwinid(bufnr) + if win ~= -1 then + vim.api.nvim_win_call(win, fn) + end + end, + } +end + +---@param ctx snacks.picker.preview.ctx +---@return PickerPreviewTarget +local function create_snacks_preview_target(ctx) + return { + get_bufnr = function() + return ctx.buf + end, + is_valid = function() + return ctx.buf ~= nil and vim.api.nvim_buf_is_valid(ctx.buf) + end, + set_lines = function(_, lines) + if ctx.preview and ctx.preview.set_lines then + ctx.preview:set_lines(lines) + elseif ctx.buf and vim.api.nvim_buf_is_valid(ctx.buf) then + create_buffer_preview_target(ctx.buf):set_lines(lines) + end + end, + with_window = function(_, fn) + if ctx.win and vim.api.nvim_win_is_valid(ctx.win) then + vim.api.nvim_win_call(ctx.win, fn) + return + end + create_buffer_preview_target(ctx.buf):with_window(fn) + end, + } +end + ---Build title with action legend ---@param base_title string The base title ---@param actions table The available actions @@ -146,7 +211,22 @@ local function telescope_ui(opts) prompt_title = opts.title, finder = finders.new_table({ results = opts.items, entry_maker = make_entry }), sorter = conf.generic_sorter({}), - previewer = opts.preview == 'file' and require('telescope.previewers').vim_buffer_vimgrep.new({}) or nil, + previewer = (function() + if opts.preview == 'file' then + return require('telescope.previewers').vim_buffer_vimgrep.new({}) + elseif opts.preview == 'custom' and opts.preview_fn then + return require('telescope.previewers').new_buffer_previewer({ + define_preview = function(self, entry) + if not entry then + return + end + opts.preview_fn(entry.value, create_buffer_preview_target(self.state.bufnr)) + end, + }) + else + return nil + end + end)(), layout_config = opts.width and { width = opts.width + 7, -- extra space for telescope UI } or nil, @@ -252,8 +332,35 @@ local function fzf_ui(opts) ['--delimiter'] = '\x01', -- use SOH as delimiter (invisible char) }, _headers = { 'actions' }, - -- Enable builtin previewer for file preview support - previewer = opts.preview == 'file' and 'builtin' or nil, + previewer = (function() + if opts.preview == 'file' then + return 'builtin' + elseif opts.preview == 'custom' and opts.preview_fn then + return { + _ctor = function() + local previewer = require('fzf-lua.previewer.builtin').buffer_or_file:extend() + function previewer:populate_preview_buf(entry_str) + if not self.win or not self.win:validate_preview() then + return + end + local idx_str = entry_str:match('^(%d+)\x01') + local idx = tonumber(idx_str) + if not idx or not opts.items[idx] then + return + end + -- Create scratch buffer, attach to preview window first + -- so preview_fn can use bufwinid for window-local ops (folds) + local buf = self:get_tmp_buffer() + self:set_preview_buf(buf, true) -- min_winopts=true + opts.preview_fn(opts.items[idx], create_buffer_preview_target(buf)) + end + return previewer + end, + } + else + return nil + end + end)(), fn_fzf_index = function(line) -- Extract the numeric index prefix before the SOH delimiter local idx_str = line:match('^(%d+)\x01') @@ -490,7 +597,8 @@ end local function snacks_picker_ui(opts) local Snacks = require('snacks') - local has_preview = opts.preview == 'file' + local has_custom_preview = opts.preview == 'custom' and opts.preview_fn ~= nil + local has_preview = opts.preview == 'file' or has_custom_preview local title = type(opts.title) == 'function' and opts.title() or opts.title ---@cast title string @@ -498,22 +606,27 @@ local function snacks_picker_ui(opts) local layout_opts = opts.layout_opts and opts.layout_opts.snacks_layout or nil local selection_made = false + local default_layout = { + preset = has_custom_preview and 'default' or 'select', + config = function(layout) + local width = opts.width and (opts.width + 3) or nil -- extra space for snacks UI + if not has_preview then + layout.layout.width = width + layout.layout.max_width = width + layout.layout.min_width = width + end + end, + } + if opts.preview == 'file' then + default_layout.preview = 'main' + elseif not has_preview then + default_layout.preview = false + end ---@type snacks.picker.Config local snack_opts = { title = title, - layout = layout_opts or { - preview = has_preview and 'main' or false, - preset = 'select', - config = function(layout) - local width = opts.width and (opts.width + 3) or nil -- extra space for snacks UI - if not has_preview then - layout.layout.width = width - layout.layout.max_width = width - layout.layout.min_width = width - end - end, - }, + layout = layout_opts or default_layout, finder = function() return opts.items end, @@ -560,9 +673,15 @@ local function snacks_picker_ui(opts) }, } - -- Add file preview if enabled - if has_preview then + if opts.preview == 'file' then snack_opts.preview = 'file' + elseif has_custom_preview then + snack_opts.preview = function(ctx) + if ctx.item then + ctx.preview:reset() + opts.preview_fn(ctx.item, create_snacks_preview_target(ctx)) + end + end else snack_opts.preview = function() return false diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 455e90e1..d4b89a83 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -482,22 +482,20 @@ function M.clear_extmarks(start_line, end_line, clear_all) pcall(vim.api.nvim_buf_clear_namespace, windows.output_buf, clear_all and -1 or M.namespace, start_line, end_line) end ----Apply extmarks to the output buffer +---Apply extmarks to any buffer (reusable for preview buffers) +---@param bufnr integer Target buffer ---@param extmarks table Extmarks indexed by line ---@param line_offset? integer Line offset to apply to extmarks, defaults to 0 -function M.set_extmarks(extmarks, line_offset) +function M.apply_extmarks(bufnr, extmarks, line_offset) if not extmarks or type(extmarks) ~= 'table' then return end - local windows = state.windows - if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end line_offset = line_offset or 0 - local output_buf = windows.output_buf - local line_indices = vim.tbl_keys(extmarks) table.sort(line_indices) @@ -525,11 +523,26 @@ function M.set_extmarks(extmarks, line_offset) end end ---@cast m vim.api.keyset.set_extmark - pcall(vim.api.nvim_buf_set_extmark, output_buf, M.namespace, target_line, start_col or 0, m) + pcall(vim.api.nvim_buf_set_extmark, bufnr, M.namespace, target_line, start_col or 0, m) end end end +---Apply extmarks to the output buffer +---@param extmarks table Extmarks indexed by line +---@param line_offset? integer Line offset to apply to extmarks, defaults to 0 +function M.set_extmarks(extmarks, line_offset) + if not extmarks or type(extmarks) ~= 'table' then + return + end + local windows = state.windows + if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then + return + end + + M.apply_extmarks(windows.output_buf, extmarks, line_offset) +end + ---@param start_line integer ---@param end_line integer function M.highlight_changed_lines(start_line, end_line) diff --git a/lua/opencode/ui/picker.lua b/lua/opencode/ui/picker.lua index 2be5dc1c..1ab42276 100644 --- a/lua/opencode/ui/picker.lua +++ b/lua/opencode/ui/picker.lua @@ -1,15 +1,25 @@ local M = {} +local picker_aliases = { + ['fzf-lua'] = 'fzf', + ['snacks.nvim'] = 'snacks', + ['telescope.nvim'] = 'telescope', +} + +local function normalize_picker_name(name) + if name == 'select' then + return nil + end + + return picker_aliases[name] or name +end + function M.get_best_picker() local config = require('opencode.config') local preferred_picker = config.preferred_picker if preferred_picker and type(preferred_picker) == 'string' and preferred_picker ~= '' then - if preferred_picker == 'select' then - return nil - end - - return preferred_picker + return normalize_picker_name(preferred_picker) end if pcall(require, 'telescope') then diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 2e525ac1..ffc41a55 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -36,6 +36,180 @@ function format_session_item(session, width) return base_picker.create_time_picker_item(session.title, updated_time, debug_text, width) end +--- Normalize message order to oldest-first (chronological) +--- API may return messages in descending order; reverse if detected. +---@param messages OpencodeMessage[] +---@return OpencodeMessage[] +local function normalize_message_order(messages) + if not messages or #messages <= 1 then + return messages or {} + end + -- Check if messages are in descending order by checking first two + local first_time = messages[1].info and messages[1].info.time and messages[1].info.time.created + local second_time = messages[2].info and messages[2].info.time and messages[2].info.time.created + if first_time and second_time and first_time > second_time then + local reversed = {} + for i = #messages, 1, -1 do + reversed[#reversed + 1] = messages[i] + end + return reversed + end + return messages +end + +--- Append extmarks from source into target, offset by line_offset +--- Uses append semantics (no overwrite of same-line marks) +---@param target table Target extmark map +---@param extmarks table Source extmark map +---@param line_offset integer Line offset for source marks +local function append_extmarks(target, extmarks, line_offset) + for line_idx, marks in pairs(extmarks or {}) do + local actual = line_idx + line_offset + target[actual] = target[actual] or {} + for _, mark in ipairs(marks) do + table.insert(target[actual], mark) + end + end +end + +--- Filter messages for preview: keep first user message + last assistant message +--- This is a display strategy — format_messages is the rendering mechanism. +---@param messages OpencodeMessage[] +---@return OpencodeMessage[], integer omitted_count +local function filter_preview_messages(messages) + if #messages <= 2 then + return messages, 0 + end + local first_user_idx = nil + local last_assistant_idx = nil + for i, msg in ipairs(messages) do + if msg.info and msg.info.role == 'user' and not first_user_idx then + first_user_idx = i + end + if msg.info and msg.info.role == 'assistant' then + last_assistant_idx = i + end + end + local result = {} + if first_user_idx then + table.insert(result, messages[first_user_idx]) + end + if last_assistant_idx then + table.insert(result, messages[last_assistant_idx]) + end + if #result == 0 then + return messages, 0 + end + local omitted = #messages - #result + return result, omitted +end + +--- Format messages using the existing formatter, aggregating all Outputs +---@param messages OpencodeMessage[] +---@param omitted_count? integer Number of messages omitted between first and second (for preview) +---@return { lines: string[], extmarks: table, fold_ranges: table<{from: integer, to: integer}> } +local function format_messages(messages, omitted_count) + local formatter = require('opencode.ui.formatter') + local all_lines = {} + local all_extmarks = {} + local all_fold_ranges = {} + local line_offset = 0 + local rendered_count = 0 + + for _, msg in ipairs(messages) do + if msg.info and msg.info.role then + -- Insert omitted notice between first and second rendered message + if rendered_count == 1 and omitted_count and omitted_count > 0 then + local notice = string.format(' ⋯ %d message(s) omitted ⋯', omitted_count) + vim.list_extend(all_lines, { '', notice, '' }) + line_offset = line_offset + 3 + end + + -- Format message header (no previous_message: show full header in preview) + local header = formatter.format_message_header(msg) + vim.list_extend(all_lines, header.lines) + append_extmarks(all_extmarks, header.extmarks, line_offset) + for _, range in ipairs(header.fold_ranges or {}) do + table.insert(all_fold_ranges, { + from = range.from + line_offset, + to = range.to + line_offset, + }) + end + line_offset = line_offset + #header.lines + + -- Format each part + local parts = msg.parts or {} + for part_idx, part in ipairs(parts) do + local is_last = part_idx == #parts + local ok, part_output = pcall(formatter.format_part, part, msg, is_last) + if ok and part_output then + vim.list_extend(all_lines, part_output.lines) + append_extmarks(all_extmarks, part_output.extmarks, line_offset) + for _, range in ipairs(part_output.fold_ranges or {}) do + table.insert(all_fold_ranges, { + from = range.from + line_offset, + to = range.to + line_offset, + }) + end + line_offset = line_offset + #part_output.lines + elseif not ok then + -- Degraded: show error line for failed part + table.insert(all_lines, '[render error]') + line_offset = line_offset + 1 + end + -- Note: Output.actions intentionally not collected (preview doesn't support interactive actions) + end + + rendered_count = rendered_count + 1 + end + end + + return { + lines = all_lines, + extmarks = all_extmarks, + fold_ranges = all_fold_ranges, + } +end + +--- Write formatted output to a preview buffer +---@param target PickerPreviewTarget +---@param formatted { lines: string[], extmarks: table, fold_ranges: table } +local function render_preview_buffer(target, formatted) + if not target:is_valid() then + return + end + local bufnr = target:get_bufnr() + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + local output_window = require('opencode.ui.output_window') + + target:set_lines(formatted.lines) + bufnr = target:get_bufnr() + if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then + return + end + + -- Clear old extmarks then apply new ones + pcall(vim.api.nvim_buf_clear_namespace, bufnr, output_window.namespace, 0, -1) + output_window.apply_extmarks(bufnr, formatted.extmarks) + + -- Apply folds (window-local operation) + target:with_window(function() + vim.api.nvim_set_option_value('number', false, { win = 0 }) + vim.api.nvim_set_option_value('relativenumber', false, { win = 0 }) + vim.api.nvim_set_option_value('foldmethod', 'manual', { win = 0 }) + vim.cmd('silent! normal! zE') -- clear existing manual folds + local line_count = vim.api.nvim_buf_line_count(bufnr) + for _, range in ipairs(formatted.fold_ranges) do + if range.from <= line_count and range.to <= line_count then + vim.cmd(range.from .. ',' .. range.to .. 'fold') + end + end + end) +end + function M.pick(sessions, callback) local actions = { rename = { @@ -149,6 +323,9 @@ function M.pick(sessions, callback) }, } + -- Preview state for race condition protection + local preview_seq = 0 + return base_picker.pick({ items = sessions, format_fn = format_session_item, @@ -157,6 +334,49 @@ function M.pick(sessions, callback) title = 'Select A Session', width = config.ui.picker_width or 100, layout_opts = config.ui.picker, + preview = 'custom', + ---@param session table + ---@param target PickerPreviewTarget + preview_fn = function(session, target) + preview_seq = preview_seq + 1 + local current_seq = preview_seq + target:set_lines({ 'Loading...' }) + + local state = require('opencode.state') + local ok, request = pcall(function() + return state.api_client:list_messages(session.id, nil) + end) + if not ok or not request then + target:set_lines({ 'No messages or failed to load' }) + return + end + + request + :and_then(function(messages) + -- Check race: another selection happened while we were loading + if current_seq ~= preview_seq then + return + end + if not target:is_valid() then + return + end + + if not messages or #messages == 0 then + target:set_lines({ 'No messages or failed to load' }) + return + end + + messages = normalize_message_order(messages) + local preview_msgs, omitted = filter_preview_messages(messages) + local formatted = format_messages(preview_msgs, omitted) + render_preview_buffer(target, formatted) + end) + :catch(function() + if current_seq == preview_seq and target:is_valid() then + target:set_lines({ 'No messages or failed to load' }) + end + end) + end, }) end diff --git a/tests/unit/base_picker_spec.lua b/tests/unit/base_picker_spec.lua index 28fa961a..bfa9cf54 100644 --- a/tests/unit/base_picker_spec.lua +++ b/tests/unit/base_picker_spec.lua @@ -134,4 +134,256 @@ describe('opencode.ui.base_picker', function() assert.equal(998000, item.score_add) end) + + describe('snacks preview', function() + local function pick_with(preview, preview_fn) + base_picker.pick({ + title = 'Test', + items = { { name = 'a' } }, + format_fn = function(item) + return base_picker.create_picker_item({ { text = item.name } }) + end, + actions = {}, + callback = function() end, + preview = preview, + preview_fn = preview_fn, + }) + end + + it('sets preview to "file" when preview="file"', function() + pick_with('file', nil) + + assert.equal('file', captured_opts.preview) + assert.equal('select', captured_opts.layout.preset) + assert.equal('main', captured_opts.layout.preview) + end) + + it('sets preview to a function that calls preview_fn with a preview target when preview="custom"', function() + local called_with = {} + local preview_fn = function(item, target) + called_with.item = item + called_with.target = target + end + + pick_with('custom', preview_fn) + + assert.is_function(captured_opts.preview) + local test_item = { name = 'test_item' } + local preview_lines + local bufnr = vim.api.nvim_create_buf(false, true) + local mock_preview = { + reset = function() end, + set_lines = function(_, lines) + preview_lines = lines + end, + } + local mock_ctx = { + item = test_item, + buf = bufnr, + preview = mock_preview, + } + captured_opts.preview(mock_ctx) + + assert.equal(test_item, called_with.item) + assert.is_table(called_with.target) + assert.equal(bufnr, called_with.target:get_bufnr()) + assert.is_true(called_with.target:is_valid()) + called_with.target:set_lines({ 'preview' }) + assert.are.same({ 'preview' }, preview_lines) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('sets preview to a function returning false when preview is nil', function() + pick_with(nil, nil) + + assert.is_function(captured_opts.preview) + assert.is_false(captured_opts.preview()) + assert.equal('select', captured_opts.layout.preset) + assert.is_false(captured_opts.layout.preview) + end) + + it('uses snacks default preview pane layout when preview="custom"', function() + pick_with('custom', function() end) + + assert.equal('default', captured_opts.layout.preset) + assert.is_nil(captured_opts.layout.preview) + end) + + it('disables preview layout when preview="custom" but preview_fn is missing', function() + pick_with('custom', nil) + + assert.is_function(captured_opts.preview) + assert.is_false(captured_opts.preview()) + assert.equal('select', captured_opts.layout.preset) + assert.is_false(captured_opts.layout.preview) + end) + + it('does not call preview_fn when preview="file"', function() + local called = false + pick_with('file', function() + called = true + end) + + assert.equal('file', captured_opts.preview) + assert.is_false(called) + end) + end) +end) + +describe('opencode.ui.base_picker fzf-lua preview', function() + local base_picker + local captured_fzf_opts + local original_schedule + local saved_modules + local next_preview_buf + + before_each(function() + original_schedule = vim.schedule + vim.schedule = function(fn) + fn() + end + + saved_modules = { + ['opencode.config'] = package.loaded['opencode.config'], + ['opencode.util'] = package.loaded['opencode.util'], + ['opencode.promise'] = package.loaded['opencode.promise'], + ['opencode.ui.picker'] = package.loaded['opencode.ui.picker'], + ['opencode.ui.base_picker'] = package.loaded['opencode.ui.base_picker'], + ['fzf-lua'] = package.loaded['fzf-lua'], + ['fzf-lua.previewer.builtin'] = package.loaded['fzf-lua.previewer.builtin'], + } + + package.loaded['opencode.config'] = { + ui = { + picker_width = 80, + }, + debug = { + show_ids = false, + }, + } + + package.loaded['opencode.util'] = { + some = function(tbl, fn) + for _, v in pairs(tbl) do + if fn(v) then + return true + end + end + return false + end, + } + + package.loaded['opencode.promise'] = { + wrap = function(value) + return { + and_then = function(_, cb) + cb(value) + end, + } + end, + } + + package.loaded['opencode.ui.picker'] = { + get_best_picker = function() + return 'fzf' + end, + } + + captured_fzf_opts = nil + + package.loaded['fzf-lua'] = { + fzf_exec = function(_, opts) + captured_fzf_opts = opts + end, + } + + next_preview_buf = nil + package.loaded['fzf-lua.previewer.builtin'] = { + buffer_or_file = { + extend = function() + return { + win = { + validate_preview = function() + return true + end, + }, + get_tmp_buffer = function() + return next_preview_buf + end, + set_preview_buf = function(self, buf) + self.preview_buf = buf + end, + } + end, + }, + } + + package.loaded['opencode.ui.base_picker'] = nil + base_picker = require('opencode.ui.base_picker') + end) + + after_each(function() + vim.schedule = original_schedule + for module_name, module_value in pairs(saved_modules) do + package.loaded[module_name] = module_value + end + end) + + local function pick_with(preview, preview_fn) + base_picker.pick({ + title = 'Test', + items = { { name = 'a' } }, + format_fn = function(item) + return base_picker.create_picker_item({ { text = item.name } }) + end, + actions = {}, + callback = function() end, + preview = preview, + preview_fn = preview_fn, + }) + end + + it('sets previewer to "builtin" when preview="file"', function() + pick_with('file', nil) + assert.equal('builtin', captured_fzf_opts.previewer) + end) + + it('sets previewer to nil when preview is nil', function() + pick_with(nil, nil) + assert.is_nil(captured_fzf_opts.previewer) + end) + + it('creates _ctor-based previewer when preview="custom"', function() + pick_with('custom', function() end) + assert.is_table(captured_fzf_opts.previewer) + assert.is_function(captured_fzf_opts.previewer._ctor) + end) + + it('sets previewer to nil when preview="custom" but no preview_fn', function() + pick_with('custom', nil) + assert.is_nil(captured_fzf_opts.previewer) + end) + + it('passes a preview target to custom preview_fn', function() + local received = {} + pick_with('custom', function(item, target) + received.item = item + received.target = target + end) + + next_preview_buf = vim.api.nvim_create_buf(false, true) + local previewer = captured_fzf_opts.previewer._ctor() + previewer:populate_preview_buf('1\001a') + + assert.equal('a', received.item.name) + assert.equal(next_preview_buf, received.target:get_bufnr()) + assert.is_true(received.target:is_valid()) + + received.target:set_lines({ 'fzf preview' }) + local lines = vim.api.nvim_buf_get_lines(next_preview_buf, 0, -1, false) + assert.are.same({ 'fzf preview' }, lines) + + vim.api.nvim_buf_delete(next_preview_buf, { force = true }) + end) end) diff --git a/tests/unit/picker_spec.lua b/tests/unit/picker_spec.lua new file mode 100644 index 00000000..766d0e18 --- /dev/null +++ b/tests/unit/picker_spec.lua @@ -0,0 +1,46 @@ +local assert = require('luassert') + +describe('opencode.ui.picker', function() + local saved_modules + + before_each(function() + saved_modules = { + ['opencode.config'] = package.loaded['opencode.config'], + ['opencode.ui.picker'] = package.loaded['opencode.ui.picker'], + } + end) + + after_each(function() + for module_name, module_value in pairs(saved_modules) do + package.loaded[module_name] = module_value + end + end) + + local function get_best_picker(preferred_picker) + package.loaded['opencode.config'] = { + preferred_picker = preferred_picker, + } + package.loaded['opencode.ui.picker'] = nil + + return require('opencode.ui.picker').get_best_picker() + end + + it('normalizes fzf-lua preferred picker to the fzf backend', function() + assert.equal('fzf', get_best_picker('fzf-lua')) + end) + + it('normalizes plugin-name aliases to picker backends', function() + assert.equal('telescope', get_best_picker('telescope.nvim')) + assert.equal('snacks', get_best_picker('snacks.nvim')) + end) + + it('preserves canonical preferred picker names', function() + assert.equal('fzf', get_best_picker('fzf')) + assert.equal('telescope', get_best_picker('telescope')) + assert.equal('snacks', get_best_picker('snacks')) + end) + + it('keeps select as the explicit vim.ui.select fallback', function() + assert.is_nil(get_best_picker('select')) + end) +end) diff --git a/tests/unit/session_picker_spec.lua b/tests/unit/session_picker_spec.lua index c674711e..f3c2eb3a 100644 --- a/tests/unit/session_picker_spec.lua +++ b/tests/unit/session_picker_spec.lua @@ -52,6 +52,62 @@ describe('opencode.ui.session_picker', function() end) end) + describe('preview_fn contract', function() + local original_api_client + local original_pick + + before_each(function() + original_api_client = state.api_client + local base_picker = require('opencode.ui.base_picker') + original_pick = base_picker.pick + end) + + after_each(function() + state.jobs.set_api_client(original_api_client) + require('opencode.ui.base_picker').pick = original_pick + end) + + it('writes through the backend-neutral preview target', function() + local base_picker = require('opencode.ui.base_picker') + local captured_opts + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + state.jobs.set_api_client({ + list_messages = function() + return Promise.new():resolve({}) + end, + }) + + session_picker.pick({ { id = 's1', title = 'Session', time = { updated = 'now' } } }, function() end) + assert.is_table(captured_opts) + + local writes = {} + local target = { + get_bufnr = function() + return nil + end, + is_valid = function() + return true + end, + set_lines = function(_, lines) + writes[#writes + 1] = lines + end, + with_window = function() end, + } + + captured_opts.preview_fn({ id = 's1' }, target) + vim.wait(100, function() + return #writes >= 2 + end) + + assert.are.same({ 'Loading...' }, writes[1]) + assert.are.same({ 'No messages or failed to load' }, writes[2]) + end) + end) + -- ----------------------------------------------------------------------- -- Integration tests: delete action triggers switch when parent/grandparent -- of the active session is deleted