From 4e3bda19e9b61264d6eb400ef2eba546dc2d99a8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 11 Dec 2025 07:19:02 -0500 Subject: [PATCH 01/46] WIP: very early ground break --- lua/opencode/api.lua | 29 +- lua/opencode/config.lua | 8 + lua/opencode/context.lua | 464 +++++++++--- lua/opencode/context.lua.backup | 792 ++++++++++++++++++++ lua/opencode/init.lua | 1 + lua/opencode/promise.lua | 44 ++ lua/opencode/quick_chat.lua | 398 ++++++++++ lua/opencode/types.lua | 1 + lua/opencode/ui/completion/engines/base.lua | 4 +- tests/unit/context_spec.lua | 43 ++ 10 files changed, 1661 insertions(+), 123 deletions(-) create mode 100644 lua/opencode/context.lua.backup create mode 100644 lua/opencode/quick_chat.lua diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 0deb2dab..550c2eae 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -3,6 +3,7 @@ local util = require('opencode.util') local session = require('opencode.session') local config_file = require('opencode.config_file') local state = require('opencode.state') +local quick_chat = require('opencode.quick_chat') local input_window = require('opencode.ui.input_window') local ui = require('opencode.ui.ui') @@ -107,6 +108,15 @@ function M.select_history() require('opencode.ui.history_picker').pick() end +function M.quick_chat(message, range) + local options = {} + if type(message) == 'table' then + message = table.concat(message, ' ') + end + + return quick_chat.quick_chat(message, options, range) +end + function M.toggle_pane() return core.open({ new_session = false, focus = 'output' }):and_then(function() ui.toggle_pane() @@ -970,6 +980,14 @@ M.commands = { fn = M.toggle_zoom, }, + quick_chat = { + desc = 'Quick chat with current context', + fn = M.quick_chat, + range = true, -- Enable range support for visual selections + nargs = '+', -- Allow multiple arguments + complete = false, -- No completion for custom messages + }, + swap = { desc = 'Swap pane position left/right', fn = M.swap_position, @@ -1309,6 +1327,14 @@ M.legacy_command_map = { function M.route_command(opts) local args = vim.split(opts.args or '', '%s+', { trimempty = true }) + local range = nil + + if opts.range and opts.range > 0 then + range = { + start = opts.line1, + stop = opts.line2, + } + end if #args == 0 then M.toggle() @@ -1319,7 +1345,7 @@ function M.route_command(opts) local subcmd_def = M.commands[subcommand] if subcmd_def and subcmd_def.fn then - subcmd_def.fn(vim.list_slice(args, 2)) + subcmd_def.fn(vim.list_slice(args, 2), range) else vim.notify('Unknown subcommand: ' .. subcommand, vim.log.levels.ERROR) end @@ -1413,6 +1439,7 @@ function M.setup() vim.api.nvim_create_user_command('Opencode', M.route_command, { desc = 'Opencode.nvim main command with nested subcommands', nargs = '*', + range = true, -- Enable range support complete = M.complete_command, }) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 21f5a1e4..f36a8b75 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -22,6 +22,7 @@ M.defaults = { ['ot'] = { 'toggle_focus', desc = 'Toggle focus' }, ['oT'] = { 'timeline', desc = 'Session timeline' }, ['oq'] = { 'close', desc = 'Close Opencode window' }, + ['oQ'] = { 'quick_chat', desc = 'Quick chat with current context' }, ['os'] = { 'select_session', desc = 'Select session' }, ['oR'] = { 'rename_session', desc = 'Rename session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, @@ -170,6 +171,7 @@ M.defaults = { enabled = true, cursor_data = { enabled = false, + context_lines = 10, -- Number of lines before and after cursor to include in context }, diagnostics = { enabled = true, @@ -204,6 +206,12 @@ M.defaults = { on_done_thinking = nil, on_permission_requested = nil, }, + quick_chat = { + default_model = nil, -- Use current model if nil + default_agent = nil, -- Use current mode if nil + include_context_by_default = true, + default_prompt = nil, -- Use built-in prompt if nil + }, } M.values = vim.deepcopy(M.defaults) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 60fdc32c..885a1a63 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -3,59 +3,83 @@ local util = require('opencode.util') local config = require('opencode.config') local state = require('opencode.state') +local func = require('vim.func') local M = {} ----@type OpencodeContext -M.context = { - -- current file - current_file = nil, - cursor_data = nil, - - -- attachments - mentioned_files = nil, - selections = {}, - linter_errors = {}, - mentioned_subagents = {}, -} +---@class ContextInstance +---@field context OpencodeContext +---@field last_context OpencodeContext|nil +---@field context_config OpencodeContextConfig|nil Optional context config override +local ContextInstance = {} +ContextInstance.__index = ContextInstance + +--- Creates a new Context instance +---@param context_config? OpencodeContextConfig Optional context config to override global config +---@return ContextInstance +function ContextInstance:new(context_config) + self = setmetatable({}, ContextInstance) + self.context = { + -- current file + current_file = nil, + cursor_data = nil, + + -- attachments + mentioned_files = nil, + selections = {}, + linter_errors = {}, + mentioned_subagents = {}, + } + self.last_context = nil + self.context_config = context_config + return self +end -function M.unload_attachments() - M.context.mentioned_files = nil - M.context.selections = nil - M.context.linter_errors = nil +function ContextInstance:unload_attachments() + self.context.mentioned_files = nil + self.context.selections = nil + self.context.linter_errors = nil end -function M.get_current_buf() +function ContextInstance:get_current_buf() local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() if util.is_buf_a_file(curr_buf) then return curr_buf, state.last_code_win_before_opencode or vim.api.nvim_get_current_win() end end -function M.load() - local buf, win = M.get_current_buf() +function ContextInstance:load() + local buf, win = self:get_current_buf() if buf then - local current_file = M.get_current_file(buf) - local cursor_data = M.get_current_cursor_data(buf, win) + local current_file = self:get_current_file(buf) + local cursor_data = self:get_current_cursor_data(buf, win) - M.context.current_file = current_file - M.context.cursor_data = cursor_data - M.context.linter_errors = M.get_diagnostics(buf) + self.context.current_file = current_file + self.context.cursor_data = cursor_data + self.context.linter_errors = self:get_diagnostics(buf) end - local current_selection = M.get_current_selection() + local current_selection = self:get_current_selection() if current_selection then - local selection = M.new_selection(M.context.current_file, current_selection.text, current_selection.lines) - M.add_selection(selection) + local selection = self:new_selection(self.context.current_file, current_selection.text, current_selection.lines) + self:add_selection(selection) end - state.context_updated_at = vim.uv.now() end -- Checks if a context feature is enabled in config or state ---@param context_key string ---@return boolean -function M.is_context_enabled(context_key) +function ContextInstance:is_context_enabled(context_key) + -- If instance has a context config, use it as the override + if self.context_config then + local override_enabled = vim.tbl_get(self.context_config, context_key, 'enabled') + if override_enabled ~= nil then + return override_enabled + end + end + + -- Fall back to the existing logic (state then global config) local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') @@ -67,8 +91,8 @@ function M.is_context_enabled(context_key) end ---@return OpencodeDiagnostic[]|nil -function M.get_diagnostics(buf) - if not M.is_context_enabled('diagnostics') then +function ContextInstance:get_diagnostics(buf) + if not self:is_context_enabled('diagnostics') then return nil end @@ -78,7 +102,8 @@ function M.get_diagnostics(buf) end local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} - local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf) or {} + local override_conf = self.context_config and vim.tbl_get(self.context_config, 'diagnostics') or {} + local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf, override_conf) or {} local severity_levels = {} if diagnostic_conf.error then @@ -96,10 +121,26 @@ function M.get_diagnostics(buf) return {} end - return diagnostics + -- Convert vim.Diagnostic[] to OpencodeDiagnostic[] + local opencode_diagnostics = {} + for _, diag in ipairs(diagnostics) do + table.insert(opencode_diagnostics, { + message = diag.message, + severity = diag.severity, + lnum = diag.lnum, + col = diag.col, + end_lnum = diag.end_lnum, + end_col = diag.end_col, + source = diag.source, + code = diag.code, + user_data = diag.user_data, + }) + end + + return opencode_diagnostics end -function M.new_selection(file, content, lines) +function ContextInstance:new_selection(file, content, lines) return { file = file, content = util.indent_code_block(content), @@ -107,37 +148,34 @@ function M.new_selection(file, content, lines) } end -function M.add_selection(selection) - if not M.context.selections then - M.context.selections = {} +function ContextInstance:add_selection(selection) + if not self.context.selections then + self.context.selections = {} end - table.insert(M.context.selections, selection) - - state.context_updated_at = vim.uv.now() + table.insert(self.context.selections, selection) end -function M.remove_selection(selection) - if not M.context.selections then +function ContextInstance:remove_selection(selection) + if not self.context.selections then return end - for i, sel in ipairs(M.context.selections) do + for i, sel in ipairs(self.context.selections) do if sel.file.path == selection.file.path and sel.lines == selection.lines then - table.remove(M.context.selections, i) + table.remove(self.context.selections, i) break end end - state.context_updated_at = vim.uv.now() end -function M.clear_selections() - M.context.selections = nil +function ContextInstance:clear_selections() + self.context.selections = nil end -function M.add_file(file) - if not M.context.mentioned_files then - M.context.mentioned_files = {} +function ContextInstance:add_file(file) + if not self.context.mentioned_files then + self.context.mentioned_files = {} end local is_file = vim.fn.filereadable(file) == 1 @@ -154,105 +192,69 @@ function M.add_file(file) file = vim.fn.fnamemodify(file, ':p') - if not vim.tbl_contains(M.context.mentioned_files, file) then - table.insert(M.context.mentioned_files, file) + if not vim.tbl_contains(self.context.mentioned_files, file) then + table.insert(self.context.mentioned_files, file) end - - state.context_updated_at = vim.uv.now() end -function M.remove_file(file) +function ContextInstance:remove_file(file) file = vim.fn.fnamemodify(file, ':p') - if not M.context.mentioned_files then + if not self.context.mentioned_files then return end - for i, f in ipairs(M.context.mentioned_files) do + for i, f in ipairs(self.context.mentioned_files) do if f == file then - table.remove(M.context.mentioned_files, i) + table.remove(self.context.mentioned_files, i) break end end - state.context_updated_at = vim.uv.now() end -function M.clear_files() - M.context.mentioned_files = nil +function ContextInstance:clear_files() + self.context.mentioned_files = nil end -function M.add_subagent(subagent) - if not M.context.mentioned_subagents then - M.context.mentioned_subagents = {} +function ContextInstance:get_mentioned_files() + return self.context.mentioned_files or {} +end + +function ContextInstance:add_subagent(subagent) + if not self.context.mentioned_subagents then + self.context.mentioned_subagents = {} end - if not vim.tbl_contains(M.context.mentioned_subagents, subagent) then - table.insert(M.context.mentioned_subagents, subagent) + if not vim.tbl_contains(self.context.mentioned_subagents, subagent) then + table.insert(self.context.mentioned_subagents, subagent) end - state.context_updated_at = vim.uv.now() end -function M.remove_subagent(subagent) - if not M.context.mentioned_subagents then +function ContextInstance:remove_subagent(subagent) + if not self.context.mentioned_subagents then return end - for i, a in ipairs(M.context.mentioned_subagents) do + for i, a in ipairs(self.context.mentioned_subagents) do if a == subagent then - table.remove(M.context.mentioned_subagents, i) + table.remove(self.context.mentioned_subagents, i) break end end - state.context_updated_at = vim.uv.now() end -function M.clear_subagents() - M.context.mentioned_subagents = nil +function ContextInstance:clear_subagents() + self.context.mentioned_subagents = nil end ----@param opts? OpencodeContextConfig ----@return OpencodeContext -function M.delta_context(opts) - opts = opts or config.context - if opts.enabled == false then - return { - current_file = nil, - mentioned_files = nil, - selections = nil, - linter_errors = nil, - cursor_data = nil, - mentioned_subagents = nil, - } - end - - local context = vim.deepcopy(M.context) - local last_context = state.last_sent_context - if not last_context then - return context - end - - -- no need to send file context again - if - context.current_file - and last_context.current_file - and context.current_file.name == last_context.current_file.name - then - context.current_file = nil - end - - -- no need to send subagents again - if - context.mentioned_subagents - and last_context.mentioned_subagents - and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents) - then - context.mentioned_subagents = nil +function ContextInstance:get_mentioned_subagents() + if not self:is_context_enabled('agents') then + return nil end - - return context + return self.context.mentioned_subagents or {} end -function M.get_current_file(buf) - if not M.is_context_enabled('current_file') then +function ContextInstance:get_current_file(buf) + if not self:is_context_enabled('current_file') then return nil end local file = vim.api.nvim_buf_get_name(buf) @@ -266,8 +268,8 @@ function M.get_current_file(buf) } end -function M.get_current_cursor_data(buf, win) - if not M.is_context_enabled('cursor_data') then +function ContextInstance:get_current_cursor_data(buf, win) + if not self:is_context_enabled('cursor_data') then return nil end @@ -277,8 +279,8 @@ function M.get_current_cursor_data(buf, win) return { line = cursor_pos[2], column = cursor_pos[3], line_content = cursor_content } end -function M.get_current_selection() - if not M.is_context_enabled('selection') then +function ContextInstance:get_current_selection() + if not self:is_context_enabled('selection') then return nil end @@ -318,6 +320,183 @@ function M.get_current_selection() } end +function ContextInstance:get_selections() + if not self:is_context_enabled('selection') then + return {} + end + return self.context.selections or {} +end + +---@param opts? OpencodeContextConfig +---@return OpencodeContext +function ContextInstance:delta_context(opts) + opts = opts or config.context + if opts.enabled == false then + return { + current_file = nil, + mentioned_files = nil, + selections = nil, + linter_errors = nil, + cursor_data = nil, + mentioned_subagents = nil, + } + end + + local context = vim.deepcopy(self.context) + local last_context = self.last_context + if not last_context then + return context + end + + -- no need to send file context again + if + context.current_file + and last_context.current_file + and context.current_file.name == last_context.current_file.name + then + context.current_file = nil + end + + -- no need to send subagents again + if + context.mentioned_subagents + and last_context.mentioned_subagents + and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents) + then + context.mentioned_subagents = nil + end + + return context +end + +--- Set the last context (used for delta calculations) +---@param last_context OpencodeContext +function ContextInstance:set_last_context(last_context) + self.last_context = last_context +end + +-- Global context instance +---@type ContextInstance +local global_context = ContextInstance:new() + +-- Exposed API +---@type OpencodeContext +M.context = global_context.context + +--- Creates a new independent context instance +---@param context_config? OpencodeContextConfig Optional context config to override global config +---@return ContextInstance +function M.new_instance(context_config) + return ContextInstance:new(context_config) +end + +function M.unload_attachments() + global_context:unload_attachments() +end + +function M.get_current_buf() + return global_context:get_current_buf() +end + +function M.load() + global_context:load() + state.context_updated_at = vim.uv.now() +end + +function M.is_context_enabled(context_key) + return global_context:is_context_enabled(context_key) +end + +function M.get_diagnostics(buf) + return global_context:get_diagnostics(buf) +end + +function M.new_selection(file, content, lines) + return global_context:new_selection(file, content, lines) +end + +function M.add_selection(selection) + global_context:add_selection(selection) + state.context_updated_at = vim.uv.now() +end + +function M.remove_selection(selection) + global_context:remove_selection(selection) + state.context_updated_at = vim.uv.now() +end + +function M.clear_selections() + global_context:clear_selections() +end + +function M.add_file(file) + global_context:add_file(file) + state.context_updated_at = vim.uv.now() +end + +function M.remove_file(file) + global_context:remove_file(file) + state.context_updated_at = vim.uv.now() +end + +function M.clear_files() + global_context:clear_files() +end + +function M.add_subagent(subagent) + global_context:add_subagent(subagent) + state.context_updated_at = vim.uv.now() +end + +function M.remove_subagent(subagent) + global_context:remove_subagent(subagent) + state.context_updated_at = vim.uv.now() +end + +function M.clear_subagents() + global_context:clear_subagents() +end + +function M.delta_context(opts) + local context = global_context:delta_context(opts) + local last_context = state.last_sent_context + if not last_context then + return context + end + + -- no need to send file context again + if + context.current_file + and last_context.current_file + and context.current_file.name == last_context.current_file.name + then + context.current_file = nil + end + + -- no need to send subagents again + if + context.mentioned_subagents + and last_context.mentioned_subagents + and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents) + then + context.mentioned_subagents = nil + end + + return context +end + +function M.get_current_file(buf) + return global_context:get_current_file(buf) +end + +function M.get_current_cursor_data(buf, win) + return global_context:get_current_cursor_data(buf, win) +end + +function M.get_current_selection() + return global_context:get_current_selection() +end + local function format_file_part(path, prompt) local rel_path = vim.fn.fnamemodify(path, ':~:.') local mention = '@' .. rel_path @@ -447,6 +626,51 @@ function M.format_message(prompt, opts) return parts end +--- Formats a prompt and context into message without state tracking (bypasses delta) +--- Used for ephemeral sessions like quick chat that don't track context state +---@param prompt string +---@param opts? OpencodeContextConfig|nil +---@param context_instance ContextInstance Optional context instance to use instead of global +---@return OpencodeMessagePart[] +function M.format_message_stateless(prompt, opts, context_instance) + opts = opts or config.context + if opts.enabled == false then + return { { type = 'text', text = prompt } } + end + local parts = { { type = 'text', text = prompt } } + + for _, path in ipairs(context_instance:get_mentioned_files() or {}) do + table.insert(parts, format_file_part(path, prompt)) + end + + for _, sel in ipairs(context_instance:get_selections() or {}) do + vim.print('⭕ ❱ context.lua:639 ❱ ƒ(_) ❱ sel =', sel) + table.insert(parts, format_selection_part(sel)) + end + + for _, agent in ipairs(context_instance:get_mentioned_subagents() or {}) do + table.insert(parts, format_subagents_part(agent, prompt)) + end + + local current_file = context_instance:get_current_file(context_instance:get_current_buf() or 0) + if current_file then + table.insert(parts, format_file_part(current_file.path)) + end + + local diagnostics = context_instance:get_diagnostics(context_instance:get_current_buf() or 0) + if diagnostics and #diagnostics > 0 then + table.insert(parts, format_diagnostics_part(diagnostics)) + end + + local cursor_data = + context_instance:get_current_cursor_data(context_instance:get_current_buf() or 0, vim.api.nvim_get_current_win()) + if cursor_data then + table.insert(parts, format_cursor_data_part(cursor_data)) + end + + return parts +end + ---@param text string ---@param context_type string|nil function M.decode_json_context(text, context_type) @@ -526,7 +750,7 @@ function M.extract_legacy_tag(tag, text) end function M.setup() - state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function(a) + state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function() M.load() end) diff --git a/lua/opencode/context.lua.backup b/lua/opencode/context.lua.backup new file mode 100644 index 00000000..0f5cd4da --- /dev/null +++ b/lua/opencode/context.lua.backup @@ -0,0 +1,792 @@ +-- Gathers editor context + +local util = require('opencode.util') +local config = require('opencode.config') +local state = require('opencode.state') + +local M = {} + +---@class ContextInstance +---@field private _context OpencodeContext +---@field private _last_context OpencodeContext|nil +local ContextInstance = {} +ContextInstance.__index = ContextInstance + +--- Creates a new Context instance +---@return ContextInstance +function ContextInstance:new() + local obj = setmetatable({}, self) + obj._context = { + -- current file + current_file = nil, + cursor_data = nil, + + -- attachments + mentioned_files = nil, + selections = {}, + linter_errors = {}, + mentioned_subagents = {}, + } + obj._last_context = nil + return obj +end + +--- Get the internal context data (read-only) +---@return OpencodeContext +function ContextInstance:get_context() + return vim.deepcopy(self._context) +end + +---@type OpencodeContext +M.context = { + -- current file + current_file = nil, + cursor_data = nil, + + -- attachments + mentioned_files = nil, + selections = {}, + linter_errors = {}, + mentioned_subagents = {}, +} + +-- Default instance for backward compatibility +---@type ContextInstance +M._default_instance = ContextInstance:new() + +function ContextInstance:unload_attachments() + self._context.mentioned_files = nil + self._context.selections = nil + self._context.linter_errors = nil +end + +function M.unload_attachments() + M._default_instance:unload_attachments() + -- Also update the global context for backward compatibility + M.context.mentioned_files = nil + M.context.selections = nil + M.context.linter_errors = nil +end + +function ContextInstance:get_current_buf() + local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() + if util.is_buf_a_file(curr_buf) then + return curr_buf, state.last_code_win_before_opencode or vim.api.nvim_get_current_win() + end +end + +function M.get_current_buf() + local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() + if util.is_buf_a_file(curr_buf) then + return curr_buf, state.last_code_win_before_opencode or vim.api.nvim_get_current_win() + end +end + +function ContextInstance:load() + local buf, win = self:get_current_buf() + + if buf then + local current_file = self:get_current_file(buf) + local cursor_data = self:get_current_cursor_data(buf, win) + + self._context.current_file = current_file + self._context.cursor_data = cursor_data + self._context.linter_errors = self:get_diagnostics(buf) + end + + local current_selection = self:get_current_selection() + if current_selection then + local selection = self:new_selection(self._context.current_file, current_selection.text, current_selection.lines) + self:add_selection(selection) + end + -- Note: We don't update state.context_updated_at for instance methods +end + +function M.load() + local buf, win = M.get_current_buf() + + if buf then + local current_file = M.get_current_file(buf) + local cursor_data = M.get_current_cursor_data(buf, win) + + M.context.current_file = current_file + M.context.cursor_data = cursor_data + M.context.linter_errors = M.get_diagnostics(buf) + end + + local current_selection = M.get_current_selection() + if current_selection then + local selection = M.new_selection(M.context.current_file, current_selection.text, current_selection.lines) + M.add_selection(selection) + end + state.context_updated_at = vim.uv.now() + + -- Update default instance to keep it in sync + M._default_instance._context = vim.deepcopy(M.context) +end + +-- Checks if a context feature is enabled in config or state +---@param context_key string +---@return boolean +function ContextInstance:is_context_enabled(context_key) + local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') + local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') + + if is_state_enabled ~= nil then + return is_state_enabled + else + return is_enabled + end +end + +-- Checks if a context feature is enabled in config or state +---@param context_key string +---@return boolean +function M.is_context_enabled(context_key) + local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') + local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') + + if is_state_enabled ~= nil then + return is_state_enabled + else + return is_enabled + end +end + +---@return OpencodeDiagnostic[]|nil +function ContextInstance:get_diagnostics(buf) + if not self:is_context_enabled('diagnostics') then + return nil + end + + local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} + if current_conf.enabled == false then + return {} + end + + local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} + local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf) or {} + + local severity_levels = {} + if diagnostic_conf.error then + table.insert(severity_levels, vim.diagnostic.severity.ERROR) + end + if diagnostic_conf.warning then + table.insert(severity_levels, vim.diagnostic.severity.WARN) + end + if diagnostic_conf.info then + table.insert(severity_levels, vim.diagnostic.severity.INFO) + end + + local diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) + if #diagnostics == 0 then + return {} + end + + -- Convert vim.Diagnostic[] to OpencodeDiagnostic[] + local opencode_diagnostics = {} + for _, diag in ipairs(diagnostics) do + table.insert(opencode_diagnostics, { + message = diag.message, + severity = diag.severity, + lnum = diag.lnum, + col = diag.col, + end_lnum = diag.end_lnum, + end_col = diag.end_col, + source = diag.source, + code = diag.code, + user_data = diag.user_data, + }) + end + + return opencode_diagnostics +end + +---@return OpencodeDiagnostic[]|nil +function M.get_diagnostics(buf) + if not M.is_context_enabled('diagnostics') then + return nil + end + + local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} + if current_conf.enabled == false then + return {} + end + + local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} + local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf) or {} + + local severity_levels = {} + if diagnostic_conf.error then + table.insert(severity_levels, vim.diagnostic.severity.ERROR) + end + if diagnostic_conf.warning then + table.insert(severity_levels, vim.diagnostic.severity.WARN) + end + if diagnostic_conf.info then + table.insert(severity_levels, vim.diagnostic.severity.INFO) + end + + local diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) + if #diagnostics == 0 then + return {} + end + + -- Convert vim.Diagnostic[] to OpencodeDiagnostic[] + local opencode_diagnostics = {} + for _, diag in ipairs(diagnostics) do + table.insert(opencode_diagnostics, { + message = diag.message, + severity = diag.severity, + lnum = diag.lnum, + col = diag.col, + end_lnum = diag.end_lnum, + end_col = diag.end_col, + source = diag.source, + code = diag.code, + user_data = diag.user_data, + }) + end + + return opencode_diagnostics +end + +function ContextInstance:new_selection(file, content, lines) + return { + file = file, + content = util.indent_code_block(content), + lines = lines, + } +end + +function M.new_selection(file, content, lines) + return { + file = file, + content = util.indent_code_block(content), + lines = lines, + } +end + +function ContextInstance:add_selection(selection) + if not self._context.selections then + self._context.selections = {} + end + + table.insert(self._context.selections, selection) + -- Note: We don't update state.context_updated_at for instance methods +end + +function M.add_selection(selection) + if not M.context.selections then + M.context.selections = {} + end + + table.insert(M.context.selections, selection) + + state.context_updated_at = vim.uv.now() +end + +function ContextInstance:remove_selection(selection) + if not self._context.selections then + return + end + + for i, sel in ipairs(self._context.selections) do + if sel.file.path == selection.file.path and sel.lines == selection.lines then + table.remove(self._context.selections, i) + break + end + end + -- Note: We don't update state.context_updated_at for instance methods +end + +function M.remove_selection(selection) + if not M.context.selections then + return + end + + for i, sel in ipairs(M.context.selections) do + if sel.file.path == selection.file.path and sel.lines == selection.lines then + table.remove(M.context.selections, i) + break + end + end + state.context_updated_at = vim.uv.now() +end + +function ContextInstance:clear_selections() + self._context.selections = nil +end + +function M.clear_selections() + M.context.selections = nil +end + +function M.add_file(file) + if not M.context.mentioned_files then + M.context.mentioned_files = {} + end + + local is_file = vim.fn.filereadable(file) == 1 + local is_dir = vim.fn.isdirectory(file) == 1 + if not is_file and not is_dir then + vim.notify('File not added to context. Could not read.') + return + end + + if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then + vim.notify('File not added to context. Must be inside current working directory.') + return + end + + file = vim.fn.fnamemodify(file, ':p') + + if not vim.tbl_contains(M.context.mentioned_files, file) then + table.insert(M.context.mentioned_files, file) + end + + state.context_updated_at = vim.uv.now() +end + +function M.remove_file(file) + file = vim.fn.fnamemodify(file, ':p') + if not M.context.mentioned_files then + return + end + + for i, f in ipairs(M.context.mentioned_files) do + if f == file then + table.remove(M.context.mentioned_files, i) + break + end + end + state.context_updated_at = vim.uv.now() +end + +function M.clear_files() + M.context.mentioned_files = nil +end + +function M.add_subagent(subagent) + if not M.context.mentioned_subagents then + M.context.mentioned_subagents = {} + end + + if not vim.tbl_contains(M.context.mentioned_subagents, subagent) then + table.insert(M.context.mentioned_subagents, subagent) + end + state.context_updated_at = vim.uv.now() +end + +function M.remove_subagent(subagent) + if not M.context.mentioned_subagents then + return + end + + for i, a in ipairs(M.context.mentioned_subagents) do + if a == subagent then + table.remove(M.context.mentioned_subagents, i) + break + end + end + state.context_updated_at = vim.uv.now() +end + +function M.clear_subagents() + M.context.mentioned_subagents = nil +end + +---@param opts? OpencodeContextConfig +---@return OpencodeContext +function M.delta_context(opts) + opts = opts or config.context + if opts.enabled == false then + return { + current_file = nil, + mentioned_files = nil, + selections = nil, + linter_errors = nil, + cursor_data = nil, + mentioned_subagents = nil, + } + end + + local context = vim.deepcopy(M.context) + local last_context = state.last_sent_context + if not last_context then + return context + end + + -- no need to send file context again + if + context.current_file + and last_context.current_file + and context.current_file.name == last_context.current_file.name + then + context.current_file = nil + end + + -- no need to send subagents again + if + context.mentioned_subagents + and last_context.mentioned_subagents + and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents) + then + context.mentioned_subagents = nil + end + + return context +end + +function M.get_current_file(buf) + if not M.is_context_enabled('current_file') then + return nil + end + local file = vim.api.nvim_buf_get_name(buf) + if not file or file == '' or vim.fn.filereadable(file) ~= 1 then + return nil + end + return { + path = file, + name = vim.fn.fnamemodify(file, ':t'), + extension = vim.fn.fnamemodify(file, ':e'), + } +end + +function M.get_current_cursor_data(buf, win) + if not M.is_context_enabled('cursor_data') then + return nil + end + + local cursor_pos = vim.fn.getcurpos(win) + local start_line = (cursor_pos[2] - 1) --[[@as integer]] + local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') + return { line = cursor_pos[2], column = cursor_pos[3], line_content = cursor_content } +end + +function M.get_current_selection() + if not M.is_context_enabled('selection') then + return nil + end + + -- Return nil if not in a visual mode + if not vim.fn.mode():match('[vV\022]') then + return nil + end + + -- Save current position and register state + local current_pos = vim.fn.getpos('.') + local old_reg = vim.fn.getreg('x') + local old_regtype = vim.fn.getregtype('x') + + -- Capture selection text and position + vim.cmd('normal! "xy') + local text = vim.fn.getreg('x') + + -- Get line numbers + vim.cmd('normal! `<') + local start_line = vim.fn.line('.') + vim.cmd('normal! `>') + local end_line = vim.fn.line('.') + + -- Restore state + vim.fn.setreg('x', old_reg, old_regtype) + vim.cmd('normal! gv') + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true) + vim.fn.setpos('.', current_pos) + + if not text or text == '' then + return nil + end + + return { + text = text and text:match('[^%s]') and text or nil, + lines = start_line .. ', ' .. end_line, + } +end + +local function format_file_part(path, prompt) + local rel_path = vim.fn.fnamemodify(path, ':~:.') + local mention = '@' .. rel_path + local pos = prompt and prompt:find(mention) + pos = pos and pos - 1 or 0 -- convert to 0-based index + + local ext = vim.fn.fnamemodify(path, ':e'):lower() + local mime_type = 'text/plain' + if ext == 'png' then + mime_type = 'image/png' + elseif ext == 'jpg' or ext == 'jpeg' then + mime_type = 'image/jpeg' + elseif ext == 'gif' then + mime_type = 'image/gif' + elseif ext == 'webp' then + mime_type = 'image/webp' + end + + local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path } + if prompt then + file_part.source = { + path = path, + type = 'file', + text = { start = pos, value = mention, ['end'] = pos + #mention }, + } + end + return file_part +end + +---@param selection OpencodeContextSelection +local function format_selection_part(selection) + local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' + + return { + type = 'text', + text = vim.json.encode({ + context_type = 'selection', + file = selection.file, + content = string.format('`````%s\n%s\n`````', lang, selection.content), + lines = selection.lines, + }), + synthetic = true, + } +end + +---@param diagnostics OpencodeDiagnostic[] +local function format_diagnostics_part(diagnostics) + local diag_list = {} + for _, diag in ipairs(diagnostics) do + local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') + table.insert( + diag_list, + { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } + ) + end + return { + type = 'text', + text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }), + synthetic = true, + } +end + +local function format_cursor_data_part(cursor_data) + local buf = (M.get_current_buf() or 0) --[[@as integer]] + local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or '' + return { + type = 'text', + text = vim.json.encode({ + context_type = 'cursor-data', + line = cursor_data.line, + column = cursor_data.column, + line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), + }), + synthetic = true, + } +end + +local function format_subagents_part(agent, prompt) + local mention = '@' .. agent + local pos = prompt:find(mention) + pos = pos and pos - 1 or 0 -- convert to 0-based index + + return { + type = 'agent', + name = agent, + source = { value = mention, start = pos, ['end'] = pos + #mention }, + } +end + +--- Formats a prompt and context into message with parts for the opencode API +---@param prompt string +---@param opts? OpencodeContextConfig|nil +---@return OpencodeMessagePart[] +function M.format_message(prompt, opts) + opts = opts or config.context + local context = M.delta_context(opts) + + local parts = { { type = 'text', text = prompt } } + + for _, path in ipairs(context.mentioned_files or {}) do + -- don't resend current file if it's also mentioned + if not context.current_file or path ~= context.current_file.path then + table.insert(parts, format_file_part(path, prompt)) + end + end + + for _, sel in ipairs(context.selections or {}) do + table.insert(parts, format_selection_part(sel)) + end + + for _, agent in ipairs(context.mentioned_subagents or {}) do + table.insert(parts, format_subagents_part(agent, prompt)) + end + + if context.current_file then + table.insert(parts, format_file_part(context.current_file.path)) + end + + if context.linter_errors and #context.linter_errors > 0 then + table.insert(parts, format_diagnostics_part(context.linter_errors)) + end + + if context.cursor_data then + table.insert(parts, format_cursor_data_part(context.cursor_data)) + end + + return parts +end + +--- Formats a prompt and context into message without state tracking (bypasses delta) +--- Used for ephemeral sessions like quick chat that don't track context state +---@param prompt string +---@param opts? OpencodeContextConfig|nil +---@return OpencodeMessagePart[] +function M.format_message_stateless(prompt, opts) + opts = opts or config.context + if opts.enabled == false then + return { { type = 'text', text = prompt } } + end + + -- Use full context instead of delta for ephemeral sessions + local context = vim.deepcopy(M.context) + + local parts = { { type = 'text', text = prompt } } + + for _, path in ipairs(context.mentioned_files or {}) do + -- don't resend current file if it's also mentioned + if not context.current_file or path ~= context.current_file.path then + table.insert(parts, format_file_part(path, prompt)) + end + end + + for _, sel in ipairs(context.selections or {}) do + table.insert(parts, format_selection_part(sel)) + end + + for _, agent in ipairs(context.mentioned_subagents or {}) do + table.insert(parts, format_subagents_part(agent, prompt)) + end + + if context.current_file then + table.insert(parts, format_file_part(context.current_file.path)) + end + + if context.linter_errors and #context.linter_errors > 0 then + table.insert(parts, format_diagnostics_part(context.linter_errors)) + end + + if context.cursor_data then + table.insert(parts, format_cursor_data_part(context.cursor_data)) + end + + return parts +end + +---@param text string +---@param context_type string|nil +function M.decode_json_context(text, context_type) + local ok, result = pcall(vim.json.decode, text) + if not ok or (context_type and result.context_type ~= context_type) then + return nil + end + return result +end + +--- Extracts context from an OpencodeMessage (with parts) +---@param message { parts: OpencodeMessagePart[] } +---@return { prompt: string|nil, selected_text: string|nil, current_file: string|nil, mentioned_files: string[]|nil} +function M.extract_from_opencode_message(message) + local ctx = { prompt = nil, selected_text = nil, current_file = nil } + + local handlers = { + text = function(part) + ctx.prompt = ctx.prompt or part.text or '' + end, + text_context = function(part) + local json = M.decode_json_context(part.text, 'selection') + ctx.selected_text = json and json.content or ctx.selected_text + end, + file = function(part) + if not part.source then + ctx.current_file = part.filename + end + end, + } + + for _, part in ipairs(message and message.parts or {}) do + local handler = handlers[part.type .. (part.synthetic and '_context' or '')] + if handler then + handler(part) + end + + if ctx.prompt and ctx.selected_text and ctx.current_file then + break + end + end + + return ctx +end + +function M.extract_from_message_legacy(text) + local current_file = M.extract_legacy_tag('current-file', text) + local context = { + prompt = M.extract_legacy_tag('user-query', text) or text, + selected_text = M.extract_legacy_tag('manually-added-selection', text), + current_file = current_file and current_file:match('Path: (.+)') or nil, + } + return context +end + +function M.extract_legacy_tag(tag, text) + local start_tag = '<' .. tag .. '>' + local end_tag = '' + + local pattern = vim.pesc(start_tag) .. '(.-)' .. vim.pesc(end_tag) + local content = text:match(pattern) + + if content then + return vim.trim(content) + end + + -- Fallback to the original method if pattern matching fails + local query_start = text:find(start_tag) + local query_end = text:find(end_tag) + + if query_start and query_end then + local query_content = text:sub(query_start + #start_tag, query_end - 1) + return vim.trim(query_content) + end + + return nil +end + +function M.setup() + state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function(a) + M.load() + end) + + local augroup = vim.api.nvim_create_augroup('OpenCodeContext', { clear = true }) + vim.api.nvim_create_autocmd('BufWritePost', { + pattern = '*', + group = augroup, + callback = function(args) + local buf = args.buf + local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() + if buf == curr_buf and util.is_buf_a_file(buf) then + M.load() + end + end, + }) + + vim.api.nvim_create_autocmd('DiagnosticChanged', { + pattern = '*', + group = augroup, + callback = function(args) + local buf = args.buf + local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() + if buf == curr_buf and util.is_buf_a_file(buf) and M.is_context_enabled('diagnostics') then + M.load() + end + end, + }) +end + +return M diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 8a8a2f2c..2aacb1ad 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -10,6 +10,7 @@ function M.setup(opts) require('opencode.ui.highlight').setup() require('opencode.core').setup() + require('opencode.quick_chat').setup() require('opencode.api').setup() require('opencode.keymap').setup(config.keymap) require('opencode.ui.completion').setup() diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index 869b7ca6..6a03637b 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -13,6 +13,7 @@ ---@field reject fun(self: Promise, err: any): Promise ---@field and_then fun(self: Promise, callback: fun(value: T): U | Promise | nil): Promise ---@field catch fun(self: Promise, error_callback: fun(err: any): any | Promise | nil): Promise +---@field finally fun(self: Promise, callback: fun(): nil): Promise ---@field wait fun(self: Promise, timeout?: integer, interval?: integer): T ---@field peek fun(self: Promise): T ---@field is_resolved fun(self: Promise): boolean @@ -184,6 +185,49 @@ function Promise:catch(error_callback) return new_promise end +---Execute a callback regardless of whether the promise resolves or rejects +---The callback is called without any arguments and its return value is ignored +---@param callback fun(): nil +---@return Promise +function Promise:finally(callback) + local new_promise = Promise.new() + + local handle_finally = function() + local ok, _ = pcall(callback) + -- Ignore callback errors and result, finally doesn't change the promise chain + if not ok then + -- Log error but don't propagate it + vim.notify("Error in finally callback", vim.log.levels.WARN) + end + end + + local handle_success = function(value) + handle_finally() + new_promise:resolve(value) + end + + local handle_error = function(err) + handle_finally() + new_promise:reject(err) + end + + if self._resolved and not self._error then + -- Promise already resolved successfully + local schedule_finally = vim.schedule_wrap(handle_success) + schedule_finally(self._value) + elseif self._resolved and self._error then + -- Promise already rejected + local schedule_finally = vim.schedule_wrap(handle_error) + schedule_finally(self._error) + else + -- Promise still pending, add callbacks + table.insert(self._then_callbacks, handle_success) + table.insert(self._catch_callbacks, handle_error) + end + + return new_promise +end + --- Synchronously wait for the promise to resolve or reject --- This will block the main thread, so use with caution --- But is useful for synchronous code paths that need the result diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua new file mode 100644 index 00000000..5c6a444f --- /dev/null +++ b/lua/opencode/quick_chat.lua @@ -0,0 +1,398 @@ +-- Quick chat functionality for opencode.nvim +-- Provides ephemeral chat sessions with context-specific prompts + +local context = require('opencode.context') +local state = require('opencode.state') +local config = require('opencode.config') +local core = require('opencode.core') +local util = require('opencode.util') +local Promise = require('opencode.promise') +local session = require('opencode.session') + +local M = {} + +-- Spinner animation frames +local SPINNER_FRAMES = { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' } +local SPINNER_INTERVAL = 100 -- ms + +-- Table to track active quick chat sessions +local active_sessions = {} + +--- Creates a namespace for extmarks +local function get_or_create_namespace() + return vim.api.nvim_create_namespace('opencode_quick_chat_spinner') +end + +--- Creates and starts a spinner at the cursor position +---@param buf integer Buffer handle +---@param row integer Row (0-indexed) +---@param col integer Column (0-indexed) +---@return table spinner_state Spinner state object +local function create_spinner(buf, row, col) + local ns = get_or_create_namespace() + + local spinner_state = { + buf = buf, + row = row, + col = col, + ns = ns, + extmark_id = nil, + frame_index = 1, + timer = nil, + active = true, + } + + -- Create initial extmark + spinner_state.extmark_id = vim.api.nvim_buf_set_extmark(buf, ns, row, col, { + virt_text = { { SPINNER_FRAMES[1] .. ' ', 'Comment' } }, + virt_text_pos = 'inline', + right_gravity = false, + }) + + -- Start animation timer + spinner_state.timer = vim.uv.new_timer() + spinner_state.timer:start( + SPINNER_INTERVAL, + SPINNER_INTERVAL, + vim.schedule_wrap(function() + if not spinner_state.active then + return + end + + -- Update frame + spinner_state.frame_index = (spinner_state.frame_index % #SPINNER_FRAMES) + 1 + local frame = SPINNER_FRAMES[spinner_state.frame_index] + + -- Update extmark if buffer is still valid + if vim.api.nvim_buf_is_valid(buf) then + pcall(vim.api.nvim_buf_set_extmark, buf, ns, row, col, { + id = spinner_state.extmark_id, + virt_text = { { frame .. ' ', 'Comment' } }, + virt_text_pos = 'inline', + right_gravity = false, + }) + else + -- Buffer is invalid, stop spinner + spinner_state.active = false + end + end) + ) + + return spinner_state +end + +--- Stops and cleans up a spinner +---@param spinner_state table Spinner state object +local function cleanup_spinner(spinner_state) + if not spinner_state or not spinner_state.active then + return + end + + spinner_state.active = false + + -- Stop timer + if spinner_state.timer then + spinner_state.timer:stop() + spinner_state.timer:close() + spinner_state.timer = nil + end + + -- Remove extmark + if spinner_state.extmark_id and vim.api.nvim_buf_is_valid(spinner_state.buf) then + pcall(vim.api.nvim_buf_del_extmark, spinner_state.buf, spinner_state.ns, spinner_state.extmark_id) + end +end + +--- Creates an ephemeral session title based on current context +---@param buf integer Buffer handle +---@return string title The session title +local function create_ephemeral_title(buf) + local file_name = vim.api.nvim_buf_get_name(buf) + local relative_path = file_name ~= '' and vim.fn.fnamemodify(file_name, ':~:.') or 'untitled' + local line_num = vim.api.nvim_win_get_cursor(0)[1] + local timestamp = os.date('%H:%M:%S') + + return string.format('[QuickChat] %s:%d (%s)', relative_path, math.floor(line_num), timestamp) +end + +--- Creates the prompt for quick chat +---@param custom_message string|nil Optional custom message +---@return string prompt +local function create_context_prompt(custom_message) + if custom_message then + return custom_message + end + + local quick_chat_config = config.quick_chat or {} + if quick_chat_config.default_prompt then + return quick_chat_config.default_prompt + end + + return 'Please provide a brief, focused response based on the current context. ' + .. 'If working with code, suggest improvements, explain functionality, or help with the current task. ' + .. "Keep the response concise and directly relevant to what I'm working on." +end + +--- Creates context configuration for quick chat +---@param options table Options +---@param custom_message string|nil Custom message +---@return OpencodeContextConfig context_opts +local function create_context_config(options, custom_message) + local quick_chat_config = config.quick_chat or {} + + -- Use config default or option override + local include_context = options.include_context + if include_context == nil then + include_context = quick_chat_config.include_context_by_default ~= false + end + + -- Use default context configuration with minimal modifications + return { + enabled = include_context, + current_file = { enabled = include_context }, + cursor_data = { enabled = include_context }, + selection = { enabled = include_context }, + diagnostics = { enabled = false }, -- Disable diagnostics for quick chat to keep it focused + agents = { enabled = false }, -- Disable agents for focused quick chat + } +end + +--- Helper to clean up session info and spinner +---@param session_info table Session tracking info +---@param session_id string Session ID +---@param message string|nil Optional message to display +local function cleanup_session(session_info, session_id, message) + if session_info then + cleanup_spinner(session_info.spinner) + end + active_sessions[session_id] = nil + if message then + vim.notify(message, vim.log.levels.WARN) + end +end + +--- Extracts text from message parts +---@param message OpencodeMessage Message object +---@return string response_text +local function extract_response_text(message) + local response_text = '' + for _, part in ipairs(message.parts or {}) do + if part.type == 'text' and part.text then + response_text = response_text .. part.text + end + end + + -- Clean up the response text + response_text = response_text:gsub('```[%w]*\n?', ''):gsub('```', '') + return vim.trim(response_text) +end + +--- Processes response from ephemeral session +---@param session_obj Session The session object +---@param session_info table Session tracking info +---@param messages OpencodeMessage[] Session messages +local function process_response(session_obj, session_info, messages) + if #messages < 2 then + cleanup_session(session_info, session_obj.id, 'Quick chat completed but no messages found') + return + end + + local response_message = messages[#messages] + if not response_message or response_message.info.role ~= 'assistant' then + cleanup_session(session_info, session_obj.id, 'Quick chat completed but no assistant response found') + return + end + + local response_text = extract_response_text(response_message) + if response_text ~= '' then + vim.cmd('checktime') -- Refresh buffer to avoid conflicts + cleanup_session(session_info, session_obj.id) + else + cleanup_session(session_info, session_obj.id, 'Quick chat completed but no text response received') + end +end + +--- Hook function called when a session is done thinking (no more pending messages) +---@param session_obj Session The session object +local on_done = Promise.async(function(session_obj) + if not (session_obj.title and vim.startswith(session_obj.title, '[QuickChat]')) then + return + end + + local session_info = active_sessions[session_obj.id] + if session_info then + local messages = session.get_messages(session_obj):await() --[[@as OpencodeMessage[] ]] + if messages then + process_response(session_obj, session_info, messages) + end + end + + -- Always delete ephemeral session + -- state.api_client:delete_session(session_obj.id):catch(function(err) + -- vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN) + -- end) +end) + +--- Unified quick chat function +---@param message string|nil Optional custom message to use instead of default prompts +---@param options {include_context?: boolean, model?: string, agent?: string}|nil Optional configuration for context and behavior +---@param range table|nil Optional range information { start = number, stop = number } +---@return Promise +function M.quick_chat(message, options, range) + options = options or {} + + -- Validate environment + local buf, win = context.get_current_buf() + if not buf or not win then + vim.notify('Quick chat requires an active file buffer', vim.log.levels.ERROR) + return Promise.resolve() + end + + -- Validate message if provided + if message and message == '' then + vim.notify('Quick chat message cannot be empty', vim.log.levels.ERROR) + return Promise.resolve() + end + + -- Setup spinner at cursor position + local cursor_pos = vim.api.nvim_win_get_cursor(win) + local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed + local spinner_state = create_spinner(buf, row, col) + + local context_config = create_context_config(options, message) + local context_instance = context.new_instance(create_context_config(options, message)) + + -- Handle range-based context by adding it as a selection + if range and range.start and range.stop then + local range_lines = vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false) + local range_text = table.concat(range_lines, '\n') + local current_file = context_instance:get_current_file(buf) + local selection = context_instance:new_selection(current_file, range_text, range.start .. ', ' .. range.stop) + context_instance:add_selection(selection) + end + + -- Create session and send message + local title = create_ephemeral_title(buf) + + return core.create_new_session(title):and_then(function(quick_chat_session) + if not quick_chat_session then + cleanup_spinner(spinner_state) + return Promise.reject('Failed to create ephemeral session') + end + --TODO only for debug + state.active_session = quick_chat_session + + -- Store session tracking info + active_sessions[quick_chat_session.id] = { + buf = buf, + row = row, + col = col, + spinner = spinner_state, + timestamp = vim.uv.now(), + } + + local prompt = create_context_prompt(message) + vim.print('⭕ ❱ quick_chat.lua:294 ❱ ƒ(prompt) ❱ prompt =', prompt) + local context_opts = create_context_config(options, message) + + local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, context_instance:get_mentioned_files()) + + if not allowed then + cleanup_spinner(spinner_state) + active_sessions[quick_chat_session.id] = nil + return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard') + end + + -- Use context.format_message_stateless with the context instance + local instructions = config.quick_chat and config.quick_chat.instructions + or { + 'Do not add, remove, or modify any code, comments, or formatting outside the specified scope.', + 'If you made changes outside the requested scope, revert those changes and only apply edits within the specified area.', + 'Only edit within the following scope: [describe scope: function, class, lines, cursor, errors, etc.]. Do not touch any code, comments, or formatting outside this scope.', + 'Use the editing capabilities of the agent to make precise changes only within the defined scope.', + 'Do not ask questions and do not provide summary explanations. Just apply requested changes.', + } + + local parts = context.format_message_stateless( + prompt, --.. '\n' .. table.concat(instructions, '\n\n'), + context_opts, + context_instance + ) + local params = { parts = parts, system = table.concat(instructions, '\n\n') } + -- Add model/agent info from options, config, or current state + local quick_chat_config = config.quick_chat or {} + + return core + .initialize_current_model() + :and_then(function(current_model) + -- Priority: options.model > quick_chat_config.default_model > current_model + local target_model = options.model or quick_chat_config.default_model or current_model + if target_model then + local provider, model = target_model:match('^(.-)/(.+)$') + if provider and model then + params.model = { providerID = provider, modelID = model } + end + end + + -- Priority: options.agent > quick_chat_config.default_agent > current mode > config.default_mode + local target_mode = options.agent + or quick_chat_config.default_agent + or state.current_mode + or config.default_mode + if target_mode then + params.agent = target_mode + end + + -- Send the message + return state.api_client:create_message(quick_chat_session.id, params) + end) + :and_then(function() + on_done(quick_chat_session) + + local message_text = message and 'Quick chat started with custom message...' + or 'Quick chat started - response will appear at cursor...' + if range then + message_text = string.format('Quick chat started for lines %d-%d...', range.start, range.stop) + end + vim.notify(message_text, vim.log.levels.INFO) + end) + :catch(function(err) + cleanup_spinner(spinner_state) + active_sessions[quick_chat_session.id] = nil + vim.notify('Error in quick chat: ' .. vim.inspect(err), vim.log.levels.ERROR) + end) + end) +end + +--- Setup function to initialize quick chat functionality +function M.setup() + -- Set up autocommands for cleanup + local augroup = vim.api.nvim_create_augroup('OpenCodeQuickChat', { clear = true }) + + -- Clean up spinners when buffer is deleted + vim.api.nvim_create_autocmd('BufDelete', { + group = augroup, + callback = function(ev) + local buf = ev.buf + for session_id, session_info in pairs(active_sessions) do + if session_info.buf == buf then + cleanup_spinner(session_info.spinner) + active_sessions[session_id] = nil + end + end + end, + }) + + -- Clean up old sessions (prevent memory leaks) + vim.api.nvim_create_autocmd('VimLeavePre', { + group = augroup, + callback = function() + for session_id, session_info in pairs(active_sessions) do + cleanup_spinner(session_info.spinner) + end + active_sessions = {} + end, + }) +end + +return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 26a323a9..e5b7a8e7 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -290,6 +290,7 @@ ---@field info MessageInfo Metadata about the message ---@field parts OpencodeMessagePart[] Parts that make up the message ---@field references CodeReference[]|nil Parsed file references from text parts (cached) +---@field system string|nil System message content ---@class MessageInfo ---@field id string Unique message identifier diff --git a/lua/opencode/ui/completion/engines/base.lua b/lua/opencode/ui/completion/engines/base.lua index 7f7ff261..9062d535 100644 --- a/lua/opencode/ui/completion/engines/base.lua +++ b/lua/opencode/ui/completion/engines/base.lua @@ -33,7 +33,7 @@ end ---Get trigger characters from config ---@return string[] -function CompletionEngine:get_trigger_characters() +function CompletionEngine.get_trigger_characters() local config = require('opencode.config') local mention_key = config.get_key_for_function('input_window', 'mention') local slash_key = config.get_key_for_function('input_window', 'slash_commands') @@ -49,7 +49,7 @@ end ---Default implementation checks if current buffer filetype is 'opencode' ---Child classes can override this to add engine-specific availability checks ---@return boolean true if the engine can be used in the current context -function CompletionEngine:is_available() +function CompletionEngine.is_available() return vim.bo.filetype == 'opencode' end diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index de0204ee..73e321b8 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -147,3 +147,46 @@ describe('add_file/add_selection/add_subagent', function() assert.same({ 'agentX' }, context.context.mentioned_subagents) end) end) + +describe('context instance with config override', function() + it('should use override config for context enabled checks', function() + local override_config = { + current_file = { enabled = false }, + diagnostics = { enabled = false }, + selection = { enabled = true }, + agents = { enabled = true } + } + + local instance = context.new_instance(override_config) + + -- Test that the override config is being used + assert.is_false(instance:is_context_enabled('current_file')) + assert.is_false(instance:is_context_enabled('diagnostics')) + assert.is_true(instance:is_context_enabled('selection')) + assert.is_true(instance:is_context_enabled('agents')) + end) + + it('should fall back to global config when override not provided', function() + local override_config = { + current_file = { enabled = false } + -- other context types not specified + } + + local instance = context.new_instance(override_config) + + -- current_file should use override + assert.is_false(instance:is_context_enabled('current_file')) + + -- other context types should fall back to normal behavior + -- (these will use global config + state, tested elsewhere) + end) + + it('should work without any override config', function() + local instance = context.new_instance() + + -- Should behave exactly like global context + -- (actual values depend on config/state, just verify no errors) + assert.is_not_nil(instance:is_context_enabled('current_file')) + assert.is_not_nil(instance:is_context_enabled('diagnostics')) + end) +end) From bac518fc63981b069e2e65573767c919162d6ac3 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 15 Dec 2025 13:05:07 -0500 Subject: [PATCH 02/46] feat: new buffer context types - git_diff: the current - current buffer: the current buffer instead of relying on the file reading (can chat with unsaved buffer) --- lua/opencode/config.lua | 9 +- lua/opencode/context.lua | 161 ++++++++++++-- lua/opencode/quick_chat.lua | 431 ++++++++++++++++++++++-------------- lua/opencode/types.lua | 8 +- lua/opencode/util.lua | 127 ++++++++++- test.lua | 41 ++++ tests/unit/util_spec.lua | 98 ++++++++ 7 files changed, 672 insertions(+), 203 deletions(-) create mode 100644 test.lua diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index f36a8b75..57f2528c 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -171,13 +171,14 @@ M.defaults = { enabled = true, cursor_data = { enabled = false, - context_lines = 10, -- Number of lines before and after cursor to include in context + context_lines = 5, -- Number of lines before and after cursor to include in context }, diagnostics = { enabled = true, info = false, warning = true, error = true, + only_closest = false, -- If true, only diagnostics for cursor/selection }, current_file = { enabled = true, @@ -193,6 +194,12 @@ M.defaults = { agents = { enabled = true, }, + buffer = { + enabled = false, -- Only used for inline editing, disabled by default + }, + git_diff = { + enabled = false, + }, }, debug = { enabled = false, diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 885a1a63..fab53e24 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -3,7 +3,7 @@ local util = require('opencode.util') local config = require('opencode.config') local state = require('opencode.state') -local func = require('vim.func') +local Promise = require('opencode.promise') local M = {} @@ -41,10 +41,12 @@ function ContextInstance:unload_attachments() self.context.linter_errors = nil end +---@return integer|nil, integer|nil function ContextInstance:get_current_buf() local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() if util.is_buf_a_file(curr_buf) then - return curr_buf, state.last_code_win_before_opencode or vim.api.nvim_get_current_win() + local win = vim.fn.win_findbuf(curr_buf --[[@as integer]])[1] + return curr_buf, state.last_code_win_before_opencode or win or vim.api.nvim_get_current_win() end end @@ -67,6 +69,20 @@ function ContextInstance:load() end end +function ContextInstance:is_enabled() + if self.context_config and self.context_config.enabled ~= nil then + return self.context_config.enabled + end + + local is_enabled = vim.tbl_get(config --[[@as table]], 'context', 'enabled') + local is_state_enabled = vim.tbl_get(state, 'current_context_config', 'enabled') + if is_state_enabled ~= nil then + return is_state_enabled + else + return is_enabled + end +end + -- Checks if a context feature is enabled in config or state ---@param context_key string ---@return boolean @@ -116,12 +132,42 @@ function ContextInstance:get_diagnostics(buf) table.insert(severity_levels, vim.diagnostic.severity.INFO) end - local diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) + local diagnostics = {} + if diagnostic_conf.only_closest then + local selections = self:get_selections() + if #selections > 0 then + local selection = selections[#selections] + if selection and selection.lines then + local range_parts = vim.split(selection.lines, ',') + local start_line = (tonumber(range_parts[1]) or 1) - 1 + local end_line = (tonumber(range_parts[2]) or 1) - 1 + for lnum = start_line, end_line do + local line_diagnostics = vim.diagnostic.get(buf, { + lnum = lnum, + severity = severity_levels, + }) + for _, diag in ipairs(line_diagnostics) do + table.insert(diagnostics, diag) + end + end + end + else + local win = vim.fn.win_findbuf(buf)[1] + local cursor_pos = vim.fn.getcurpos(win) + local line_diagnostics = vim.diagnostic.get(buf, { + lnum = cursor_pos[2] - 1, + severity = severity_levels, + }) + diagnostics = line_diagnostics + end + else + diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) + end + if #diagnostics == 0 then return {} end - -- Convert vim.Diagnostic[] to OpencodeDiagnostic[] local opencode_diagnostics = {} for _, diag in ipairs(diagnostics) do table.insert(opencode_diagnostics, { @@ -273,10 +319,20 @@ function ContextInstance:get_current_cursor_data(buf, win) return nil end + local num_lines = config.context.cursor_data.context_lines --[[@as integer]] + or 0 local cursor_pos = vim.fn.getcurpos(win) local start_line = (cursor_pos[2] - 1) --[[@as integer]] local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') - return { line = cursor_pos[2], column = cursor_pos[3], line_content = cursor_content } + local lines_before = vim.api.nvim_buf_get_lines(buf, math.max(0, start_line - num_lines), start_line, false) + local lines_after = vim.api.nvim_buf_get_lines(buf, cursor_pos[2], cursor_pos[2] + num_lines, false) + return { + line = cursor_pos[2], + column = cursor_pos[3], + line_content = cursor_content, + lines_before = lines_before, + lines_after = lines_after, + } end function ContextInstance:get_current_selection() @@ -327,6 +383,14 @@ function ContextInstance:get_selections() return self.context.selections or {} end +ContextInstance.get_git_diff = Promise.async(function(self) + if not self:is_context_enabled('git_diff') then + return nil + end + + Promise.system({ 'git', 'diff', '--cached' }) +end) + ---@param opts? OpencodeContextConfig ---@return OpencodeContext function ContextInstance:delta_context(opts) @@ -532,10 +596,13 @@ local function format_selection_part(selection) return { type = 'text', + metadata = { + context_type = 'selection', + }, text = vim.json.encode({ context_type = 'selection', file = selection.file, - content = string.format('`````%s\n%s\n`````', lang, selection.content), + content = string.format('`````%s\n%s\n`````', lang, selection.content), --@TODO remove code fence and only use it when displaying lines = selection.lines, }), synthetic = true, @@ -543,17 +610,23 @@ local function format_selection_part(selection) end ---@param diagnostics OpencodeDiagnostic[] -local function format_diagnostics_part(diagnostics) +---@param range? { start_line: integer, end_line: integer }|nil +local function format_diagnostics_part(diagnostics, range) local diag_list = {} for _, diag in ipairs(diagnostics) do - local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') - table.insert( - diag_list, - { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } - ) + if not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line) then + local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') + table.insert( + diag_list, + { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } + ) + end end return { type = 'text', + meradata = { + context_type = 'diagnostics', + }, text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }), synthetic = true, } @@ -564,11 +637,17 @@ local function format_cursor_data_part(cursor_data) local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or '' return { type = 'text', + metadata = { + context_type = 'cursor-data', + lang = lang, + }, text = vim.json.encode({ context_type = 'cursor-data', line = cursor_data.line, column = cursor_data.column, - line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), + line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), --@TODO remove code fence and only use it when displaying + lines_before = cursor_data.lines_before, + lines_after = cursor_data.lines_after, }), synthetic = true, } @@ -586,6 +665,32 @@ local function format_subagents_part(agent, prompt) } end +local function format_buffer_part(buf) + local file = vim.api.nvim_buf_get_name(buf) + local rel_path = vim.fn.fnamemodify(file, ':~:.') + return { + type = 'text', + text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n'), + metadata = { + context_type = 'file-content', + filename = rel_path, + mime = 'text/plain', + }, + synthetic = true, + } +end + +local function format_git_diff_part(diff_text) + return { + type = 'text', + metadata = { + context_type = 'git-diff', + }, + text = diff_text, + synthetic = true, + } +end + --- Formats a prompt and context into message with parts for the opencode API ---@param prompt string ---@param opts? OpencodeContextConfig|nil @@ -629,22 +734,20 @@ end --- Formats a prompt and context into message without state tracking (bypasses delta) --- Used for ephemeral sessions like quick chat that don't track context state ---@param prompt string ----@param opts? OpencodeContextConfig|nil ---@param context_instance ContextInstance Optional context instance to use instead of global ---@return OpencodeMessagePart[] -function M.format_message_stateless(prompt, opts, context_instance) - opts = opts or config.context - if opts.enabled == false then - return { { type = 'text', text = prompt } } - end +M.format_message_quick_chat = Promise.async(function(prompt, context_instance) local parts = { { type = 'text', text = prompt } } + if context_instance:is_enabled() == false then + return parts + end + for _, path in ipairs(context_instance:get_mentioned_files() or {}) do table.insert(parts, format_file_part(path, prompt)) end for _, sel in ipairs(context_instance:get_selections() or {}) do - vim.print('⭕ ❱ context.lua:639 ❱ ƒ(_) ❱ sel =', sel) table.insert(parts, format_selection_part(sel)) end @@ -662,14 +765,26 @@ function M.format_message_stateless(prompt, opts, context_instance) table.insert(parts, format_diagnostics_part(diagnostics)) end - local cursor_data = - context_instance:get_current_cursor_data(context_instance:get_current_buf() or 0, vim.api.nvim_get_current_win()) + local current_buf, current_win = context_instance:get_current_buf() + local cursor_data = context_instance:get_current_cursor_data(current_buf or 0, current_win or 0) if cursor_data then table.insert(parts, format_cursor_data_part(cursor_data)) end + if context_instance:is_context_enabled('buffer') then + local buf = context_instance:get_current_buf() + if buf then + table.insert(parts, format_buffer_part(buf)) + end + end + + local diff_text = context_instance:get_git_diff():await() + if diff_text and diff_text ~= '' then + table.insert(parts, format_git_diff_part(diff_text)) + end + return parts -end +end) ---@param text string ---@param context_type string|nil diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 5c6a444f..649f5d08 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -8,152 +8,114 @@ local core = require('opencode.core') local util = require('opencode.util') local Promise = require('opencode.promise') local session = require('opencode.session') +local Timer = require('opencode.ui.timer') local M = {} --- Spinner animation frames -local SPINNER_FRAMES = { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' } -local SPINNER_INTERVAL = 100 -- ms - --- Table to track active quick chat sessions local active_sessions = {} ---- Creates a namespace for extmarks -local function get_or_create_namespace() - return vim.api.nvim_create_namespace('opencode_quick_chat_spinner') +--- Simple cursor spinner using the same animation logic as loading_animation.lua +local CursorSpinner = {} +CursorSpinner.__index = CursorSpinner + +function CursorSpinner.new(buf, row, col) + local self = setmetatable({}, CursorSpinner) + self.buf = buf + self.row = row + self.col = col + self.ns_id = vim.api.nvim_create_namespace('opencode_quick_chat_spinner') + self.extmark_id = nil + self.current_frame = 1 + self.timer = nil + self.active = true + + self.frames = config.values.ui.loading_animation and config.values.ui.loading_animation.frames + or { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' } + + self:render() + self:start_timer() + return self end ---- Creates and starts a spinner at the cursor position ----@param buf integer Buffer handle ----@param row integer Row (0-indexed) ----@param col integer Column (0-indexed) ----@return table spinner_state Spinner state object -local function create_spinner(buf, row, col) - local ns = get_or_create_namespace() - - local spinner_state = { - buf = buf, - row = row, - col = col, - ns = ns, - extmark_id = nil, - frame_index = 1, - timer = nil, - active = true, - } +function CursorSpinner:render() + if not self.active or not vim.api.nvim_buf_is_valid(self.buf) then + return + end - -- Create initial extmark - spinner_state.extmark_id = vim.api.nvim_buf_set_extmark(buf, ns, row, col, { - virt_text = { { SPINNER_FRAMES[1] .. ' ', 'Comment' } }, - virt_text_pos = 'inline', + local frame = ' ' .. self.frames[self.current_frame] + self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, self.ns_id, self.row, self.col, { + id = self.extmark_id, + virt_text = { { frame .. ' ', 'Comment' } }, + virt_text_pos = 'overlay', right_gravity = false, }) +end - -- Start animation timer - spinner_state.timer = vim.uv.new_timer() - spinner_state.timer:start( - SPINNER_INTERVAL, - SPINNER_INTERVAL, - vim.schedule_wrap(function() - if not spinner_state.active then - return - end +function CursorSpinner:next_frame() + self.current_frame = (self.current_frame % #self.frames) + 1 +end - -- Update frame - spinner_state.frame_index = (spinner_state.frame_index % #SPINNER_FRAMES) + 1 - local frame = SPINNER_FRAMES[spinner_state.frame_index] - - -- Update extmark if buffer is still valid - if vim.api.nvim_buf_is_valid(buf) then - pcall(vim.api.nvim_buf_set_extmark, buf, ns, row, col, { - id = spinner_state.extmark_id, - virt_text = { { frame .. ' ', 'Comment' } }, - virt_text_pos = 'inline', - right_gravity = false, - }) - else - -- Buffer is invalid, stop spinner - spinner_state.active = false +function CursorSpinner:start_timer() + self.timer = Timer.new({ + interval = 100, -- 10 FPS like the main loading animation + on_tick = function() + if not self.active then + return false end - end) - ) - - return spinner_state + self:next_frame() + self:render() + return true + end, + repeat_timer = true, + }) + self.timer:start() end ---- Stops and cleans up a spinner ----@param spinner_state table Spinner state object -local function cleanup_spinner(spinner_state) - if not spinner_state or not spinner_state.active then +function CursorSpinner:stop() + if not self.active then return end - spinner_state.active = false + self.active = false - -- Stop timer - if spinner_state.timer then - spinner_state.timer:stop() - spinner_state.timer:close() - spinner_state.timer = nil + if self.timer then + self.timer:stop() + self.timer = nil end - -- Remove extmark - if spinner_state.extmark_id and vim.api.nvim_buf_is_valid(spinner_state.buf) then - pcall(vim.api.nvim_buf_del_extmark, spinner_state.buf, spinner_state.ns, spinner_state.extmark_id) + if self.extmark_id and vim.api.nvim_buf_is_valid(self.buf) then + pcall(vim.api.nvim_buf_del_extmark, self.buf, self.ns_id, self.extmark_id) end end ---- Creates an ephemeral session title based on current context +--- Creates an ephemeral session title ---@param buf integer Buffer handle ---@return string title The session title -local function create_ephemeral_title(buf) +local function create_session_title(buf) local file_name = vim.api.nvim_buf_get_name(buf) local relative_path = file_name ~= '' and vim.fn.fnamemodify(file_name, ':~:.') or 'untitled' local line_num = vim.api.nvim_win_get_cursor(0)[1] local timestamp = os.date('%H:%M:%S') - return string.format('[QuickChat] %s:%d (%s)', relative_path, math.floor(line_num), timestamp) -end - ---- Creates the prompt for quick chat ----@param custom_message string|nil Optional custom message ----@return string prompt -local function create_context_prompt(custom_message) - if custom_message then - return custom_message - end - - local quick_chat_config = config.quick_chat or {} - if quick_chat_config.default_prompt then - return quick_chat_config.default_prompt - end - - return 'Please provide a brief, focused response based on the current context. ' - .. 'If working with code, suggest improvements, explain functionality, or help with the current task. ' - .. "Keep the response concise and directly relevant to what I'm working on." + return string.format('[QuickChat] %s:%d (%s)', relative_path, line_num, timestamp) end --- Creates context configuration for quick chat ----@param options table Options ----@param custom_message string|nil Custom message +---@param has_range boolean Whether a range is specified ---@return OpencodeContextConfig context_opts -local function create_context_config(options, custom_message) - local quick_chat_config = config.quick_chat or {} - - -- Use config default or option override - local include_context = options.include_context - if include_context == nil then - include_context = quick_chat_config.include_context_by_default ~= false - end - - -- Use default context configuration with minimal modifications +local function create_context_config(has_range) return { - enabled = include_context, - current_file = { enabled = include_context }, - cursor_data = { enabled = include_context }, - selection = { enabled = include_context }, - diagnostics = { enabled = false }, -- Disable diagnostics for quick chat to keep it focused - agents = { enabled = false }, -- Disable agents for focused quick chat + enabled = true, + current_file = { enabled = false }, + cursor_data = { enabled = not has_range }, + selection = { enabled = has_range }, + diagnostics = { + enabled = false, + error = false, + info = false, + warning = false, + }, + agents = { enabled = false }, } end @@ -162,8 +124,8 @@ end ---@param session_id string Session ID ---@param message string|nil Optional message to display local function cleanup_session(session_info, session_id, message) - if session_info then - cleanup_spinner(session_info.spinner) + if session_info and session_info.spinner then + session_info.spinner:stop() end active_sessions[session_id] = nil if message then @@ -182,11 +144,95 @@ local function extract_response_text(message) end end - -- Clean up the response text - response_text = response_text:gsub('```[%w]*\n?', ''):gsub('```', '') return vim.trim(response_text) end +--- Applies line replacements to a buffer using JSON format +---@param buf integer Buffer handle +---@param response_text string JSON-formatted response containing replacements +---@return boolean success Whether the replacements were applied successfully +local function apply_line_replacements(buf, response_text) + if not vim.api.nvim_buf_is_valid(buf) then + vim.notify('Buffer is not valid for applying changes', vim.log.levels.ERROR) + return false + end + + -- Try to extract JSON from response text (handle cases where JSON is in code blocks) + local json_text = response_text + -- Look for JSON in code blocks + local json_match = response_text:match('```json\n(.-)\n```') or response_text:match('```\n(.-)\n```') + if json_match then + json_text = json_match + end + + -- Try to parse JSON format + local ok, replacement_data = pcall(vim.json.decode, json_text) + if not ok then + vim.notify('Failed to parse replacement data as JSON: ' .. tostring(replacement_data), vim.log.levels.ERROR) + return false + end + + if not replacement_data.replacements or type(replacement_data.replacements) ~= 'table' then + vim.notify('Invalid replacement format - missing replacements array', vim.log.levels.ERROR) + return false + end + + local buf_line_count = vim.api.nvim_buf_line_count(buf) + + table.sort(replacement_data.replacements, function(a, b) + return (a.start_line or a.line) > (b.start_line or b.line) + end) + + local total_replacements = 0 + for _, replacement in ipairs(replacement_data.replacements) do + local start_line = replacement.start_line or replacement.line + local end_line = replacement.end_line or start_line + local new_lines = replacement.lines or replacement.content + + if type(new_lines) == 'string' then + new_lines = vim.split(new_lines, '\n', { plain = true }) + end + + if start_line and start_line >= 1 and start_line <= buf_line_count then + local start_idx = start_line - 1 -- Convert to 0-indexed + local end_idx = math.min(end_line, buf_line_count) + + local success, err = pcall(vim.api.nvim_buf_set_lines, buf, start_idx, end_idx, false, new_lines) + if not success then + vim.notify('Failed to apply replacement: ' .. tostring(err), vim.log.levels.ERROR) + return false + end + + total_replacements = total_replacements + 1 + else + vim.notify( + string.format('Could not apply replacement - start_line %d is out of bounds', start_line), + vim.log.levels.WARN + ) + end + end + + return total_replacements > 0 +end + +--- Checks if response text is in JSON replacement format +---@param response_text string Response text to check +---@return boolean is_json True if text contains JSON replacement format +local function is_json_replacement_format(response_text) + -- Check for JSON in code blocks first + local json_match = response_text:match('```json\n(.-)\n```') or response_text:match('```\n(.-)\n```') + local json_text = json_match or response_text + + -- Try to parse as JSON + local ok, data = pcall(vim.json.decode, json_text) + if not ok then + return false + end + + -- Check if it has the expected replacements structure + return type(data) == 'table' and type(data.replacements) == 'table' and #data.replacements > 0 +end + --- Processes response from ephemeral session ---@param session_obj Session The session object ---@param session_info table Session tracking info @@ -203,13 +249,22 @@ local function process_response(session_obj, session_info, messages) return end - local response_text = extract_response_text(response_message) + local response_text = extract_response_text(response_message) or '' + if response_text ~= '' then - vim.cmd('checktime') -- Refresh buffer to avoid conflicts - cleanup_session(session_info, session_obj.id) - else - cleanup_session(session_info, session_obj.id, 'Quick chat completed but no text response received') + -- Try JSON format first (preferred) + if is_json_replacement_format(response_text) then + local success = apply_line_replacements(session_info.buf, response_text) + if success then + cleanup_session(session_info, session_obj.id) + else + cleanup_session(session_info, session_obj.id, 'Failed to apply code edits') + end + return + end end + + cleanup_session(session_info, session_obj.id, 'Quick chat completed but no recognized response format found') end --- Hook function called when a session is done thinking (no more pending messages) @@ -233,36 +288,70 @@ local on_done = Promise.async(function(session_obj) -- end) end) +--- Helper function to save file if modified +---@param buf integer Buffer handle +---@return boolean success True if file was saved successfully or didn't need saving +local function ensure_file_saved(buf) + if not vim.api.nvim_get_option_value('modified', { buf = buf }) then + return true + end + + local filename = vim.api.nvim_buf_get_name(buf) + if not filename or filename == '' then + vim.notify('Cannot save unnamed buffer. Please save the file first.', vim.log.levels.WARN) + return false + end + + if vim.fn.filewritable(filename) ~= 1 and vim.fn.filewritable(vim.fn.fnamemodify(filename, ':h')) ~= 2 then + vim.notify('File is not writable: ' .. filename, vim.log.levels.ERROR) + return false + end + + local ok, err = pcall(function() + vim.api.nvim_buf_call(buf, function() + vim.cmd('write') + end) + end) + + if not ok then + vim.notify('Failed to save file: ' .. tostring(err), vim.log.levels.ERROR) + return false + end + + return true +end + --- Unified quick chat function ----@param message string|nil Optional custom message to use instead of default prompts +---@param message string Optional custom message to use instead of default prompts ---@param options {include_context?: boolean, model?: string, agent?: string}|nil Optional configuration for context and behavior ---@param range table|nil Optional range information { start = number, stop = number } ---@return Promise function M.quick_chat(message, options, range) options = options or {} - -- Validate environment local buf, win = context.get_current_buf() if not buf or not win then vim.notify('Quick chat requires an active file buffer', vim.log.levels.ERROR) - return Promise.resolve() + return Promise.new():resolve(nil) end - -- Validate message if provided if message and message == '' then vim.notify('Quick chat message cannot be empty', vim.log.levels.ERROR) - return Promise.resolve() + return Promise.new():resolve(nil) end - -- Setup spinner at cursor position + -- if not ensure_file_saved(buf) then + -- vim.notify('Quick chat cancelled - file must be saved first', vim.log.levels.ERROR) + -- return Promise.new():resolve(nil) + -- end + local cursor_pos = vim.api.nvim_win_get_cursor(win) local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed - local spinner_state = create_spinner(buf, row, col) + local spinner = CursorSpinner.new(buf, row, col) - local context_config = create_context_config(options, message) - local context_instance = context.new_instance(create_context_config(options, message)) + local context_config = create_context_config(range ~= nil) + local context_instance = context.new_instance(context_config) - -- Handle range-based context by adding it as a selection if range and range.start and range.stop then local range_lines = vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false) local range_text = table.concat(range_lines, '\n') @@ -271,61 +360,67 @@ function M.quick_chat(message, options, range) context_instance:add_selection(selection) end - -- Create session and send message - local title = create_ephemeral_title(buf) + local title = create_session_title(buf) return core.create_new_session(title):and_then(function(quick_chat_session) if not quick_chat_session then - cleanup_spinner(spinner_state) - return Promise.reject('Failed to create ephemeral session') + spinner:stop() + return Promise.new():reject('Failed to create ephemeral session') end + --TODO only for debug state.active_session = quick_chat_session - -- Store session tracking info active_sessions[quick_chat_session.id] = { buf = buf, row = row, col = col, - spinner = spinner_state, + spinner = spinner, timestamp = vim.uv.now(), } - local prompt = create_context_prompt(message) - vim.print('⭕ ❱ quick_chat.lua:294 ❱ ƒ(prompt) ❱ prompt =', prompt) - local context_opts = create_context_config(options, message) - - local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, context_instance:get_mentioned_files()) + local allowed, err_msg = + util.check_prompt_allowed(config.values.prompt_guard, context_instance:get_mentioned_files()) if not allowed then - cleanup_spinner(spinner_state) + spinner:stop() active_sessions[quick_chat_session.id] = nil return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard') end - -- Use context.format_message_stateless with the context instance local instructions = config.quick_chat and config.quick_chat.instructions or { - 'Do not add, remove, or modify any code, comments, or formatting outside the specified scope.', - 'If you made changes outside the requested scope, revert those changes and only apply edits within the specified area.', - 'Only edit within the following scope: [describe scope: function, class, lines, cursor, errors, etc.]. Do not touch any code, comments, or formatting outside this scope.', - 'Use the editing capabilities of the agent to make precise changes only within the defined scope.', - 'Do not ask questions and do not provide summary explanations. Just apply requested changes.', + 'You are an expert code assistant helping with code and text editing tasks.', + 'You are operating in a temporary quick chat session with limited context.', + 'CRITICAL: You MUST respond ONLY in valid JSON format for line replacements. Use this exact structure:', + '', + '```json', + '{', + ' "replacements": [', + ' {', + ' "start_line": 10,', + ' "end_line": 12,', + ' "lines": ["new content line 1", "new content line 2"]', + ' }', + ' ]', + '}', + '```', + '', + 'ALWAYS split multiple line replacements into separate entries in the "replacements" array.', + 'NEVER add any explanations, apologies, or additional text outside the JSON structure.', + 'IMPORTANT: Use 1-indexed line numbers. Each replacement replaces lines start_line through end_line (inclusive).', + 'The "lines" array contains the new content. If replacing a single line, end_line can equal start_line.', + 'Only provide changes that are directly relevant to the current context, cursor position, or selection.', + 'The provided context is in JSON format - use the plain text content to determine what changes to make.', } - local parts = context.format_message_stateless( - prompt, --.. '\n' .. table.concat(instructions, '\n\n'), - context_opts, - context_instance - ) + local parts = context.format_message_stateless(message, context_instance) local params = { parts = parts, system = table.concat(instructions, '\n\n') } - -- Add model/agent info from options, config, or current state - local quick_chat_config = config.quick_chat or {} + local quick_chat_config = config.values.quick_chat or {} return core .initialize_current_model() :and_then(function(current_model) - -- Priority: options.model > quick_chat_config.default_model > current_model local target_model = options.model or quick_chat_config.default_model or current_model if target_model then local provider, model = target_model:match('^(.-)/(.+)$') @@ -334,30 +429,21 @@ function M.quick_chat(message, options, range) end end - -- Priority: options.agent > quick_chat_config.default_agent > current mode > config.default_mode local target_mode = options.agent or quick_chat_config.default_agent or state.current_mode - or config.default_mode + or config.values.default_mode if target_mode then params.agent = target_mode end - -- Send the message return state.api_client:create_message(quick_chat_session.id, params) end) :and_then(function() on_done(quick_chat_session) - - local message_text = message and 'Quick chat started with custom message...' - or 'Quick chat started - response will appear at cursor...' - if range then - message_text = string.format('Quick chat started for lines %d-%d...', range.start, range.stop) - end - vim.notify(message_text, vim.log.levels.INFO) end) :catch(function(err) - cleanup_spinner(spinner_state) + spinner:stop() active_sessions[quick_chat_session.id] = nil vim.notify('Error in quick chat: ' .. vim.inspect(err), vim.log.levels.ERROR) end) @@ -366,29 +452,30 @@ end --- Setup function to initialize quick chat functionality function M.setup() - -- Set up autocommands for cleanup local augroup = vim.api.nvim_create_augroup('OpenCodeQuickChat', { clear = true }) - -- Clean up spinners when buffer is deleted vim.api.nvim_create_autocmd('BufDelete', { group = augroup, callback = function(ev) local buf = ev.buf for session_id, session_info in pairs(active_sessions) do if session_info.buf == buf then - cleanup_spinner(session_info.spinner) + if session_info.spinner then + session_info.spinner:stop() + end active_sessions[session_id] = nil end end end, }) - -- Clean up old sessions (prevent memory leaks) vim.api.nvim_create_autocmd('VimLeavePre', { group = augroup, callback = function() - for session_id, session_info in pairs(active_sessions) do - cleanup_spinner(session_info.spinner) + for _session_id, session_info in pairs(active_sessions) do + if session_info.spinner then + session_info.spinner:stop() + end end active_sessions = {} end, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index e5b7a8e7..48bf7d76 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -147,11 +147,13 @@ ---@class OpencodeContextConfig ---@field enabled boolean ----@field cursor_data { enabled: boolean } ----@field diagnostics { enabled:boolean, info: boolean, warning: boolean, error: boolean } +---@field cursor_data { enabled: boolean, context_lines?: number } +---@field diagnostics { enabled:boolean, info: boolean, warning: boolean, error: boolean, only_closest: boolean} ---@field current_file { enabled: boolean } ---@field selection { enabled: boolean } ---@field agents { enabled: boolean } +---@field buffer { enabled: boolean } +---@field git_diff { enabled: boolean } ---@class OpencodeDebugConfig ---@field enabled boolean @@ -370,6 +372,8 @@ ---@field line number ---@field column number ---@field line_content string +---@field lines_before string[] +---@field lines_after string[] ---@class OpencodeContextFile ---@field path string diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index de7d1424..f1877976 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -1,4 +1,5 @@ local Path = require('plenary.path') +local v = require('jit.v') local M = {} function M.uid() @@ -134,7 +135,12 @@ function M.is_git_project() if _is_git_project ~= nil then return _is_git_project end - local git_dir = Path:new(vim.fn.getcwd()):joinpath('.git') + local cwd = vim.fn.getcwd() + if not cwd then + _is_git_project = false + return _is_git_project + end + local git_dir = Path:new(cwd):joinpath('.git') _is_git_project = git_dir:exists() return _is_git_project end @@ -263,14 +269,16 @@ function M.parse_dot_args(args_str) t[parts[i]] = t[parts[i]] or {} t = t[parts[i]] end + -- Convert string values to appropriate types + local parsed_value = value if value == 'true' then - value = true + parsed_value = true elseif value == 'false' then - value = false + parsed_value = false elseif tonumber(value) then - value = tonumber(value) + parsed_value = tonumber(value) end - t[parts[#parts]] = value + t[parts[#parts]] = parsed_value end end return result @@ -344,6 +352,7 @@ end --- Parse run command arguments with optional agent, model, and context prefixes. --- Returns opts table and remaining prompt string. --- Format: [agent=] [model=] [context=] +--- Also supports quick context syntax like "#buffer #git_diff" in the prompt --- @param args string[] --- @return table opts, string prompt function M.parse_run_args(args) @@ -372,6 +381,13 @@ function M.parse_run_args(args) local prompt_tokens = vim.list_slice(args, prompt_start_idx) local prompt = table.concat(prompt_tokens, ' ') + if prompt:find('#') then + local cleaned_prompt, quick_context = M.parse_quick_context_args(prompt) + prompt = cleaned_prompt + + opts.context = vim.tbl_deep_extend('force', opts.context or {}, quick_context) --[[@as OpencodeContextConfig]] + end + return opts, prompt end @@ -410,4 +426,105 @@ function M.is_temp_path(path, pattern) return true end +--- Parse quick context arguments and extract prompt. +--- Transforms quick context items like "generate a conventional commit #git_diff #buffer" +--- into a partial ContextConfig object with only enabled fields and returns the remaining text as prompt. +--- @param prompt string Context arguments string (e.g., "generate a conventional commit #buffer #git_diff") +--- @return string prompt, OpencodeContextConfig config +function M.parse_quick_context_args(prompt) + ---@type OpencodeContextConfig + local config = { enabled = true } + + if not prompt or prompt == '' then + return '', config + end + + local function extract(items) + local found = false + for _, item in ipairs(items) do + local pattern = '#' .. item + local start_pos = prompt:lower():find(pattern:lower(), 1, true) + if start_pos then + found = true + local end_pos = start_pos + #pattern - 1 + prompt = prompt:sub(1, start_pos - 1) .. prompt:sub(end_pos + 1) + end + end + return found + end + + local cursor_enabled = extract({ 'cursor_data', 'cursor' }) + if cursor_enabled then + config.cursor_data = { enabled = true, context_lines = 5 } + end + + local info_enabled = extract({ 'info' }) + local warning_enabled = extract({ 'warnings', 'warning', 'warn' }) + local error_enabled = extract({ 'errors' }) + + if info_enabled or warning_enabled or error_enabled then + config.diagnostics = { enabled = true, only_closest = true } + if info_enabled then + config.diagnostics.info = true + end + if warning_enabled then + config.diagnostics.warning = true + end + if error_enabled then + config.diagnostics.error = true + end + end + + local current_file_enabled = extract({ 'current_file', 'file' }) + if current_file_enabled then + config.current_file = { enabled = true } + end + + local selection_enabled = extract({ 'selection' }) + if selection_enabled then + config.selection = { enabled = true } + end + + local agents_enabled = extract({ 'agents' }) + if agents_enabled then + config.agents = { enabled = true } + end + + local buffer_enabled = extract({ 'buffer' }) + if buffer_enabled then + config.buffer = { enabled = true } + end + + local git_diff_enabled = extract({ 'git_diff', 'diff' }) + if git_diff_enabled then + config.git_diff = { enabled = true } + end + + return vim.trim(prompt:gsub('%s+', ' ')), config +end + +function M.get_visual_range() + if not vim.fn.mode():match('[vV\022]') then + return nil + end + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true) + + local bufnr = vim.api.nvim_get_current_buf() + local start_pos = vim.fn.getpos("'<") + local end_pos = vim.fn.getpos("'>") + + local start_line = start_pos[2] + local start_col = start_pos[3] + local end_line = end_pos[2] + local end_col = end_pos[3] + + return { + bufnr = bufnr, + start_line = start_line, + start_col = start_col, + end_line = end_line, + end_col = end_col, + } +end + return M diff --git a/test.lua b/test.lua new file mode 100644 index 00000000..d3aa88e1 --- /dev/null +++ b/test.lua @@ -0,0 +1,41 @@ +local summary = { + '✅ Fully Functional and Tested', + 'All unit tests passing', + 'Syntax validation successful', + 'Complete spinner lifecycle management', + 'Robust error handling and cleanup', + 'Ready for production use', +} + +---@param n number The upper limit for the FizzBuzz sequence +---@return table A table containing the FizzBuzz sequence +function fizz_buzz(n) + local result = {} + for i = 1, n do + if i % 15 == 0 then + result[i] = 'FizzBuzz' + elseif i % 3 == 0 then + result[i] = 'Fizz' + elseif i % 5 == 0 then + result[i] = 'Buzz' + else + result[i] = tostring(i) + end + end + return result +end + +---@param n number The number of Fibonacci numbers to generate +---@return table A table containing the Fibonacci sequence +function fibbonacci(n) + if n <= 0 then + return {} + elseif n == 1 then + return { 0 } + end + local seq = { 0, 1 } + for i = 3, n do + seq[i] = seq[i - 1] + seq[i - 2] + end + return seq +end diff --git a/tests/unit/util_spec.lua b/tests/unit/util_spec.lua index bba42543..4d63af39 100644 --- a/tests/unit/util_spec.lua +++ b/tests/unit/util_spec.lua @@ -264,3 +264,101 @@ describe('util.format_time', function() end) end) end) + +describe('util.parse_quick_context_args', function() + local function parse_and_verify(input, expected_prompt, context_checks) + local prompt, config = util.parse_quick_context_args(input) + assert.is_true(config.enabled) + assert.equals(expected_prompt, prompt) + + if context_checks then + for context_type, checks in pairs(context_checks) do + if context_type == 'diagnostics' then + assert.is_true(config.diagnostics.enabled) + if checks.only_closest ~= nil then + assert.equals(checks.only_closest, config.diagnostics.only_closest) + end + for _, diag_type in ipairs({ 'warning', 'error', 'info' }) do + if checks[diag_type] ~= nil then + assert.equals(checks[diag_type], config.diagnostics[diag_type]) + end + end + else + assert.is_true(config[context_type].enabled) + end + end + end + end + + -- Test cases with expected results + local test_cases = { + -- Basic cases + { '', '', nil }, + { nil, '', nil }, + + -- Single context types + { '#buffer', '', { buffer = {} } }, + { 'add something #buffer', 'add something', { buffer = {} } }, + { 'generate a conventional commit #git_diff', 'generate a conventional commit', { git_diff = {} } }, + { 'explain this code #current_file', 'explain this code', { current_file = {} } }, + { 'explain this code #file', 'explain this code', { current_file = {} } }, -- alias test + { 'refactor this #selection', 'refactor this', { selection = {} } }, + { 'complete this line #cursor_data', 'complete this line', { cursor_data = {} } }, + { 'complete this line #cursor', 'complete this line', { cursor_data = {} } }, -- alias test + { 'help with this task #agents', 'help with this task', { agents = {} } }, + + -- Diagnostic types + { 'fix these issues #warnings', 'fix these issues', { diagnostics = { warning = true, only_closest = true } } }, + { 'debug this #errors', 'debug this', { diagnostics = { error = true, only_closest = true } } }, + { 'review #info', 'review', { diagnostics = { info = true, only_closest = true } } }, + + -- Multiple contexts + { + 'generate a conventional commit #buffer #git_diff #warnings #errors', + 'generate a conventional commit', + { buffer = {}, git_diff = {}, diagnostics = { warning = true, error = true } }, + }, + + -- Edge cases + { + 'generate #buffer a conventional #git_diff commit', + 'generate a conventional commit', + { buffer = {}, git_diff = {} }, + }, + { + 'Generate Code #BUFFER #Git_Diff #WaRnInGs', + 'Generate Code', + { buffer = {}, git_diff = {}, diagnostics = { warning = true } }, + }, + { + 'create function #buffer #invalid #git_diff #unknown', + 'create function #invalid #unknown', + { buffer = {}, git_diff = {} }, + }, + { + ' generate code #buffer #git_diff #warnings ', + 'generate code', + { buffer = {}, git_diff = {}, diagnostics = { warning = true } }, + }, + { + 'check code quality #warnings #errors #info', + 'check code quality', + { diagnostics = { warning = true, error = true, info = true } }, + }, + { + 'help me with this task #buffer #errors', + 'help me with this task', + { buffer = {}, diagnostics = { error = true } }, + }, + { '#buffer #git_diff #warnings', '', { buffer = {}, git_diff = {}, diagnostics = { warning = true } } }, + } + + for _, case in ipairs(test_cases) do + local input, expected_prompt, context_checks = case[1], case[2], case[3] + local test_name = input and input ~= '' and ('parses "' .. input .. '"') or 'handles empty/nil input' + + it(test_name, function() + parse_and_verify(input, expected_prompt, context_checks) + end) + end +end) From d9b8076ca2c0d8b0743814be0c4d4fe091d2d6fd Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 15 Dec 2025 13:08:25 -0500 Subject: [PATCH 03/46] feat: quick chat ask for input if no message --- lua/opencode/api.lua | 29 ++- lua/opencode/config.lua | 3 +- lua/opencode/quick_chat.lua | 430 +++++++++++++++++++----------------- 3 files changed, 252 insertions(+), 210 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 550c2eae..9b2a3571 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -109,12 +109,35 @@ function M.select_history() end function M.quick_chat(message, range) - local options = {} + if not range then + if vim.fn.mode():match('[vV\022]') then + local visual_range = util.get_visual_range() + if visual_range then + range = { + start_line = visual_range.start_line, + end_line = visual_range.end_line, + } + end + end + end + if type(message) == 'table' then message = table.concat(message, ' ') end - return quick_chat.quick_chat(message, options, range) + -- If no message, prompt for input (range is captured above) + if not message or #message == 0 then + vim.ui.input({ prompt = 'Quick Chat Message: ' }, function(input) + local prompt, ctx = util.parse_quick_context_args(input) + if input and input ~= '' then + quick_chat.quick_chat(prompt, { context_config = ctx }, range) + end + end) + return + end + + local prompt, ctx = util.parse_quick_context_args(message) + quick_chat.quick_chat(prompt, { context_config = ctx }, range) end function M.toggle_pane() @@ -981,7 +1004,7 @@ M.commands = { }, quick_chat = { - desc = 'Quick chat with current context', + desc = 'Quick chat with current buffer or visual selection', fn = M.quick_chat, range = true, -- Enable range support for visual selections nargs = '+', -- Allow multiple arguments diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 57f2528c..4445fc02 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -22,7 +22,7 @@ M.defaults = { ['ot'] = { 'toggle_focus', desc = 'Toggle focus' }, ['oT'] = { 'timeline', desc = 'Session timeline' }, ['oq'] = { 'close', desc = 'Close Opencode window' }, - ['oQ'] = { 'quick_chat', desc = 'Quick chat with current context' }, + ['oQ'] = { 'quick_chat', desc = 'Quick chat with current context', mode = { 'n', 'x' } }, ['os'] = { 'select_session', desc = 'Select session' }, ['oR'] = { 'rename_session', desc = 'Rename session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, @@ -216,7 +216,6 @@ M.defaults = { quick_chat = { default_model = nil, -- Use current model if nil default_agent = nil, -- Use current mode if nil - include_context_by_default = true, default_prompt = nil, -- Use built-in prompt if nil }, } diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 649f5d08..59686b29 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -1,13 +1,10 @@ --- Quick chat functionality for opencode.nvim --- Provides ephemeral chat sessions with context-specific prompts - local context = require('opencode.context') local state = require('opencode.state') local config = require('opencode.config') local core = require('opencode.core') local util = require('opencode.util') -local Promise = require('opencode.promise') local session = require('opencode.session') +local Promise = require('opencode.promise') local Timer = require('opencode.ui.timer') local M = {} @@ -116,6 +113,8 @@ local function create_context_config(has_range) warning = false, }, agents = { enabled = false }, + buffer = { enabled = true }, + git_diff = { enabled = false }, } end @@ -147,16 +146,10 @@ local function extract_response_text(message) return vim.trim(response_text) end ---- Applies line replacements to a buffer using JSON format ----@param buf integer Buffer handle ----@param response_text string JSON-formatted response containing replacements ----@return boolean success Whether the replacements were applied successfully -local function apply_line_replacements(buf, response_text) - if not vim.api.nvim_buf_is_valid(buf) then - vim.notify('Buffer is not valid for applying changes', vim.log.levels.ERROR) - return false - end - +--- Extracts and parses JSON replacement data from response text +---@param response_text string Response text that may contain JSON in code blocks +---@return table|nil replacement_data Parsed replacement data or nil if invalid +local function parse_replacement_json(response_text) -- Try to extract JSON from response text (handle cases where JSON is in code blocks) local json_text = response_text -- Look for JSON in code blocks @@ -168,12 +161,55 @@ local function apply_line_replacements(buf, response_text) -- Try to parse JSON format local ok, replacement_data = pcall(vim.json.decode, json_text) if not ok then - vim.notify('Failed to parse replacement data as JSON: ' .. tostring(replacement_data), vim.log.levels.ERROR) - return false + return nil end if not replacement_data.replacements or type(replacement_data.replacements) ~= 'table' then - vim.notify('Invalid replacement format - missing replacements array', vim.log.levels.ERROR) + return nil + end + + if #replacement_data.replacements == 0 then + return nil + end + + return replacement_data +end + +--- Converts object format like {"1": "line1", "2": "line2"} to array +---@param obj_lines table Object with string keys representing line numbers +---@return string[] lines_array Array of lines in correct order +local function convert_object_to_lines_array(obj_lines) + local lines_array = {} + local numeric_keys = {} + + -- Collect all numeric string keys + for key, _ in pairs(obj_lines) do + local num_key = tonumber(key) + if num_key and num_key > 0 and math.floor(num_key) == num_key then + table.insert(numeric_keys, num_key) + end + end + + -- Sort keys to ensure correct order + table.sort(numeric_keys) + + for _, num_key in ipairs(numeric_keys) do + local line_content = obj_lines[tostring(num_key)] + if line_content then + table.insert(lines_array, line_content) + end + end + + return lines_array +end + +--- Applies line replacements to a buffer using parsed replacement data +---@param buf integer Buffer handle +---@param replacement_data table Parsed replacement data +---@return boolean success Whether the replacements were applied successfully +local function apply_line_replacements(buf, replacement_data) + if not vim.api.nvim_buf_is_valid(buf) then + vim.notify('Buffer is not valid for applying changes', vim.log.levels.ERROR) return false end @@ -189,82 +225,52 @@ local function apply_line_replacements(buf, response_text) local end_line = replacement.end_line or start_line local new_lines = replacement.lines or replacement.content + -- Convert string to array if type(new_lines) == 'string' then - new_lines = vim.split(new_lines, '\n', { plain = true }) + new_lines = vim.split(new_lines, '\n') + elseif type(new_lines) == 'table' then + -- Check if it's object format like {"1": "line1", "2": "line2"} + local first_key = next(new_lines) + if first_key and type(first_key) == 'string' and tonumber(first_key) then + new_lines = convert_object_to_lines_array(new_lines) + end end - if start_line and start_line >= 1 and start_line <= buf_line_count then - local start_idx = start_line - 1 -- Convert to 0-indexed - local end_idx = math.min(end_line, buf_line_count) - - local success, err = pcall(vim.api.nvim_buf_set_lines, buf, start_idx, end_idx, false, new_lines) - if not success then - vim.notify('Failed to apply replacement: ' .. tostring(err), vim.log.levels.ERROR) - return false - end + -- Apply replacement if valid + if start_line and start_line >= 1 and start_line <= buf_line_count and new_lines and #new_lines > 0 then + local start_idx = math.floor(math.max(0, start_line - 1)) + local end_idx = math.floor(math.min(end_line, buf_line_count)) + pcall(vim.api.nvim_buf_set_lines, buf, start_idx, end_idx, false, new_lines) total_replacements = total_replacements + 1 - else - vim.notify( - string.format('Could not apply replacement - start_line %d is out of bounds', start_line), - vim.log.levels.WARN - ) end end return total_replacements > 0 end ---- Checks if response text is in JSON replacement format ----@param response_text string Response text to check ----@return boolean is_json True if text contains JSON replacement format -local function is_json_replacement_format(response_text) - -- Check for JSON in code blocks first - local json_match = response_text:match('```json\n(.-)\n```') or response_text:match('```\n(.-)\n```') - local json_text = json_match or response_text - - -- Try to parse as JSON - local ok, data = pcall(vim.json.decode, json_text) - if not ok then - return false - end - - -- Check if it has the expected replacements structure - return type(data) == 'table' and type(data.replacements) == 'table' and #data.replacements > 0 -end - --- Processes response from ephemeral session ----@param session_obj Session The session object ---@param session_info table Session tracking info ---@param messages OpencodeMessage[] Session messages -local function process_response(session_obj, session_info, messages) - if #messages < 2 then - cleanup_session(session_info, session_obj.id, 'Quick chat completed but no messages found') - return - end - +---@return boolean success Whether the response was processed successfully +local function process_response(session_info, messages) local response_message = messages[#messages] - if not response_message or response_message.info.role ~= 'assistant' then - cleanup_session(session_info, session_obj.id, 'Quick chat completed but no assistant response found') - return + if #messages < 2 and (not response_message or response_message.info.role ~= 'assistant') then + return false end + ---@cast response_message OpencodeMessage local response_text = extract_response_text(response_message) or '' + if response_text == '' then + return false + end - if response_text ~= '' then - -- Try JSON format first (preferred) - if is_json_replacement_format(response_text) then - local success = apply_line_replacements(session_info.buf, response_text) - if success then - cleanup_session(session_info, session_obj.id) - else - cleanup_session(session_info, session_obj.id, 'Failed to apply code edits') - end - return - end + local replacement_data = parse_replacement_json(response_text) + if not replacement_data then + return false end - cleanup_session(session_info, session_obj.id, 'Quick chat completed but no recognized response format found') + return apply_line_replacements(session_info.buf, replacement_data) end --- Hook function called when a session is done thinking (no more pending messages) @@ -275,11 +281,21 @@ local on_done = Promise.async(function(session_obj) end local session_info = active_sessions[session_obj.id] - if session_info then - local messages = session.get_messages(session_obj):await() --[[@as OpencodeMessage[] ]] - if messages then - process_response(session_obj, session_info, messages) - end + if not session_info then + return + end + + local messages = session.get_messages(session_obj):await() --[[@as OpencodeMessage[] ]] + if not messages then + cleanup_session(session_info, session_obj.id, 'Failed to update file with quick chat response') + return + end + + local success = process_response(session_info, messages) + if success then + cleanup_session(session_info, session_obj.id) -- Success cleanup (no error message) + else + cleanup_session(session_info, session_obj.id, 'Failed to update file with quick chat response') -- Error cleanup end -- Always delete ephemeral session @@ -288,167 +304,171 @@ local on_done = Promise.async(function(session_obj) -- end) end) ---- Helper function to save file if modified ----@param buf integer Buffer handle ----@return boolean success True if file was saved successfully or didn't need saving -local function ensure_file_saved(buf) - if not vim.api.nvim_get_option_value('modified', { buf = buf }) then - return true +--- Validates quick chat prerequisites +---@param message string|nil The message to validate +---@return boolean valid +---@return string|nil error_message +local function validate_quick_chat_prerequisites(message) + local buf = vim.api.nvim_get_current_buf() + local win = vim.api.nvim_get_current_win() + + if not buf or not win then + return false, 'Quick chat requires an active file buffer' end - local filename = vim.api.nvim_buf_get_name(buf) - if not filename or filename == '' then - vim.notify('Cannot save unnamed buffer. Please save the file first.', vim.log.levels.WARN) - return false + if message and message == '' then + return false, 'Quick chat message cannot be empty' end - if vim.fn.filewritable(filename) ~= 1 and vim.fn.filewritable(vim.fn.fnamemodify(filename, ':h')) ~= 2 then - vim.notify('File is not writable: ' .. filename, vim.log.levels.ERROR) - return false + return true +end + +--- Sets up context and range for quick chat +---@param buf integer Buffer handle +---@param context_config OpencodeContextConfig Context configuration +---@param range table|nil Range information +---@return table context_instance +local function setup_quick_chat_context(buf, context_config, range) + local context_instance = context.new_instance(context_config) + + if range and range.start and range.stop then + local start_line = math.floor(math.max(0, range.start - 1)) + local end_line = math.floor(range.stop + 1) + local range_lines = vim.api.nvim_buf_get_lines(buf, start_line, end_line, false) + local range_text = table.concat(range_lines, '\n') + local current_file = context_instance:get_current_file(buf) + local selection = context_instance:new_selection(current_file, range_text, range.start .. ', ' .. range.stop) + context_instance:add_selection(selection) end - local ok, err = pcall(function() - vim.api.nvim_buf_call(buf, function() - vim.cmd('write') - end) - end) + return context_instance +end - if not ok then - vim.notify('Failed to save file: ' .. tostring(err), vim.log.levels.ERROR) - return false +--- Creates message parameters for quick chat +---@param message string The user message +---@param context_instance table Context instance +---@param options table Options including model and agent +---@return table params Message parameters +local function create_message_params(message, context_instance, options) + local quick_chat_config = config.values.quick_chat or {} + local instructions = quick_chat_config.instructions + or { + 'You are an expert code assistant helping with code and text editing tasks.', + 'You are operating in a temporary quick chat session with limited context.', + "Your task is to modify the provided code according to the user's request. Follow these instructions precisely:", + 'CRITICAL: At the end of your job You MUST add a message with a valid JSON format for line replacements. Use this exact structure:', + '', + '```json', + '{', + ' "replacements": [', + ' {', + ' "start_line": 10,', + ' "end_line": 11,', + ' "lines": ["new content line 1", "new content line 2"]', + ' }', + ' ]', + '}', + '```', + '', + 'Maintain the *SAME INDENTATION* in the returned code as in the source code', + 'NEVER add any explanations, apologies, or additional text outside the JSON structure.', + 'ALWAYS split multiple line replacements into separate entries in the "replacements" array.', + 'IMPORTANT: Use 1-indexed line numbers. Each replacement replaces lines start_line through end_line (inclusive).', + 'The "lines" array contains the new content. If replacing a single line, end_line can equal start_line.', + 'Ensure the returned code is complete and can be directly used as a replacement for the original code.', + 'Remember that Your response SHOULD CONTAIN ONLY THE MODIFIED CODE to be used as DIRECT REPLACEMENT to the original file.', + } + + local parts = context.format_message_quick_chat(message, context_instance):await() + local params = { parts = parts, system = table.concat(instructions, '\n'), synthetic = true } + + -- Set model if specified + local current_model = core.initialize_current_model():await() + local target_model = options.model or quick_chat_config.default_model or current_model + if target_model then + local provider, model = target_model:match('^(.-)/(.+)$') + if provider and model then + params.model = { providerID = provider, modelID = model } + end end - return true + -- Set agent if specified + local target_mode = options.agent + or quick_chat_config.default_agent + or state.current_mode + or config.values.default_mode + if target_mode then + params.agent = target_mode + end + + return params end --- Unified quick chat function ---@param message string Optional custom message to use instead of default prompts ----@param options {include_context?: boolean, model?: string, agent?: string}|nil Optional configuration for context and behavior +---@param options {context_config?:OpencodeContextConfig, model?: string, agent?: string}|nil Optional configuration for context and behavior ---@param range table|nil Optional range information { start = number, stop = number } ---@return Promise -function M.quick_chat(message, options, range) +M.quick_chat = Promise.async(function(message, options, range) options = options or {} - local buf, win = context.get_current_buf() - if not buf or not win then - vim.notify('Quick chat requires an active file buffer', vim.log.levels.ERROR) - return Promise.new():resolve(nil) - end - - if message and message == '' then - vim.notify('Quick chat message cannot be empty', vim.log.levels.ERROR) + -- Validate prerequisites + local valid, error_msg = validate_quick_chat_prerequisites(message) + if not valid then + vim.notify(error_msg or 'Unknown error', vim.log.levels.ERROR) return Promise.new():resolve(nil) end - -- if not ensure_file_saved(buf) then - -- vim.notify('Quick chat cancelled - file must be saved first', vim.log.levels.ERROR) - -- return Promise.new():resolve(nil) - -- end - + local buf = vim.api.nvim_get_current_buf() + local win = vim.api.nvim_get_current_win() local cursor_pos = vim.api.nvim_win_get_cursor(win) local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed local spinner = CursorSpinner.new(buf, row, col) - local context_config = create_context_config(range ~= nil) - local context_instance = context.new_instance(context_config) + -- Setup context + local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) + local context_instance = setup_quick_chat_context(buf, context_config, range) - if range and range.start and range.stop then - local range_lines = vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false) - local range_text = table.concat(range_lines, '\n') - local current_file = context_instance:get_current_file(buf) - local selection = context_instance:new_selection(current_file, range_text, range.start .. ', ' .. range.stop) - context_instance:add_selection(selection) + -- Check prompt guard + local allowed, err_msg = util.check_prompt_allowed(config.values.prompt_guard, context_instance:get_mentioned_files()) + if not allowed then + spinner:stop() + return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard') end + -- Create session local title = create_session_title(buf) + local quick_chat_session = core.create_new_session(title):await() + if not quick_chat_session then + spinner:stop() + return Promise.new():reject('Failed to create ephemeral session') + end - return core.create_new_session(title):and_then(function(quick_chat_session) - if not quick_chat_session then - spinner:stop() - return Promise.new():reject('Failed to create ephemeral session') - end - - --TODO only for debug - state.active_session = quick_chat_session - - active_sessions[quick_chat_session.id] = { - buf = buf, - row = row, - col = col, - spinner = spinner, - timestamp = vim.uv.now(), - } - - local allowed, err_msg = - util.check_prompt_allowed(config.values.prompt_guard, context_instance:get_mentioned_files()) - - if not allowed then - spinner:stop() - active_sessions[quick_chat_session.id] = nil - return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard') - end + --TODO only for debug + state.active_session = quick_chat_session - local instructions = config.quick_chat and config.quick_chat.instructions - or { - 'You are an expert code assistant helping with code and text editing tasks.', - 'You are operating in a temporary quick chat session with limited context.', - 'CRITICAL: You MUST respond ONLY in valid JSON format for line replacements. Use this exact structure:', - '', - '```json', - '{', - ' "replacements": [', - ' {', - ' "start_line": 10,', - ' "end_line": 12,', - ' "lines": ["new content line 1", "new content line 2"]', - ' }', - ' ]', - '}', - '```', - '', - 'ALWAYS split multiple line replacements into separate entries in the "replacements" array.', - 'NEVER add any explanations, apologies, or additional text outside the JSON structure.', - 'IMPORTANT: Use 1-indexed line numbers. Each replacement replaces lines start_line through end_line (inclusive).', - 'The "lines" array contains the new content. If replacing a single line, end_line can equal start_line.', - 'Only provide changes that are directly relevant to the current context, cursor position, or selection.', - 'The provided context is in JSON format - use the plain text content to determine what changes to make.', - } - - local parts = context.format_message_stateless(message, context_instance) - local params = { parts = parts, system = table.concat(instructions, '\n\n') } - local quick_chat_config = config.values.quick_chat or {} - - return core - .initialize_current_model() - :and_then(function(current_model) - local target_model = options.model or quick_chat_config.default_model or current_model - if target_model then - local provider, model = target_model:match('^(.-)/(.+)$') - if provider and model then - params.model = { providerID = provider, modelID = model } - end - end + active_sessions[quick_chat_session.id] = { + buf = buf, + row = row, + col = col, + spinner = spinner, + timestamp = vim.uv.now(), + } - local target_mode = options.agent - or quick_chat_config.default_agent - or state.current_mode - or config.values.default_mode - if target_mode then - params.agent = target_mode - end + -- Create and send message + local params = create_message_params(message, context_instance, options) - return state.api_client:create_message(quick_chat_session.id, params) - end) - :and_then(function() - on_done(quick_chat_session) - end) - :catch(function(err) - spinner:stop() - active_sessions[quick_chat_session.id] = nil - vim.notify('Error in quick chat: ' .. vim.inspect(err), vim.log.levels.ERROR) - end) + local success, err = pcall(function() + state.api_client:create_message(quick_chat_session.id, params):await() + on_done(quick_chat_session):await() end) -end + + if not success then + spinner:stop() + active_sessions[quick_chat_session.id] = nil + vim.notify('Error in quick chat: ' .. vim.inspect(err), vim.log.levels.ERROR) + end +end) --- Setup function to initialize quick chat functionality function M.setup() From b70e3793bd3d9d0cd5872d15c74769564bccc49d Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 15 Dec 2025 13:26:09 -0500 Subject: [PATCH 04/46] fix: git_diff not returning value --- lua/opencode/context.lua | 3 ++- lua/opencode/quick_chat.lua | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index fab53e24..7e914c15 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -388,7 +388,7 @@ ContextInstance.get_git_diff = Promise.async(function(self) return nil end - Promise.system({ 'git', 'diff', '--cached' }) + return Promise.system({ 'git', 'diff', '--cached' }) end) ---@param opts? OpencodeContextConfig @@ -779,6 +779,7 @@ M.format_message_quick_chat = Promise.async(function(prompt, context_instance) end local diff_text = context_instance:get_git_diff():await() + vim.print('⭕ ❱ context.lua:781 ❱ ƒ(diff_text) ❱ diff_text =', diff_text) if diff_text and diff_text ~= '' then table.insert(parts, format_git_diff_part(diff_text)) end diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 59686b29..c8e253f1 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -298,6 +298,7 @@ local on_done = Promise.async(function(session_obj) cleanup_session(session_info, session_obj.id, 'Failed to update file with quick chat response') -- Error cleanup end + --@TODO: enable session deletion after testing -- Always delete ephemeral session -- state.api_client:delete_session(session_obj.id):catch(function(err) -- vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN) @@ -349,8 +350,8 @@ end ---@param context_instance table Context instance ---@param options table Options including model and agent ---@return table params Message parameters -local function create_message_params(message, context_instance, options) - local quick_chat_config = config.values.quick_chat or {} +local create_message = Promise.async(function(message, context_instance, options) + local quick_chat_config = config.quick_chat or {} local instructions = quick_chat_config.instructions or { 'You are an expert code assistant helping with code and text editing tasks.', @@ -402,7 +403,7 @@ local function create_message_params(message, context_instance, options) end return params -end +end) --- Unified quick chat function ---@param message string Optional custom message to use instead of default prompts @@ -428,7 +429,6 @@ M.quick_chat = Promise.async(function(message, options, range) -- Setup context local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) local context_instance = setup_quick_chat_context(buf, context_config, range) - -- Check prompt guard local allowed, err_msg = util.check_prompt_allowed(config.values.prompt_guard, context_instance:get_mentioned_files()) if not allowed then @@ -456,7 +456,8 @@ M.quick_chat = Promise.async(function(message, options, range) } -- Create and send message - local params = create_message_params(message, context_instance, options) + local params = create_message(message, context_instance, options):await() + spinner:stop() local success, err = pcall(function() state.api_client:create_message(quick_chat_session.id, params):await() From 1ec3cd5e7c86f1738bb2e78f13613dec9eb59c89 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 15 Dec 2025 15:13:29 -0500 Subject: [PATCH 05/46] fix: range transformation for selection --- lua/opencode/api.lua | 4 +-- lua/opencode/context.lua | 1 - lua/opencode/quick_chat.lua | 61 ++++++++++++++++++------------------- 3 files changed, 32 insertions(+), 34 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 9b2a3571..8746d26e 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -114,8 +114,8 @@ function M.quick_chat(message, range) local visual_range = util.get_visual_range() if visual_range then range = { - start_line = visual_range.start_line, - end_line = visual_range.end_line, + start = visual_range.start_line, + stop = visual_range.end_line, } end end diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 7e914c15..2171b1d9 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -779,7 +779,6 @@ M.format_message_quick_chat = Promise.async(function(prompt, context_instance) end local diff_text = context_instance:get_git_diff():await() - vim.print('⭕ ❱ context.lua:781 ❱ ƒ(diff_text) ❱ diff_text =', diff_text) if diff_text and diff_text ~= '' then table.insert(parts, format_git_diff_part(diff_text)) end diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index c8e253f1..f6338f34 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -9,7 +9,15 @@ local Timer = require('opencode.ui.timer') local M = {} -local active_sessions = {} +---@class OpencodeQuickChatRunningSession +---@field buf integer Buffer handle +---@field row integer Row position for spinner +---@field col integer Column position for spinner +---@field spinner CursorSpinner Spinner instance +---@field timestamp integer Timestamp when session started + +---@type table +local running_sessions = {} --- Simple cursor spinner using the same animation logic as loading_animation.lua local CursorSpinner = {} @@ -126,7 +134,7 @@ local function cleanup_session(session_info, session_id, message) if session_info and session_info.spinner then session_info.spinner:stop() end - active_sessions[session_id] = nil + running_sessions[session_id] = nil if message then vim.notify(message, vim.log.levels.WARN) end @@ -150,15 +158,12 @@ end ---@param response_text string Response text that may contain JSON in code blocks ---@return table|nil replacement_data Parsed replacement data or nil if invalid local function parse_replacement_json(response_text) - -- Try to extract JSON from response text (handle cases where JSON is in code blocks) local json_text = response_text - -- Look for JSON in code blocks local json_match = response_text:match('```json\n(.-)\n```') or response_text:match('```\n(.-)\n```') if json_match then json_text = json_match end - -- Try to parse JSON format local ok, replacement_data = pcall(vim.json.decode, json_text) if not ok then return nil @@ -176,6 +181,7 @@ local function parse_replacement_json(response_text) end --- Converts object format like {"1": "line1", "2": "line2"} to array +--- Some LLMs may return line replacements in this format instead of an array ---@param obj_lines table Object with string keys representing line numbers ---@return string[] lines_array Array of lines in correct order local function convert_object_to_lines_array(obj_lines) @@ -236,7 +242,6 @@ local function apply_line_replacements(buf, replacement_data) end end - -- Apply replacement if valid if start_line and start_line >= 1 and start_line <= buf_line_count and new_lines and #new_lines > 0 then local start_idx = math.floor(math.max(0, start_line - 1)) local end_idx = math.floor(math.min(end_line, buf_line_count)) @@ -274,28 +279,28 @@ local function process_response(session_info, messages) end --- Hook function called when a session is done thinking (no more pending messages) ----@param session_obj Session The session object -local on_done = Promise.async(function(session_obj) - if not (session_obj.title and vim.startswith(session_obj.title, '[QuickChat]')) then +---@param active_session Session The session object +local on_done = Promise.async(function(active_session) + if not (active_session.title and vim.startswith(active_session.title, '[QuickChat]')) then return end - local session_info = active_sessions[session_obj.id] - if not session_info then + local running_session = running_sessions[active_session.id] + if not running_session then return end - local messages = session.get_messages(session_obj):await() --[[@as OpencodeMessage[] ]] + local messages = session.get_messages(active_session):await() --[[@as OpencodeMessage[] ]] if not messages then - cleanup_session(session_info, session_obj.id, 'Failed to update file with quick chat response') + cleanup_session(running_session, active_session.id, 'Failed to update file with quick chat response') return end - local success = process_response(session_info, messages) + local success = process_response(running_session, messages) if success then - cleanup_session(session_info, session_obj.id) -- Success cleanup (no error message) + cleanup_session(running_session, active_session.id) else - cleanup_session(session_info, session_obj.id, 'Failed to update file with quick chat response') -- Error cleanup + cleanup_session(running_session, active_session.id, 'Failed to update file with quick chat response') end --@TODO: enable session deletion after testing @@ -305,7 +310,6 @@ local on_done = Promise.async(function(session_obj) -- end) end) ---- Validates quick chat prerequisites ---@param message string|nil The message to validate ---@return boolean valid ---@return string|nil error_message @@ -329,7 +333,7 @@ end ---@param context_config OpencodeContextConfig Context configuration ---@param range table|nil Range information ---@return table context_instance -local function setup_quick_chat_context(buf, context_config, range) +local function init_context(buf, context_config, range) local context_instance = context.new_instance(context_config) if range and range.start and range.stop then @@ -383,7 +387,6 @@ local create_message = Promise.async(function(message, context_instance, options local parts = context.format_message_quick_chat(message, context_instance):await() local params = { parts = parts, system = table.concat(instructions, '\n'), synthetic = true } - -- Set model if specified local current_model = core.initialize_current_model():await() local target_model = options.model or quick_chat_config.default_model or current_model if target_model then @@ -413,7 +416,6 @@ end) M.quick_chat = Promise.async(function(message, options, range) options = options or {} - -- Validate prerequisites local valid, error_msg = validate_quick_chat_prerequisites(message) if not valid then vim.notify(error_msg or 'Unknown error', vim.log.levels.ERROR) @@ -426,17 +428,15 @@ M.quick_chat = Promise.async(function(message, options, range) local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed local spinner = CursorSpinner.new(buf, row, col) - -- Setup context local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) - local context_instance = setup_quick_chat_context(buf, context_config, range) - -- Check prompt guard + local context_instance = init_context(buf, context_config, range) + local allowed, err_msg = util.check_prompt_allowed(config.values.prompt_guard, context_instance:get_mentioned_files()) if not allowed then spinner:stop() return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard') end - -- Create session local title = create_session_title(buf) local quick_chat_session = core.create_new_session(title):await() if not quick_chat_session then @@ -447,7 +447,7 @@ M.quick_chat = Promise.async(function(message, options, range) --TODO only for debug state.active_session = quick_chat_session - active_sessions[quick_chat_session.id] = { + running_sessions[quick_chat_session.id] = { buf = buf, row = row, col = col, @@ -455,7 +455,6 @@ M.quick_chat = Promise.async(function(message, options, range) timestamp = vim.uv.now(), } - -- Create and send message local params = create_message(message, context_instance, options):await() spinner:stop() @@ -466,7 +465,7 @@ M.quick_chat = Promise.async(function(message, options, range) if not success then spinner:stop() - active_sessions[quick_chat_session.id] = nil + running_sessions[quick_chat_session.id] = nil vim.notify('Error in quick chat: ' .. vim.inspect(err), vim.log.levels.ERROR) end end) @@ -479,12 +478,12 @@ function M.setup() group = augroup, callback = function(ev) local buf = ev.buf - for session_id, session_info in pairs(active_sessions) do + for session_id, session_info in pairs(running_sessions) do if session_info.buf == buf then if session_info.spinner then session_info.spinner:stop() end - active_sessions[session_id] = nil + running_sessions[session_id] = nil end end end, @@ -493,12 +492,12 @@ function M.setup() vim.api.nvim_create_autocmd('VimLeavePre', { group = augroup, callback = function() - for _session_id, session_info in pairs(active_sessions) do + for _session_id, session_info in pairs(running_sessions) do if session_info.spinner then session_info.spinner:stop() end end - active_sessions = {} + running_sessions = {} end, }) end From 32245ba20d146f0c00742fcd8a974090262dddd4 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 16 Dec 2025 08:07:41 -0500 Subject: [PATCH 06/46] fix: promise with nil arguments --- lua/opencode/promise.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index 6a03637b..beebfb07 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -197,7 +197,7 @@ function Promise:finally(callback) -- Ignore callback errors and result, finally doesn't change the promise chain if not ok then -- Log error but don't propagate it - vim.notify("Error in finally callback", vim.log.levels.WARN) + vim.notify('Error in finally callback', vim.log.levels.WARN) end end @@ -374,9 +374,11 @@ end ---@return fun(...): Promise function Promise.async(fn) return function(...) + -- Capture both args and count to handle nil values correctly + local n = select('#', ...) local args = { ... } return Promise.spawn(function() - return fn(unpack(args)) + return fn(unpack(args, 1, n)) end) end end From 752f5956efb15be5eea48f23c79774eec8905127 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 16 Dec 2025 08:35:16 -0500 Subject: [PATCH 07/46] refactor(context): modularize context gathering and add search/replace output to quick_chat - Extracts core context logic into separate modules: `base.lua`, `json_formatter.lua`, `plain_text_formatter.lua` - Updates `quick_chat` to output and apply LLM-generated SEARCH/REPLACE blocks (instead of JSON object replacement) - Improves context formatting for both LLM-friendly plain text and API JSON - Refactors quick chat flow for simpler, more robust edit workflows --- lua/opencode/context.lua | 647 +----------------- lua/opencode/context/base.lua | 442 ++++++++++++ lua/opencode/context/json_formatter.lua | 153 +++++ lua/opencode/context/plain_text_formatter.lua | 195 ++++++ lua/opencode/quick_chat.lua | 524 ++++++++++---- lua/opencode/types.lua | 6 + 6 files changed, 1200 insertions(+), 767 deletions(-) create mode 100644 lua/opencode/context/base.lua create mode 100644 lua/opencode/context/json_formatter.lua create mode 100644 lua/opencode/context/plain_text_formatter.lua diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 2171b1d9..f7c804bc 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -1,443 +1,18 @@ -- Gathers editor context +-- This module acts as a facade for backward compatibility, +-- delegating to the extracted modules in context/ local util = require('opencode.util') local config = require('opencode.config') local state = require('opencode.state') local Promise = require('opencode.promise') -local M = {} - ----@class ContextInstance ----@field context OpencodeContext ----@field last_context OpencodeContext|nil ----@field context_config OpencodeContextConfig|nil Optional context config override -local ContextInstance = {} -ContextInstance.__index = ContextInstance - ---- Creates a new Context instance ----@param context_config? OpencodeContextConfig Optional context config to override global config ----@return ContextInstance -function ContextInstance:new(context_config) - self = setmetatable({}, ContextInstance) - self.context = { - -- current file - current_file = nil, - cursor_data = nil, - - -- attachments - mentioned_files = nil, - selections = {}, - linter_errors = {}, - mentioned_subagents = {}, - } - self.last_context = nil - self.context_config = context_config - return self -end - -function ContextInstance:unload_attachments() - self.context.mentioned_files = nil - self.context.selections = nil - self.context.linter_errors = nil -end - ----@return integer|nil, integer|nil -function ContextInstance:get_current_buf() - local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() - if util.is_buf_a_file(curr_buf) then - local win = vim.fn.win_findbuf(curr_buf --[[@as integer]])[1] - return curr_buf, state.last_code_win_before_opencode or win or vim.api.nvim_get_current_win() - end -end - -function ContextInstance:load() - local buf, win = self:get_current_buf() - - if buf then - local current_file = self:get_current_file(buf) - local cursor_data = self:get_current_cursor_data(buf, win) - - self.context.current_file = current_file - self.context.cursor_data = cursor_data - self.context.linter_errors = self:get_diagnostics(buf) - end - - local current_selection = self:get_current_selection() - if current_selection then - local selection = self:new_selection(self.context.current_file, current_selection.text, current_selection.lines) - self:add_selection(selection) - end -end - -function ContextInstance:is_enabled() - if self.context_config and self.context_config.enabled ~= nil then - return self.context_config.enabled - end - - local is_enabled = vim.tbl_get(config --[[@as table]], 'context', 'enabled') - local is_state_enabled = vim.tbl_get(state, 'current_context_config', 'enabled') - if is_state_enabled ~= nil then - return is_state_enabled - else - return is_enabled - end -end - --- Checks if a context feature is enabled in config or state ----@param context_key string ----@return boolean -function ContextInstance:is_context_enabled(context_key) - -- If instance has a context config, use it as the override - if self.context_config then - local override_enabled = vim.tbl_get(self.context_config, context_key, 'enabled') - if override_enabled ~= nil then - return override_enabled - end - end - - -- Fall back to the existing logic (state then global config) - local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') - local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') - - if is_state_enabled ~= nil then - return is_state_enabled - else - return is_enabled - end -end - ----@return OpencodeDiagnostic[]|nil -function ContextInstance:get_diagnostics(buf) - if not self:is_context_enabled('diagnostics') then - return nil - end - - local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} - if current_conf.enabled == false then - return {} - end - - local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} - local override_conf = self.context_config and vim.tbl_get(self.context_config, 'diagnostics') or {} - local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf, override_conf) or {} - - local severity_levels = {} - if diagnostic_conf.error then - table.insert(severity_levels, vim.diagnostic.severity.ERROR) - end - if diagnostic_conf.warning then - table.insert(severity_levels, vim.diagnostic.severity.WARN) - end - if diagnostic_conf.info then - table.insert(severity_levels, vim.diagnostic.severity.INFO) - end - - local diagnostics = {} - if diagnostic_conf.only_closest then - local selections = self:get_selections() - if #selections > 0 then - local selection = selections[#selections] - if selection and selection.lines then - local range_parts = vim.split(selection.lines, ',') - local start_line = (tonumber(range_parts[1]) or 1) - 1 - local end_line = (tonumber(range_parts[2]) or 1) - 1 - for lnum = start_line, end_line do - local line_diagnostics = vim.diagnostic.get(buf, { - lnum = lnum, - severity = severity_levels, - }) - for _, diag in ipairs(line_diagnostics) do - table.insert(diagnostics, diag) - end - end - end - else - local win = vim.fn.win_findbuf(buf)[1] - local cursor_pos = vim.fn.getcurpos(win) - local line_diagnostics = vim.diagnostic.get(buf, { - lnum = cursor_pos[2] - 1, - severity = severity_levels, - }) - diagnostics = line_diagnostics - end - else - diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) - end - - if #diagnostics == 0 then - return {} - end - - local opencode_diagnostics = {} - for _, diag in ipairs(diagnostics) do - table.insert(opencode_diagnostics, { - message = diag.message, - severity = diag.severity, - lnum = diag.lnum, - col = diag.col, - end_lnum = diag.end_lnum, - end_col = diag.end_col, - source = diag.source, - code = diag.code, - user_data = diag.user_data, - }) - end - - return opencode_diagnostics -end - -function ContextInstance:new_selection(file, content, lines) - return { - file = file, - content = util.indent_code_block(content), - lines = lines, - } -end - -function ContextInstance:add_selection(selection) - if not self.context.selections then - self.context.selections = {} - end - - table.insert(self.context.selections, selection) -end - -function ContextInstance:remove_selection(selection) - if not self.context.selections then - return - end - - for i, sel in ipairs(self.context.selections) do - if sel.file.path == selection.file.path and sel.lines == selection.lines then - table.remove(self.context.selections, i) - break - end - end -end - -function ContextInstance:clear_selections() - self.context.selections = nil -end - -function ContextInstance:add_file(file) - if not self.context.mentioned_files then - self.context.mentioned_files = {} - end - - local is_file = vim.fn.filereadable(file) == 1 - local is_dir = vim.fn.isdirectory(file) == 1 - if not is_file and not is_dir then - vim.notify('File not added to context. Could not read.') - return - end - - if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then - vim.notify('File not added to context. Must be inside current working directory.') - return - end - - file = vim.fn.fnamemodify(file, ':p') - - if not vim.tbl_contains(self.context.mentioned_files, file) then - table.insert(self.context.mentioned_files, file) - end -end - -function ContextInstance:remove_file(file) - file = vim.fn.fnamemodify(file, ':p') - if not self.context.mentioned_files then - return - end - - for i, f in ipairs(self.context.mentioned_files) do - if f == file then - table.remove(self.context.mentioned_files, i) - break - end - end -end - -function ContextInstance:clear_files() - self.context.mentioned_files = nil -end - -function ContextInstance:get_mentioned_files() - return self.context.mentioned_files or {} -end - -function ContextInstance:add_subagent(subagent) - if not self.context.mentioned_subagents then - self.context.mentioned_subagents = {} - end - - if not vim.tbl_contains(self.context.mentioned_subagents, subagent) then - table.insert(self.context.mentioned_subagents, subagent) - end -end - -function ContextInstance:remove_subagent(subagent) - if not self.context.mentioned_subagents then - return - end - - for i, a in ipairs(self.context.mentioned_subagents) do - if a == subagent then - table.remove(self.context.mentioned_subagents, i) - break - end - end -end - -function ContextInstance:clear_subagents() - self.context.mentioned_subagents = nil -end - -function ContextInstance:get_mentioned_subagents() - if not self:is_context_enabled('agents') then - return nil - end - return self.context.mentioned_subagents or {} -end - -function ContextInstance:get_current_file(buf) - if not self:is_context_enabled('current_file') then - return nil - end - local file = vim.api.nvim_buf_get_name(buf) - if not file or file == '' or vim.fn.filereadable(file) ~= 1 then - return nil - end - return { - path = file, - name = vim.fn.fnamemodify(file, ':t'), - extension = vim.fn.fnamemodify(file, ':e'), - } -end - -function ContextInstance:get_current_cursor_data(buf, win) - if not self:is_context_enabled('cursor_data') then - return nil - end - - local num_lines = config.context.cursor_data.context_lines --[[@as integer]] - or 0 - local cursor_pos = vim.fn.getcurpos(win) - local start_line = (cursor_pos[2] - 1) --[[@as integer]] - local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') - local lines_before = vim.api.nvim_buf_get_lines(buf, math.max(0, start_line - num_lines), start_line, false) - local lines_after = vim.api.nvim_buf_get_lines(buf, cursor_pos[2], cursor_pos[2] + num_lines, false) - return { - line = cursor_pos[2], - column = cursor_pos[3], - line_content = cursor_content, - lines_before = lines_before, - lines_after = lines_after, - } -end - -function ContextInstance:get_current_selection() - if not self:is_context_enabled('selection') then - return nil - end - - -- Return nil if not in a visual mode - if not vim.fn.mode():match('[vV\022]') then - return nil - end - - -- Save current position and register state - local current_pos = vim.fn.getpos('.') - local old_reg = vim.fn.getreg('x') - local old_regtype = vim.fn.getregtype('x') - - -- Capture selection text and position - vim.cmd('normal! "xy') - local text = vim.fn.getreg('x') - - -- Get line numbers - vim.cmd('normal! `<') - local start_line = vim.fn.line('.') - vim.cmd('normal! `>') - local end_line = vim.fn.line('.') - - -- Restore state - vim.fn.setreg('x', old_reg, old_regtype) - vim.cmd('normal! gv') - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true) - vim.fn.setpos('.', current_pos) - - if not text or text == '' then - return nil - end - - return { - text = text and text:match('[^%s]') and text or nil, - lines = start_line .. ', ' .. end_line, - } -end - -function ContextInstance:get_selections() - if not self:is_context_enabled('selection') then - return {} - end - return self.context.selections or {} -end - -ContextInstance.get_git_diff = Promise.async(function(self) - if not self:is_context_enabled('git_diff') then - return nil - end - - return Promise.system({ 'git', 'diff', '--cached' }) -end) - ----@param opts? OpencodeContextConfig ----@return OpencodeContext -function ContextInstance:delta_context(opts) - opts = opts or config.context - if opts.enabled == false then - return { - current_file = nil, - mentioned_files = nil, - selections = nil, - linter_errors = nil, - cursor_data = nil, - mentioned_subagents = nil, - } - end - - local context = vim.deepcopy(self.context) - local last_context = self.last_context - if not last_context then - return context - end - - -- no need to send file context again - if - context.current_file - and last_context.current_file - and context.current_file.name == last_context.current_file.name - then - context.current_file = nil - end - - -- no need to send subagents again - if - context.mentioned_subagents - and last_context.mentioned_subagents - and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents) - then - context.mentioned_subagents = nil - end +-- Import extracted modules +local ContextInstance = require('opencode.context.base') +local json_formatter = require('opencode.context.json_formatter') +local plain_text_formatter = require('opencode.context.plain_text_formatter') - return context -end - ---- Set the last context (used for delta calculations) ----@param last_context OpencodeContext -function ContextInstance:set_last_context(last_context) - self.last_context = last_context -end +local M = {} -- Global context instance ---@type ContextInstance @@ -561,135 +136,14 @@ function M.get_current_selection() return global_context:get_current_selection() end -local function format_file_part(path, prompt) - local rel_path = vim.fn.fnamemodify(path, ':~:.') - local mention = '@' .. rel_path - local pos = prompt and prompt:find(mention) - pos = pos and pos - 1 or 0 -- convert to 0-based index - - local ext = vim.fn.fnamemodify(path, ':e'):lower() - local mime_type = 'text/plain' - if ext == 'png' then - mime_type = 'image/png' - elseif ext == 'jpg' or ext == 'jpeg' then - mime_type = 'image/jpeg' - elseif ext == 'gif' then - mime_type = 'image/gif' - elseif ext == 'webp' then - mime_type = 'image/webp' - end - - local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path } - if prompt then - file_part.source = { - path = path, - type = 'file', - text = { start = pos, value = mention, ['end'] = pos + #mention }, - } - end - return file_part -end - ----@param selection OpencodeContextSelection -local function format_selection_part(selection) - local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' - - return { - type = 'text', - metadata = { - context_type = 'selection', - }, - text = vim.json.encode({ - context_type = 'selection', - file = selection.file, - content = string.format('`````%s\n%s\n`````', lang, selection.content), --@TODO remove code fence and only use it when displaying - lines = selection.lines, - }), - synthetic = true, - } -end - ----@param diagnostics OpencodeDiagnostic[] ----@param range? { start_line: integer, end_line: integer }|nil -local function format_diagnostics_part(diagnostics, range) - local diag_list = {} - for _, diag in ipairs(diagnostics) do - if not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line) then - local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') - table.insert( - diag_list, - { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } - ) - end - end - return { - type = 'text', - meradata = { - context_type = 'diagnostics', - }, - text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }), - synthetic = true, - } -end - -local function format_cursor_data_part(cursor_data) - local buf = (M.get_current_buf() or 0) --[[@as integer]] - local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or '' - return { - type = 'text', - metadata = { - context_type = 'cursor-data', - lang = lang, - }, - text = vim.json.encode({ - context_type = 'cursor-data', - line = cursor_data.line, - column = cursor_data.column, - line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), --@TODO remove code fence and only use it when displaying - lines_before = cursor_data.lines_before, - lines_after = cursor_data.lines_after, - }), - synthetic = true, - } -end - -local function format_subagents_part(agent, prompt) - local mention = '@' .. agent - local pos = prompt:find(mention) - pos = pos and pos - 1 or 0 -- convert to 0-based index - - return { - type = 'agent', - name = agent, - source = { value = mention, start = pos, ['end'] = pos + #mention }, - } -end - -local function format_buffer_part(buf) - local file = vim.api.nvim_buf_get_name(buf) - local rel_path = vim.fn.fnamemodify(file, ':~:.') - return { - type = 'text', - text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n'), - metadata = { - context_type = 'file-content', - filename = rel_path, - mime = 'text/plain', - }, - synthetic = true, - } -end - -local function format_git_diff_part(diff_text) - return { - type = 'text', - metadata = { - context_type = 'git-diff', - }, - text = diff_text, - synthetic = true, - } -end +--- Formats context as plain text for LLM consumption +--- Outputs human-readable text instead of JSON message parts +--- Alias: format_message_quick_chat +---@param prompt string The user's instruction/prompt +---@param context_instance ContextInstance Context instance to use +---@param opts? { range?: { start: integer, stop: integer }, buf?: integer } +---@return table result { text: string, parts: OpencodeMessagePart[] } +M.format_message_plain_text = plain_text_formatter.format_message --- Formats a prompt and context into message with parts for the opencode API ---@param prompt string @@ -704,87 +158,40 @@ function M.format_message(prompt, opts) for _, path in ipairs(context.mentioned_files or {}) do -- don't resend current file if it's also mentioned if not context.current_file or path ~= context.current_file.path then - table.insert(parts, format_file_part(path, prompt)) + table.insert(parts, json_formatter.format_file_part(path, prompt)) end end for _, sel in ipairs(context.selections or {}) do - table.insert(parts, format_selection_part(sel)) + table.insert(parts, json_formatter.format_selection_part(sel)) end for _, agent in ipairs(context.mentioned_subagents or {}) do - table.insert(parts, format_subagents_part(agent, prompt)) + table.insert(parts, json_formatter.format_subagents_part(agent, prompt)) end if context.current_file then - table.insert(parts, format_file_part(context.current_file.path)) + table.insert(parts, json_formatter.format_file_part(context.current_file.path)) end if context.linter_errors and #context.linter_errors > 0 then - table.insert(parts, format_diagnostics_part(context.linter_errors)) + table.insert(parts, json_formatter.format_diagnostics_part(context.linter_errors)) end if context.cursor_data then - table.insert(parts, format_cursor_data_part(context.cursor_data)) + table.insert(parts, json_formatter.format_cursor_data_part(context.cursor_data, M.get_current_buf)) end return parts end ---- Formats a prompt and context into message without state tracking (bypasses delta) ---- Used for ephemeral sessions like quick chat that don't track context state +--- Formats a prompt and context into plain text message for quick chat +--- Alias for format_message_plain_text - used for ephemeral sessions ---@param prompt string ----@param context_instance ContextInstance Optional context instance to use instead of global ----@return OpencodeMessagePart[] -M.format_message_quick_chat = Promise.async(function(prompt, context_instance) - local parts = { { type = 'text', text = prompt } } - - if context_instance:is_enabled() == false then - return parts - end - - for _, path in ipairs(context_instance:get_mentioned_files() or {}) do - table.insert(parts, format_file_part(path, prompt)) - end - - for _, sel in ipairs(context_instance:get_selections() or {}) do - table.insert(parts, format_selection_part(sel)) - end - - for _, agent in ipairs(context_instance:get_mentioned_subagents() or {}) do - table.insert(parts, format_subagents_part(agent, prompt)) - end - - local current_file = context_instance:get_current_file(context_instance:get_current_buf() or 0) - if current_file then - table.insert(parts, format_file_part(current_file.path)) - end - - local diagnostics = context_instance:get_diagnostics(context_instance:get_current_buf() or 0) - if diagnostics and #diagnostics > 0 then - table.insert(parts, format_diagnostics_part(diagnostics)) - end - - local current_buf, current_win = context_instance:get_current_buf() - local cursor_data = context_instance:get_current_cursor_data(current_buf or 0, current_win or 0) - if cursor_data then - table.insert(parts, format_cursor_data_part(cursor_data)) - end - - if context_instance:is_context_enabled('buffer') then - local buf = context_instance:get_current_buf() - if buf then - table.insert(parts, format_buffer_part(buf)) - end - end - - local diff_text = context_instance:get_git_diff():await() - if diff_text and diff_text ~= '' then - table.insert(parts, format_git_diff_part(diff_text)) - end - - return parts -end) +---@param context_instance ContextInstance Context instance to use +---@param opts? { range?: { start: integer, stop: integer }, buf?: integer } +---@return table result { text: string, parts: OpencodeMessagePart[] } +M.format_message_quick_chat = plain_text_formatter.format_message ---@param text string ---@param context_type string|nil diff --git a/lua/opencode/context/base.lua b/lua/opencode/context/base.lua new file mode 100644 index 00000000..a69f4724 --- /dev/null +++ b/lua/opencode/context/base.lua @@ -0,0 +1,442 @@ +-- Base class for context gathering +-- Handles collecting editor context (files, selections, diagnostics, cursor, etc.) + +local util = require('opencode.util') +local config = require('opencode.config') +local state = require('opencode.state') +local Promise = require('opencode.promise') + +---@class ContextInstance +---@field context OpencodeContext +---@field last_context OpencodeContext|nil +---@field context_config OpencodeContextConfig|nil Optional context config override +local ContextInstance = {} +ContextInstance.__index = ContextInstance + +--- Creates a new Context instance +---@param context_config? OpencodeContextConfig Optional context config to override global config +---@return ContextInstance +function ContextInstance:new(context_config) + local instance = setmetatable({}, self) + instance.context = { + -- current file + current_file = nil, + cursor_data = nil, + + -- attachments + mentioned_files = nil, + selections = {}, + linter_errors = {}, + mentioned_subagents = {}, + } + instance.last_context = nil + instance.context_config = context_config + return instance +end + +function ContextInstance:unload_attachments() + self.context.mentioned_files = nil + self.context.selections = nil + self.context.linter_errors = nil +end + +---@return integer|nil, integer|nil +function ContextInstance:get_current_buf() + local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() + if util.is_buf_a_file(curr_buf) then + local win = vim.fn.win_findbuf(curr_buf --[[@as integer]])[1] + return curr_buf, state.last_code_win_before_opencode or win or vim.api.nvim_get_current_win() + end +end + +function ContextInstance:load() + local buf, win = self:get_current_buf() + + if buf then + local current_file = self:get_current_file(buf) + local cursor_data = self:get_current_cursor_data(buf, win) + + self.context.current_file = current_file + self.context.cursor_data = cursor_data + self.context.linter_errors = self:get_diagnostics(buf) + end + + local current_selection = self:get_current_selection() + if current_selection then + local selection = self:new_selection(self.context.current_file, current_selection.text, current_selection.lines) + self:add_selection(selection) + end +end + +function ContextInstance:is_enabled() + if self.context_config and self.context_config.enabled ~= nil then + return self.context_config.enabled + end + + local is_enabled = vim.tbl_get(config --[[@as table]], 'context', 'enabled') + local is_state_enabled = vim.tbl_get(state, 'current_context_config', 'enabled') + if is_state_enabled ~= nil then + return is_state_enabled + else + return is_enabled + end +end + +-- Checks if a context feature is enabled in config or state +---@param context_key string +---@return boolean +function ContextInstance:is_context_enabled(context_key) + -- If instance has a context config, use it as the override + if self.context_config then + local override_enabled = vim.tbl_get(self.context_config, context_key, 'enabled') + if override_enabled ~= nil then + return override_enabled + end + end + + -- Fall back to the existing logic (state then global config) + local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') + local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') + + if is_state_enabled ~= nil then + return is_state_enabled + else + return is_enabled + end +end + +---@return OpencodeDiagnostic[]|nil +function ContextInstance:get_diagnostics(buf) + if not self:is_context_enabled('diagnostics') then + return nil + end + + local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} + if current_conf.enabled == false then + return {} + end + + local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} + local override_conf = self.context_config and vim.tbl_get(self.context_config, 'diagnostics') or {} + local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf, override_conf) or {} + + local severity_levels = {} + if diagnostic_conf.error then + table.insert(severity_levels, vim.diagnostic.severity.ERROR) + end + if diagnostic_conf.warning then + table.insert(severity_levels, vim.diagnostic.severity.WARN) + end + if diagnostic_conf.info then + table.insert(severity_levels, vim.diagnostic.severity.INFO) + end + + local diagnostics = {} + if diagnostic_conf.only_closest then + local selections = self:get_selections() + if #selections > 0 then + local selection = selections[#selections] + if selection and selection.lines then + local range_parts = vim.split(selection.lines, ',') + local start_line = (tonumber(range_parts[1]) or 1) - 1 + local end_line = (tonumber(range_parts[2]) or 1) - 1 + for lnum = start_line, end_line do + local line_diagnostics = vim.diagnostic.get(buf, { + lnum = lnum, + severity = severity_levels, + }) + for _, diag in ipairs(line_diagnostics) do + table.insert(diagnostics, diag) + end + end + end + else + local win = vim.fn.win_findbuf(buf)[1] + local cursor_pos = vim.fn.getcurpos(win) + local line_diagnostics = vim.diagnostic.get(buf, { + lnum = cursor_pos[2] - 1, + severity = severity_levels, + }) + diagnostics = line_diagnostics + end + else + diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) + end + + if #diagnostics == 0 then + return {} + end + + local opencode_diagnostics = {} + for _, diag in ipairs(diagnostics) do + table.insert(opencode_diagnostics, { + message = diag.message, + severity = diag.severity, + lnum = diag.lnum, + col = diag.col, + end_lnum = diag.end_lnum, + end_col = diag.end_col, + source = diag.source, + code = diag.code, + user_data = diag.user_data, + }) + end + + return opencode_diagnostics +end + +function ContextInstance:new_selection(file, content, lines) + return { + file = file, + content = util.indent_code_block(content), + lines = lines, + } +end + +function ContextInstance:add_selection(selection) + if not self.context.selections then + self.context.selections = {} + end + + table.insert(self.context.selections, selection) +end + +function ContextInstance:remove_selection(selection) + if not self.context.selections then + return + end + + for i, sel in ipairs(self.context.selections) do + if sel.file.path == selection.file.path and sel.lines == selection.lines then + table.remove(self.context.selections, i) + break + end + end +end + +function ContextInstance:clear_selections() + self.context.selections = nil +end + +function ContextInstance:add_file(file) + if not self.context.mentioned_files then + self.context.mentioned_files = {} + end + + local is_file = vim.fn.filereadable(file) == 1 + local is_dir = vim.fn.isdirectory(file) == 1 + if not is_file and not is_dir then + vim.notify('File not added to context. Could not read.') + return + end + + if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then + vim.notify('File not added to context. Must be inside current working directory.') + return + end + + file = vim.fn.fnamemodify(file, ':p') + + if not vim.tbl_contains(self.context.mentioned_files, file) then + table.insert(self.context.mentioned_files, file) + end +end + +function ContextInstance:remove_file(file) + file = vim.fn.fnamemodify(file, ':p') + if not self.context.mentioned_files then + return + end + + for i, f in ipairs(self.context.mentioned_files) do + if f == file then + table.remove(self.context.mentioned_files, i) + break + end + end +end + +function ContextInstance:clear_files() + self.context.mentioned_files = nil +end + +function ContextInstance:get_mentioned_files() + return self.context.mentioned_files or {} +end + +function ContextInstance:add_subagent(subagent) + if not self.context.mentioned_subagents then + self.context.mentioned_subagents = {} + end + + if not vim.tbl_contains(self.context.mentioned_subagents, subagent) then + table.insert(self.context.mentioned_subagents, subagent) + end +end + +function ContextInstance:remove_subagent(subagent) + if not self.context.mentioned_subagents then + return + end + + for i, a in ipairs(self.context.mentioned_subagents) do + if a == subagent then + table.remove(self.context.mentioned_subagents, i) + break + end + end +end + +function ContextInstance:clear_subagents() + self.context.mentioned_subagents = nil +end + +function ContextInstance:get_mentioned_subagents() + if not self:is_context_enabled('agents') then + return nil + end + return self.context.mentioned_subagents or {} +end + +function ContextInstance:get_current_file(buf) + if not self:is_context_enabled('current_file') then + return nil + end + local file = vim.api.nvim_buf_get_name(buf) + if not file or file == '' or vim.fn.filereadable(file) ~= 1 then + return nil + end + return { + path = file, + name = vim.fn.fnamemodify(file, ':t'), + extension = vim.fn.fnamemodify(file, ':e'), + } +end + +function ContextInstance:get_current_cursor_data(buf, win) + if not self:is_context_enabled('cursor_data') then + return nil + end + + local num_lines = config.context.cursor_data.context_lines --[[@as integer]] + or 0 + local cursor_pos = vim.fn.getcurpos(win) + local start_line = (cursor_pos[2] - 1) --[[@as integer]] + local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') + local lines_before = vim.api.nvim_buf_get_lines(buf, math.max(0, start_line - num_lines), start_line, false) + local lines_after = vim.api.nvim_buf_get_lines(buf, cursor_pos[2], cursor_pos[2] + num_lines, false) + return { + line = cursor_pos[2], + column = cursor_pos[3], + line_content = cursor_content, + lines_before = lines_before, + lines_after = lines_after, + } +end + +function ContextInstance:get_current_selection() + if not self:is_context_enabled('selection') then + return nil + end + + -- Return nil if not in a visual mode + if not vim.fn.mode():match('[vV\022]') then + return nil + end + + -- Save current position and register state + local current_pos = vim.fn.getpos('.') + local old_reg = vim.fn.getreg('x') + local old_regtype = vim.fn.getregtype('x') + + -- Capture selection text and position + vim.cmd('normal! "xy') + local text = vim.fn.getreg('x') + + -- Get line numbers + vim.cmd('normal! `<') + local start_line = vim.fn.line('.') + vim.cmd('normal! `>') + local end_line = vim.fn.line('.') + + -- Restore state + vim.fn.setreg('x', old_reg, old_regtype) + vim.cmd('normal! gv') + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true) + vim.fn.setpos('.', current_pos) + + if not text or text == '' then + return nil + end + + return { + text = text and text:match('[^%s]') and text or nil, + lines = start_line .. ', ' .. end_line, + } +end + +function ContextInstance:get_selections() + if not self:is_context_enabled('selection') then + return {} + end + return self.context.selections or {} +end + +ContextInstance.get_git_diff = Promise.async(function(self) + if not self:is_context_enabled('git_diff') then + return nil + end + + return Promise.system({ 'git', 'diff', '--cached' }):and_then(function(output) + if output == '' then + return nil + end + return output.stdout + end) +end) + +---@param opts? OpencodeContextConfig +---@return OpencodeContext +function ContextInstance:delta_context(opts) + opts = opts or config.context + if opts.enabled == false then + return { + current_file = nil, + mentioned_files = nil, + selections = nil, + linter_errors = nil, + cursor_data = nil, + mentioned_subagents = nil, + } + end + + local ctx = vim.deepcopy(self.context) + local last_context = self.last_context + if not last_context then + return ctx + end + + -- no need to send file context again + if ctx.current_file and last_context.current_file and ctx.current_file.name == last_context.current_file.name then + ctx.current_file = nil + end + + -- no need to send subagents again + if + ctx.mentioned_subagents + and last_context.mentioned_subagents + and vim.deep_equal(ctx.mentioned_subagents, last_context.mentioned_subagents) + then + ctx.mentioned_subagents = nil + end + + return ctx +end + +--- Set the last context (used for delta calculations) +---@param last_context OpencodeContext +function ContextInstance:set_last_context(last_context) + self.last_context = last_context +end + +return ContextInstance diff --git a/lua/opencode/context/json_formatter.lua b/lua/opencode/context/json_formatter.lua new file mode 100644 index 00000000..3031be15 --- /dev/null +++ b/lua/opencode/context/json_formatter.lua @@ -0,0 +1,153 @@ +-- JSON formatter for context +-- Outputs JSON-formatted context parts for the opencode API + +local util = require('opencode.util') + +local M = {} + +---@param path string +---@param prompt? string +---@return OpencodeMessagePart +function M.format_file_part(path, prompt) + local rel_path = vim.fn.fnamemodify(path, ':~:.') + local mention = '@' .. rel_path + local pos = prompt and prompt:find(mention) + pos = pos and pos - 1 or 0 -- convert to 0-based index + + local ext = vim.fn.fnamemodify(path, ':e'):lower() + local mime_type = 'text/plain' + if ext == 'png' then + mime_type = 'image/png' + elseif ext == 'jpg' or ext == 'jpeg' then + mime_type = 'image/jpeg' + elseif ext == 'gif' then + mime_type = 'image/gif' + elseif ext == 'webp' then + mime_type = 'image/webp' + end + + local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path } + if prompt then + file_part.source = { + path = path, + type = 'file', + text = { start = pos, value = mention, ['end'] = pos + #mention }, + } + end + return file_part +end + +---@param selection OpencodeContextSelection +---@return OpencodeMessagePart +function M.format_selection_part(selection) + local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' + + return { + type = 'text', + metadata = { + context_type = 'selection', + }, + text = vim.json.encode({ + context_type = 'selection', + file = selection.file, + content = string.format('`````%s\n%s\n`````', lang, selection.content), + lines = selection.lines, + }), + synthetic = true, + } +end + +---@param diagnostics OpencodeDiagnostic[] +---@param range? { start_line: integer, end_line: integer }|nil +---@return OpencodeMessagePart +function M.format_diagnostics_part(diagnostics, range) + local diag_list = {} + for _, diag in ipairs(diagnostics) do + if not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line) then + local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') + table.insert( + diag_list, + { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } + ) + end + end + return { + type = 'text', + metadata = { + context_type = 'diagnostics', + }, + text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }), + synthetic = true, + } +end + +---@param cursor_data table +---@param get_current_buf fun(): integer|nil Function to get current buffer +---@return OpencodeMessagePart +function M.format_cursor_data_part(cursor_data, get_current_buf) + local buf = (get_current_buf() or 0) --[[@as integer]] + local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or '' + return { + type = 'text', + metadata = { + context_type = 'cursor-data', + lang = lang, + }, + text = vim.json.encode({ + context_type = 'cursor-data', + line = cursor_data.line, + column = cursor_data.column, + line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), + lines_before = cursor_data.lines_before, + lines_after = cursor_data.lines_after, + }), + synthetic = true, + } +end + +---@param agent string +---@param prompt string +---@return OpencodeMessagePart +function M.format_subagents_part(agent, prompt) + local mention = '@' .. agent + local pos = prompt:find(mention) + pos = pos and pos - 1 or 0 -- convert to 0-based index + + return { + type = 'agent', + name = agent, + source = { value = mention, start = pos, ['end'] = pos + #mention }, + } +end + +---@param buf integer +---@return OpencodeMessagePart +function M.format_buffer_part(buf) + local file = vim.api.nvim_buf_get_name(buf) + local rel_path = vim.fn.fnamemodify(file, ':~:.') + return { + type = 'text', + text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n'), + metadata = { + context_type = 'file-content', + filename = rel_path, + mime = 'text/plain', + }, + synthetic = true, + } +end + +---@param diff_text string +---@return OpencodeMessagePart +function M.format_git_diff_part(diff_text) + return { + type = 'text', + metadata = { + context_type = 'git-diff', + }, + text = diff_text, + synthetic = true, + } +end + +return M diff --git a/lua/opencode/context/plain_text_formatter.lua b/lua/opencode/context/plain_text_formatter.lua new file mode 100644 index 00000000..8e1e47ec --- /dev/null +++ b/lua/opencode/context/plain_text_formatter.lua @@ -0,0 +1,195 @@ +-- Plain text formatter for context +-- Outputs human-readable plain text for LLM consumption (used by quick chat) + +local util = require('opencode.util') +local Promise = require('opencode.promise') + +local M = {} + +local severity_names = { + [1] = 'ERROR', + [2] = 'WARNING', + [3] = 'INFO', + [4] = 'HINT', +} + +---@param selection OpencodeContextSelection +---@return string +function M.format_selection(selection) + local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' + local file_info = selection.file and selection.file.name or 'unknown' + local lines_info = selection.lines and (' (lines ' .. selection.lines .. ')') or '' + + local parts = { + 'SELECTED CODE from ' .. file_info .. lines_info .. ':', + '```' .. lang, + selection.content, + '```', + } + return table.concat(parts, '\n') +end + +---@param diagnostics OpencodeDiagnostic[] +---@param range? { start_line: integer, end_line: integer }|nil +---@return string|nil +function M.format_diagnostics(diagnostics, range) + if not diagnostics or #diagnostics == 0 then + return nil + end + + local filtered = {} + for _, diag in ipairs(diagnostics) do + local in_range = not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line) + if in_range then + local severity = severity_names[diag.severity] or 'UNKNOWN' + local line_num = diag.lnum + 1 + local col_num = diag.col + 1 + local msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') + table.insert(filtered, string.format(' Line %d, Col %d [%s]: %s', line_num, col_num, severity, msg)) + end + end + + if #filtered == 0 then + return nil + end + + return 'DIAGNOSTICS:\n' .. table.concat(filtered, '\n') +end + +---@param cursor_data table +---@param lang string|nil +---@return string +function M.format_cursor_data(cursor_data, lang) + lang = lang or '' + local parts = { + string.format('CURSOR POSITION: Line %d, Column %d', cursor_data.line, cursor_data.column), + } + + if cursor_data.lines_before and #cursor_data.lines_before > 0 then + table.insert(parts, 'Lines before cursor:') + table.insert(parts, '```' .. lang) + table.insert(parts, table.concat(cursor_data.lines_before, '\n')) + table.insert(parts, '```') + end + + table.insert(parts, 'Current line:') + table.insert(parts, '```' .. lang) + table.insert(parts, cursor_data.line_content) + table.insert(parts, '```') + + if cursor_data.lines_after and #cursor_data.lines_after > 0 then + table.insert(parts, 'Lines after cursor:') + table.insert(parts, '```' .. lang) + table.insert(parts, table.concat(cursor_data.lines_after, '\n')) + table.insert(parts, '```') + end + + return table.concat(parts, '\n') +end + +---@param diff_text string +---@return string +function M.format_git_diff(diff_text) + return 'GIT DIFF (staged changes):\n```diff\n' .. diff_text .. '\n```' +end + +---@param buf integer +---@param lang string|nil +---@return string +function M.format_buffer(buf, lang) + lang = lang or '' + local file = vim.api.nvim_buf_get_name(buf) + local rel_path = vim.fn.fnamemodify(file, ':~:.') + local content = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n') + + return string.format('FILE: %s\n\n```%s\n%s\n```', rel_path, lang, content) +end + +--- Formats context as plain text for LLM consumption (used by quick chat) +--- Unlike format_message_quick_chat, this outputs human-readable text instead of JSON +---@param prompt string The user's instruction/prompt +---@param context_instance ContextInstance Context instance to use +---@param opts? { range?: { start: integer, stop: integer }, buf?: integer } +---@return table result { text: string, parts: OpencodeMessagePart[] } +M.format_message = Promise.async(function(prompt, context_instance, opts) + opts = opts or {} + local buf = opts.buf or context_instance:get_current_buf() or vim.api.nvim_get_current_buf() + local range = opts.range + + local file_name = vim.api.nvim_buf_get_name(buf) + local lang = util.get_markdown_filetype(file_name) or vim.fn.fnamemodify(file_name, ':e') or '' + local rel_path = file_name ~= '' and vim.fn.fnamemodify(file_name, ':~:.') or 'untitled' + + local text_parts = {} + + -- Add file/buffer content + if context_instance:is_context_enabled('buffer') then + if range and range.start and range.stop then + local start_line = math.max(1, range.start) + local end_line = range.stop + local range_lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) + local range_text = table.concat(range_lines, '\n') + + table.insert(text_parts, string.format('FILE: %s (lines %d-%d)', rel_path, start_line, end_line)) + table.insert(text_parts, '') + table.insert(text_parts, '```' .. lang) + table.insert(text_parts, range_text) + table.insert(text_parts, '```') + else + table.insert(text_parts, M.format_buffer(buf, lang)) + end + end + + -- Add selections + for _, sel in ipairs(context_instance:get_selections() or {}) do + table.insert(text_parts, '') + table.insert(text_parts, M.format_selection(sel)) + end + + -- Add diagnostics + local diagnostics = context_instance:get_diagnostics(buf) + if diagnostics and #diagnostics > 0 then + local diag_range = nil + if range then + diag_range = { start_line = range.start - 1, end_line = range.stop - 1 } + end + local formatted_diag = M.format_diagnostics(diagnostics, diag_range) + if formatted_diag then + table.insert(text_parts, '') + table.insert(text_parts, formatted_diag) + end + end + + -- Add cursor data + if context_instance:is_context_enabled('cursor_data') then + local current_buf, current_win = context_instance:get_current_buf() + local cursor_data = context_instance:get_current_cursor_data(current_buf or buf, current_win or 0) + if cursor_data then + table.insert(text_parts, '') + table.insert(text_parts, M.format_cursor_data(cursor_data, lang)) + end + end + + -- Add git diff + if context_instance:is_context_enabled('git_diff') then + local diff_text = context_instance:get_git_diff():await() + if diff_text and diff_text ~= '' then + table.insert(text_parts, '') + table.insert(text_parts, M.format_git_diff(diff_text)) + end + end + + -- Add instruction + table.insert(text_parts, '') + table.insert(text_parts, 'INSTRUCTION: ' .. prompt) + + local full_text = table.concat(text_parts, '\n') + + -- Return both the plain text and a parts array for the API + return { + text = full_text, + parts = { { type = 'text', text = full_text } }, + } +end) + +return M diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index f6338f34..2c3d2e2d 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -105,27 +105,6 @@ local function create_session_title(buf) return string.format('[QuickChat] %s:%d (%s)', relative_path, line_num, timestamp) end ---- Creates context configuration for quick chat ----@param has_range boolean Whether a range is specified ----@return OpencodeContextConfig context_opts -local function create_context_config(has_range) - return { - enabled = true, - current_file = { enabled = false }, - cursor_data = { enabled = not has_range }, - selection = { enabled = has_range }, - diagnostics = { - enabled = false, - error = false, - info = false, - warning = false, - }, - agents = { enabled = false }, - buffer = { enabled = true }, - git_diff = { enabled = false }, - } -end - --- Helper to clean up session info and spinner ---@param session_info table Session tracking info ---@param session_id string Session ID @@ -154,104 +133,297 @@ local function extract_response_text(message) return vim.trim(response_text) end ---- Extracts and parses JSON replacement data from response text ----@param response_text string Response text that may contain JSON in code blocks ----@return table|nil replacement_data Parsed replacement data or nil if invalid -local function parse_replacement_json(response_text) - local json_text = response_text - local json_match = response_text:match('```json\n(.-)\n```') or response_text:match('```\n(.-)\n```') - if json_match then - json_text = json_match +--- Parses SEARCH/REPLACE blocks from response text +--- Format: +--- <<<<<<< SEARCH +--- original code +--- ======= +--- replacement code +--- >>>>>>> REPLACE +---@param response_text string Response text containing SEARCH/REPLACE blocks +---@return table[] replacements Array of {search=string, replace=string} +local function parse_search_replace_blocks(response_text) + local replacements = {} + + -- Normalize line endings + local text = response_text:gsub('\r\n', '\n') + + -- Pattern to match SEARCH/REPLACE blocks + -- Captures content between markers, handling various whitespace + local pos = 1 + while pos <= #text do + -- Find the start marker + local search_start = text:find('<<<<<<<%s*SEARCH%s*\n', pos) + if not search_start then + break + end + + local should_continue = false + + -- Find the separator + local content_start_pos = text:find('\n', search_start) + if not content_start_pos then + pos = search_start + 1 + should_continue = true + end + + if not should_continue then + local content_start = content_start_pos + 1 + local separator = text:find('\n=======%s*\n', content_start) + if not separator then + -- Try without leading newline (in case of edge formatting) + separator = text:find('=======%s*\n', content_start) + if not separator then + pos = search_start + 1 + should_continue = true + end + end + + if not should_continue then + -- Find the end marker + local replace_start = text:find('\n', separator + 1) + if replace_start then + replace_start = replace_start + 1 + else + pos = search_start + 1 + should_continue = true + end + + if not should_continue then + local end_marker = text:find('\n?>>>>>>>%s*REPLACE', replace_start) + if not end_marker then + pos = search_start + 1 + should_continue = true + end + + if not should_continue then + -- Extract the search and replace content + local search_content = text:sub(content_start, separator - 1) + local replace_content = text:sub(replace_start, end_marker - 1) + + -- Handle trailing newline in replace content + if replace_content:sub(-1) == '\n' then + replace_content = replace_content:sub(1, -2) + end + + table.insert(replacements, { + search = search_content, + replace = replace_content, + }) + + -- Move past this block + pos = end_marker + 1 + end + end + end + end end - local ok, replacement_data = pcall(vim.json.decode, json_text) - if not ok then - return nil + return replacements +end + +--- Normalizes indentation by detecting and removing common leading whitespace +---@param text string The text to normalize +---@return string normalized The text with common indentation removed +---@return string indent The common indentation that was removed +local function normalize_indentation(text) + local lines = vim.split(text, '\n', { plain = true }) + local min_indent = math.huge + local indent_char = nil + + -- Find minimum indentation (ignoring empty lines) + for _, line in ipairs(lines) do + if line:match('%S') then -- non-empty line + local leading = line:match('^([ \t]*)') + if #leading < min_indent then + min_indent = #leading + indent_char = leading + end + end end - if not replacement_data.replacements or type(replacement_data.replacements) ~= 'table' then - return nil + if min_indent == math.huge or min_indent == 0 then + return text, '' end - if #replacement_data.replacements == 0 then - return nil + -- Remove common indentation + local normalized_lines = {} + for _, line in ipairs(lines) do + if line:match('%S') then + table.insert(normalized_lines, line:sub(min_indent + 1)) + else + table.insert(normalized_lines, line) + end end - return replacement_data + return table.concat(normalized_lines, '\n'), (indent_char or ''):sub(1, min_indent) end ---- Converts object format like {"1": "line1", "2": "line2"} to array ---- Some LLMs may return line replacements in this format instead of an array ----@param obj_lines table Object with string keys representing line numbers ----@return string[] lines_array Array of lines in correct order -local function convert_object_to_lines_array(obj_lines) - local lines_array = {} - local numeric_keys = {} - - -- Collect all numeric string keys - for key, _ in pairs(obj_lines) do - local num_key = tonumber(key) - if num_key and num_key > 0 and math.floor(num_key) == num_key then - table.insert(numeric_keys, num_key) +--- Tries to find search text in content with flexible whitespace matching +---@param content string The buffer content +---@param search string The search text +---@return number|nil start_pos Start position if found +---@return number|nil end_pos End position if found +local function find_with_flexible_whitespace(content, search) + -- First try exact match + local start_pos, end_pos = content:find(search, 1, true) + if start_pos then + return start_pos, end_pos + end + + -- Normalize the search text (remove its indentation) + local normalized_search, _ = normalize_indentation(search) + + -- Try to find each line of the normalized search in sequence + local search_lines = vim.split(normalized_search, '\n', { plain = true }) + if #search_lines == 0 then + return nil, nil + end + + -- Find the first non-empty search line + local first_search_line = nil + for _, line in ipairs(search_lines) do + if line:match('%S') then + first_search_line = line + break end end - -- Sort keys to ensure correct order - table.sort(numeric_keys) + if not first_search_line then + return nil, nil + end + + -- Escape special pattern characters for the search + local escaped_first = vim.pesc(first_search_line) + + -- Search for the first line with any leading whitespace + local pattern = '[ \t]*' .. escaped_first + local match_start = content:find(pattern) + + if not match_start then + return nil, nil + end + + -- Find the actual start (beginning of the line) + local line_start = match_start + while line_start > 1 and content:sub(line_start - 1, line_start - 1) ~= '\n' do + line_start = line_start - 1 + end - for _, num_key in ipairs(numeric_keys) do - local line_content = obj_lines[tostring(num_key)] - if line_content then - table.insert(lines_array, line_content) + -- Now verify all subsequent lines match + local content_lines = vim.split(content:sub(line_start), '\n', { plain = true }) + local search_idx = 1 + local matched_content = {} + + for _, content_line in ipairs(content_lines) do + if search_idx > #search_lines then + break + end + + local search_line = search_lines[search_idx] + + -- Normalize both lines for comparison (trim leading/trailing whitespace for matching) + local content_trimmed = (content_line and content_line:match('^%s*(.-)%s*$')) or '' + local search_trimmed = (search_line and search_line:match('^%s*(.-)%s*$')) or '' + + if content_trimmed == search_trimmed then + table.insert(matched_content, content_line) + search_idx = search_idx + 1 + elseif search_trimmed == '' then + -- Empty search line matches empty content line + if content_trimmed == '' then + table.insert(matched_content, content_line) + search_idx = search_idx + 1 + else + break + end + else + break end end - return lines_array + -- Check if we matched all search lines + if search_idx > #search_lines then + local matched_text = table.concat(matched_content, '\n') + local actual_end = line_start + #matched_text - 1 + return line_start, actual_end + end + + return nil, nil end ---- Applies line replacements to a buffer using parsed replacement data +--- Applies SEARCH/REPLACE blocks to buffer content ---@param buf integer Buffer handle ----@param replacement_data table Parsed replacement data ----@return boolean success Whether the replacements were applied successfully -local function apply_line_replacements(buf, replacement_data) +---@param replacements table[] Array of {search=string, replace=string} +---@return boolean success Whether any replacements were applied +---@return string[] errors List of error messages for failed replacements +local function apply_search_replace(buf, replacements) if not vim.api.nvim_buf_is_valid(buf) then - vim.notify('Buffer is not valid for applying changes', vim.log.levels.ERROR) - return false + return false, { 'Buffer is not valid' } end - local buf_line_count = vim.api.nvim_buf_line_count(buf) + -- Get full buffer content + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local content = table.concat(lines, '\n') - table.sort(replacement_data.replacements, function(a, b) - return (a.start_line or a.line) > (b.start_line or b.line) - end) + local applied_count = 0 + local errors = {} + + for i, replacement in ipairs(replacements) do + local search = replacement.search + local replace = replacement.replace - local total_replacements = 0 - for _, replacement in ipairs(replacement_data.replacements) do - local start_line = replacement.start_line or replacement.line - local end_line = replacement.end_line or start_line - local new_lines = replacement.lines or replacement.content - - -- Convert string to array - if type(new_lines) == 'string' then - new_lines = vim.split(new_lines, '\n') - elseif type(new_lines) == 'table' then - -- Check if it's object format like {"1": "line1", "2": "line2"} - local first_key = next(new_lines) - if first_key and type(first_key) == 'string' and tonumber(first_key) then - new_lines = convert_object_to_lines_array(new_lines) + -- Find the search text in content (with flexible whitespace matching) + local start_pos, end_pos = find_with_flexible_whitespace(content, search) + + if start_pos and end_pos then + -- Detect the indentation of the matched content + local line_start = start_pos + while line_start > 1 and content:sub(line_start - 1, line_start - 1) ~= '\n' do + line_start = line_start - 1 + end + local existing_indent = content:sub(line_start, start_pos - 1) + + -- Apply the same indentation to replacement if it doesn't have it + local replace_lines = vim.split(replace, '\n', { plain = true }) + local indented_replace_lines = {} + + for j, line in ipairs(replace_lines) do + if line:match('%S') then + -- Check if line already has indentation + local line_indent = line:match('^([ \t]*)') + if #line_indent == 0 and #existing_indent > 0 then + table.insert(indented_replace_lines, existing_indent .. line) + else + table.insert(indented_replace_lines, line) + end + else + table.insert(indented_replace_lines, line) + end end - end - if start_line and start_line >= 1 and start_line <= buf_line_count and new_lines and #new_lines > 0 then - local start_idx = math.floor(math.max(0, start_line - 1)) - local end_idx = math.floor(math.min(end_line, buf_line_count)) + local indented_replace = table.concat(indented_replace_lines, '\n') - pcall(vim.api.nvim_buf_set_lines, buf, start_idx, end_idx, false, new_lines) - total_replacements = total_replacements + 1 + -- Replace the content + content = content:sub(1, start_pos - 1) .. indented_replace .. content:sub(end_pos + 1) + applied_count = applied_count + 1 + else + -- Try to provide helpful error message + local search_preview = search:sub(1, 50):gsub('\n', '\\n') + if #search > 50 then + search_preview = search_preview .. '...' + end + table.insert(errors, string.format('Block %d: SEARCH not found: "%s"', i, search_preview)) end end - return total_replacements > 0 + if applied_count > 0 then + -- Write back to buffer + local new_lines = vim.split(content, '\n', { plain = true }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) + end + + return applied_count > 0, errors end --- Processes response from ephemeral session @@ -270,12 +442,21 @@ local function process_response(session_info, messages) return false end - local replacement_data = parse_replacement_json(response_text) - if not replacement_data then + local replacements = parse_search_replace_blocks(response_text) + if #replacements == 0 then return false end - return apply_line_replacements(session_info.buf, replacement_data) + local success, errors = apply_search_replace(session_info.buf, replacements) + + -- Log errors for debugging but don't fail completely if some replacements worked + if #errors > 0 then + for _, err in ipairs(errors) do + vim.notify('Quick chat: ' .. err, vim.log.levels.WARN) + end + end + + return success end --- Hook function called when a session is done thinking (no more pending messages) @@ -328,64 +509,109 @@ local function validate_quick_chat_prerequisites(message) return true end ---- Sets up context and range for quick chat ----@param buf integer Buffer handle ----@param context_config OpencodeContextConfig Context configuration ----@param range table|nil Range information ----@return table context_instance -local function init_context(buf, context_config, range) - local context_instance = context.new_instance(context_config) - - if range and range.start and range.stop then - local start_line = math.floor(math.max(0, range.start - 1)) - local end_line = math.floor(range.stop + 1) - local range_lines = vim.api.nvim_buf_get_lines(buf, start_line, end_line, false) - local range_text = table.concat(range_lines, '\n') - local current_file = context_instance:get_current_file(buf) - local selection = context_instance:new_selection(current_file, range_text, range.start .. ', ' .. range.stop) - context_instance:add_selection(selection) - end - - return context_instance +--- Creates context configuration for quick chat +---@param has_range boolean Whether a range is specified +---@return OpencodeContextConfig context_opts +local function create_context_config(has_range) + return { + enabled = true, + current_file = { enabled = false }, + cursor_data = { enabled = not has_range }, + selection = { enabled = has_range }, + diagnostics = { + enabled = true, + error = true, + warning = true, + info = false, + only_closest = has_range, + }, + agents = { enabled = false }, + buffer = { enabled = true }, + git_diff = { enabled = false }, + } end --- Creates message parameters for quick chat ---@param message string The user message ----@param context_instance table Context instance +---@param buf integer Buffer handle +---@param range table|nil Range information +---@param context_instance ContextInstance Context instance ---@param options table Options including model and agent ---@return table params Message parameters -local create_message = Promise.async(function(message, context_instance, options) - local quick_chat_config = config.quick_chat or {} - local instructions = quick_chat_config.instructions - or { - 'You are an expert code assistant helping with code and text editing tasks.', - 'You are operating in a temporary quick chat session with limited context.', - "Your task is to modify the provided code according to the user's request. Follow these instructions precisely:", - 'CRITICAL: At the end of your job You MUST add a message with a valid JSON format for line replacements. Use this exact structure:', - '', - '```json', - '{', - ' "replacements": [', - ' {', - ' "start_line": 10,', - ' "end_line": 11,', - ' "lines": ["new content line 1", "new content line 2"]', - ' }', - ' ]', - '}', - '```', - '', - 'Maintain the *SAME INDENTATION* in the returned code as in the source code', - 'NEVER add any explanations, apologies, or additional text outside the JSON structure.', - 'ALWAYS split multiple line replacements into separate entries in the "replacements" array.', - 'IMPORTANT: Use 1-indexed line numbers. Each replacement replaces lines start_line through end_line (inclusive).', - 'The "lines" array contains the new content. If replacing a single line, end_line can equal start_line.', - 'Ensure the returned code is complete and can be directly used as a replacement for the original code.', - 'Remember that Your response SHOULD CONTAIN ONLY THE MODIFIED CODE to be used as DIRECT REPLACEMENT to the original file.', - } - - local parts = context.format_message_quick_chat(message, context_instance):await() - local params = { parts = parts, system = table.concat(instructions, '\n'), synthetic = true } +local create_message = Promise.async(function(message, buf, range, context_instance, options) + local quick_chat_config = config.values.quick_chat or {} + -- stylua: ignore + local instructions = quick_chat_config.instructions or { + 'You are a code editing assistant. Modify the provided code according to the user instruction.', + 'Your ONLY output format is SEARCH/REPLACE blocks. Do NOT explain, comment, or add any other text.', + '', + 'FORMAT:', + '<<<<<<< SEARCH', + 'exact lines to find (copy from the provided code)', + '=======', + 'modified lines', + '>>>>>>> REPLACE', + '', + 'RULES:', + '1. ONLY output SEARCH/REPLACE blocks - absolutely no explanations or markdown', + '2. Copy the SEARCH content EXACTLY from the provided code between the ``` markers', + '3. Include 1-3 surrounding lines in SEARCH for unique matching', + '4. REPLACE contains the modified version of SEARCH content', + '5. Multiple changes = multiple SEARCH/REPLACE blocks', + '6. Delete lines by omitting them from REPLACE', + '7. Add lines by including them in REPLACE', + '8. If DIAGNOSTICS are provided, use them to understand what needs fixing', + '9. If a SELECTION is provided, only modify code within that selection', + '10. If CURSOR_DATA is provided, focus modifications near that cursor position', + '11. GIT_DIFF context is for reference only - never use git diff hunks as SEARCH content', + '', + 'EXAMPLE - Change return value:', + '<<<<<<< SEARCH', + 'function getValue()', + ' return 42', + 'end', + '=======', + 'function getValue()', + ' return 100', + 'end', + '>>>>>>> REPLACE', + '', + 'EXAMPLE - Add a line:', + '<<<<<<< SEARCH', + 'local x = 1', + 'local y = 2', + '=======', + 'local x = 1', + 'local z = 1.5', + 'local y = 2', + '>>>>>>> REPLACE', + '', + 'EXAMPLE - Delete a line:', + '<<<<<<< SEARCH', + '-- old comment', + 'local unused = true', + 'local needed = false', + '=======', + 'local needed = false', + '>>>>>>> REPLACE', + '', + 'Remember: Output ONLY SEARCH/REPLACE blocks. The SEARCH text must match the code exactly.', + } + + local format_opts = { buf = buf } + if range then + format_opts.range = { start = range.start, stop = range.stop } + end + + local result = context.format_message_plain_text(message, context_instance, format_opts):await() + + -- Prepend instructions to the message text (in addition to system param) + -- This ensures the LLM sees the instructions even if system prompt isn't honored + local instructions_text = table.concat(instructions, '\n') + local full_text = instructions_text .. '\n\n---\n\n' .. result.text + local parts = { { type = 'text', text = full_text } } + + local params = { parts = parts, system = instructions_text } local current_model = core.initialize_current_model():await() local target_model = options.model or quick_chat_config.default_model or current_model @@ -428,10 +654,14 @@ M.quick_chat = Promise.async(function(message, options, range) local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed local spinner = CursorSpinner.new(buf, row, col) + -- Create context instance for diagnostics and other context local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) - local context_instance = init_context(buf, context_config, range) + local context_instance = context.new_instance(context_config) - local allowed, err_msg = util.check_prompt_allowed(config.values.prompt_guard, context_instance:get_mentioned_files()) + -- Check prompt guard with the current file + local file_name = vim.api.nvim_buf_get_name(buf) + local mentioned_files = file_name ~= '' and { file_name } or {} + local allowed, err_msg = util.check_prompt_allowed(config.values.prompt_guard, mentioned_files) if not allowed then spinner:stop() return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard') @@ -455,8 +685,8 @@ M.quick_chat = Promise.async(function(message, options, range) timestamp = vim.uv.now(), } - local params = create_message(message, context_instance, options):await() - spinner:stop() + local params = create_message(message, buf, range, context_instance, options):await() + vim.print('⭕ ❱ quick_chat.lua:685 ❱ ƒ(params) ❱ params =', params) local success, err = pcall(function() state.api_client:create_message(quick_chat_session.id, params):await() diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 48bf7d76..4d59edf1 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -175,6 +175,11 @@ ---@field setup fun(opts?: OpencodeConfig): nil ---@field get_key_for_function fun(scope: 'editor'|'input_window'|'output_window', function_name: string): string|nil +---@class OpencodeQuickChatConfig +---@field default_model? string -- Use current model if nil +---@field default_agent? string -- Use current mode if nil +---@field instructions? string[] -- Custom instructions for quick chat + ---@class OpencodeConfig ---@field preferred_picker 'telescope' | 'fzf' | 'mini.pick' | 'snacks' | 'select' | nil ---@field preferred_completion 'blink' | 'nvim-cmp' | 'vim_complete' | nil -- Preferred completion strategy for mentons and commands @@ -188,6 +193,7 @@ ---@field prompt_guard? fun(mentioned_files: string[]): boolean ---@field hooks OpencodeHooks ---@field legacy_commands boolean +---@field quick_chat OpencodeQuickChatConfig ---@class MessagePartState ---@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput Input data for the tool From a8179c07c7aadec9740b134b8f45032cd42a9f98 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 16 Dec 2025 09:31:39 -0500 Subject: [PATCH 08/46] refactor(quick_chat): extract search/replace and spinner into separate modules Move CursorSpinner class and SEARCH/REPLACE parsing logic into dedicated modules under quick_chat/. Add context-aware instruction generation and improve error feedback for replacement operations. > --- lua/opencode/context/base.lua | 21 + lua/opencode/quick_chat.lua | 567 ++++++--------------- lua/opencode/quick_chat/search_replace.lua | 114 +++++ lua/opencode/quick_chat/spinner.lua | 87 ++++ tests/unit/search_replace_spec.lua | 280 ++++++++++ 5 files changed, 647 insertions(+), 422 deletions(-) create mode 100644 lua/opencode/quick_chat/search_replace.lua create mode 100644 lua/opencode/quick_chat/spinner.lua create mode 100644 tests/unit/search_replace_spec.lua diff --git a/lua/opencode/context/base.lua b/lua/opencode/context/base.lua index a69f4724..4ded0a24 100644 --- a/lua/opencode/context/base.lua +++ b/lua/opencode/context/base.lua @@ -375,6 +375,27 @@ function ContextInstance:get_current_selection() } end +ContextInstance.has = Promise.async(function(self, context_type) + if context_type == 'file' then + return self:get_mentioned_files() and #self:get_mentioned_files() > 0 + elseif context_type == 'selection' then + return self:get_selections() and #self:get_selections() > 0 + elseif context_type == 'subagent' then + return self:get_mentioned_subagents() and #self:get_mentioned_subagents() > 0 + elseif context_type == 'diagnostics' then + return self.context.linter_errors and #self.context.linter_errors > 0 + elseif context_type == 'git_diff' then + local git_diff = Promise.await(self:get_git_diff()) + return git_diff ~= nil + elseif context_type == 'current_file' then + return self.context.current_file ~= nil + elseif context_type == 'cursor_data' then + return self.context.cursor_data ~= nil + end + + return false +end) + function ContextInstance:get_selections() if not self:is_context_enabled('selection') then return {} diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 2c3d2e2d..3a934c68 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -5,7 +5,8 @@ local core = require('opencode.core') local util = require('opencode.util') local session = require('opencode.session') local Promise = require('opencode.promise') -local Timer = require('opencode.ui.timer') +local search_replace = require('opencode.quick_chat.search_replace') +local CursorSpinner = require('opencode.quick_chat.spinner') local M = {} @@ -19,80 +20,6 @@ local M = {} ---@type table local running_sessions = {} ---- Simple cursor spinner using the same animation logic as loading_animation.lua -local CursorSpinner = {} -CursorSpinner.__index = CursorSpinner - -function CursorSpinner.new(buf, row, col) - local self = setmetatable({}, CursorSpinner) - self.buf = buf - self.row = row - self.col = col - self.ns_id = vim.api.nvim_create_namespace('opencode_quick_chat_spinner') - self.extmark_id = nil - self.current_frame = 1 - self.timer = nil - self.active = true - - self.frames = config.values.ui.loading_animation and config.values.ui.loading_animation.frames - or { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' } - - self:render() - self:start_timer() - return self -end - -function CursorSpinner:render() - if not self.active or not vim.api.nvim_buf_is_valid(self.buf) then - return - end - - local frame = ' ' .. self.frames[self.current_frame] - self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, self.ns_id, self.row, self.col, { - id = self.extmark_id, - virt_text = { { frame .. ' ', 'Comment' } }, - virt_text_pos = 'overlay', - right_gravity = false, - }) -end - -function CursorSpinner:next_frame() - self.current_frame = (self.current_frame % #self.frames) + 1 -end - -function CursorSpinner:start_timer() - self.timer = Timer.new({ - interval = 100, -- 10 FPS like the main loading animation - on_tick = function() - if not self.active then - return false - end - self:next_frame() - self:render() - return true - end, - repeat_timer = true, - }) - self.timer:start() -end - -function CursorSpinner:stop() - if not self.active then - return - end - - self.active = false - - if self.timer then - self.timer:stop() - self.timer = nil - end - - if self.extmark_id and vim.api.nvim_buf_is_valid(self.buf) then - pcall(vim.api.nvim_buf_del_extmark, self.buf, self.ns_id, self.extmark_id) - end -end - --- Creates an ephemeral session title ---@param buf integer Buffer handle ---@return string title The session title @@ -133,299 +60,6 @@ local function extract_response_text(message) return vim.trim(response_text) end ---- Parses SEARCH/REPLACE blocks from response text ---- Format: ---- <<<<<<< SEARCH ---- original code ---- ======= ---- replacement code ---- >>>>>>> REPLACE ----@param response_text string Response text containing SEARCH/REPLACE blocks ----@return table[] replacements Array of {search=string, replace=string} -local function parse_search_replace_blocks(response_text) - local replacements = {} - - -- Normalize line endings - local text = response_text:gsub('\r\n', '\n') - - -- Pattern to match SEARCH/REPLACE blocks - -- Captures content between markers, handling various whitespace - local pos = 1 - while pos <= #text do - -- Find the start marker - local search_start = text:find('<<<<<<<%s*SEARCH%s*\n', pos) - if not search_start then - break - end - - local should_continue = false - - -- Find the separator - local content_start_pos = text:find('\n', search_start) - if not content_start_pos then - pos = search_start + 1 - should_continue = true - end - - if not should_continue then - local content_start = content_start_pos + 1 - local separator = text:find('\n=======%s*\n', content_start) - if not separator then - -- Try without leading newline (in case of edge formatting) - separator = text:find('=======%s*\n', content_start) - if not separator then - pos = search_start + 1 - should_continue = true - end - end - - if not should_continue then - -- Find the end marker - local replace_start = text:find('\n', separator + 1) - if replace_start then - replace_start = replace_start + 1 - else - pos = search_start + 1 - should_continue = true - end - - if not should_continue then - local end_marker = text:find('\n?>>>>>>>%s*REPLACE', replace_start) - if not end_marker then - pos = search_start + 1 - should_continue = true - end - - if not should_continue then - -- Extract the search and replace content - local search_content = text:sub(content_start, separator - 1) - local replace_content = text:sub(replace_start, end_marker - 1) - - -- Handle trailing newline in replace content - if replace_content:sub(-1) == '\n' then - replace_content = replace_content:sub(1, -2) - end - - table.insert(replacements, { - search = search_content, - replace = replace_content, - }) - - -- Move past this block - pos = end_marker + 1 - end - end - end - end - end - - return replacements -end - ---- Normalizes indentation by detecting and removing common leading whitespace ----@param text string The text to normalize ----@return string normalized The text with common indentation removed ----@return string indent The common indentation that was removed -local function normalize_indentation(text) - local lines = vim.split(text, '\n', { plain = true }) - local min_indent = math.huge - local indent_char = nil - - -- Find minimum indentation (ignoring empty lines) - for _, line in ipairs(lines) do - if line:match('%S') then -- non-empty line - local leading = line:match('^([ \t]*)') - if #leading < min_indent then - min_indent = #leading - indent_char = leading - end - end - end - - if min_indent == math.huge or min_indent == 0 then - return text, '' - end - - -- Remove common indentation - local normalized_lines = {} - for _, line in ipairs(lines) do - if line:match('%S') then - table.insert(normalized_lines, line:sub(min_indent + 1)) - else - table.insert(normalized_lines, line) - end - end - - return table.concat(normalized_lines, '\n'), (indent_char or ''):sub(1, min_indent) -end - ---- Tries to find search text in content with flexible whitespace matching ----@param content string The buffer content ----@param search string The search text ----@return number|nil start_pos Start position if found ----@return number|nil end_pos End position if found -local function find_with_flexible_whitespace(content, search) - -- First try exact match - local start_pos, end_pos = content:find(search, 1, true) - if start_pos then - return start_pos, end_pos - end - - -- Normalize the search text (remove its indentation) - local normalized_search, _ = normalize_indentation(search) - - -- Try to find each line of the normalized search in sequence - local search_lines = vim.split(normalized_search, '\n', { plain = true }) - if #search_lines == 0 then - return nil, nil - end - - -- Find the first non-empty search line - local first_search_line = nil - for _, line in ipairs(search_lines) do - if line:match('%S') then - first_search_line = line - break - end - end - - if not first_search_line then - return nil, nil - end - - -- Escape special pattern characters for the search - local escaped_first = vim.pesc(first_search_line) - - -- Search for the first line with any leading whitespace - local pattern = '[ \t]*' .. escaped_first - local match_start = content:find(pattern) - - if not match_start then - return nil, nil - end - - -- Find the actual start (beginning of the line) - local line_start = match_start - while line_start > 1 and content:sub(line_start - 1, line_start - 1) ~= '\n' do - line_start = line_start - 1 - end - - -- Now verify all subsequent lines match - local content_lines = vim.split(content:sub(line_start), '\n', { plain = true }) - local search_idx = 1 - local matched_content = {} - - for _, content_line in ipairs(content_lines) do - if search_idx > #search_lines then - break - end - - local search_line = search_lines[search_idx] - - -- Normalize both lines for comparison (trim leading/trailing whitespace for matching) - local content_trimmed = (content_line and content_line:match('^%s*(.-)%s*$')) or '' - local search_trimmed = (search_line and search_line:match('^%s*(.-)%s*$')) or '' - - if content_trimmed == search_trimmed then - table.insert(matched_content, content_line) - search_idx = search_idx + 1 - elseif search_trimmed == '' then - -- Empty search line matches empty content line - if content_trimmed == '' then - table.insert(matched_content, content_line) - search_idx = search_idx + 1 - else - break - end - else - break - end - end - - -- Check if we matched all search lines - if search_idx > #search_lines then - local matched_text = table.concat(matched_content, '\n') - local actual_end = line_start + #matched_text - 1 - return line_start, actual_end - end - - return nil, nil -end - ---- Applies SEARCH/REPLACE blocks to buffer content ----@param buf integer Buffer handle ----@param replacements table[] Array of {search=string, replace=string} ----@return boolean success Whether any replacements were applied ----@return string[] errors List of error messages for failed replacements -local function apply_search_replace(buf, replacements) - if not vim.api.nvim_buf_is_valid(buf) then - return false, { 'Buffer is not valid' } - end - - -- Get full buffer content - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local content = table.concat(lines, '\n') - - local applied_count = 0 - local errors = {} - - for i, replacement in ipairs(replacements) do - local search = replacement.search - local replace = replacement.replace - - -- Find the search text in content (with flexible whitespace matching) - local start_pos, end_pos = find_with_flexible_whitespace(content, search) - - if start_pos and end_pos then - -- Detect the indentation of the matched content - local line_start = start_pos - while line_start > 1 and content:sub(line_start - 1, line_start - 1) ~= '\n' do - line_start = line_start - 1 - end - local existing_indent = content:sub(line_start, start_pos - 1) - - -- Apply the same indentation to replacement if it doesn't have it - local replace_lines = vim.split(replace, '\n', { plain = true }) - local indented_replace_lines = {} - - for j, line in ipairs(replace_lines) do - if line:match('%S') then - -- Check if line already has indentation - local line_indent = line:match('^([ \t]*)') - if #line_indent == 0 and #existing_indent > 0 then - table.insert(indented_replace_lines, existing_indent .. line) - else - table.insert(indented_replace_lines, line) - end - else - table.insert(indented_replace_lines, line) - end - end - - local indented_replace = table.concat(indented_replace_lines, '\n') - - -- Replace the content - content = content:sub(1, start_pos - 1) .. indented_replace .. content:sub(end_pos + 1) - applied_count = applied_count + 1 - else - -- Try to provide helpful error message - local search_preview = search:sub(1, 50):gsub('\n', '\\n') - if #search > 50 then - search_preview = search_preview .. '...' - end - table.insert(errors, string.format('Block %d: SEARCH not found: "%s"', i, search_preview)) - end - end - - if applied_count > 0 then - -- Write back to buffer - local new_lines = vim.split(content, '\n', { plain = true }) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) - end - - return applied_count > 0, errors -end - --- Processes response from ephemeral session ---@param session_info table Session tracking info ---@param messages OpencodeMessage[] Session messages @@ -439,17 +73,40 @@ local function process_response(session_info, messages) local response_text = extract_response_text(response_message) or '' if response_text == '' then + vim.notify('Quick chat: Received empty response from assistant', vim.log.levels.WARN) return false end - local replacements = parse_search_replace_blocks(response_text) + local replacements, parse_warnings = search_replace.parse_blocks(response_text) + + -- Show parse warnings + if #parse_warnings > 0 then + for _, warning in ipairs(parse_warnings) do + vim.notify('Quick chat: ' .. warning, vim.log.levels.WARN) + end + end + if #replacements == 0 then + vim.notify('Quick chat: No valid SEARCH/REPLACE blocks found in response', vim.log.levels.WARN) return false end - local success, errors = apply_search_replace(session_info.buf, replacements) + local success, errors, applied_count = search_replace.apply(session_info.buf, replacements) + + -- Provide detailed feedback + if applied_count > 0 then + local total_blocks = #replacements + if applied_count == total_blocks then + vim.notify( + string.format('Quick chat: Applied %d change%s', applied_count, applied_count > 1 and 's' or ''), + vim.log.levels.INFO + ) + else + vim.notify(string.format('Quick chat: Applied %d/%d changes', applied_count, total_blocks), vim.log.levels.INFO) + end + end - -- Log errors for debugging but don't fail completely if some replacements worked + -- Log errors but don't fail completely if some replacements worked if #errors > 0 then for _, err in ipairs(errors) do vim.notify('Quick chat: ' .. err, vim.log.levels.WARN) @@ -531,73 +188,133 @@ local function create_context_config(has_range) } end ---- Creates message parameters for quick chat ----@param message string The user message ----@param buf integer Buffer handle ----@param range table|nil Range information +--- Generates instructions for the LLM to follow the SEARCH/REPLACE format ---@param context_instance ContextInstance Context instance ----@param options table Options including model and agent ----@return table params Message parameters -local create_message = Promise.async(function(message, buf, range, context_instance, options) - local quick_chat_config = config.values.quick_chat or {} - -- stylua: ignore - local instructions = quick_chat_config.instructions or { - 'You are a code editing assistant. Modify the provided code according to the user instruction.', - 'Your ONLY output format is SEARCH/REPLACE blocks. Do NOT explain, comment, or add any other text.', +---@return string[] instructions Array of instruction lines +local function generate_search_replace_instructions(context_instance) + local base_instructions = { + '# ROLE', + 'You are a precise code editing assistant. Your task is to modify code based on user instructions.', + '', + '# OUTPUT FORMAT', + 'You MUST output ONLY in SEARCH/REPLACE blocks. No explanations, no markdown, no additional text.', '', - 'FORMAT:', + '```', '<<<<<<< SEARCH', - 'exact lines to find (copy from the provided code)', + '[exact lines from the original code]', '=======', - 'modified lines', + '[modified version of those lines]', '>>>>>>> REPLACE', + '```', '', - 'RULES:', - '1. ONLY output SEARCH/REPLACE blocks - absolutely no explanations or markdown', - '2. Copy the SEARCH content EXACTLY from the provided code between the ``` markers', - '3. Include 1-3 surrounding lines in SEARCH for unique matching', - '4. REPLACE contains the modified version of SEARCH content', - '5. Multiple changes = multiple SEARCH/REPLACE blocks', - '6. Delete lines by omitting them from REPLACE', - '7. Add lines by including them in REPLACE', - '8. If DIAGNOSTICS are provided, use them to understand what needs fixing', - '9. If a SELECTION is provided, only modify code within that selection', - '10. If CURSOR_DATA is provided, focus modifications near that cursor position', - '11. GIT_DIFF context is for reference only - never use git diff hunks as SEARCH content', + '# CRITICAL RULES', + '1. **Exact matching**: Copy SEARCH content EXACTLY character-for-character from the provided code', + '2. **Context lines**: Include 1-3 unchanged surrounding lines in SEARCH for unique identification', + '3. **Indentation**: Preserve the exact indentation from the original code', + '4. **Multiple changes**: Use separate SEARCH/REPLACE blocks for each distinct change', + '5. **No explanations**: Output ONLY the SEARCH/REPLACE blocks, nothing else', '', - 'EXAMPLE - Change return value:', + } + + -- Add context-specific guidance + local context_guidance = {} + + if context_instance:has('diagnostics') then + table.insert(context_guidance, '**DIAGNOSTICS context**: Use error/warning information to guide your fixes') + end + + if context_instance:has('selection') then + table.insert(context_guidance, '**SELECTION context**: Only modify code within the selected range') + elseif context_instance:has('cursor_data') then + table.insert(context_guidance, '**CURSOR context**: Focus modifications near the cursor position') + end + + if context_instance:has('git_diff') then + table.insert(context_guidance, '**GIT_DIFF context**: For reference only - never copy git diff syntax into SEARCH') + end + + if #context_guidance > 0 then + table.insert(base_instructions, '# CONTEXT USAGE') + for _, guidance in ipairs(context_guidance) do + table.insert(base_instructions, '- ' .. guidance) + end + table.insert(base_instructions, '') + end + + -- Add practical examples + local examples = { + '# EXAMPLES', + '', + '**Modify a return value:**', + '```', '<<<<<<< SEARCH', - 'function getValue()', - ' return 42', + 'function calculate()', + ' local result = x + y', + ' return result * 2', 'end', '=======', - 'function getValue()', - ' return 100', + 'function calculate()', + ' local result = x + y', + ' return result * 3 -- Changed multiplier', 'end', '>>>>>>> REPLACE', + '```', '', - 'EXAMPLE - Add a line:', + '**Insert a new line:**', + '```', '<<<<<<< SEARCH', - 'local x = 1', - 'local y = 2', + 'local config = {', + ' timeout = 5000,', + '}', '=======', - 'local x = 1', - 'local z = 1.5', - 'local y = 2', + 'local config = {', + ' timeout = 5000,', + ' retry_count = 3,', + '}', '>>>>>>> REPLACE', + '```', '', - 'EXAMPLE - Delete a line:', + '**Remove a line:**', + '```', '<<<<<<< SEARCH', - '-- old comment', - 'local unused = true', - 'local needed = false', + 'local debug_mode = true', + 'local verbose = true', + 'local silent = false', '=======', - 'local needed = false', + 'local debug_mode = true', + 'local silent = false', '>>>>>>> REPLACE', + '```', '', - 'Remember: Output ONLY SEARCH/REPLACE blocks. The SEARCH text must match the code exactly.', + '# FINAL REMINDER', + 'Output ONLY the SEARCH/REPLACE blocks. The SEARCH section must match the original code exactly.', } + for _, line in ipairs(examples) do + table.insert(base_instructions, line) + end + + return base_instructions +end + +--- Creates message parameters for quick chat +---@param message string The user message +---@param buf integer Buffer handle +---@param range table|nil Range information +---@param context_instance ContextInstance Context instance +---@param options table Options including model and agent +---@return table params Message parameters +local create_message = Promise.async(function(message, buf, range, context_instance, options) + local quick_chat_config = config.quick_chat or {} + + -- Generate instructions (allow user override) + local instructions + if quick_chat_config.instructions then + instructions = quick_chat_config.instructions + else + instructions = generate_search_replace_instructions(context_instance) + end + local format_opts = { buf = buf } if range then format_opts.range = { start = range.start, stop = range.stop } @@ -605,12 +322,15 @@ local create_message = Promise.async(function(message, buf, range, context_insta local result = context.format_message_plain_text(message, context_instance, format_opts):await() - -- Prepend instructions to the message text (in addition to system param) - -- This ensures the LLM sees the instructions even if system prompt isn't honored - local instructions_text = table.concat(instructions, '\n') - local full_text = instructions_text .. '\n\n---\n\n' .. result.text + -- Convert instructions to text + local instructions_text = type(instructions) == 'table' and table.concat(instructions, '\n') or tostring(instructions) + + -- Create a clear separator between instructions and user request + local full_text = instructions_text .. '\n\n' .. string.rep('=', 80) .. '\n\n' .. '# USER REQUEST\n\n' .. result.text + local parts = { { type = 'text', text = full_text } } + -- Use instructions as system prompt for models that support it local params = { parts = parts, system = instructions_text } local current_model = core.initialize_current_model():await() @@ -686,7 +406,6 @@ M.quick_chat = Promise.async(function(message, options, range) } local params = create_message(message, buf, range, context_instance, options):await() - vim.print('⭕ ❱ quick_chat.lua:685 ❱ ƒ(params) ❱ params =', params) local success, err = pcall(function() state.api_client:create_message(quick_chat_session.id, params):await() @@ -710,7 +429,9 @@ function M.setup() local buf = ev.buf for session_id, session_info in pairs(running_sessions) do if session_info.buf == buf then - if session_info.spinner then + ---@diagnostic disable-next-line: undefined-field + if session_info.spinner and session_info.spinner.stop then + ---@diagnostic disable-next-line: undefined-field session_info.spinner:stop() end running_sessions[session_id] = nil @@ -723,7 +444,9 @@ function M.setup() group = augroup, callback = function() for _session_id, session_info in pairs(running_sessions) do - if session_info.spinner then + ---@diagnostic disable-next-line: undefined-field + if session_info.spinner and session_info.spinner.stop then + ---@diagnostic disable-next-line: undefined-field session_info.spinner:stop() end end diff --git a/lua/opencode/quick_chat/search_replace.lua b/lua/opencode/quick_chat/search_replace.lua new file mode 100644 index 00000000..02e87425 --- /dev/null +++ b/lua/opencode/quick_chat/search_replace.lua @@ -0,0 +1,114 @@ +local M = {} + +--- Parses SEARCH/REPLACE blocks from response text +--- Supports both raw and code-fenced formats: +--- <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE +--- ```\n<<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE\n``` +---@param response_text string Response text containing SEARCH/REPLACE blocks +---@return table[] replacements Array of {search=string, replace=string, block_number=number} +---@return string[] warnings Array of warning messages for malformed blocks +function M.parse_blocks(response_text) + local replacements = {} + local warnings = {} + + -- Normalize line endings + local text = response_text:gsub('\r\n', '\n') + + -- Remove code fences if present (```...```) + text = text:gsub('```[^\n]*\n(.-)```', '%1') + + local block_number = 0 + local pos = 1 + + while pos <= #text do + -- Find the start marker (require at least 7 < characters) + local search_start, search_end = text:find('<<<<<<<[<]*[ \t]*SEARCH[ \t]*\n', pos) + if not search_start or not search_end then + break + end + + block_number = block_number + 1 + local content_start = search_end + 1 + + -- Find the separator (require exactly 7 = characters) + local separator_start, separator_end = text:find('\n=======%s*\n', content_start) + if not separator_start then + table.insert(warnings, string.format('Block %d: Missing separator (=======)', block_number)) + pos = search_start + 1 + else + local search_content = text:sub(content_start, separator_start - 1) + + -- Find the end marker (require at least 7 > characters, newline optional for empty replace) + local replace_start = separator_end + 1 + local end_marker_start, end_marker_end = text:find('\n?>>>>>>>[>]*%s*REPLACE[^\n]*', replace_start) + if not end_marker_start then + table.insert(warnings, string.format('Block %d: Missing end marker (>>>>>>> REPLACE)', block_number)) + pos = search_start + 1 + else + -- Extract replace content (everything between separator and end marker) + local replace_content = text:sub(replace_start, end_marker_start - 1) + + if search_content:match('^%s*$') then + table.insert(warnings, string.format('Block %d: Empty SEARCH section', block_number)) + pos = end_marker_end + 1 + else + table.insert(replacements, { + search = search_content, + replace = replace_content, + block_number = block_number, + }) + pos = end_marker_end + 1 + end + end + end + end + + return replacements, warnings +end + +--- Applies SEARCH/REPLACE blocks to buffer content using exact matching +---@param buf integer Buffer handle +---@param replacements table[] Array of {search=string, replace=string, block_number=number} +---@return boolean success Whether any replacements were applied +---@return string[] errors List of error messages for failed replacements +---@return number applied_count Number of successfully applied replacements +function M.apply(buf, replacements) + if not vim.api.nvim_buf_is_valid(buf) then + return false, { 'Buffer is not valid' }, 0 + end + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local content = table.concat(lines, '\n') + + local applied_count = 0 + local errors = {} + + for _, replacement in ipairs(replacements) do + local search = replacement.search + local replace = replacement.replace + local block_num = replacement.block_number or '?' + + -- Exact match only + local start_pos, end_pos = content:find(search, 1, true) + + if start_pos and end_pos then + content = content:sub(1, start_pos - 1) .. replace .. content:sub(end_pos + 1) + applied_count = applied_count + 1 + else + local search_preview = search:sub(1, 60):gsub('\n', '\\n') + if #search > 60 then + search_preview = search_preview .. '...' + end + table.insert(errors, string.format('Block %d: No exact match for: "%s"', block_num, search_preview)) + end + end + + if applied_count > 0 then + local new_lines = vim.split(content, '\n', { plain = true }) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) + end + + return applied_count > 0, errors, applied_count +end + +return M diff --git a/lua/opencode/quick_chat/spinner.lua b/lua/opencode/quick_chat/spinner.lua new file mode 100644 index 00000000..e1a667bf --- /dev/null +++ b/lua/opencode/quick_chat/spinner.lua @@ -0,0 +1,87 @@ +local config = require('opencode.config') +local Timer = require('opencode.ui.timer') + +---@class CursorSpinner +---@field buf integer +---@field row integer +---@field col integer +---@field ns_id integer +---@field extmark_id integer|nil +---@field current_frame integer +---@field timer Timer|nil +---@field active boolean +---@field frames string[] +local CursorSpinner = {} +CursorSpinner.__index = CursorSpinner + +function CursorSpinner.new(buf, row, col) + local self = setmetatable({}, CursorSpinner) + self.buf = buf + self.row = row + self.col = col + self.ns_id = vim.api.nvim_create_namespace('opencode_quick_chat_spinner') + self.extmark_id = nil + self.current_frame = 1 + self.timer = nil + self.active = true + + self.frames = config.values.ui.loading_animation and config.values.ui.loading_animation.frames + or { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' } + + self:render() + self:start_timer() + return self +end + +function CursorSpinner:render() + if not self.active or not vim.api.nvim_buf_is_valid(self.buf) then + return + end + + local frame = ' ' .. self.frames[self.current_frame] + self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, self.ns_id, self.row, self.col, { + id = self.extmark_id, + virt_text = { { frame .. ' ', 'Comment' } }, + virt_text_pos = 'overlay', + right_gravity = false, + }) +end + +function CursorSpinner:next_frame() + self.current_frame = (self.current_frame % #self.frames) + 1 +end + +function CursorSpinner:start_timer() + self.timer = Timer.new({ + interval = 100, -- 10 FPS like the main loading animation + on_tick = function() + if not self.active then + return false + end + self:next_frame() + self:render() + return true + end, + repeat_timer = true, + }) + self.timer:start() +end + +function CursorSpinner:stop() + if not self.active then + return + end + + self.active = false + + if self.timer then + self.timer:stop() + self.timer = nil + end + + if self.extmark_id and vim.api.nvim_buf_is_valid(self.buf) then + pcall(vim.api.nvim_buf_del_extmark, self.buf, self.ns_id, self.extmark_id) + end +end + +return CursorSpinner diff --git a/tests/unit/search_replace_spec.lua b/tests/unit/search_replace_spec.lua new file mode 100644 index 00000000..1c060061 --- /dev/null +++ b/tests/unit/search_replace_spec.lua @@ -0,0 +1,280 @@ +local search_replace = require('opencode.quick_chat.search_replace') + +describe('search_replace.parse_blocks', function() + it('parses a single SEARCH/REPLACE block', function() + local input = [[ +<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + assert.equals('local x = 1', replacements[1].search) + assert.equals('local x = 2', replacements[1].replace) + assert.equals(1, replacements[1].block_number) + end) + + it('parses multiple SEARCH/REPLACE blocks', function() + local input = [[ +<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>>> REPLACE + +<<<<<<< SEARCH +function foo() + return 42 +end +======= +function foo() + return 100 +end +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(2, #replacements) + assert.equals(0, #warnings) + assert.equals('local x = 1', replacements[1].search) + assert.equals('local x = 2', replacements[1].replace) + assert.equals(1, replacements[1].block_number) + assert.equals('function foo()\n return 42\nend', replacements[2].search) + assert.equals('function foo()\n return 100\nend', replacements[2].replace) + assert.equals(2, replacements[2].block_number) + end) + + it('handles code fences around blocks', function() + local input = [[ +``` +<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>>> REPLACE +``` +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + assert.equals('local x = 1', replacements[1].search) + end) + + it('handles code fences with language specifier', function() + local input = [[ +```lua +<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>>> REPLACE +``` +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + end) + + it('warns on missing separator', function() + local input = [[ +<<<<<<< SEARCH +local x = 1 +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(0, #replacements) + assert.equals(1, #warnings) + assert.matches('Missing separator', warnings[1]) + end) + + it('warns on missing end marker', function() + local input = [[ +<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(0, #replacements) + assert.equals(1, #warnings) + assert.matches('Missing end marker', warnings[1]) + end) + + it('warns on empty SEARCH section', function() + -- Note: Empty search with content on same line as separator + -- The parser requires \n======= so whitespace-only search still needs proper structure + local input = [[ +<<<<<<< SEARCH + +======= +local x = 2 +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(0, #replacements) + assert.equals(1, #warnings) + assert.matches('Empty SEARCH section', warnings[1]) + end) + + it('handles empty REPLACE section (deletion)', function() + -- Note: Empty replace needs proper newline structure + local input = [[ +<<<<<<< SEARCH +local unused = true +======= + +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + assert.equals('local unused = true', replacements[1].search) + -- Replace section contains single empty line + assert.equals('', replacements[1].replace) + end) + + it('normalizes CRLF line endings', function() + local input = "<<<<<<< SEARCH\r\nlocal x = 1\r\n=======\r\nlocal x = 2\r\n>>>>>>> REPLACE\r\n" + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + assert.equals('local x = 1', replacements[1].search) + end) + + it('handles extra angle brackets in markers', function() + local input = [[ +<<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + end) + + it('ignores text before first block', function() + local input = [[ +Here is my response explaining the changes: + +<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + assert.equals('local x = 1', replacements[1].search) + end) + + it('ignores text between blocks', function() + local input = [[ +<<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>>> REPLACE + +And here is another change: + +<<<<<<< SEARCH +local y = 3 +======= +local y = 4 +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(2, #replacements) + assert.equals(0, #warnings) + end) + + it('returns empty array for no blocks', function() + local input = 'Just some regular text with no blocks' + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(0, #replacements) + assert.equals(0, #warnings) + end) + + it('requires at least 7 angle brackets', function() + local input = [[ +<<<<<< SEARCH +local x = 1 +======= +local x = 2 +>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(0, #replacements) + assert.equals(0, #warnings) + end) +end) + +describe('search_replace.apply', function() + it('applies replacements to buffer', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'local x = 1', 'local y = 2' }) + + local replacements = { + { search = 'local x = 1', replace = 'local x = 100', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.are.same({ 'local x = 100', 'local y = 2' }, lines) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns error for invalid buffer', function() + local success, errors, count = search_replace.apply(99999, {}) + + assert.is_false(success) + assert.equals(1, #errors) + assert.matches('Buffer is not valid', errors[1]) + assert.equals(0, count) + end) + + it('does not modify buffer if no matches', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'local x = 1' }) + + local replacements = { + { search = 'local y = 2', replace = 'local y = 200', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_false(success) + assert.equals(1, #errors) + assert.equals(0, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.are.same({ 'local x = 1' }, lines) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) +end) From 8e6bac6363a00821f42bada811ca4b132cd44579 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 16 Dec 2025 11:03:10 -0500 Subject: [PATCH 09/46] feat(search_replace): support empty SEARCH block as insert operation - `parse_blocks` and `apply` now treat empty SEARCH sections as "insert at cursor" operations - Added insert logic, warnings, and error cases for missing cursor row - Updates to quick_chat instructions for insert at cursor - tests for insert/empty SEARCH now included in search_replace_spec --- lua/opencode/quick_chat.lua | 12 ++- lua/opencode/quick_chat/search_replace.lua | 65 +++++++----- tests/unit/search_replace_spec.lua | 109 ++++++++++++++++++++- 3 files changed, 157 insertions(+), 29 deletions(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 3a934c68..bd812e81 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -91,7 +91,7 @@ local function process_response(session_info, messages) return false end - local success, errors, applied_count = search_replace.apply(session_info.buf, replacements) + local success, errors, applied_count = search_replace.apply(session_info.buf, replacements, session_info.row) -- Provide detailed feedback if applied_count > 0 then @@ -286,8 +286,18 @@ local function generate_search_replace_instructions(context_instance) '>>>>>>> REPLACE', '```', '', + '**Insert new code at cursor (empty SEARCH):**', + 'When the cursor is on an empty line or you need to insert without replacing, use an empty SEARCH section:', + '```', + '<<<<<<< SEARCH', + '=======', + 'local new_variable = "inserted at cursor"', + '>>>>>>> REPLACE', + '```', + '', '# FINAL REMINDER', 'Output ONLY the SEARCH/REPLACE blocks. The SEARCH section must match the original code exactly.', + 'Use an empty SEARCH section to insert new code at the cursor position.', } for _, line in ipairs(examples) do diff --git a/lua/opencode/quick_chat/search_replace.lua b/lua/opencode/quick_chat/search_replace.lua index 02e87425..bcb98a01 100644 --- a/lua/opencode/quick_chat/search_replace.lua +++ b/lua/opencode/quick_chat/search_replace.lua @@ -4,8 +4,9 @@ local M = {} --- Supports both raw and code-fenced formats: --- <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE --- ```\n<<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE\n``` +--- Empty SEARCH sections are valid and indicate "insert at cursor position" ---@param response_text string Response text containing SEARCH/REPLACE blocks ----@return table[] replacements Array of {search=string, replace=string, block_number=number} +---@return table[] replacements Array of {search=string, replace=string, block_number=number, is_insert=boolean} ---@return string[] warnings Array of warning messages for malformed blocks function M.parse_blocks(response_text) local replacements = {} @@ -48,17 +49,14 @@ function M.parse_blocks(response_text) -- Extract replace content (everything between separator and end marker) local replace_content = text:sub(replace_start, end_marker_start - 1) - if search_content:match('^%s*$') then - table.insert(warnings, string.format('Block %d: Empty SEARCH section', block_number)) - pos = end_marker_end + 1 - else - table.insert(replacements, { - search = search_content, - replace = replace_content, - block_number = block_number, - }) - pos = end_marker_end + 1 - end + local is_insert = search_content:match('^%s*$') ~= nil + table.insert(replacements, { + search = is_insert and '' or search_content, + replace = replace_content, + block_number = block_number, + is_insert = is_insert, + }) + pos = end_marker_end + 1 end end end @@ -67,12 +65,14 @@ function M.parse_blocks(response_text) end --- Applies SEARCH/REPLACE blocks to buffer content using exact matching +--- Empty SEARCH sections (is_insert=true) will insert at the specified cursor row ---@param buf integer Buffer handle ----@param replacements table[] Array of {search=string, replace=string, block_number=number} +---@param replacements table[] Array of {search=string, replace=string, block_number=number, is_insert=boolean} +---@param cursor_row? integer Optional cursor row (0-indexed) for insert operations ---@return boolean success Whether any replacements were applied ---@return string[] errors List of error messages for failed replacements ---@return number applied_count Number of successfully applied replacements -function M.apply(buf, replacements) +function M.apply(buf, replacements, cursor_row) if not vim.api.nvim_buf_is_valid(buf) then return false, { 'Buffer is not valid' }, 0 end @@ -87,22 +87,39 @@ function M.apply(buf, replacements) local search = replacement.search local replace = replacement.replace local block_num = replacement.block_number or '?' + local is_insert = replacement.is_insert - -- Exact match only - local start_pos, end_pos = content:find(search, 1, true) - - if start_pos and end_pos then - content = content:sub(1, start_pos - 1) .. replace .. content:sub(end_pos + 1) - applied_count = applied_count + 1 + if is_insert then + -- Empty SEARCH: insert at cursor row + if not cursor_row then + table.insert(errors, string.format('Block %d: Insert operation requires cursor position', block_num)) + else + -- Split replace content into lines and insert at cursor row + local replace_lines = vim.split(replace, '\n', { plain = true }) + vim.api.nvim_buf_set_lines(buf, cursor_row, cursor_row, false, replace_lines) + applied_count = applied_count + 1 + -- Refresh lines and content after direct buffer modification + lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + content = table.concat(lines, '\n') + end else - local search_preview = search:sub(1, 60):gsub('\n', '\\n') - if #search > 60 then - search_preview = search_preview .. '...' + -- Exact match only + local start_pos, end_pos = content:find(search, 1, true) + + if start_pos and end_pos then + content = content:sub(1, start_pos - 1) .. replace .. content:sub(end_pos + 1) + applied_count = applied_count + 1 + else + local search_preview = search:sub(1, 60):gsub('\n', '\\n') + if #search > 60 then + search_preview = search_preview .. '...' + end + table.insert(errors, string.format('Block %d: No exact match for: "%s"', block_num, search_preview)) end - table.insert(errors, string.format('Block %d: No exact match for: "%s"', block_num, search_preview)) end end + -- Apply remaining content changes (for non-insert replacements) if applied_count > 0 then local new_lines = vim.split(content, '\n', { plain = true }) vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) diff --git a/tests/unit/search_replace_spec.lua b/tests/unit/search_replace_spec.lua index 1c060061..9548f33b 100644 --- a/tests/unit/search_replace_spec.lua +++ b/tests/unit/search_replace_spec.lua @@ -108,7 +108,25 @@ local x = 2 assert.matches('Missing end marker', warnings[1]) end) - it('warns on empty SEARCH section', function() + it('parses empty SEARCH section as insert operation', function() + -- Empty search means "insert at cursor position" + local input = [[ +<<<<<<< SEARCH + +======= +local x = 2 +>>>>>>> REPLACE +]] + local replacements, warnings = search_replace.parse_blocks(input) + + assert.equals(1, #replacements) + assert.equals(0, #warnings) + assert.equals('', replacements[1].search) + assert.equals('local x = 2', replacements[1].replace) + assert.is_true(replacements[1].is_insert) + end) + + it('parses whitespace-only SEARCH section as insert operation', function() -- Note: Empty search with content on same line as separator -- The parser requires \n======= so whitespace-only search still needs proper structure local input = [[ @@ -120,9 +138,10 @@ local x = 2 ]] local replacements, warnings = search_replace.parse_blocks(input) - assert.equals(0, #replacements) - assert.equals(1, #warnings) - assert.matches('Empty SEARCH section', warnings[1]) + assert.equals(1, #replacements) + assert.equals(0, #warnings) + assert.equals('', replacements[1].search) + assert.is_true(replacements[1].is_insert) end) it('handles empty REPLACE section (deletion)', function() @@ -277,4 +296,86 @@ describe('search_replace.apply', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + + it('inserts at cursor row when is_insert is true', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', 'line 3' }) + + local replacements = { + { search = '', replace = 'inserted text', block_number = 1, is_insert = true }, + } + + -- Insert before row 1 (0-indexed), so inserts before "line 2" + local success, errors, count = search_replace.apply(buf, replacements, 1) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.are.same({ 'line 1', 'inserted text', 'line 2', 'line 3' }, lines) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('inserts at empty line cursor position', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', '', 'line 3' }) + + local replacements = { + { search = '', replace = 'new content', block_number = 1, is_insert = true }, + } + + -- Insert before row 1 (0-indexed), the empty line + local success, errors, count = search_replace.apply(buf, replacements, 1) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.are.same({ 'line 1', 'new content', '', 'line 3' }, lines) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('inserts multiline content at cursor row', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', '', 'line 3' }) + + local replacements = { + { search = '', replace = 'first\nsecond\nthird', block_number = 1, is_insert = true }, + } + + -- Insert before row 1 (0-indexed) + local success, errors, count = search_replace.apply(buf, replacements, 1) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.are.same({ 'line 1', 'first', 'second', 'third', '', 'line 3' }, lines) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns error for insert without cursor_row', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1' }) + + local replacements = { + { search = '', replace = 'inserted text', block_number = 1, is_insert = true }, + } + + -- No cursor_row provided + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_false(success) + assert.equals(1, #errors) + assert.matches('Insert operation requires cursor position', errors[1]) + assert.equals(0, count) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) end) From dc5e376faa048e619ddd966844dcd01a034f8b1a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 16 Dec 2025 13:16:13 -0500 Subject: [PATCH 10/46] refactor(quick_chat): improve prompt structure and formatting - Remove markdown code fences from search/replace examples - Split instructions and user request into separate message parts --- lua/opencode/quick_chat.lua | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index bd812e81..721962bf 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -199,13 +199,11 @@ local function generate_search_replace_instructions(context_instance) '# OUTPUT FORMAT', 'You MUST output ONLY in SEARCH/REPLACE blocks. No explanations, no markdown, no additional text.', '', - '```', '<<<<<<< SEARCH', '[exact lines from the original code]', '=======', '[modified version of those lines]', '>>>>>>> REPLACE', - '```', '', '# CRITICAL RULES', '1. **Exact matching**: Copy SEARCH content EXACTLY character-for-character from the provided code', @@ -246,7 +244,6 @@ local function generate_search_replace_instructions(context_instance) '# EXAMPLES', '', '**Modify a return value:**', - '```', '<<<<<<< SEARCH', 'function calculate()', ' local result = x + y', @@ -258,10 +255,8 @@ local function generate_search_replace_instructions(context_instance) ' return result * 3 -- Changed multiplier', 'end', '>>>>>>> REPLACE', - '```', '', '**Insert a new line:**', - '```', '<<<<<<< SEARCH', 'local config = {', ' timeout = 5000,', @@ -272,10 +267,8 @@ local function generate_search_replace_instructions(context_instance) ' retry_count = 3,', '}', '>>>>>>> REPLACE', - '```', '', '**Remove a line:**', - '```', '<<<<<<< SEARCH', 'local debug_mode = true', 'local verbose = true', @@ -284,16 +277,13 @@ local function generate_search_replace_instructions(context_instance) 'local debug_mode = true', 'local silent = false', '>>>>>>> REPLACE', - '```', '', '**Insert new code at cursor (empty SEARCH):**', 'When the cursor is on an empty line or you need to insert without replacing, use an empty SEARCH section:', - '```', '<<<<<<< SEARCH', '=======', 'local new_variable = "inserted at cursor"', '>>>>>>> REPLACE', - '```', '', '# FINAL REMINDER', 'Output ONLY the SEARCH/REPLACE blocks. The SEARCH section must match the original code exactly.', @@ -332,13 +322,10 @@ local create_message = Promise.async(function(message, buf, range, context_insta local result = context.format_message_plain_text(message, context_instance, format_opts):await() - -- Convert instructions to text - local instructions_text = type(instructions) == 'table' and table.concat(instructions, '\n') or tostring(instructions) - - -- Create a clear separator between instructions and user request - local full_text = instructions_text .. '\n\n' .. string.rep('=', 80) .. '\n\n' .. '# USER REQUEST\n\n' .. result.text - - local parts = { { type = 'text', text = full_text } } + local parts = { + { type = 'text', text = instructions_text }, + { type = 'text', text = '\n\n' .. string.rep('=', 80) .. '\n\n' .. '# USER REQUEST\n\n' .. result.text }, + } -- Use instructions as system prompt for models that support it local params = { parts = parts, system = instructions_text } From d5ec089a4e1da54c32958d58596055efd9e2a999 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 16 Dec 2025 13:27:16 -0500 Subject: [PATCH 11/46] fix(quick_chat): remove duplicated instructions --- lua/opencode/context/plain_text_formatter.lua | 8 +------- lua/opencode/quick_chat.lua | 18 ++++++------------ 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/lua/opencode/context/plain_text_formatter.lua b/lua/opencode/context/plain_text_formatter.lua index 8e1e47ec..15355478 100644 --- a/lua/opencode/context/plain_text_formatter.lua +++ b/lua/opencode/context/plain_text_formatter.lua @@ -140,13 +140,11 @@ M.format_message = Promise.async(function(prompt, context_instance, opts) end end - -- Add selections for _, sel in ipairs(context_instance:get_selections() or {}) do table.insert(text_parts, '') table.insert(text_parts, M.format_selection(sel)) end - -- Add diagnostics local diagnostics = context_instance:get_diagnostics(buf) if diagnostics and #diagnostics > 0 then local diag_range = nil @@ -160,7 +158,6 @@ M.format_message = Promise.async(function(prompt, context_instance, opts) end end - -- Add cursor data if context_instance:is_context_enabled('cursor_data') then local current_buf, current_win = context_instance:get_current_buf() local cursor_data = context_instance:get_current_cursor_data(current_buf or buf, current_win or 0) @@ -170,7 +167,6 @@ M.format_message = Promise.async(function(prompt, context_instance, opts) end end - -- Add git diff if context_instance:is_context_enabled('git_diff') then local diff_text = context_instance:get_git_diff():await() if diff_text and diff_text ~= '' then @@ -179,13 +175,11 @@ M.format_message = Promise.async(function(prompt, context_instance, opts) end end - -- Add instruction table.insert(text_parts, '') - table.insert(text_parts, 'INSTRUCTION: ' .. prompt) + table.insert(text_parts, 'USER PROMPT: ' .. prompt) local full_text = table.concat(text_parts, '\n') - -- Return both the plain text and a parts array for the API return { text = full_text, parts = { { type = 'text', text = full_text } }, diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 721962bf..8f77a225 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -307,7 +307,6 @@ end local create_message = Promise.async(function(message, buf, range, context_instance, options) local quick_chat_config = config.quick_chat or {} - -- Generate instructions (allow user override) local instructions if quick_chat_config.instructions then instructions = quick_chat_config.instructions @@ -323,12 +322,11 @@ local create_message = Promise.async(function(message, buf, range, context_insta local result = context.format_message_plain_text(message, context_instance, format_opts):await() local parts = { - { type = 'text', text = instructions_text }, - { type = 'text', text = '\n\n' .. string.rep('=', 80) .. '\n\n' .. '# USER REQUEST\n\n' .. result.text }, + { type = 'text', text = table.concat(instructions, '\n') }, + { type = 'text', text = result.text }, } - -- Use instructions as system prompt for models that support it - local params = { parts = parts, system = instructions_text } + local params = { parts = parts } local current_model = core.initialize_current_model():await() local target_model = options.model or quick_chat_config.default_model or current_model @@ -339,13 +337,9 @@ local create_message = Promise.async(function(message, buf, range, context_insta end end - -- Set agent if specified - local target_mode = options.agent - or quick_chat_config.default_agent - or state.current_mode - or config.values.default_mode - if target_mode then - params.agent = target_mode + local target_agent = options.agent or quick_chat_config.default_agent or state.current_mode or config.default_mode + if target_agent then + params.agent = target_agent end return params From d6ffb06611af7b90b4153e2c47620468c139f3a8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 08:39:44 -0500 Subject: [PATCH 12/46] feat: optimize SEARCH/REPLACE quick chat for concise context and output rules - Refactor quick chat SEARCH/REPLACE message setup for stricter Aider-style output - Only emit SEARCH/REPLACE blocks, with clear output and matching rules - Remove buffer context from quick chat, improving token efficiency, can be added with #buffer - Centralize and clarify context-specific guidance and dynamic rule composition --- lua/opencode/context.lua | 15 -- lua/opencode/context/plain_text_formatter.lua | 46 ++++-- lua/opencode/quick_chat.lua | 144 +++++------------- lua/opencode/types.lua | 1 + 4 files changed, 68 insertions(+), 138 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index f7c804bc..66524910 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -5,9 +5,7 @@ local util = require('opencode.util') local config = require('opencode.config') local state = require('opencode.state') -local Promise = require('opencode.promise') --- Import extracted modules local ContextInstance = require('opencode.context.base') local json_formatter = require('opencode.context.json_formatter') local plain_text_formatter = require('opencode.context.plain_text_formatter') @@ -136,13 +134,6 @@ function M.get_current_selection() return global_context:get_current_selection() end ---- Formats context as plain text for LLM consumption ---- Outputs human-readable text instead of JSON message parts ---- Alias: format_message_quick_chat ----@param prompt string The user's instruction/prompt ----@param context_instance ContextInstance Context instance to use ----@param opts? { range?: { start: integer, stop: integer }, buf?: integer } ----@return table result { text: string, parts: OpencodeMessagePart[] } M.format_message_plain_text = plain_text_formatter.format_message --- Formats a prompt and context into message with parts for the opencode API @@ -185,12 +176,6 @@ function M.format_message(prompt, opts) return parts end ---- Formats a prompt and context into plain text message for quick chat ---- Alias for format_message_plain_text - used for ephemeral sessions ----@param prompt string ----@param context_instance ContextInstance Context instance to use ----@param opts? { range?: { start: integer, stop: integer }, buf?: integer } ----@return table result { text: string, parts: OpencodeMessagePart[] } M.format_message_quick_chat = plain_text_formatter.format_message ---@param text string diff --git a/lua/opencode/context/plain_text_formatter.lua b/lua/opencode/context/plain_text_formatter.lua index 15355478..d42827e1 100644 --- a/lua/opencode/context/plain_text_formatter.lua +++ b/lua/opencode/context/plain_text_formatter.lua @@ -105,6 +105,26 @@ function M.format_buffer(buf, lang) return string.format('FILE: %s\n\n```%s\n%s\n```', rel_path, lang, content) end +---@param buf integer +---@param lang string +---@param rel_path string +---@param range {start: integer, stop: integer} +---@return string +function M.format_range(buf, lang, rel_path, range) + local start_line = math.max(1, range.start) + local end_line = range.stop + local range_lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) + local range_text = table.concat(range_lines, '\n') + + local parts = { + string.format('Selected range from %s (lines %d-%d):', rel_path, start_line, end_line), + '```' .. lang, + range_text, + '```', + } + return table.concat(parts, '\n') +end + --- Formats context as plain text for LLM consumption (used by quick chat) --- Unlike format_message_quick_chat, this outputs human-readable text instead of JSON ---@param prompt string The user's instruction/prompt @@ -113,7 +133,9 @@ end ---@return table result { text: string, parts: OpencodeMessagePart[] } M.format_message = Promise.async(function(prompt, context_instance, opts) opts = opts or {} - local buf = opts.buf or context_instance:get_current_buf() or vim.api.nvim_get_current_buf() + local buf = vim.api.nvim_get_current_buf() + local win = vim.api.nvim_get_current_win() + local range = opts.range local file_name = vim.api.nvim_buf_get_name(buf) @@ -122,24 +144,17 @@ M.format_message = Promise.async(function(prompt, context_instance, opts) local text_parts = {} - -- Add file/buffer content - if context_instance:is_context_enabled('buffer') then + if context_instance:is_context_enabled('selection') then if range and range.start and range.stop then - local start_line = math.max(1, range.start) - local end_line = range.stop - local range_lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) - local range_text = table.concat(range_lines, '\n') - - table.insert(text_parts, string.format('FILE: %s (lines %d-%d)', rel_path, start_line, end_line)) table.insert(text_parts, '') - table.insert(text_parts, '```' .. lang) - table.insert(text_parts, range_text) - table.insert(text_parts, '```') - else - table.insert(text_parts, M.format_buffer(buf, lang)) + table.insert(text_parts, M.format_range(buf, lang, rel_path, range)) end end + if context_instance:is_context_enabled('buffer') then + table.insert(text_parts, M.format_buffer(buf, lang)) + end + for _, sel in ipairs(context_instance:get_selections() or {}) do table.insert(text_parts, '') table.insert(text_parts, M.format_selection(sel)) @@ -159,8 +174,7 @@ M.format_message = Promise.async(function(prompt, context_instance, opts) end if context_instance:is_context_enabled('cursor_data') then - local current_buf, current_win = context_instance:get_current_buf() - local cursor_data = context_instance:get_current_cursor_data(current_buf or buf, current_win or 0) + local cursor_data = context_instance:get_current_cursor_data(buf, win) if cursor_data then table.insert(text_parts, '') table.insert(text_parts, M.format_cursor_data(cursor_data, lang)) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 8f77a225..19d57433 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -106,7 +106,6 @@ local function process_response(session_info, messages) end end - -- Log errors but don't fail completely if some replacements worked if #errors > 0 then for _, err in ipairs(errors) do vim.notify('Quick chat: ' .. err, vim.log.levels.WARN) @@ -141,11 +140,13 @@ local on_done = Promise.async(function(active_session) cleanup_session(running_session, active_session.id, 'Failed to update file with quick chat response') end - --@TODO: enable session deletion after testing - -- Always delete ephemeral session - -- state.api_client:delete_session(session_obj.id):catch(function(err) - -- vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN) - -- end) + if config.debug.quick_chat and config.debug.quick_chat.keep_session then + return + end + + state.api_client:delete_session(active_session.id):catch(function(err) + vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN) + end) end) ---@param message string|nil The message to validate @@ -167,135 +168,66 @@ local function validate_quick_chat_prerequisites(message) end --- Creates context configuration for quick chat +--- Optimized for minimal token usage while providing essential context ---@param has_range boolean Whether a range is specified ---@return OpencodeContextConfig context_opts local function create_context_config(has_range) return { enabled = true, - current_file = { enabled = false }, - cursor_data = { enabled = not has_range }, - selection = { enabled = has_range }, + current_file = { enabled = false }, -- Disable full file content + cursor_data = { enabled = not has_range, context_lines = 10 }, -- Only cursor position when no selection + selection = { enabled = has_range }, -- Only selected text when range provided diagnostics = { enabled = true, error = true, warning = true, info = false, - only_closest = has_range, + only_closest = true, -- Only closest diagnostics, not all file diagnostics }, - agents = { enabled = false }, - buffer = { enabled = true }, - git_diff = { enabled = false }, + agents = { enabled = false }, -- No agent context needed + buffer = { enabled = false }, -- Disable full buffer content for token efficiency + git_diff = { enabled = false }, -- No git context needed } end --- Generates instructions for the LLM to follow the SEARCH/REPLACE format +--- This is inspired from Aider Chat approach ---@param context_instance ContextInstance Context instance ---@return string[] instructions Array of instruction lines -local function generate_search_replace_instructions(context_instance) +local generate_search_replace_instructions = Promise.async(function(context_instance) local base_instructions = { - '# ROLE', - 'You are a precise code editing assistant. Your task is to modify code based on user instructions.', - '', - '# OUTPUT FORMAT', - 'You MUST output ONLY in SEARCH/REPLACE blocks. No explanations, no markdown, no additional text.', - '', + 'Output ONLY SEARCH/REPLACE blocks, no explanations:', '<<<<<<< SEARCH', - '[exact lines from the original code]', + '[exact original code]', '=======', - '[modified version of those lines]', + '[modified code]', '>>>>>>> REPLACE', '', - '# CRITICAL RULES', - '1. **Exact matching**: Copy SEARCH content EXACTLY character-for-character from the provided code', - '2. **Context lines**: Include 1-3 unchanged surrounding lines in SEARCH for unique identification', - '3. **Indentation**: Preserve the exact indentation from the original code', - '4. **Multiple changes**: Use separate SEARCH/REPLACE blocks for each distinct change', - '5. **No explanations**: Output ONLY the SEARCH/REPLACE blocks, nothing else', - '', + 'Rules: Copy SEARCH content exactly. Include 1-3 context lines for unique matching. Use empty SEARCH to insert at cursor. Output multiple blocks only if needed for more complex operations.', } - -- Add context-specific guidance local context_guidance = {} - if context_instance:has('diagnostics') then - table.insert(context_guidance, '**DIAGNOSTICS context**: Use error/warning information to guide your fixes') + if context_instance:has('diagnostics'):await() then + table.insert(context_guidance, 'Fix errors/warnings (if asked)') end - if context_instance:has('selection') then - table.insert(context_guidance, '**SELECTION context**: Only modify code within the selected range') + if context_instance:has('selection'):await() then + table.insert(context_guidance, 'Modify only selected range') elseif context_instance:has('cursor_data') then - table.insert(context_guidance, '**CURSOR context**: Focus modifications near the cursor position') + table.insert(context_guidance, 'Modify only near cursor') end - if context_instance:has('git_diff') then - table.insert(context_guidance, '**GIT_DIFF context**: For reference only - never copy git diff syntax into SEARCH') + if context_instance:has('git_diff'):await() then + table.insert(context_guidance, "ONLY Reference git diff (don't copy syntax)") end if #context_guidance > 0 then - table.insert(base_instructions, '# CONTEXT USAGE') - for _, guidance in ipairs(context_guidance) do - table.insert(base_instructions, '- ' .. guidance) - end - table.insert(base_instructions, '') - end - - -- Add practical examples - local examples = { - '# EXAMPLES', - '', - '**Modify a return value:**', - '<<<<<<< SEARCH', - 'function calculate()', - ' local result = x + y', - ' return result * 2', - 'end', - '=======', - 'function calculate()', - ' local result = x + y', - ' return result * 3 -- Changed multiplier', - 'end', - '>>>>>>> REPLACE', - '', - '**Insert a new line:**', - '<<<<<<< SEARCH', - 'local config = {', - ' timeout = 5000,', - '}', - '=======', - 'local config = {', - ' timeout = 5000,', - ' retry_count = 3,', - '}', - '>>>>>>> REPLACE', - '', - '**Remove a line:**', - '<<<<<<< SEARCH', - 'local debug_mode = true', - 'local verbose = true', - 'local silent = false', - '=======', - 'local debug_mode = true', - 'local silent = false', - '>>>>>>> REPLACE', - '', - '**Insert new code at cursor (empty SEARCH):**', - 'When the cursor is on an empty line or you need to insert without replacing, use an empty SEARCH section:', - '<<<<<<< SEARCH', - '=======', - 'local new_variable = "inserted at cursor"', - '>>>>>>> REPLACE', - '', - '# FINAL REMINDER', - 'Output ONLY the SEARCH/REPLACE blocks. The SEARCH section must match the original code exactly.', - 'Use an empty SEARCH section to insert new code at the cursor position.', - } - - for _, line in ipairs(examples) do - table.insert(base_instructions, line) + table.insert(base_instructions, 'Context: ' .. table.concat(context_guidance, ', ') .. '.') end return base_instructions -end +end) --- Creates message parameters for quick chat ---@param message string The user message @@ -311,7 +243,7 @@ local create_message = Promise.async(function(message, buf, range, context_insta if quick_chat_config.instructions then instructions = quick_chat_config.instructions else - instructions = generate_search_replace_instructions(context_instance) + instructions = generate_search_replace_instructions(context_instance):await() end local format_opts = { buf = buf } @@ -365,14 +297,9 @@ M.quick_chat = Promise.async(function(message, options, range) local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed local spinner = CursorSpinner.new(buf, row, col) - -- Create context instance for diagnostics and other context - local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) - local context_instance = context.new_instance(context_config) - - -- Check prompt guard with the current file local file_name = vim.api.nvim_buf_get_name(buf) local mentioned_files = file_name ~= '' and { file_name } or {} - local allowed, err_msg = util.check_prompt_allowed(config.values.prompt_guard, mentioned_files) + local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files) if not allowed then spinner:stop() return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard') @@ -385,8 +312,9 @@ M.quick_chat = Promise.async(function(message, options, range) return Promise.new():reject('Failed to create ephemeral session') end - --TODO only for debug - state.active_session = quick_chat_session + if config.debug.quick_chat and config.debug.quick_chat.set_active_session then + state.active_session = quick_chat_session + end running_sessions[quick_chat_session.id] = { buf = buf, @@ -396,6 +324,8 @@ M.quick_chat = Promise.async(function(message, options, range) timestamp = vim.uv.now(), } + local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) + local context_instance = context.new_instance(context_config) local params = create_message(message, buf, range, context_instance, options):await() local success, err = pcall(function() diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 4d59edf1..bdf12942 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -159,6 +159,7 @@ ---@field enabled boolean ---@field capture_streamed_events boolean ---@field show_ids boolean +---@field quick_chat {keep_session: boolean, set_active_session: boolean} ---@class OpencodeHooks ---@field on_file_edited? fun(file: string): nil From f7a70fcffd013735385788a071a99b5d3dece3ca Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 09:07:32 -0500 Subject: [PATCH 13/46] feat(quick_chat): cheat for position of window for snacks --- lua/opencode/api.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 8746d26e..e71be00a 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -125,9 +125,8 @@ function M.quick_chat(message, range) message = table.concat(message, ' ') end - -- If no message, prompt for input (range is captured above) if not message or #message == 0 then - vim.ui.input({ prompt = 'Quick Chat Message: ' }, function(input) + vim.ui.input({ prompt = 'Quick Chat Message: ', win = { relative = 'cursor' } }, function(input) local prompt, ctx = util.parse_quick_context_args(input) if input and input ~= '' then quick_chat.quick_chat(prompt, { context_config = ctx }, range) From cdd55e4c2d3c08954a1e6c2b20e8514e17238838 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 09:08:20 -0500 Subject: [PATCH 14/46] feat(quick_chat): update default conf --- lua/opencode/config.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 4445fc02..660e6d57 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -195,7 +195,7 @@ M.defaults = { enabled = true, }, buffer = { - enabled = false, -- Only used for inline editing, disabled by default + enabled = false, -- Disable entire buffer context by default, only used in quick chat }, git_diff = { enabled = false, @@ -205,6 +205,10 @@ M.defaults = { enabled = false, capture_streamed_events = false, show_ids = true, + quick_chat = { + keep_session = false, + set_active_session = false, + }, }, prompt_guard = nil, hooks = { @@ -214,8 +218,8 @@ M.defaults = { on_permission_requested = nil, }, quick_chat = { - default_model = nil, -- Use current model if nil - default_agent = nil, -- Use current mode if nil + default_model = nil, + default_agent = 'plan', -- plan ensure no file modifications by default default_prompt = nil, -- Use built-in prompt if nil }, } From bd19f34843bebb62752d55057dab7f301b800b39 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 09:08:52 -0500 Subject: [PATCH 15/46] feat(ui): use floating window for CursorSpinner - Refactor CursorSpinner to render its spinner animation using a minimal-style floating window at the cursor position instead of an extmark-based virtual text overlay. --- lua/opencode/quick_chat/spinner.lua | 62 ++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/lua/opencode/quick_chat/spinner.lua b/lua/opencode/quick_chat/spinner.lua index e1a667bf..0b57342f 100644 --- a/lua/opencode/quick_chat/spinner.lua +++ b/lua/opencode/quick_chat/spinner.lua @@ -1,6 +1,11 @@ local config = require('opencode.config') local Timer = require('opencode.ui.timer') +---@class Timer +---@field start function +---@field stop function +---@field is_running function + ---@class CursorSpinner ---@field buf integer ---@field row integer @@ -11,6 +16,8 @@ local Timer = require('opencode.ui.timer') ---@field timer Timer|nil ---@field active boolean ---@field frames string[] +---@field float_win integer|nil +---@field float_buf integer|nil local CursorSpinner = {} CursorSpinner.__index = CursorSpinner @@ -24,27 +31,54 @@ function CursorSpinner.new(buf, row, col) self.current_frame = 1 self.timer = nil self.active = true + self.float_win = nil + self.float_buf = nil - self.frames = config.values.ui.loading_animation and config.values.ui.loading_animation.frames + self.frames = config.ui.loading_animation and config.ui.loading_animation.frames or { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' } + self:create_float() self:render() self:start_timer() return self end -function CursorSpinner:render() +function CursorSpinner:create_float() if not self.active or not vim.api.nvim_buf_is_valid(self.buf) then return end - local frame = ' ' .. self.frames[self.current_frame] - self.extmark_id = vim.api.nvim_buf_set_extmark(self.buf, self.ns_id, self.row, self.col, { - id = self.extmark_id, - virt_text = { { frame .. ' ', 'Comment' } }, - virt_text_pos = 'overlay', - right_gravity = false, - }) + self.float_buf = vim.api.nvim_create_buf(false, true) + + local win_config = self:get_float_config() + + self.float_win = vim.api.nvim_open_win(self.float_buf, false, win_config) + + vim.api.nvim_set_option_value('winhl', 'Normal:Comment', { win = self.float_win }) + vim.api.nvim_set_option_value('wrap', false, { win = self.float_win }) +end + +function CursorSpinner:get_float_config() + return { + relative = 'cursor', + width = 3, + height = 1, + row = 0, + col = 2, -- 2 columns to the right of cursor + style = 'minimal', + border = 'rounded', + focusable = false, + zindex = 1000, + } +end + +function CursorSpinner:render() + if not self.active or not self.float_buf or not vim.api.nvim_buf_is_valid(self.float_buf) then + return + end + + local frame = ' ' .. self.frames[self.current_frame] .. ' ' + vim.api.nvim_buf_set_lines(self.float_buf, 0, -1, false, { frame }) end function CursorSpinner:next_frame() @@ -53,7 +87,7 @@ end function CursorSpinner:start_timer() self.timer = Timer.new({ - interval = 100, -- 10 FPS like the main loading animation + interval = 100, -- 10 FPS on_tick = function() if not self.active then return false @@ -79,6 +113,14 @@ function CursorSpinner:stop() self.timer = nil end + if self.float_win and vim.api.nvim_win_is_valid(self.float_win) then + pcall(vim.api.nvim_win_close, self.float_win, true) + end + + if self.float_buf and vim.api.nvim_buf_is_valid(self.float_buf) then + pcall(vim.api.nvim_buf_delete, self.float_buf, { force = true }) + end + if self.extmark_id and vim.api.nvim_buf_is_valid(self.buf) then pcall(vim.api.nvim_buf_del_extmark, self.buf, self.ns_id, self.extmark_id) end From e76825f4d1ad3a0519d313eeea572095c24b15c8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 09:19:43 -0500 Subject: [PATCH 16/46] fix(quick_chat): session deletion --- lua/opencode/quick_chat.lua | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 19d57433..5044b334 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -40,6 +40,13 @@ local function cleanup_session(session_info, session_id, message) if session_info and session_info.spinner then session_info.spinner:stop() end + + if config.debug.quick_chat and not config.debug.quick_chat.keep_session then + state.api_client:delete_session(session_id):catch(function(err) + vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN) + end) + end + running_sessions[session_id] = nil if message then vim.notify(message, vim.log.levels.WARN) @@ -139,14 +146,6 @@ local on_done = Promise.async(function(active_session) else cleanup_session(running_session, active_session.id, 'Failed to update file with quick chat response') end - - if config.debug.quick_chat and config.debug.quick_chat.keep_session then - return - end - - state.api_client:delete_session(active_session.id):catch(function(err) - vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN) - end) end) ---@param message string|nil The message to validate From f7420066e4597f2d1c21614c8368f785892590ee Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 11:28:13 -0500 Subject: [PATCH 17/46] feat: add cancel keymap for cancelling quick_chat session --- lua/opencode/config.lua | 17 ++---- lua/opencode/quick_chat.lua | 85 +++++++++++++++++++++++++++-- lua/opencode/quick_chat/spinner.lua | 53 ++++++++++++++---- lua/opencode/types.lua | 4 ++ 4 files changed, 131 insertions(+), 28 deletions(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 660e6d57..e68f8520 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -22,7 +22,6 @@ M.defaults = { ['ot'] = { 'toggle_focus', desc = 'Toggle focus' }, ['oT'] = { 'timeline', desc = 'Session timeline' }, ['oq'] = { 'close', desc = 'Close Opencode window' }, - ['oQ'] = { 'quick_chat', desc = 'Quick chat with current context', mode = { 'n', 'x' } }, ['os'] = { 'select_session', desc = 'Select session' }, ['oR'] = { 'rename_session', desc = 'Rename session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, @@ -44,6 +43,7 @@ M.defaults = { ['oPd'] = { 'permission_deny', desc = 'Deny permission' }, ['otr'] = { 'toggle_reasoning_output', desc = 'Toggle reasoning output' }, ['ott'] = { 'toggle_tool_output', desc = 'Toggle tool output' }, + ['o/'] = { 'quick_chat', desc = 'Quick chat with current context', mode = { 'n', 'x' } }, }, output_window = { [''] = { 'close' }, @@ -95,6 +95,9 @@ M.defaults = { delete_entry = { '', mode = { 'i', 'n' } }, clear_all = { '', mode = { 'i', 'n' } }, }, + quick_chat = { + cancel = { '', mode = { 'i', 'n' } }, + }, }, ui = { position = 'right', @@ -226,18 +229,6 @@ M.defaults = { M.values = vim.deepcopy(M.defaults) ----Get function names from keymap config, used when normalizing legacy config ----@param keymap_config table -local function get_function_names(keymap_config) - local names = {} - for _, config in pairs(keymap_config) do - if type(config) == 'table' and config[1] then - table.insert(names, config[1]) - end - end - return names -end - local function update_keymap_prefix(prefix, default_prefix) if prefix == default_prefix or not prefix then return diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 5044b334..101114f8 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -20,7 +20,11 @@ local M = {} ---@type table local running_sessions = {} ---- Creates an ephemeral session title +--- Global keymaps that are active during quick chat sessions +---@type table +local active_global_keymaps = {} + +--- Creates an quicklchat session title ---@param buf integer Buffer handle ---@return string title The session title local function create_session_title(buf) @@ -32,6 +36,69 @@ local function create_session_title(buf) return string.format('[QuickChat] %s:%d (%s)', relative_path, line_num, timestamp) end +--- Removes global keymaps for quick chat +local function teardown_global_keymaps() + if not next(active_global_keymaps) then + return + end + + for key, _ in pairs(active_global_keymaps) do + pcall(vim.keymap.del, { 'n', 'i' }, key) + end + + active_global_keymaps = {} +end + +--- Cancels all running quick chat sessions +local function cancel_all_quick_chat_sessions() + for session_id, session_info in pairs(running_sessions) do + if state.api_client then + local ok, result = pcall(function() + return state.api_client:abort_session(session_id):wait() + end) + + if not ok then + vim.notify('Quick chat abort error: ' .. vim.inspect(result), vim.log.levels.WARN) + end + end + + if session_info and session_info.spinner then + session_info.spinner:stop() + end + + if config.values.debug.quick_chat and not config.values.debug.quick_chat.keep_session then + state.api_client:delete_session(session_id):catch(function(err) + vim.notify('Error deleting quicklchat session: ' .. vim.inspect(err), vim.log.levels.WARN) + end) + end + + running_sessions[session_id] = nil + end + + -- Teardown keymaps once at the end + teardown_global_keymaps() + vim.notify('Quick chat cancelled by user', vim.log.levels.WARN) +end + +--- Sets up global keymaps for quick chat +local function setup_global_keymaps() + if next(active_global_keymaps) then + return + end + + local quick_chat_keymap = config.keymap.quick_chat or {} + if quick_chat_keymap.cancel then + vim.keymap.set(quick_chat_keymap.cancel.mode or { 'n', 'i' }, quick_chat_keymap.cancel[1], function() + cancel_all_quick_chat_sessions() + end, { + desc = quick_chat_keymap.cancel.desc or 'Cancel quick chat session', + silent = true, + }) + + active_global_keymaps[quick_chat_keymap.cancel[1]] = true + end +end + --- Helper to clean up session info and spinner ---@param session_info table Session tracking info ---@param session_id string Session ID @@ -43,11 +110,17 @@ local function cleanup_session(session_info, session_id, message) if config.debug.quick_chat and not config.debug.quick_chat.keep_session then state.api_client:delete_session(session_id):catch(function(err) - vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN) + vim.notify('Error deleting quicklchat session: ' .. vim.inspect(err), vim.log.levels.WARN) end) end running_sessions[session_id] = nil + + -- Check if there are no more running sessions and teardown global keymaps + if not next(running_sessions) then + teardown_global_keymaps() + end + if message then vim.notify(message, vim.log.levels.WARN) end @@ -67,7 +140,7 @@ local function extract_response_text(message) return vim.trim(response_text) end ---- Processes response from ephemeral session +--- Processes response from quicklchat session ---@param session_info table Session tracking info ---@param messages OpencodeMessage[] Session messages ---@return boolean success Whether the response was processed successfully @@ -308,7 +381,7 @@ M.quick_chat = Promise.async(function(message, options, range) local quick_chat_session = core.create_new_session(title):await() if not quick_chat_session then spinner:stop() - return Promise.new():reject('Failed to create ephemeral session') + return Promise.new():reject('Failed to create quicklchat session') end if config.debug.quick_chat and config.debug.quick_chat.set_active_session then @@ -323,6 +396,9 @@ M.quick_chat = Promise.async(function(message, options, range) timestamp = vim.uv.now(), } + -- Set up global keymaps for quick chat + setup_global_keymaps() + local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) local context_instance = context.new_instance(context_config) local params = create_message(message, buf, range, context_instance, options):await() @@ -371,6 +447,7 @@ function M.setup() end end running_sessions = {} + teardown_global_keymaps() end, }) end diff --git a/lua/opencode/quick_chat/spinner.lua b/lua/opencode/quick_chat/spinner.lua index 0b57342f..e27988cc 100644 --- a/lua/opencode/quick_chat/spinner.lua +++ b/lua/opencode/quick_chat/spinner.lua @@ -1,19 +1,15 @@ local config = require('opencode.config') local Timer = require('opencode.ui.timer') ----@class Timer ----@field start function ----@field stop function ----@field is_running function - ---@class CursorSpinner ---@field buf integer ---@field row integer ---@field col integer ---@field ns_id integer ---@field extmark_id integer|nil +---@field highlight_extmark_id integer|nil ---@field current_frame integer ----@field timer Timer|nil +---@field timer table|nil ---@field active boolean ---@field frames string[] ---@field float_win integer|nil @@ -28,6 +24,7 @@ function CursorSpinner.new(buf, row, col) self.col = col self.ns_id = vim.api.nvim_create_namespace('opencode_quick_chat_spinner') self.extmark_id = nil + self.highlight_extmark_id = nil self.current_frame = 1 self.timer = nil self.active = true @@ -58,10 +55,21 @@ function CursorSpinner:create_float() vim.api.nvim_set_option_value('wrap', false, { win = self.float_win }) end +function CursorSpinner:get_cancel_key() + local quick_chat_keymap = config.values.keymap.quick_chat or {} + return quick_chat_keymap.cancel and quick_chat_keymap.cancel[1] or '' +end + function CursorSpinner:get_float_config() + local cancel_key = self:get_cancel_key() + local legend = ' ' .. cancel_key .. ' to cancel' + local spinner_width = 3 + local legend_width = #legend + local total_width = spinner_width + legend_width + 1 -- +1 for spacing + return { relative = 'cursor', - width = 3, + width = total_width, height = 1, row = 0, col = 2, -- 2 columns to the right of cursor @@ -77,8 +85,25 @@ function CursorSpinner:render() return end - local frame = ' ' .. self.frames[self.current_frame] .. ' ' - vim.api.nvim_buf_set_lines(self.float_buf, 0, -1, false, { frame }) + local spinner_part = ' ' .. self.frames[self.current_frame] .. ' ' + + local cancel_key = self:get_cancel_key() or '' + local legend_part = cancel_key and ' ' .. cancel_key .. ' to cancel' or '' + + local content = spinner_part .. legend_part + + vim.api.nvim_buf_set_lines(self.float_buf, 0, -1, false, { content }) + + if self.highlight_extmark_id and vim.api.nvim_buf_is_valid(self.float_buf) then + pcall(vim.api.nvim_buf_del_extmark, self.float_buf, self.ns_id, self.highlight_extmark_id) + end + + if vim.api.nvim_buf_is_valid(self.float_buf) then + self.highlight_extmark_id = vim.api.nvim_buf_set_extmark(self.float_buf, self.ns_id, 0, #spinner_part + 1, { + end_col = #spinner_part + 1 + #cancel_key, + hl_group = 'WarningMsg', + }) + end end function CursorSpinner:next_frame() @@ -98,7 +123,9 @@ function CursorSpinner:start_timer() end, repeat_timer = true, }) - self.timer:start() + if self.timer then + self.timer:start() + end end function CursorSpinner:stop() @@ -108,7 +135,7 @@ function CursorSpinner:stop() self.active = false - if self.timer then + if self.timer and self.timer.stop then self.timer:stop() self.timer = nil end @@ -124,6 +151,10 @@ function CursorSpinner:stop() if self.extmark_id and vim.api.nvim_buf_is_valid(self.buf) then pcall(vim.api.nvim_buf_del_extmark, self.buf, self.ns_id, self.extmark_id) end + + if self.highlight_extmark_id and vim.api.nvim_buf_is_valid(self.float_buf or self.buf) then + pcall(vim.api.nvim_buf_del_extmark, self.float_buf or self.buf, self.ns_id, self.highlight_extmark_id) + end end return CursorSpinner diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index bdf12942..7d5f91ca 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -86,6 +86,7 @@ ---@field session_picker OpencodeSessionPickerKeymap ---@field timeline_picker OpencodeTimelinePickerKeymap ---@field history_picker OpencodeHistoryPickerKeymap +---@field quick_chat OpencodeQuickChatKeymap ---@class OpencodeSessionPickerKeymap ---@field delete_session OpencodeKeymapEntry @@ -100,6 +101,9 @@ ---@field delete_entry OpencodeKeymapEntry ---@field clear_all OpencodeKeymapEntry +---@class OpencodeQuickChatKeymap +---@field cancel OpencodeKeymapEntry + ---@class OpencodeCompletionFileSourcesConfig ---@field enabled boolean ---@field preferred_cli_tool 'server'|'fd'|'fdfind'|'rg'|'git' From 6a08f8c9ab6f3c1874af4d22e87d38e2c6a9317f Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 15:20:58 -0500 Subject: [PATCH 18/46] feat: update prompt and configs - Update prompt to better guide Output format - Update cursor context lines from 5 to 10 for better code understanding - Add configs to the readme --- README.md | 153 +++++++++++++++++++++++------------- lua/opencode/config.lua | 2 +- lua/opencode/quick_chat.lua | 27 ++++++- 3 files changed, 124 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 1549515c..0d4409c6 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ require('opencode').setup({ ['opd'] = { 'permission_deny' }, -- Deny permission request once ['ott'] = { 'toggle_tool_output' }, -- Toggle tools output (diffs, cmd output, etc.) ['otr'] = { 'toggle_reasoning_output' }, -- Toggle reasoning output (thinking steps) + ['o/'] = { 'quick_chat', mode = { 'n', 'x' } }, -- Open quick chat input with selection context in visual mode or current line context in normal mode }, input_window = { [''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode) @@ -244,21 +245,41 @@ require('opencode').setup({ enabled = true, -- Enable automatic context capturing cursor_data = { enabled = false, -- Include cursor position and line content in the context + context_lines = 5, -- Number of lines before and after cursor to include in context }, diagnostics = { info = false, -- Include diagnostics info in the context (default to false warn = true, -- Include diagnostics warnings in the context error = true, -- Include diagnostics errors in the context + only_closest = false, -- If true, only diagnostics for cursor/selection }, current_file = { enabled = true, -- Include current file path and content in the context + show_full_path = true, + }, + files = { + enabled = true, + show_full_path = true, }, selection = { enabled = true, -- Include selected text in the context }, + buffer = { + enabled = false, -- Disable entire buffer context by default, only used in quick chat + }, + git_diff = { + enabled = false, + }, }, debug = { enabled = false, -- Enable debug messages in the output window + enabled = false, + capture_streamed_events = false, + show_ids = true, + quick_chat = { + keep_session = false, -- Keep quick_chat sessions for inspection, this can pollute your sessions list + set_active_session = false, + }, }, prompt_guard = nil, -- Optional function that returns boolean to control when prompts can be sent (see Prompt Guard section) @@ -269,6 +290,11 @@ require('opencode').setup({ on_done_thinking = nil, -- Called when opencode finishes thinking (all jobs complete). on_permission_requested = nil, -- Called when a permission request is issued. }, + quick_chat = { + default_model = nil, -- works better with a fast model like gpt-4.1 + default_agent = 'plan', -- plan ensure no file modifications by default + default_prompt = nil, -- Use built-in prompt if nil + }, }) ``` @@ -341,62 +367,61 @@ The plugin provides the following actions that can be triggered via keymaps, com > **Note:** Commands have been restructured into a single `:Opencode` command with subcommands. Legacy `Opencode*` commands (e.g., `:OpencodeOpenInput`) are still available by default but will be removed in a future version. Update your scripts and workflows to use the new nested syntax. -| Action | Default keymap | Command | API Function | -| --------------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | -| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` | -| Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` | -| Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` | -| Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` | -| Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) | -| Rename current session | `oR` | `:Opencode session rename ` | `:Opencode session rename ` (user command) | -| Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` | -| Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` | -| Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` | -| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` | -| Open timeline picker (navigate/undo/redo/fork to message) | `oT` | `:Opencode timeline` | `require('opencode.api').timeline()` | -| Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` | -| Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | -| Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` | -| Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` | -| Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` | -| Close diff view tab | `oc` | `:Opencode diff close` | `require('opencode.api').diff_close()` | -| Revert all file changes since last prompt | `ora` | `:Opencode revert all prompt` | `require('opencode.api').diff_revert_all_last_prompt()` | -| Revert current file changes last prompt | `ort` | `:Opencode revert this prompt` | `require('opencode.api').diff_revert_this_last_prompt()` | -| Revert all file changes since last session | `orA` | `:Opencode revert all session` | `require('opencode.api').diff_revert_all_session()` | -| Revert current file changes last session | `orT` | `:Opencode revert this session` | `require('opencode.api').diff_revert_this_session()` | -| Revert all files to a specific snapshot | - | `:Opencode revert all_to_snapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` | -| Revert current file to a specific snapshot | - | `:Opencode revert this_to_snapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` | -| Restore a file to a restore point | - | `:Opencode restore snapshot_file` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` | -| Restore all files to a restore point | - | `:Opencode restore snapshot_all` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` | -| Initialize/update AGENTS.md file | - | `:Opencode session agents_init` | `require('opencode.api').initialize()` | -| Run prompt (continue session) [Run opts](#run-opts) | - | `:Opencode run ` | `require('opencode.api').run("prompt", opts)` | -| Run prompt (new session) [Run opts](#run-opts) | - | `:Opencode run new_session ` | `require('opencode.api').run_new_session("prompt", opts)` | -| Cancel opencode while it is running | `` | `:Opencode cancel` | `require('opencode.api').cancel()` | -| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` | -| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` | -| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` | -| Display list of availale mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` | -| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` | -| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` | -| Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` | -| Compact current session (summarize) | - | `:Opencode session compact` / `/compact` | `require('opencode.api').compact_session()` | -| Undo last opencode action | - | `:Opencode undo` / `/undo` | `require('opencode.api').undo()` | -| Redo last opencode action | - | `:Opencode redo` / `/redo` | `require('opencode.api').redo()` | -| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:Opencode permission accept` | `require('opencode.api').permission_accept()` | -| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:Opencode permission accept_all` | `require('opencode.api').permission_accept_all()` | -| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:Opencode permission deny` | `require('opencode.api').permission_deny()` | -| Insert mention (file/ agent) | `@` | - | - | -| [Pick a file and add to context](#file-mentions) | `~` | - | - | -| Navigate to next message | `]]` | - | - | -| Navigate to previous message | `[[` | - | - | -| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` | -| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` | -| Toggle input/output panes | `` | - | - | -| Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` | -| Toggle tools output (diffs, cmd output, etc.) | `ott` | `:Opencode toggle_tool_output` | `require('opencode.api').toggle_tool_output()` | -| Toggle reasoning output (thinking steps) | `otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` | - ---- +| Action | Default keymap | Command | API Function | +| ----------------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- | +| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` | +| Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` | +| Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` | +| Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` | +| Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) | +| Rename current session | `oR` | `:Opencode session rename ` | `:Opencode session rename ` (user command) | +| Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` | +| Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` | +| Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` | +| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` | +| Open timeline picker (navigate/undo/redo/fork to message) | `oT` | `:Opencode timeline` | `require('opencode.api').timeline()` | +| Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` | +| Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | +| Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` | +| Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` | +| Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` | +| Close diff view tab | `oc` | `:Opencode diff close` | `require('opencode.api').diff_close()` | +| Revert all file changes since last prompt | `ora` | `:Opencode revert all prompt` | `require('opencode.api').diff_revert_all_last_prompt()` | +| Revert current file changes last prompt | `ort` | `:Opencode revert this prompt` | `require('opencode.api').diff_revert_this_last_prompt()` | +| Revert all file changes since last session | `orA` | `:Opencode revert all session` | `require('opencode.api').diff_revert_all_session()` | +| Revert current file changes last session | `orT` | `:Opencode revert this session` | `require('opencode.api').diff_revert_this_session()` | +| Revert all files to a specific snapshot | - | `:Opencode revert all_to_snapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` | +| Revert current file to a specific snapshot | - | `:Opencode revert this_to_snapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` | +| Restore a file to a restore point | - | `:Opencode restore snapshot_file` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` | +| Restore all files to a restore point | - | `:Opencode restore snapshot_all` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` | +| Initialize/update AGENTS.md file | - | `:Opencode session agents_init` | `require('opencode.api').initialize()` | +| Run prompt (continue session) [Run opts](#run-opts) | - | `:Opencode run ` | `require('opencode.api').run("prompt", opts)` | +| Run prompt (new session) [Run opts](#run-opts) | - | `:Opencode run new_session ` | `require('opencode.api').run_new_session("prompt", opts)` | +| Cancel opencode while it is running | `` | `:Opencode cancel` | `require('opencode.api').cancel()` | +| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` | +| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` | +| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` | +| Display list of availale mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` | +| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` | +| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` | +| Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` | +| Compact current session (summarize) | - | `:Opencode session compact` / `/compact` | `require('opencode.api').compact_session()` | +| Undo last opencode action | - | `:Opencode undo` / `/undo` | `require('opencode.api').undo()` | +| Redo last opencode action | - | `:Opencode redo` / `/redo` | `require('opencode.api').redo()` | +| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:Opencode permission accept` | `require('opencode.api').permission_accept()` | +| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:Opencode permission accept_all` | `require('opencode.api').permission_accept_all()` | +| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:Opencode permission deny` | `require('opencode.api').permission_deny()` | +| Insert mention (file/ agent) | `@` | - | - | +| [Pick a file and add to context](#file-mentions) | `~` | - | - | +| Navigate to next message | `]]` | - | - | +| Navigate to previous message | `[[` | - | - | +| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` | +| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` | +| Toggle input/output panes | `` | - | - | +| Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` | +| Toggle tools output (diffs, cmd output, etc.) | `ott` | `:Opencode toggle_tool_output` | `require('opencode.api').toggle_tool_output()` | +| Toggle reasoning output (thinking steps) | `otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` | +| Open a quick chat input with selection/current line context | `o/` | `:Opencode quick_chat` | `require('opencode.api').quick_chat()` | ### Run opts @@ -658,6 +683,22 @@ require('opencode').setup({ - **No parameters**: The guard function receives no parameters. Access vim state directly (e.g., `vim.fn.getcwd()`, `vim.bo.filetype`). - **Error handling**: If the guard function throws an error or returns a non-boolean value, the prompt is denied with an appropriate error message. +## Quick chat + +Quick chat allows you to start a temporary opencode session with context from the current line or selection. +This is optimized for narrow code edits or insertion. When the request is complex it will and require more context, it is recommended to use the full opencode UI. + +### Starting a quick chat + +Press `o/` in normal mode to open a quick chat input window. + +### Example chat prompts + +- Transform to a lua array +- Add lua annotations +- Write a conventional commit message for my changes #diff +- Fix this warnings #warn + ## 🔧 Setting up Opencode If you're new to opencode: diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index e68f8520..322b628e 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -174,7 +174,7 @@ M.defaults = { enabled = true, cursor_data = { enabled = false, - context_lines = 5, -- Number of lines before and after cursor to include in context + context_lines = 10, -- Number of lines before and after cursor to include in context }, diagnostics = { enabled = true, diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 101114f8..36dba973 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -268,6 +268,7 @@ end ---@return string[] instructions Array of instruction lines local generate_search_replace_instructions = Promise.async(function(context_instance) local base_instructions = { + "You are an expert programming assistant. Modify the user's code according to their instructions using the SEARCH/REPLACE format described below.", 'Output ONLY SEARCH/REPLACE blocks, no explanations:', '<<<<<<< SEARCH', '[exact original code]', @@ -276,6 +277,23 @@ local generate_search_replace_instructions = Promise.async(function(context_inst '>>>>>>> REPLACE', '', 'Rules: Copy SEARCH content exactly. Include 1-3 context lines for unique matching. Use empty SEARCH to insert at cursor. Output multiple blocks only if needed for more complex operations.', + 'Example 1 - Fix function:', + '<<<<<<< SEARCH', + 'function hello() {', + ' console.log("hello")', + '}', + '=======', + 'function hello() {', + ' console.log("hello");', + '}', + '>>>>>>> REPLACE', + '', + 'Example 2 - Insert at cursor:', + '<<<<<<< SEARCH', + '', + '=======', + 'local new_variable = "value"', + '>>>>>>> REPLACE', } local context_guidance = {} @@ -298,6 +316,13 @@ local generate_search_replace_instructions = Promise.async(function(context_inst table.insert(base_instructions, 'Context: ' .. table.concat(context_guidance, ', ') .. '.') end + table.insert(base_instructions, '') + table.insert( + base_instructions, + '*CRITICAL*: Only answer in SEARCH/REPLACE format,NEVER add explanations!, NEVER ask questions!, NEVER add extra text!, NEVER run tools.' + ) + table.insert(base_instructions, '') + return base_instructions end) @@ -330,7 +355,7 @@ local create_message = Promise.async(function(message, buf, range, context_insta { type = 'text', text = result.text }, } - local params = { parts = parts } + local params = { parts = parts, system = table.concat(instructions, '\n') } local current_model = core.initialize_current_model():await() local target_model = options.model or quick_chat_config.default_model or current_model From ca5065dc44d457c35929ad39ee4536c310471fce Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 17 Dec 2025 16:05:15 -0500 Subject: [PATCH 19/46] feat: update docs toi add images of quick chat --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 0d4409c6..d925bde9 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ Opencode logo +## Quick buffer chat + +
+ +
+ > neovim frontend for opencode - a terminal-based AI coding agent
@@ -692,12 +698,20 @@ This is optimized for narrow code edits or insertion. When the request is comple Press `o/` in normal mode to open a quick chat input window. +
+ +
+
+ +
+ ### Example chat prompts - Transform to a lua array - Add lua annotations - Write a conventional commit message for my changes #diff - Fix this warnings #warn +- complete this function ## 🔧 Setting up Opencode From ed7ff03db5f904c80f508ca686937712059d10b3 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 18 Dec 2025 06:23:02 -0500 Subject: [PATCH 20/46] chore:remove test file Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- test.lua | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/test.lua b/test.lua index d3aa88e1..e69de29b 100644 --- a/test.lua +++ b/test.lua @@ -1,41 +0,0 @@ -local summary = { - '✅ Fully Functional and Tested', - 'All unit tests passing', - 'Syntax validation successful', - 'Complete spinner lifecycle management', - 'Robust error handling and cleanup', - 'Ready for production use', -} - ----@param n number The upper limit for the FizzBuzz sequence ----@return table A table containing the FizzBuzz sequence -function fizz_buzz(n) - local result = {} - for i = 1, n do - if i % 15 == 0 then - result[i] = 'FizzBuzz' - elseif i % 3 == 0 then - result[i] = 'Fizz' - elseif i % 5 == 0 then - result[i] = 'Buzz' - else - result[i] = tostring(i) - end - end - return result -end - ----@param n number The number of Fibonacci numbers to generate ----@return table A table containing the Fibonacci sequence -function fibbonacci(n) - if n <= 0 then - return {} - elseif n == 1 then - return { 0 } - end - local seq = { 0, 1 } - for i = 3, n do - seq[i] = seq[i - 1] + seq[i - 2] - end - return seq -end From 6478340390b8748b3bdfa5c61db80d943910f2e6 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 18 Dec 2025 06:53:18 -0500 Subject: [PATCH 21/46] chore: fix typo --- lua/opencode/quick_chat.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 36dba973..28735066 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -24,7 +24,7 @@ local running_sessions = {} ---@type table local active_global_keymaps = {} ---- Creates an quicklchat session title +--- Creates an quickchat session title ---@param buf integer Buffer handle ---@return string title The session title local function create_session_title(buf) @@ -68,7 +68,7 @@ local function cancel_all_quick_chat_sessions() if config.values.debug.quick_chat and not config.values.debug.quick_chat.keep_session then state.api_client:delete_session(session_id):catch(function(err) - vim.notify('Error deleting quicklchat session: ' .. vim.inspect(err), vim.log.levels.WARN) + vim.notify('Error deleting quickchat session: ' .. vim.inspect(err), vim.log.levels.WARN) end) end @@ -110,7 +110,7 @@ local function cleanup_session(session_info, session_id, message) if config.debug.quick_chat and not config.debug.quick_chat.keep_session then state.api_client:delete_session(session_id):catch(function(err) - vim.notify('Error deleting quicklchat session: ' .. vim.inspect(err), vim.log.levels.WARN) + vim.notify('Error deleting quickchat session: ' .. vim.inspect(err), vim.log.levels.WARN) end) end @@ -140,7 +140,7 @@ local function extract_response_text(message) return vim.trim(response_text) end ---- Processes response from quicklchat session +--- Processes response from quickchat session ---@param session_info table Session tracking info ---@param messages OpencodeMessage[] Session messages ---@return boolean success Whether the response was processed successfully @@ -406,7 +406,7 @@ M.quick_chat = Promise.async(function(message, options, range) local quick_chat_session = core.create_new_session(title):await() if not quick_chat_session then spinner:stop() - return Promise.new():reject('Failed to create quicklchat session') + return Promise.new():reject('Failed to create quickchat session') end if config.debug.quick_chat and config.debug.quick_chat.set_active_session then From 7e8eade7cab9459649343405279d867441a1b235 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 18 Dec 2025 06:55:28 -0500 Subject: [PATCH 22/46] fix: typo in readme Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d925bde9..ad8d2fd8 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,7 @@ The plugin provides the following actions that can be triggered via keymaps, com | Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` | | Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` | | Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` | -| Display list of availale mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` | +| Display list of available mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` | | Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` | | Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` | | Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` | From af4e12faf35e5e251411eed06937acd27ee79ead Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 18 Dec 2025 07:02:03 -0500 Subject: [PATCH 23/46] fix(quick_chat): rename default_prompt to instructions --- lua/opencode/config.lua | 2 +- lua/opencode/quick_chat.lua | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 322b628e..e1963c06 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -223,7 +223,7 @@ M.defaults = { quick_chat = { default_model = nil, default_agent = 'plan', -- plan ensure no file modifications by default - default_prompt = nil, -- Use built-in prompt if nil + instructions = nil, -- Use instructions prompt by default }, } diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 28735066..e9e3c2ee 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -336,12 +336,7 @@ end) local create_message = Promise.async(function(message, buf, range, context_instance, options) local quick_chat_config = config.quick_chat or {} - local instructions - if quick_chat_config.instructions then - instructions = quick_chat_config.instructions - else - instructions = generate_search_replace_instructions(context_instance):await() - end + local instructions = quick_chat_config.instructions or generate_search_replace_instructions(context_instance):await() local format_opts = { buf = buf } if range then From cbf6c075ca589954625f9b6935f494df21c8f2cf Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 18 Dec 2025 07:05:24 -0500 Subject: [PATCH 24/46] fix: typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ad8d2fd8..9277955a 100644 --- a/README.md +++ b/README.md @@ -710,7 +710,7 @@ Press `o/` in normal mode to open a quick chat input window. - Transform to a lua array - Add lua annotations - Write a conventional commit message for my changes #diff -- Fix this warnings #warn +- Fix these warnings #warn - complete this function ## 🔧 Setting up Opencode From a421014f05b16c754fbe455e55d4f3e601c253df Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 07:58:11 -0500 Subject: [PATCH 25/46] feat: refactor context system for modularity and quick chat improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactored context system to use static modules (ChatContext, QuickChatContext, BaseContext) for better modularity and testability. - Removed legacy context instance class and JSON/plain-text formatters; now each context type has its own implementation. - Updated quick chat formatting, increased flexibility for selection and diagnostics context, and improved whitespace-tolerant patching. - Improved README: clarified quick chat, moved acknowledgements, added links and instructions for prompt guard. - Enhanced test coverage—especially for flexible whitespace patch application. --- README.md | 59 ++- lua/opencode/api.lua | 10 +- lua/opencode/config.lua | 2 +- lua/opencode/context.lua | 193 +++---- lua/opencode/context/base.lua | 463 ----------------- lua/opencode/context/base_context.lua | 244 +++++++++ lua/opencode/context/chat_context.lua | 473 ++++++++++++++++++ lua/opencode/context/json_formatter.lua | 153 ------ ...t_formatter.lua => quick_chat_context.lua} | 130 ++--- lua/opencode/quick_chat.lua | 62 +-- lua/opencode/quick_chat/search_replace.lua | 110 +++- tests/unit/context_spec.lua | 49 +- tests/unit/keymap_spec.lua | 51 +- tests/unit/search_replace_spec.lua | 242 +++++++++ 14 files changed, 1335 insertions(+), 906 deletions(-) delete mode 100644 lua/opencode/context/base.lua create mode 100644 lua/opencode/context/base_context.lua create mode 100644 lua/opencode/context/chat_context.lua delete mode 100644 lua/opencode/context/json_formatter.lua rename lua/opencode/context/{plain_text_formatter.lua => quick_chat_context.lua} (52%) diff --git a/README.md b/README.md index 9277955a..fcd10e6b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,27 @@ # 🤖 opencode.nvim +> neovim frontend for opencode - a terminal-based AI coding agent + +## Main Features + +### Chat Panel +
Opencode logo
-## Quick buffer chat +### Quick buffer chat (o/) EXPERIMENTAL: + +This is an experimental feature that allows you to chat with the AI using the current buffer context. In visual mode, it captures the selected text as context, while in normal mode, it uses the current line. The AI will respond with quick edits to the files that are applied by the plugin. + +Don't hesitate to give it a try and provide feedback! + +Refer to the [Quick Chat](#-quick-chat) section for more details.
-> neovim frontend for opencode - a terminal-based AI coding agent -
![Neovim](https://img.shields.io/badge/NeoVim-%2357A143.svg?&style=for-the-badge&logo=neovim&logoColor=white) @@ -20,11 +30,6 @@
-## 🙏 Acknowledgements - -This plugin is a fork of the original [goose.nvim](https://github.com/azorng/goose.nvim) plugin by [azorng](https://github.com/azorng/) -For git history purposes the original code is copied instead of just forked. - ## ✨ Description This plugin provides a bridge between neovim and the [opencode](https://github.com/sst/opencode) AI agent, creating a chat interface while capturing editor context (current file, selections) to enhance your prompts. It maintains persistent sessions tied to your workspace, allowing for continuous conversations with the AI assistant similar to what tools like Cursor AI offer. @@ -44,6 +49,8 @@ This plugin provides a bridge between neovim and the [opencode](https://github.c - [Agents](#-agents) - [User Commands](#user-commands) - [Contextual Actions for Snapshots](#-contextual-actions-for-snapshots) +- [Prompt Guard](#-prompt-guard) +- [Quick Chat](#-quick-chat) - [Setting up opencode](#-setting-up-opencode) ## ⚠️Caution @@ -636,6 +643,21 @@ The plugin defines several highlight groups that can be customized to match your The `prompt_guard` configuration option allows you to control when prompts can be sent to Opencode. This is useful for preventing accidental or unauthorized AI interactions in certain contexts. +### Configuration + +Set `prompt_guard` to a function that returns a boolean: + +```lua +require('opencode').setup({ + prompt_guard = function() + -- Your custom logic here + -- Return true to allow, false to deny + return true + end, +}) + +``` + ## 🪝 Custom user hooks You can define custom functions to be called at specific events in Opencode: @@ -668,20 +690,6 @@ require('opencode').setup({ }) ``` -### Configuration - -Set `prompt_guard` to a function that returns a boolean: - -```lua -require('opencode').setup({ - prompt_guard = function() - -- Your custom logic here - -- Return true to allow, false to deny - return true - end, -}) -``` - ### Behavior - **Before sending prompts**: The guard is checked before any prompt is sent to the AI. If denied, an ERROR notification is shown and the prompt is not sent. @@ -694,6 +702,8 @@ require('opencode').setup({ Quick chat allows you to start a temporary opencode session with context from the current line or selection. This is optimized for narrow code edits or insertion. When the request is complex it will and require more context, it is recommended to use the full opencode UI. +Due to the narrow context the resulting may be less accurate and edits may sometime fails. For best results, try to keep the request focused and simple. + ### Starting a quick chat Press `o/` in normal mode to open a quick chat input window. @@ -728,3 +738,8 @@ If you're new to opencode: 3. **Configuration:** - Run `opencode auth login` to set up your LLM provider - Configure your preferred LLM provider and model in the `~/.config/opencode/config.json` or `~/.config/opencode/opencode.json` file + +## 🙏 Acknowledgements + +This plugin is a fork of the original [goose.nvim](https://github.com/azorng/goose.nvim) plugin by [azorng](https://github.com/azorng/) +For git history purposes the original code is copied instead of just forked. diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index e71be00a..8f5f1c0f 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -127,8 +127,8 @@ function M.quick_chat(message, range) if not message or #message == 0 then vim.ui.input({ prompt = 'Quick Chat Message: ', win = { relative = 'cursor' } }, function(input) - local prompt, ctx = util.parse_quick_context_args(input) if input and input ~= '' then + local prompt, ctx = util.parse_quick_context_args(input) quick_chat.quick_chat(prompt, { context_config = ctx }, range) end end) @@ -1051,7 +1051,7 @@ M.commands = { local title = table.concat(vim.list_slice(args, 2), ' ') M.rename_session(state.active_session, title) else - local valid_subcmds = table.concat(M.commands.session.completions, ', ') + local valid_subcmds = table.concat(M.commands.session.completions or {}, ', ') vim.notify('Invalid session subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) end end, @@ -1081,7 +1081,7 @@ M.commands = { elseif subcmd == 'close' then M.diff_close() else - local valid_subcmds = table.concat(M.commands.diff.completions, ', ') + local valid_subcmds = table.concat(M.commands.diff.completions or {}, ', ') vim.notify('Invalid diff subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) end end, @@ -1160,7 +1160,7 @@ M.commands = { elseif subcmd == 'select' then M.select_agent() else - local valid_subcmds = table.concat(M.commands.agent.completions, ', ') + local valid_subcmds = table.concat(M.commands.agent.completions or {}, ', ') vim.notify('Invalid agent subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) end end, @@ -1248,7 +1248,7 @@ M.commands = { elseif subcmd == 'deny' then M.permission_deny() else - local valid_subcmds = table.concat(M.commands.permission.completions, ', ') + local valid_subcmds = table.concat(M.commands.permission.completions or {}, ', ') vim.notify('Invalid permission subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR) end end, diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index e1963c06..2f6fa39e 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -222,7 +222,7 @@ M.defaults = { }, quick_chat = { default_model = nil, - default_agent = 'plan', -- plan ensure no file modifications by default + default_agent = nil, instructions = nil, -- Use instructions prompt by default }, } diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 66524910..911b2bd6 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -1,182 +1,151 @@ --- Gathers editor context --- This module acts as a facade for backward compatibility, --- delegating to the extracted modules in context/ - local util = require('opencode.util') local config = require('opencode.config') local state = require('opencode.state') +local Promise = require('opencode.promise') -local ContextInstance = require('opencode.context.base') -local json_formatter = require('opencode.context.json_formatter') -local plain_text_formatter = require('opencode.context.plain_text_formatter') +local ChatContext = require('opencode.context.chat_context') +local QuickChatContext = require('opencode.context.quick_chat_context') +local BaseContext = require('opencode.context.base_context') local M = {} --- Global context instance ----@type ContextInstance -local global_context = ContextInstance:new() +M.ChatContext = ChatContext +M.QuickChatContext = QuickChatContext + +--- Formats context for main chat interface (new simplified API) +---@param prompt string The user's instruction/prompt +---@param context_config? OpencodeContextConfig Optional context config +---@param opts? { range?: { start: integer, stop: integer } } +---@return table result { parts: OpencodeMessagePart[] } +M.format_chat_message = function(prompt, context_config, opts) + opts = opts or {} + opts.context_config = context_config + return ChatContext.format_message(prompt, opts) +end --- Exposed API ----@type OpencodeContext -M.context = global_context.context +--- Formats context for quick chat interface (new simplified API) +---@param prompt string The user's instruction/prompt +---@param context_config? OpencodeContextConfig Optional context config +---@param opts? { range?: { start: integer, stop: integer } } +---@return table result { text: string, parts: OpencodeMessagePart[] } +M.format_quick_chat_message = function(prompt, context_config, opts) + opts = opts or {} + opts.context_config = context_config + return QuickChatContext.format_message(prompt, opts) +end ---- Creates a new independent context instance ----@param context_config? OpencodeContextConfig Optional context config to override global config ----@return ContextInstance -function M.new_instance(context_config) - return ContextInstance:new(context_config) +function M.get_current_buf() + return BaseContext.get_current_buf() end -function M.unload_attachments() - global_context:unload_attachments() +function M.is_context_enabled(context_key, context_config) + return BaseContext.is_context_enabled(context_key, context_config) end -function M.get_current_buf() - return global_context:get_current_buf() +function M.get_diagnostics(buf, context_config, range) + return BaseContext.get_diagnostics(buf, context_config, range) end -function M.load() - global_context:load() - state.context_updated_at = vim.uv.now() +function M.get_current_file(buf, context_config) + return BaseContext.get_current_file(buf, context_config) end -function M.is_context_enabled(context_key) - return global_context:is_context_enabled(context_key) +function M.get_current_cursor_data(buf, win, context_config) + return BaseContext.get_current_cursor_data(buf, win, context_config) end -function M.get_diagnostics(buf) - return global_context:get_diagnostics(buf) +function M.get_current_selection(context_config) + return BaseContext.get_current_selection(context_config) end function M.new_selection(file, content, lines) - return global_context:new_selection(file, content, lines) + return BaseContext.new_selection(file, content, lines) end +-- Delegate global state management to ChatContext function M.add_selection(selection) - global_context:add_selection(selection) + ChatContext.add_selection(selection) state.context_updated_at = vim.uv.now() end function M.remove_selection(selection) - global_context:remove_selection(selection) + ChatContext.remove_selection(selection) state.context_updated_at = vim.uv.now() end function M.clear_selections() - global_context:clear_selections() + ChatContext.clear_selections() end function M.add_file(file) - global_context:add_file(file) + ChatContext.context.mentioned_files = ChatContext.context.mentioned_files or {} + + local is_file = vim.fn.filereadable(file) == 1 + local is_dir = vim.fn.isdirectory(file) == 1 + if not is_file and not is_dir then + vim.notify('File not added to context. Could not read.') + return + end + + if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then + vim.notify('File not added to context. Must be inside current working directory.') + return + end + + file = vim.fn.fnamemodify(file, ':p') + ChatContext.add_file(file) state.context_updated_at = vim.uv.now() end function M.remove_file(file) - global_context:remove_file(file) + file = vim.fn.fnamemodify(file, ':p') + ChatContext.remove_file(file) state.context_updated_at = vim.uv.now() end function M.clear_files() - global_context:clear_files() + ChatContext.clear_files() end function M.add_subagent(subagent) - global_context:add_subagent(subagent) + ChatContext.add_subagent(subagent) state.context_updated_at = vim.uv.now() end function M.remove_subagent(subagent) - global_context:remove_subagent(subagent) + ChatContext.remove_subagent(subagent) state.context_updated_at = vim.uv.now() end function M.clear_subagents() - global_context:clear_subagents() -end - -function M.delta_context(opts) - local context = global_context:delta_context(opts) - local last_context = state.last_sent_context - if not last_context then - return context - end - - -- no need to send file context again - if - context.current_file - and last_context.current_file - and context.current_file.name == last_context.current_file.name - then - context.current_file = nil - end - - -- no need to send subagents again - if - context.mentioned_subagents - and last_context.mentioned_subagents - and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents) - then - context.mentioned_subagents = nil - end - - return context + ChatContext.clear_subagents() end -function M.get_current_file(buf) - return global_context:get_current_file(buf) +function M.unload_attachments() + ChatContext.clear_files() + ChatContext.clear_selections() end -function M.get_current_cursor_data(buf, win) - return global_context:get_current_cursor_data(buf, win) +function M.load() + -- Delegate to ChatContext which manages the global state + ChatContext.load() + state.context_updated_at = vim.uv.now() end -function M.get_current_selection() - return global_context:get_current_selection() +-- Context creation with delta logic (delegates to ChatContext) +function M.delta_context(opts) + return ChatContext.delta_context(opts) end -M.format_message_plain_text = plain_text_formatter.format_message +M.context = ChatContext.context ---- Formats a prompt and context into message with parts for the opencode API ---@param prompt string ---@param opts? OpencodeContextConfig|nil ---@return OpencodeMessagePart[] -function M.format_message(prompt, opts) - opts = opts or config.context - local context = M.delta_context(opts) - - local parts = { { type = 'text', text = prompt } } - - for _, path in ipairs(context.mentioned_files or {}) do - -- don't resend current file if it's also mentioned - if not context.current_file or path ~= context.current_file.path then - table.insert(parts, json_formatter.format_file_part(path, prompt)) - end - end - - for _, sel in ipairs(context.selections or {}) do - table.insert(parts, json_formatter.format_selection_part(sel)) - end - - for _, agent in ipairs(context.mentioned_subagents or {}) do - table.insert(parts, json_formatter.format_subagents_part(agent, prompt)) - end - - if context.current_file then - table.insert(parts, json_formatter.format_file_part(context.current_file.path)) - end - - if context.linter_errors and #context.linter_errors > 0 then - table.insert(parts, json_formatter.format_diagnostics_part(context.linter_errors)) - end - - if context.cursor_data then - table.insert(parts, json_formatter.format_cursor_data_part(context.cursor_data, M.get_current_buf)) - end - - return parts -end - -M.format_message_quick_chat = plain_text_formatter.format_message +M.format_message = Promise.async(function(prompt, opts) + local result = ChatContext.format_message(prompt, { context_config = opts }):await() + return result.parts +end) ---@param text string ---@param context_type string|nil diff --git a/lua/opencode/context/base.lua b/lua/opencode/context/base.lua deleted file mode 100644 index 4ded0a24..00000000 --- a/lua/opencode/context/base.lua +++ /dev/null @@ -1,463 +0,0 @@ --- Base class for context gathering --- Handles collecting editor context (files, selections, diagnostics, cursor, etc.) - -local util = require('opencode.util') -local config = require('opencode.config') -local state = require('opencode.state') -local Promise = require('opencode.promise') - ----@class ContextInstance ----@field context OpencodeContext ----@field last_context OpencodeContext|nil ----@field context_config OpencodeContextConfig|nil Optional context config override -local ContextInstance = {} -ContextInstance.__index = ContextInstance - ---- Creates a new Context instance ----@param context_config? OpencodeContextConfig Optional context config to override global config ----@return ContextInstance -function ContextInstance:new(context_config) - local instance = setmetatable({}, self) - instance.context = { - -- current file - current_file = nil, - cursor_data = nil, - - -- attachments - mentioned_files = nil, - selections = {}, - linter_errors = {}, - mentioned_subagents = {}, - } - instance.last_context = nil - instance.context_config = context_config - return instance -end - -function ContextInstance:unload_attachments() - self.context.mentioned_files = nil - self.context.selections = nil - self.context.linter_errors = nil -end - ----@return integer|nil, integer|nil -function ContextInstance:get_current_buf() - local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() - if util.is_buf_a_file(curr_buf) then - local win = vim.fn.win_findbuf(curr_buf --[[@as integer]])[1] - return curr_buf, state.last_code_win_before_opencode or win or vim.api.nvim_get_current_win() - end -end - -function ContextInstance:load() - local buf, win = self:get_current_buf() - - if buf then - local current_file = self:get_current_file(buf) - local cursor_data = self:get_current_cursor_data(buf, win) - - self.context.current_file = current_file - self.context.cursor_data = cursor_data - self.context.linter_errors = self:get_diagnostics(buf) - end - - local current_selection = self:get_current_selection() - if current_selection then - local selection = self:new_selection(self.context.current_file, current_selection.text, current_selection.lines) - self:add_selection(selection) - end -end - -function ContextInstance:is_enabled() - if self.context_config and self.context_config.enabled ~= nil then - return self.context_config.enabled - end - - local is_enabled = vim.tbl_get(config --[[@as table]], 'context', 'enabled') - local is_state_enabled = vim.tbl_get(state, 'current_context_config', 'enabled') - if is_state_enabled ~= nil then - return is_state_enabled - else - return is_enabled - end -end - --- Checks if a context feature is enabled in config or state ----@param context_key string ----@return boolean -function ContextInstance:is_context_enabled(context_key) - -- If instance has a context config, use it as the override - if self.context_config then - local override_enabled = vim.tbl_get(self.context_config, context_key, 'enabled') - if override_enabled ~= nil then - return override_enabled - end - end - - -- Fall back to the existing logic (state then global config) - local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') - local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') - - if is_state_enabled ~= nil then - return is_state_enabled - else - return is_enabled - end -end - ----@return OpencodeDiagnostic[]|nil -function ContextInstance:get_diagnostics(buf) - if not self:is_context_enabled('diagnostics') then - return nil - end - - local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} - if current_conf.enabled == false then - return {} - end - - local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} - local override_conf = self.context_config and vim.tbl_get(self.context_config, 'diagnostics') or {} - local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf, override_conf) or {} - - local severity_levels = {} - if diagnostic_conf.error then - table.insert(severity_levels, vim.diagnostic.severity.ERROR) - end - if diagnostic_conf.warning then - table.insert(severity_levels, vim.diagnostic.severity.WARN) - end - if diagnostic_conf.info then - table.insert(severity_levels, vim.diagnostic.severity.INFO) - end - - local diagnostics = {} - if diagnostic_conf.only_closest then - local selections = self:get_selections() - if #selections > 0 then - local selection = selections[#selections] - if selection and selection.lines then - local range_parts = vim.split(selection.lines, ',') - local start_line = (tonumber(range_parts[1]) or 1) - 1 - local end_line = (tonumber(range_parts[2]) or 1) - 1 - for lnum = start_line, end_line do - local line_diagnostics = vim.diagnostic.get(buf, { - lnum = lnum, - severity = severity_levels, - }) - for _, diag in ipairs(line_diagnostics) do - table.insert(diagnostics, diag) - end - end - end - else - local win = vim.fn.win_findbuf(buf)[1] - local cursor_pos = vim.fn.getcurpos(win) - local line_diagnostics = vim.diagnostic.get(buf, { - lnum = cursor_pos[2] - 1, - severity = severity_levels, - }) - diagnostics = line_diagnostics - end - else - diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) - end - - if #diagnostics == 0 then - return {} - end - - local opencode_diagnostics = {} - for _, diag in ipairs(diagnostics) do - table.insert(opencode_diagnostics, { - message = diag.message, - severity = diag.severity, - lnum = diag.lnum, - col = diag.col, - end_lnum = diag.end_lnum, - end_col = diag.end_col, - source = diag.source, - code = diag.code, - user_data = diag.user_data, - }) - end - - return opencode_diagnostics -end - -function ContextInstance:new_selection(file, content, lines) - return { - file = file, - content = util.indent_code_block(content), - lines = lines, - } -end - -function ContextInstance:add_selection(selection) - if not self.context.selections then - self.context.selections = {} - end - - table.insert(self.context.selections, selection) -end - -function ContextInstance:remove_selection(selection) - if not self.context.selections then - return - end - - for i, sel in ipairs(self.context.selections) do - if sel.file.path == selection.file.path and sel.lines == selection.lines then - table.remove(self.context.selections, i) - break - end - end -end - -function ContextInstance:clear_selections() - self.context.selections = nil -end - -function ContextInstance:add_file(file) - if not self.context.mentioned_files then - self.context.mentioned_files = {} - end - - local is_file = vim.fn.filereadable(file) == 1 - local is_dir = vim.fn.isdirectory(file) == 1 - if not is_file and not is_dir then - vim.notify('File not added to context. Could not read.') - return - end - - if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then - vim.notify('File not added to context. Must be inside current working directory.') - return - end - - file = vim.fn.fnamemodify(file, ':p') - - if not vim.tbl_contains(self.context.mentioned_files, file) then - table.insert(self.context.mentioned_files, file) - end -end - -function ContextInstance:remove_file(file) - file = vim.fn.fnamemodify(file, ':p') - if not self.context.mentioned_files then - return - end - - for i, f in ipairs(self.context.mentioned_files) do - if f == file then - table.remove(self.context.mentioned_files, i) - break - end - end -end - -function ContextInstance:clear_files() - self.context.mentioned_files = nil -end - -function ContextInstance:get_mentioned_files() - return self.context.mentioned_files or {} -end - -function ContextInstance:add_subagent(subagent) - if not self.context.mentioned_subagents then - self.context.mentioned_subagents = {} - end - - if not vim.tbl_contains(self.context.mentioned_subagents, subagent) then - table.insert(self.context.mentioned_subagents, subagent) - end -end - -function ContextInstance:remove_subagent(subagent) - if not self.context.mentioned_subagents then - return - end - - for i, a in ipairs(self.context.mentioned_subagents) do - if a == subagent then - table.remove(self.context.mentioned_subagents, i) - break - end - end -end - -function ContextInstance:clear_subagents() - self.context.mentioned_subagents = nil -end - -function ContextInstance:get_mentioned_subagents() - if not self:is_context_enabled('agents') then - return nil - end - return self.context.mentioned_subagents or {} -end - -function ContextInstance:get_current_file(buf) - if not self:is_context_enabled('current_file') then - return nil - end - local file = vim.api.nvim_buf_get_name(buf) - if not file or file == '' or vim.fn.filereadable(file) ~= 1 then - return nil - end - return { - path = file, - name = vim.fn.fnamemodify(file, ':t'), - extension = vim.fn.fnamemodify(file, ':e'), - } -end - -function ContextInstance:get_current_cursor_data(buf, win) - if not self:is_context_enabled('cursor_data') then - return nil - end - - local num_lines = config.context.cursor_data.context_lines --[[@as integer]] - or 0 - local cursor_pos = vim.fn.getcurpos(win) - local start_line = (cursor_pos[2] - 1) --[[@as integer]] - local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') - local lines_before = vim.api.nvim_buf_get_lines(buf, math.max(0, start_line - num_lines), start_line, false) - local lines_after = vim.api.nvim_buf_get_lines(buf, cursor_pos[2], cursor_pos[2] + num_lines, false) - return { - line = cursor_pos[2], - column = cursor_pos[3], - line_content = cursor_content, - lines_before = lines_before, - lines_after = lines_after, - } -end - -function ContextInstance:get_current_selection() - if not self:is_context_enabled('selection') then - return nil - end - - -- Return nil if not in a visual mode - if not vim.fn.mode():match('[vV\022]') then - return nil - end - - -- Save current position and register state - local current_pos = vim.fn.getpos('.') - local old_reg = vim.fn.getreg('x') - local old_regtype = vim.fn.getregtype('x') - - -- Capture selection text and position - vim.cmd('normal! "xy') - local text = vim.fn.getreg('x') - - -- Get line numbers - vim.cmd('normal! `<') - local start_line = vim.fn.line('.') - vim.cmd('normal! `>') - local end_line = vim.fn.line('.') - - -- Restore state - vim.fn.setreg('x', old_reg, old_regtype) - vim.cmd('normal! gv') - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true) - vim.fn.setpos('.', current_pos) - - if not text or text == '' then - return nil - end - - return { - text = text and text:match('[^%s]') and text or nil, - lines = start_line .. ', ' .. end_line, - } -end - -ContextInstance.has = Promise.async(function(self, context_type) - if context_type == 'file' then - return self:get_mentioned_files() and #self:get_mentioned_files() > 0 - elseif context_type == 'selection' then - return self:get_selections() and #self:get_selections() > 0 - elseif context_type == 'subagent' then - return self:get_mentioned_subagents() and #self:get_mentioned_subagents() > 0 - elseif context_type == 'diagnostics' then - return self.context.linter_errors and #self.context.linter_errors > 0 - elseif context_type == 'git_diff' then - local git_diff = Promise.await(self:get_git_diff()) - return git_diff ~= nil - elseif context_type == 'current_file' then - return self.context.current_file ~= nil - elseif context_type == 'cursor_data' then - return self.context.cursor_data ~= nil - end - - return false -end) - -function ContextInstance:get_selections() - if not self:is_context_enabled('selection') then - return {} - end - return self.context.selections or {} -end - -ContextInstance.get_git_diff = Promise.async(function(self) - if not self:is_context_enabled('git_diff') then - return nil - end - - return Promise.system({ 'git', 'diff', '--cached' }):and_then(function(output) - if output == '' then - return nil - end - return output.stdout - end) -end) - ----@param opts? OpencodeContextConfig ----@return OpencodeContext -function ContextInstance:delta_context(opts) - opts = opts or config.context - if opts.enabled == false then - return { - current_file = nil, - mentioned_files = nil, - selections = nil, - linter_errors = nil, - cursor_data = nil, - mentioned_subagents = nil, - } - end - - local ctx = vim.deepcopy(self.context) - local last_context = self.last_context - if not last_context then - return ctx - end - - -- no need to send file context again - if ctx.current_file and last_context.current_file and ctx.current_file.name == last_context.current_file.name then - ctx.current_file = nil - end - - -- no need to send subagents again - if - ctx.mentioned_subagents - and last_context.mentioned_subagents - and vim.deep_equal(ctx.mentioned_subagents, last_context.mentioned_subagents) - then - ctx.mentioned_subagents = nil - end - - return ctx -end - ---- Set the last context (used for delta calculations) ----@param last_context OpencodeContext -function ContextInstance:set_last_context(last_context) - self.last_context = last_context -end - -return ContextInstance diff --git a/lua/opencode/context/base_context.lua b/lua/opencode/context/base_context.lua new file mode 100644 index 00000000..56d662d5 --- /dev/null +++ b/lua/opencode/context/base_context.lua @@ -0,0 +1,244 @@ +-- Base context utilities +-- Static methods for gathering context data from the editor + +local util = require('opencode.util') +local config = require('opencode.config') +local state = require('opencode.state') +local Promise = require('opencode.promise') + +local M = {} + +---@return integer|nil, integer|nil +function M.get_current_buf() + local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() + if util.is_buf_a_file(curr_buf) then + local win = vim.fn.win_findbuf(curr_buf --[[@as integer]])[1] + return curr_buf, state.last_code_win_before_opencode or win or vim.api.nvim_get_current_win() + end +end + +-- Checks if a context feature is enabled in config or state +---@param context_key string +---@param context_config? OpencodeContextConfig +---@return boolean +function M.is_context_enabled(context_key, context_config) + if context_config then + local override_enabled = vim.tbl_get(context_config, context_key, 'enabled') + if override_enabled ~= nil then + return override_enabled + end + end + + local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') + local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') + + if is_state_enabled ~= nil then + return is_state_enabled + else + return is_enabled + end +end + +---@param buf integer +---@param context_config? OpencodeContextConfig +---@param range? { start_line: integer, end_line: integer } +---@return OpencodeDiagnostic[]|nil +function M.get_diagnostics(buf, context_config, range) + if not M.is_context_enabled('diagnostics', context_config) then + return nil + end + + local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} + if current_conf.enabled == false then + return {} + end + + local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} + local override_conf = context_config and vim.tbl_get(context_config, 'diagnostics') or {} + local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf, override_conf) or {} + + local severity_levels = {} + if diagnostic_conf.error then + table.insert(severity_levels, vim.diagnostic.severity.ERROR) + end + if diagnostic_conf.warning then + table.insert(severity_levels, vim.diagnostic.severity.WARN) + end + if diagnostic_conf.info then + table.insert(severity_levels, vim.diagnostic.severity.INFO) + end + + local diagnostics = {} + if diagnostic_conf.only_closest then + if range then + -- Get diagnostics for the specified range + for line_num = range.start_line, range.end_line do + local line_diagnostics = vim.diagnostic.get(buf, { + lnum = line_num, + severity = severity_levels, + }) + for _, diag in ipairs(line_diagnostics) do + table.insert(diagnostics, diag) + end + end + else + -- Get diagnostics for current cursor line only + local win = vim.fn.win_findbuf(buf)[1] + local cursor_pos = vim.fn.getcurpos(win) + local line_diagnostics = vim.diagnostic.get(buf, { + lnum = cursor_pos[2] - 1, + severity = severity_levels, + }) + diagnostics = line_diagnostics + end + else + -- Get all diagnostics, optionally filtered by range + diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) + if range then + local filtered_diagnostics = {} + for _, diag in ipairs(diagnostics) do + if diag.lnum >= range.start_line and diag.lnum <= range.end_line then + table.insert(filtered_diagnostics, diag) + end + end + diagnostics = filtered_diagnostics + end + end + + if #diagnostics == 0 then + return {} + end + + local opencode_diagnostics = {} + for _, diag in ipairs(diagnostics) do + table.insert(opencode_diagnostics, { + message = diag.message, + severity = diag.severity, + lnum = diag.lnum, + col = diag.col, + end_lnum = diag.end_lnum, + end_col = diag.end_col, + source = diag.source, + code = diag.code, + user_data = diag.user_data, + }) + end + + return opencode_diagnostics +end + +---@param buf integer +---@param context_config? OpencodeContextConfig +---@return table|nil +function M.get_current_file(buf, context_config) + if not M.is_context_enabled('current_file', context_config) then + return nil + end + local file = vim.api.nvim_buf_get_name(buf) + if not file or file == '' or vim.fn.filereadable(file) ~= 1 then + return nil + end + return { + path = file, + name = vim.fn.fnamemodify(file, ':t'), + extension = vim.fn.fnamemodify(file, ':e'), + } +end + +---@param buf integer +---@param win integer +---@param context_config? OpencodeContextConfig +---@return table|nil +function M.get_current_cursor_data(buf, win, context_config) + if not M.is_context_enabled('cursor_data', context_config) then + return nil + end + + local num_lines = config.context.cursor_data.context_lines --[[@as integer]] + or 0 + local cursor_pos = vim.fn.getcurpos(win) + local start_line = (cursor_pos[2] - 1) --[[@as integer]] + local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') + local lines_before = vim.api.nvim_buf_get_lines(buf, math.max(0, start_line - num_lines), start_line, false) + local lines_after = vim.api.nvim_buf_get_lines(buf, cursor_pos[2], cursor_pos[2] + num_lines, false) + return { + line = cursor_pos[2], + column = cursor_pos[3], + line_content = cursor_content, + lines_before = lines_before, + lines_after = lines_after, + } +end + +---@param context_config? OpencodeContextConfig +---@return table|nil +function M.get_current_selection(context_config) + if not M.is_context_enabled('selection', context_config) then + return nil + end + + -- Return nil if not in a visual mode + if not vim.fn.mode():match('[vV\022]') then + return nil + end + + -- Save current position and register state + local current_pos = vim.fn.getpos('.') + local old_reg = vim.fn.getreg('x') + local old_regtype = vim.fn.getregtype('x') + + -- Capture selection text and position + vim.cmd('normal! "xy') + local text = vim.fn.getreg('x') + + -- Get line numbers + vim.cmd('normal! `<') + local start_line = vim.fn.line('.') + vim.cmd('normal! `>') + local end_line = vim.fn.line('.') + + -- Restore state + vim.fn.setreg('x', old_reg, old_regtype) + vim.cmd('normal! gv') + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true) + vim.fn.setpos('.', current_pos) + + if not text or text == '' then + return nil + end + + return { + text = text and text:match('[^%s]') and text or nil, + lines = start_line .. ', ' .. end_line, + } +end + +---@param context_config? OpencodeContextConfig +---@return Promise +M.get_git_diff = Promise.async(function(context_config) + if not M.is_context_enabled('git_diff', context_config) then + return nil + end + + return Promise.system({ 'git', 'diff', '--cached' }):and_then(function(output) + if output == '' then + return nil + end + return output.stdout + end) +end) + +---@param file table +---@param content string +---@param lines string +---@return table +function M.new_selection(file, content, lines) + return { + file = file, + content = util.indent_code_block(content), + lines = lines, + } +end + +return M + diff --git a/lua/opencode/context/chat_context.lua b/lua/opencode/context/chat_context.lua new file mode 100644 index 00000000..a40ab126 --- /dev/null +++ b/lua/opencode/context/chat_context.lua @@ -0,0 +1,473 @@ +local base_context = require('opencode.context.base_context') +local util = require('opencode.util') +local state = require('opencode.state') +local Promise = require('opencode.promise') + +local M = {} + +M.context = { + mentioned_files = {}, + selections = {}, + mentioned_subagents = {}, + current_file = nil, + cursor_data = nil, + linter_errors = nil, +} + +---@param path string +---@param prompt? string +---@return OpencodeMessagePart +local function format_file_part(path, prompt) + local rel_path = vim.fn.fnamemodify(path, ':~:.') + local mention = '@' .. rel_path + local pos = prompt and prompt:find(mention) + pos = pos and pos - 1 or 0 -- convert to 0-based index + + local ext = vim.fn.fnamemodify(path, ':e'):lower() + local mime_type = 'text/plain' + if ext == 'png' then + mime_type = 'image/png' + elseif ext == 'jpg' or ext == 'jpeg' then + mime_type = 'image/jpeg' + elseif ext == 'gif' then + mime_type = 'image/gif' + elseif ext == 'webp' then + mime_type = 'image/webp' + end + + local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path } + if prompt then + file_part.source = { + path = path, + type = 'file', + text = { start = pos, value = mention, ['end'] = pos + #mention }, + } + end + return file_part +end + +---@param selection OpencodeContextSelection +---@return OpencodeMessagePart +local function format_selection_part(selection) + local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' + + return { + type = 'text', + metadata = { + context_type = 'selection', + }, + text = vim.json.encode({ + context_type = 'selection', + file = selection.file, + content = string.format('`````%s\n%s\n`````', lang, selection.content), + lines = selection.lines, + }), + synthetic = true, + } +end + +---@param diagnostics OpencodeDiagnostic[] +---@param range? { start_line: integer, end_line: integer }|nil +---@return OpencodeMessagePart +local function format_diagnostics_part(diagnostics, range) + local diag_list = {} + for _, diag in ipairs(diagnostics) do + if not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line) then + local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') + table.insert( + diag_list, + { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } + ) + end + end + return { + type = 'text', + metadata = { + context_type = 'diagnostics', + }, + text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }), + synthetic = true, + } +end + +---@param cursor_data table +---@param get_current_buf fun(): integer|nil Function to get current buffer +---@return OpencodeMessagePart +local function format_cursor_data_part(cursor_data, get_current_buf) + local buf = (get_current_buf() or 0) --[[@as integer]] + local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or '' + return { + type = 'text', + metadata = { + context_type = 'cursor-data', + lang = lang, + }, + text = vim.json.encode({ + context_type = 'cursor-data', + line = cursor_data.line, + column = cursor_data.column, + line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), + lines_before = cursor_data.lines_before, + lines_after = cursor_data.lines_after, + }), + synthetic = true, + } +end + +---@param agent string +---@param prompt string +---@return OpencodeMessagePart +local function format_subagents_part(agent, prompt) + local mention = '@' .. agent + local pos = prompt:find(mention) + pos = pos and pos - 1 or 0 -- convert to 0-based index + + return { + type = 'agent', + name = agent, + source = { value = mention, start = pos, ['end'] = pos + #mention }, + } +end + +---@param buf integer +---@return OpencodeMessagePart +local function format_buffer_part(buf) + local file = vim.api.nvim_buf_get_name(buf) + local rel_path = vim.fn.fnamemodify(file, ':~:.') + return { + type = 'text', + text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n'), + metadata = { + context_type = 'file-content', + filename = rel_path, + mime = 'text/plain', + }, + synthetic = true, + } +end + +---@param diff_text string +---@return OpencodeMessagePart +local function format_git_diff_part(diff_text) + return { + type = 'text', + metadata = { + context_type = 'git-diff', + }, + text = diff_text, + synthetic = true, + } +end + +-- Global context management functions + +function M.add_selection(selection) + -- Ensure selections is always a table + if not M.context.selections then + M.context.selections = {} + end + + table.insert(M.context.selections, selection) + state.context_updated_at = vim.uv.now() +end + +function M.remove_selection(selection) + if not M.context.selections then + M.context.selections = {} + return + end + + for i, sel in ipairs(M.context.selections) do + if sel.file.path == selection.file.path and sel.lines == selection.lines then + table.remove(M.context.selections, i) + break + end + end + state.context_updated_at = vim.uv.now() +end + +function M.clear_selections() + M.context.selections = {} +end + +function M.add_file(file) + local is_file = vim.fn.filereadable(file) == 1 + local is_dir = vim.fn.isdirectory(file) == 1 + if not is_file and not is_dir then + vim.notify('File not added to context. Could not read.') + return + end + + if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then + vim.notify('File not added to context. Must be inside current working directory.') + return + end + + file = vim.fn.fnamemodify(file, ':p') + + if not M.context.mentioned_files then + M.context.mentioned_files = {} + end + + if not vim.tbl_contains(M.context.mentioned_files, file) then + table.insert(M.context.mentioned_files, file) + end + state.context_updated_at = vim.uv.now() +end + +function M.remove_file(file) + if not M.context.mentioned_files then + M.context.mentioned_files = {} + return + end + + file = vim.fn.fnamemodify(file, ':p') + for i, f in ipairs(M.context.mentioned_files) do + if f == file then + table.remove(M.context.mentioned_files, i) + break + end + end + state.context_updated_at = vim.uv.now() +end + +function M.clear_files() + M.context.mentioned_files = {} +end + +function M.add_subagent(subagent) + -- Ensure mentioned_subagents is always a table + if not M.context.mentioned_subagents then + M.context.mentioned_subagents = {} + end + + if not vim.tbl_contains(M.context.mentioned_subagents, subagent) then + table.insert(M.context.mentioned_subagents, subagent) + end + state.context_updated_at = vim.uv.now() +end + +function M.remove_subagent(subagent) + if not M.context.mentioned_subagents then + M.context.mentioned_subagents = {} + return + end + + for i, a in ipairs(M.context.mentioned_subagents) do + if a == subagent then + table.remove(M.context.mentioned_subagents, i) + break + end + end + state.context_updated_at = vim.uv.now() +end + +function M.clear_subagents() + M.context.mentioned_subagents = {} +end + +function M.unload_attachments() + M.context.mentioned_files = {} + M.context.selections = {} +end + +function M.get_mentioned_files() + return M.context.mentioned_files or {} +end + +function M.get_selections() + return M.context.selections or {} +end + +function M.get_mentioned_subagents() + return M.context.mentioned_subagents or {} +end + +-- Load function that populates the global context state +-- This is the core loading logic that was originally in the main context module +function M.load() + local buf, win = base_context.get_current_buf() + + if buf then + local current_file = base_context.get_current_file(buf) + local cursor_data = base_context.get_current_cursor_data(buf, win) + + M.context.current_file = current_file + M.context.cursor_data = cursor_data + M.context.linter_errors = base_context.get_diagnostics(buf, nil, nil) + end + + local current_selection = base_context.get_current_selection() + if current_selection and M.context.current_file then + local selection = + base_context.new_selection(M.context.current_file, current_selection.text, current_selection.lines) + M.add_selection(selection) + end +end + +-- This function creates a context snapshot with delta logic against the last sent context +function M.delta_context(opts) + local config = require('opencode.config') + local state = require('opencode.state') + + opts = opts or config.context + if opts.enabled == false then + return { + current_file = nil, + mentioned_files = nil, + selections = nil, + linter_errors = nil, + cursor_data = nil, + mentioned_subagents = nil, + } + end + + local buf, win = base_context.get_current_buf() + if not buf or not win then + return {} + end + + local ctx = { + current_file = base_context.get_current_file(buf, opts), + cursor_data = base_context.get_current_cursor_data(buf, win, opts), + mentioned_files = M.context.mentioned_files or {}, + selections = M.context.selections or {}, + linter_errors = base_context.get_diagnostics(buf, opts, nil), + mentioned_subagents = M.context.mentioned_subagents or {}, + } + + -- Delta logic against last sent context + local last_context = state.last_sent_context + if last_context then + -- no need to send file context again + if ctx.current_file and last_context.current_file and ctx.current_file.name == last_context.current_file.name then + ctx.current_file = nil + end + + -- no need to send subagents again + if + ctx.mentioned_subagents + and last_context.mentioned_subagents + and vim.deep_equal(ctx.mentioned_subagents, last_context.mentioned_subagents) + then + ctx.mentioned_subagents = nil + end + end + + return ctx +end + +--- Formats context as structured message parts for the main chat interface +--- This is the main function that includes global state (mentioned files, selections, etc.) +---@param prompt string The user's instruction/prompt +---@param opts? { range?: { start: integer, stop: integer }, context_config?: OpencodeContextConfig } +---@return table result { parts: OpencodeMessagePart[] } +M.format_message = Promise.async(function(prompt, opts) + opts = opts or {} + local context_config = opts.context_config + local buf, win = base_context.get_current_buf() + local range = opts.range + local parts = {} + + -- Add mentioned files from global state (always process, even without buffer) + for _, file_path in ipairs(M.context.mentioned_files or {}) do + table.insert(parts, format_file_part(file_path, prompt)) + end + + -- Add mentioned subagents from global state (always process, even without buffer) + for _, agent in ipairs(M.context.mentioned_subagents or {}) do + table.insert(parts, format_subagents_part(agent, prompt)) + end + + if not buf or not win then + -- Add the main prompt + table.insert(parts, { type = 'text', text = prompt }) + return { parts = parts } + end + + -- Add selections (both from range and global state) + if base_context.is_context_enabled('selection', context_config) then + local selections = {} + + -- Add range selection if specified + if range and range.start and range.stop then + local file = base_context.get_current_file(buf, context_config) + if file then + local selection = base_context.new_selection( + file, + table.concat(vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false), '\n'), + string.format('%d-%d', range.start, range.stop) + ) + table.insert(selections, selection) + end + end + + -- Add current visual selection if available + local current_selection = base_context.get_current_selection(context_config) + if current_selection then + local file = base_context.get_current_file(buf, context_config) + if file then + local selection = base_context.new_selection(file, current_selection.text, current_selection.lines) + table.insert(selections, selection) + end + end + + -- Add selections from global state + for _, sel in ipairs(M.context.selections or {}) do + table.insert(selections, sel) + end + + for _, sel in ipairs(selections) do + table.insert(parts, format_selection_part(sel)) + end + end + + -- Add current file if enabled and not already mentioned + local current_file = base_context.get_current_file(buf, context_config) + if current_file and not vim.tbl_contains(M.context.mentioned_files or {}, current_file.path) then + table.insert(parts, format_file_part(current_file.path)) + end + + -- Add buffer content if enabled + if base_context.is_context_enabled('buffer', context_config) then + table.insert(parts, format_buffer_part(buf)) + end + + -- Add diagnostics + local diag_range = nil + if range then + diag_range = { start_line = range.start - 1, end_line = range.stop - 1 } + end + local diagnostics = base_context.get_diagnostics(buf, context_config, diag_range) + if diagnostics and #diagnostics > 0 then + table.insert(parts, format_diagnostics_part(diagnostics, nil)) -- No need to filter again + end + + -- Add cursor data + if base_context.is_context_enabled('cursor_data', context_config) then + local cursor_data = base_context.get_current_cursor_data(buf, win, context_config) + if cursor_data then + table.insert( + parts, + format_cursor_data_part(cursor_data, function() + return buf + end) + ) + end + end + + -- Add git diff + if base_context.is_context_enabled('git_diff', context_config) then + local diff_text = base_context.get_git_diff(context_config):await() + if diff_text and diff_text ~= '' then + table.insert(parts, format_git_diff_part(diff_text)) + end + end + + -- Add the main prompt + table.insert(parts, { type = 'text', text = prompt }) + + return { parts = parts } +end) + +return M diff --git a/lua/opencode/context/json_formatter.lua b/lua/opencode/context/json_formatter.lua deleted file mode 100644 index 3031be15..00000000 --- a/lua/opencode/context/json_formatter.lua +++ /dev/null @@ -1,153 +0,0 @@ --- JSON formatter for context --- Outputs JSON-formatted context parts for the opencode API - -local util = require('opencode.util') - -local M = {} - ----@param path string ----@param prompt? string ----@return OpencodeMessagePart -function M.format_file_part(path, prompt) - local rel_path = vim.fn.fnamemodify(path, ':~:.') - local mention = '@' .. rel_path - local pos = prompt and prompt:find(mention) - pos = pos and pos - 1 or 0 -- convert to 0-based index - - local ext = vim.fn.fnamemodify(path, ':e'):lower() - local mime_type = 'text/plain' - if ext == 'png' then - mime_type = 'image/png' - elseif ext == 'jpg' or ext == 'jpeg' then - mime_type = 'image/jpeg' - elseif ext == 'gif' then - mime_type = 'image/gif' - elseif ext == 'webp' then - mime_type = 'image/webp' - end - - local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path } - if prompt then - file_part.source = { - path = path, - type = 'file', - text = { start = pos, value = mention, ['end'] = pos + #mention }, - } - end - return file_part -end - ----@param selection OpencodeContextSelection ----@return OpencodeMessagePart -function M.format_selection_part(selection) - local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' - - return { - type = 'text', - metadata = { - context_type = 'selection', - }, - text = vim.json.encode({ - context_type = 'selection', - file = selection.file, - content = string.format('`````%s\n%s\n`````', lang, selection.content), - lines = selection.lines, - }), - synthetic = true, - } -end - ----@param diagnostics OpencodeDiagnostic[] ----@param range? { start_line: integer, end_line: integer }|nil ----@return OpencodeMessagePart -function M.format_diagnostics_part(diagnostics, range) - local diag_list = {} - for _, diag in ipairs(diagnostics) do - if not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line) then - local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') - table.insert( - diag_list, - { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } - ) - end - end - return { - type = 'text', - metadata = { - context_type = 'diagnostics', - }, - text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }), - synthetic = true, - } -end - ----@param cursor_data table ----@param get_current_buf fun(): integer|nil Function to get current buffer ----@return OpencodeMessagePart -function M.format_cursor_data_part(cursor_data, get_current_buf) - local buf = (get_current_buf() or 0) --[[@as integer]] - local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or '' - return { - type = 'text', - metadata = { - context_type = 'cursor-data', - lang = lang, - }, - text = vim.json.encode({ - context_type = 'cursor-data', - line = cursor_data.line, - column = cursor_data.column, - line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), - lines_before = cursor_data.lines_before, - lines_after = cursor_data.lines_after, - }), - synthetic = true, - } -end - ----@param agent string ----@param prompt string ----@return OpencodeMessagePart -function M.format_subagents_part(agent, prompt) - local mention = '@' .. agent - local pos = prompt:find(mention) - pos = pos and pos - 1 or 0 -- convert to 0-based index - - return { - type = 'agent', - name = agent, - source = { value = mention, start = pos, ['end'] = pos + #mention }, - } -end - ----@param buf integer ----@return OpencodeMessagePart -function M.format_buffer_part(buf) - local file = vim.api.nvim_buf_get_name(buf) - local rel_path = vim.fn.fnamemodify(file, ':~:.') - return { - type = 'text', - text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n'), - metadata = { - context_type = 'file-content', - filename = rel_path, - mime = 'text/plain', - }, - synthetic = true, - } -end - ----@param diff_text string ----@return OpencodeMessagePart -function M.format_git_diff_part(diff_text) - return { - type = 'text', - metadata = { - context_type = 'git-diff', - }, - text = diff_text, - synthetic = true, - } -end - -return M diff --git a/lua/opencode/context/plain_text_formatter.lua b/lua/opencode/context/quick_chat_context.lua similarity index 52% rename from lua/opencode/context/plain_text_formatter.lua rename to lua/opencode/context/quick_chat_context.lua index d42827e1..5a02221d 100644 --- a/lua/opencode/context/plain_text_formatter.lua +++ b/lua/opencode/context/quick_chat_context.lua @@ -1,6 +1,7 @@ --- Plain text formatter for context --- Outputs human-readable plain text for LLM consumption (used by quick chat) +-- QuickChatContext for quick chat interface +-- Outputs plain text formatted context for simple LLM consumption +local base_context = require('opencode.context.base_context') local util = require('opencode.util') local Promise = require('opencode.promise') @@ -13,15 +14,15 @@ local severity_names = { [4] = 'HINT', } ----@param selection OpencodeContextSelection +---@param selection table ---@return string -function M.format_selection(selection) +local function format_selection(selection) local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' local file_info = selection.file and selection.file.name or 'unknown' local lines_info = selection.lines and (' (lines ' .. selection.lines .. ')') or '' local parts = { - 'SELECTED CODE from ' .. file_info .. lines_info .. ':', + '[SELECTED CODE] from ' .. file_info .. lines_info .. ':', '```' .. lang, selection.content, '```', @@ -32,7 +33,7 @@ end ---@param diagnostics OpencodeDiagnostic[] ---@param range? { start_line: integer, end_line: integer }|nil ---@return string|nil -function M.format_diagnostics(diagnostics, range) +local function format_diagnostics(diagnostics, range) if not diagnostics or #diagnostics == 0 then return nil end @@ -53,32 +54,34 @@ function M.format_diagnostics(diagnostics, range) return nil end - return 'DIAGNOSTICS:\n' .. table.concat(filtered, '\n') + return '[DIAGNOSTICS]:\n' .. table.concat(filtered, '\n') end ---@param cursor_data table ---@param lang string|nil ---@return string -function M.format_cursor_data(cursor_data, lang) +local function format_cursor_data(cursor_data, lang) + local file = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf()) + lang = lang or '' local parts = { - string.format('CURSOR POSITION: Line %d, Column %d', cursor_data.line, cursor_data.column), + string.format('[CURSOR POSITION]: File %s ,Line %d, Column %d', file, cursor_data.line, cursor_data.column), } if cursor_data.lines_before and #cursor_data.lines_before > 0 then - table.insert(parts, 'Lines before cursor:') + table.insert(parts, '[BEFORE CURSOR]:') table.insert(parts, '```' .. lang) table.insert(parts, table.concat(cursor_data.lines_before, '\n')) table.insert(parts, '```') end - table.insert(parts, 'Current line:') + table.insert(parts, '[CURRENT LINE]:') table.insert(parts, '```' .. lang) table.insert(parts, cursor_data.line_content) table.insert(parts, '```') if cursor_data.lines_after and #cursor_data.lines_after > 0 then - table.insert(parts, 'Lines after cursor:') + table.insert(parts, '[AFTER CURSOR]:') table.insert(parts, '```' .. lang) table.insert(parts, table.concat(cursor_data.lines_after, '\n')) table.insert(parts, '```') @@ -89,108 +92,105 @@ end ---@param diff_text string ---@return string -function M.format_git_diff(diff_text) - return 'GIT DIFF (staged changes):\n```diff\n' .. diff_text .. '\n```' +local function format_git_diff(diff_text) + return '[GIT DIFF] (staged changes):\n```diff\n' .. diff_text .. '\n```' end ---@param buf integer ---@param lang string|nil ---@return string -function M.format_buffer(buf, lang) +local function format_buffer(buf, lang) lang = lang or '' local file = vim.api.nvim_buf_get_name(buf) local rel_path = vim.fn.fnamemodify(file, ':~:.') local content = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n') - return string.format('FILE: %s\n\n```%s\n%s\n```', rel_path, lang, content) -end - ----@param buf integer ----@param lang string ----@param rel_path string ----@param range {start: integer, stop: integer} ----@return string -function M.format_range(buf, lang, rel_path, range) - local start_line = math.max(1, range.start) - local end_line = range.stop - local range_lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false) - local range_text = table.concat(range_lines, '\n') - - local parts = { - string.format('Selected range from %s (lines %d-%d):', rel_path, start_line, end_line), - '```' .. lang, - range_text, - '```', - } - return table.concat(parts, '\n') + return string.format('[FILE]: %s\n\n```%s\n%s\n```', rel_path, lang, content) end --- Formats context as plain text for LLM consumption (used by quick chat) ---- Unlike format_message_quick_chat, this outputs human-readable text instead of JSON +--- Unlike ChatContext, this outputs human-readable text instead of structured JSON ---@param prompt string The user's instruction/prompt ----@param context_instance ContextInstance Context instance to use ----@param opts? { range?: { start: integer, stop: integer }, buf?: integer } +---@param opts? { range?: { start: integer, stop: integer }, context_config?: OpencodeContextConfig } ---@return table result { text: string, parts: OpencodeMessagePart[] } -M.format_message = Promise.async(function(prompt, context_instance, opts) +M.format_message = Promise.async(function(prompt, opts) opts = opts or {} - local buf = vim.api.nvim_get_current_buf() - local win = vim.api.nvim_get_current_win() + local context_config = opts.context_config + local buf, win = base_context.get_current_buf() + + if not buf or not win then + return { + text = '[USER PROMPT]: ' .. prompt, + parts = { { type = 'text', text = '[USER PROMPT]: ' .. prompt } }, + } + end local range = opts.range - local file_name = vim.api.nvim_buf_get_name(buf) local lang = util.get_markdown_filetype(file_name) or vim.fn.fnamemodify(file_name, ':e') or '' - local rel_path = file_name ~= '' and vim.fn.fnamemodify(file_name, ':~:.') or 'untitled' local text_parts = {} - if context_instance:is_context_enabled('selection') then + if base_context.is_context_enabled('selection', context_config) then + local selections = {} + if range and range.start and range.stop then + local file = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ':~:.') + if file then + local selection = base_context.new_selection( + { + name = file, + path = vim.api.nvim_buf_get_name(buf), + extension = vim.fn.fnamemodify(file, ':e'), + }, + table.concat(vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false), '\n'), + string.format('%d-%d', range.start, range.stop) + ) + table.insert(selections, selection) + end + end + + for _, sel in ipairs(selections) do table.insert(text_parts, '') - table.insert(text_parts, M.format_range(buf, lang, rel_path, range)) + table.insert(text_parts, format_selection(sel)) end end - if context_instance:is_context_enabled('buffer') then - table.insert(text_parts, M.format_buffer(buf, lang)) + if base_context.is_context_enabled('buffer', context_config) then + table.insert(text_parts, format_buffer(buf, lang)) end - for _, sel in ipairs(context_instance:get_selections() or {}) do - table.insert(text_parts, '') - table.insert(text_parts, M.format_selection(sel)) + local diag_range = nil + if range then + diag_range = { start_line = range.start - 1, end_line = range.stop - 1 } end - - local diagnostics = context_instance:get_diagnostics(buf) + local diagnostics = base_context.get_diagnostics(buf, context_config, diag_range) if diagnostics and #diagnostics > 0 then - local diag_range = nil - if range then - diag_range = { start_line = range.start - 1, end_line = range.stop - 1 } - end - local formatted_diag = M.format_diagnostics(diagnostics, diag_range) + local formatted_diag = format_diagnostics(diagnostics, nil) -- No need to filter again if formatted_diag then table.insert(text_parts, '') table.insert(text_parts, formatted_diag) end end - if context_instance:is_context_enabled('cursor_data') then - local cursor_data = context_instance:get_current_cursor_data(buf, win) + if base_context.is_context_enabled('cursor_data', context_config) then + local cursor_data = base_context.get_current_cursor_data(buf, win, context_config) if cursor_data then table.insert(text_parts, '') - table.insert(text_parts, M.format_cursor_data(cursor_data, lang)) + table.insert(text_parts, format_cursor_data(cursor_data, lang)) end end - if context_instance:is_context_enabled('git_diff') then - local diff_text = context_instance:get_git_diff():await() + if base_context.is_context_enabled('git_diff', context_config) then + local diff_text = base_context.get_git_diff(context_config):await() if diff_text and diff_text ~= '' then table.insert(text_parts, '') - table.insert(text_parts, M.format_git_diff(diff_text)) + table.insert(text_parts, format_git_diff(diff_text)) end end table.insert(text_parts, '') - table.insert(text_parts, 'USER PROMPT: ' .. prompt) + table.insert(text_parts, '[USER PROMPT]: ' .. prompt) local full_text = table.concat(text_parts, '\n') diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index e9e3c2ee..2b82c5e7 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -264,19 +264,30 @@ end --- Generates instructions for the LLM to follow the SEARCH/REPLACE format --- This is inspired from Aider Chat approach ----@param context_instance ContextInstance Context instance +---@param context_config OpencodeContextConfig Context configuration ---@return string[] instructions Array of instruction lines -local generate_search_replace_instructions = Promise.async(function(context_instance) +local generate_search_replace_instructions = Promise.async(function(context_config) local base_instructions = { - "You are an expert programming assistant. Modify the user's code according to their instructions using the SEARCH/REPLACE format described below.", - 'Output ONLY SEARCH/REPLACE blocks, no explanations:', + 'You are a patch generation engine.', + 'TASK:', + 'Generate search/replace blocks to implement the requested change.', + '', + 'OUTPUT FORMAT (MANDATORY):', '<<<<<<< SEARCH', '[exact original code]', '=======', '[modified code]', '>>>>>>> REPLACE', '', - 'Rules: Copy SEARCH content exactly. Include 1-3 context lines for unique matching. Use empty SEARCH to insert at cursor. Output multiple blocks only if needed for more complex operations.', + 'RULES:', + '- Output ONLY RAW patch blocks', + '- Marker lines must match EXACTLY', + '- Include 1-3 lines of context for unique matching', + '- Only REPLACE may differ', + '- Preserve whitespace', + '- NEVER add explanations or extra text', + '', + 'EXAMPLES (use ONLY as reference):', 'Example 1 - Fix function:', '<<<<<<< SEARCH', 'function hello() {', @@ -294,33 +305,30 @@ local generate_search_replace_instructions = Promise.async(function(context_inst '=======', 'local new_variable = "value"', '>>>>>>> REPLACE', + '', } local context_guidance = {} - if context_instance:has('diagnostics'):await() then - table.insert(context_guidance, 'Fix errors/warnings (if asked)') + -- Check context configuration to determine guidance + if context_config.diagnostics and context_config.diagnostics.enabled then + table.insert(context_guidance, 'Fix [DIAGNOSTICS] only (if asked)') end - if context_instance:has('selection'):await() then - table.insert(context_guidance, 'Modify only selected range') - elseif context_instance:has('cursor_data') then - table.insert(context_guidance, 'Modify only near cursor') + if context_config.selection then + table.insert(context_guidance, 'Modify only [SELECTED RANGE]') + elseif context_config.cursor_data and context_config.cursor_data.enabled then + table.insert(context_guidance, 'Modify only [CURSOR POSITION]') end - if context_instance:has('git_diff'):await() then - table.insert(context_guidance, "ONLY Reference git diff (don't copy syntax)") + if context_config.git_diff and context_config.git_diff.enabled then + table.insert(context_guidance, "Use [GIT DIFF] only as reference (don't copy syntax)") end if #context_guidance > 0 then - table.insert(base_instructions, 'Context: ' .. table.concat(context_guidance, ', ') .. '.') + table.insert(base_instructions, 'CONTEXT GUIDANCE: ' .. table.concat(context_guidance, ', ') .. '.') end - table.insert(base_instructions, '') - table.insert( - base_instructions, - '*CRITICAL*: Only answer in SEARCH/REPLACE format,NEVER add explanations!, NEVER ask questions!, NEVER add extra text!, NEVER run tools.' - ) table.insert(base_instructions, '') return base_instructions @@ -330,27 +338,26 @@ end) ---@param message string The user message ---@param buf integer Buffer handle ---@param range table|nil Range information ----@param context_instance ContextInstance Context instance +---@param context_config OpencodeContextConfig Context configuration ---@param options table Options including model and agent ---@return table params Message parameters -local create_message = Promise.async(function(message, buf, range, context_instance, options) +local create_message = Promise.async(function(message, buf, range, context_config, options) local quick_chat_config = config.quick_chat or {} - local instructions = quick_chat_config.instructions or generate_search_replace_instructions(context_instance):await() - - local format_opts = { buf = buf } + local format_opts = { context_config = context_config } if range then format_opts.range = { start = range.start, stop = range.stop } end - local result = context.format_message_plain_text(message, context_instance, format_opts):await() + local result = context.format_quick_chat_message(message, context_config, format_opts):await() + local instructions = quick_chat_config.instructions or generate_search_replace_instructions(context_config):await() local parts = { { type = 'text', text = table.concat(instructions, '\n') }, { type = 'text', text = result.text }, } - local params = { parts = parts, system = table.concat(instructions, '\n') } + local params = { parts = parts } local current_model = core.initialize_current_model():await() local target_model = options.model or quick_chat_config.default_model or current_model @@ -420,8 +427,7 @@ M.quick_chat = Promise.async(function(message, options, range) setup_global_keymaps() local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {}) - local context_instance = context.new_instance(context_config) - local params = create_message(message, buf, range, context_instance, options):await() + local params = create_message(message, buf, range, context_config, options):await() local success, err = pcall(function() state.api_client:create_message(quick_chat_session.id, params):await() diff --git a/lua/opencode/quick_chat/search_replace.lua b/lua/opencode/quick_chat/search_replace.lua index bcb98a01..6be5bc27 100644 --- a/lua/opencode/quick_chat/search_replace.lua +++ b/lua/opencode/quick_chat/search_replace.lua @@ -1,5 +1,60 @@ local M = {} +--- Normalizes whitespace for flexible matching +--- Converts all whitespace sequences to single spaces and trims +---@param text string Text to normalize +---@return string normalized Normalized text +local function normalize_whitespace(text) + return (text:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')) +end + +--- Attempts to find text with flexible whitespace matching +--- First tries exact match, then falls back to normalized whitespace matching +---@param content string Content to search in +---@param search string Text to search for +---@return number|nil start_pos Start position of match +---@return number|nil end_pos End position of match +---@return boolean was_exact Whether the match was exact or flexible +local function find_with_flexible_whitespace(content, search) + -- Try exact match first + local start_pos, end_pos = content:find(search, 1, true) + if start_pos and end_pos then + return start_pos, end_pos, true + end + + -- Fall back to flexible whitespace matching + local normalized_search = normalize_whitespace(search) + if normalized_search == '' then + return nil, nil, false + end + + local escaped_search = normalized_search:gsub('[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1') + + local flexible_pattern = escaped_search:gsub(' ', '%%s+') + + local match_start, match_end = content:find(flexible_pattern) + if match_start then + return match_start, match_end, false + end + + -- If still no match, try with more flexible approach: match word boundaries + -- Split into words and create a pattern that allows flexible whitespace between them + local words = {} + for word in normalized_search:gmatch('%S+') do + words[#words + 1] = word:gsub('[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1') + end + + if #words > 1 then + local word_pattern = table.concat(words, '%%s+') + local word_start, word_end = content:find(word_pattern) + if word_start then + return word_start, word_end, false + end + end + + return nil, nil, false +end + --- Parses SEARCH/REPLACE blocks from response text --- Supports both raw and code-fenced formats: --- <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE @@ -40,23 +95,25 @@ function M.parse_blocks(response_text) local search_content = text:sub(content_start, separator_start - 1) -- Find the end marker (require at least 7 > characters, newline optional for empty replace) - local replace_start = separator_end + 1 - local end_marker_start, end_marker_end = text:find('\n?>>>>>>>[>]*%s*REPLACE[^\n]*', replace_start) - if not end_marker_start then - table.insert(warnings, string.format('Block %d: Missing end marker (>>>>>>> REPLACE)', block_number)) - pos = search_start + 1 - else - -- Extract replace content (everything between separator and end marker) - local replace_content = text:sub(replace_start, end_marker_start - 1) - - local is_insert = search_content:match('^%s*$') ~= nil - table.insert(replacements, { - search = is_insert and '' or search_content, - replace = replace_content, - block_number = block_number, - is_insert = is_insert, - }) - pos = end_marker_end + 1 + if separator_end then + local replace_start = separator_end + 1 + local end_marker_start, end_marker_end = text:find('\n?>>>>>>>[>]*%s*REPLACE[^\n]*', replace_start) + if not end_marker_start then + table.insert(warnings, string.format('Block %d: Missing end marker (>>>>>>> REPLACE)', block_number)) + pos = search_start + 1 + else + -- Extract replace content (everything between separator and end marker) + local replace_content = text:sub(replace_start, end_marker_start - 1) + + local is_insert = search_content:match('^%s*$') ~= nil + table.insert(replacements, { + search = is_insert and '' or search_content, + replace = replace_content, + block_number = block_number, + is_insert = is_insert, + }) + pos = end_marker_end and (end_marker_end + 1) or (#text + 1) + end end end end @@ -64,7 +121,7 @@ function M.parse_blocks(response_text) return replacements, warnings end ---- Applies SEARCH/REPLACE blocks to buffer content using exact matching +--- Applies SEARCH/REPLACE blocks to buffer content using exact matching with flexible whitespace fallback --- Empty SEARCH sections (is_insert=true) will insert at the specified cursor row ---@param buf integer Buffer handle ---@param replacements table[] Array of {search=string, replace=string, block_number=number, is_insert=boolean} @@ -94,32 +151,35 @@ function M.apply(buf, replacements, cursor_row) if not cursor_row then table.insert(errors, string.format('Block %d: Insert operation requires cursor position', block_num)) else - -- Split replace content into lines and insert at cursor row local replace_lines = vim.split(replace, '\n', { plain = true }) vim.api.nvim_buf_set_lines(buf, cursor_row, cursor_row, false, replace_lines) applied_count = applied_count + 1 - -- Refresh lines and content after direct buffer modification + lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) content = table.concat(lines, '\n') end else - -- Exact match only - local start_pos, end_pos = content:find(search, 1, true) + -- Try flexible whitespace matching + local start_pos, end_pos, _was_exact = find_with_flexible_whitespace(content, search) if start_pos and end_pos then - content = content:sub(1, start_pos - 1) .. replace .. content:sub(end_pos + 1) + local start_int = math.floor(start_pos) + local end_int = math.floor(end_pos) + content = content:sub(1, start_int - 1) .. replace .. content:sub(end_int + 1) applied_count = applied_count + 1 else local search_preview = search:sub(1, 60):gsub('\n', '\\n') if #search > 60 then search_preview = search_preview .. '...' end - table.insert(errors, string.format('Block %d: No exact match for: "%s"', block_num, search_preview)) + table.insert( + errors, + string.format('Block %d: No match (exact or flexible) for: "%s"', block_num, search_preview) + ) end end end - -- Apply remaining content changes (for non-insert replacements) if applied_count > 0 then local new_lines = vim.split(content, '\n', { plain = true }) vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 73e321b8..4aadb683 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -68,7 +68,7 @@ describe('format_message', function() end) it('returns a parts array with prompt as first part', function() - local parts = context.format_message('hello world') + local parts = context.format_message('hello world'):wait() assert.is_table(parts) assert.equal('hello world', parts[1].text) assert.equal('text', parts[1].type) @@ -76,7 +76,7 @@ describe('format_message', function() it('includes mentioned_files and subagents', function() context.context.mentioned_files = { '/tmp/foo.lua' } context.context.mentioned_subagents = { 'agent1' } - local parts = context.format_message('prompt @foo.lua @agent1') + local parts = context.format_message('prompt @foo.lua @agent1'):wait() assert.is_true(#parts > 2) local found_file, found_agent = false, false for _, p in ipairs(parts) do @@ -148,45 +148,38 @@ describe('add_file/add_selection/add_subagent', function() end) end) -describe('context instance with config override', function() +describe('context static API with config override', function() it('should use override config for context enabled checks', function() local override_config = { current_file = { enabled = false }, diagnostics = { enabled = false }, selection = { enabled = true }, - agents = { enabled = true } + agents = { enabled = true }, } - - local instance = context.new_instance(override_config) - - -- Test that the override config is being used - assert.is_false(instance:is_context_enabled('current_file')) - assert.is_false(instance:is_context_enabled('diagnostics')) - assert.is_true(instance:is_context_enabled('selection')) - assert.is_true(instance:is_context_enabled('agents')) - end) - + + -- Test using static API with config parameter + assert.is_false(context.is_context_enabled('current_file', override_config)) + assert.is_false(context.is_context_enabled('diagnostics', override_config)) + assert.is_true(context.is_context_enabled('selection', override_config)) + assert.is_true(context.is_context_enabled('agents', override_config)) + end) + it('should fall back to global config when override not provided', function() local override_config = { - current_file = { enabled = false } + current_file = { enabled = false }, -- other context types not specified } - - local instance = context.new_instance(override_config) - - -- current_file should use override - assert.is_false(instance:is_context_enabled('current_file')) - + + -- Test using static API with partial config + assert.is_false(context.is_context_enabled('current_file', override_config)) + -- other context types should fall back to normal behavior -- (these will use global config + state, tested elsewhere) end) - + it('should work without any override config', function() - local instance = context.new_instance() - - -- Should behave exactly like global context - -- (actual values depend on config/state, just verify no errors) - assert.is_not_nil(instance:is_context_enabled('current_file')) - assert.is_not_nil(instance:is_context_enabled('diagnostics')) + -- Should behave exactly like global context using static API + assert.is_not_nil(context.is_context_enabled('current_file')) + assert.is_not_nil(context.is_context_enabled('diagnostics')) end) end) diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua index d0b8796c..f340a363 100644 --- a/tests/unit/keymap_spec.lua +++ b/tests/unit/keymap_spec.lua @@ -1,7 +1,3 @@ --- tests/unit/keymap_spec.lua --- Tests for the keymap module - -local keymap = require('opencode.keymap') local assert = require('luassert') describe('opencode.keymap', function() @@ -15,6 +11,10 @@ describe('opencode.keymap', function() local original_keymap_set local original_vim_cmd + -- Mock the API module to break circular dependency + local mock_api + local keymap + before_each(function() set_keymaps = {} cmd_calls = {} @@ -34,12 +34,55 @@ describe('opencode.keymap', function() vim.cmd = function(command) table.insert(cmd_calls, command) end + + -- Mock the API module before requiring keymap + mock_api = { + open_input = function() end, + toggle = function() end, + submit_input_prompt = function() end, + permission_accept = function() end, + permission_accept_all = function() end, + permission_deny = function() end, + commands = { + open_input = { desc = 'Open input window' }, + toggle = { desc = 'Toggle opencode windows' }, + submit_input_prompt = { desc = 'Submit input prompt' }, + }, + } + package.loaded['opencode.api'] = mock_api + + -- Mock the state module + local mock_state = { + current_permission = nil, + } + package.loaded['opencode.state'] = mock_state + + -- Mock the config module + local mock_config = { + keymap = { + permission = { + accept = 'a', + accept_all = 'A', + deny = 'd', + }, + }, + } + package.loaded['opencode.config'] = mock_config + + -- Now require the keymap module + keymap = require('opencode.keymap') end) after_each(function() -- Restore original functions vim.keymap.set = original_keymap_set vim.cmd = original_vim_cmd + + -- Clean up package loading + package.loaded['opencode.keymap'] = nil + package.loaded['opencode.api'] = nil + package.loaded['opencode.state'] = nil + package.loaded['opencode.config'] = nil end) describe('normalize_keymap', function() diff --git a/tests/unit/search_replace_spec.lua b/tests/unit/search_replace_spec.lua index 9548f33b..52654484 100644 --- a/tests/unit/search_replace_spec.lua +++ b/tests/unit/search_replace_spec.lua @@ -378,4 +378,246 @@ describe('search_replace.apply', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + + describe('flexible space matching', function() + it('matches with different whitespace when exact match fails', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'function test()', + ' local x = 1', + 'end' + }) + + -- Search with different whitespace pattern + local replacements = { + { search = 'local x = 1', replace = 'local x = 2', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals(' local x = 2', lines[2]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('matches across multiple whitespace variations', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'if condition then', + ' print("hello")', + 'end' + }) + + -- Search text with single spaces + local replacements = { + { search = 'if condition then', replace = 'if new_condition then', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals('if new_condition then', lines[1]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('matches with tabs and spaces mixed', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + '\tlocal\t\tx\t =\t\t1' + }) + + -- Search with regular spaces + local replacements = { + { search = 'local x = 1', replace = 'local x = 2', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals('\tlocal x = 2', lines[1]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('prefers exact match over flexible match', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'local x = 1', -- exact match + 'local x = 1', -- flexible match candidate + }) + + local replacements = { + { search = 'local x = 1', replace = 'local x = 99', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals('local x = 99', lines[1]) -- exact match replaced + assert.equals('local x = 1', lines[2]) -- flexible match untouched + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('matches with newlines and extra whitespace', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'function test()', + ' return 42', + 'end' + }) + + -- Search for multiline with different whitespace + local replacements = { + { search = 'function test()\n return 42\nend', replace = 'function test()\n return 100\nend', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals('function test()', lines[1]) + assert.equals(' return 100', lines[2]) + assert.equals('end', lines[3]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles word boundary matching for complex patterns', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'const result = calculate( a, b )' + }) + + -- Search with minimal spaces between words + local replacements = { + { search = 'const result = calculate( a, b )', replace = 'const result = compute(a, b)', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals('const result = compute(a, b)', lines[1]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('fails gracefully when no flexible match is possible', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'function test() { return 42; }' + }) + + -- Search for completely different content + local replacements = { + { search = 'class MyClass extends Base', replace = 'class NewClass extends Base', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_false(success) + assert.equals(1, #errors) + assert.equals(0, count) + assert.matches('No match %(exact or flexible%)', errors[1]) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals('function test() { return 42; }', lines[1]) -- unchanged + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles special regex characters in search text', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'const pattern = /^test.*$/g' + }) + + -- Search with regex special characters and different whitespace + local replacements = { + { search = 'const pattern = /^test.*$/g', replace = 'const pattern = /^new.*$/g', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals('const pattern = /^new.*$/g', lines[1]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles empty normalized search gracefully', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'some content' }) + + -- Search with only whitespace (should be treated as insert) + local replacements = { + { search = ' \t\n ', replace = 'new content', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_false(success) + assert.equals(1, #errors) + assert.equals(0, count) + assert.matches('No match %(exact or flexible%)', errors[1]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('works with indented code blocks', function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + ' if (condition) {', + ' console.log("test");', + ' }' + }) + + -- Search with normalized whitespace + local replacements = { + { search = 'if (condition) {\n console.log("test");\n}', replace = 'if (condition) {\n console.log("modified");\n}', block_number = 1 }, + } + + local success, errors, count = search_replace.apply(buf, replacements) + + assert.is_true(success) + assert.equals(0, #errors) + assert.equals(1, count) + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + assert.equals(' if (condition) {', lines[1]) + assert.equals(' console.log("modified");', lines[2]) + assert.equals('}', lines[3]) + + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) end) From 22de13cc57e9f7b97e1fb7530a1c70d23e84f172 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 08:03:13 -0500 Subject: [PATCH 26/46] chore: remove rogue import --- lua/opencode/util.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index f1877976..bbfff2d6 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -1,5 +1,4 @@ local Path = require('plenary.path') -local v = require('jit.v') local M = {} function M.uid() From eb5437112501d1b7d4cd9b93d7a02c8a667125bb Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 08:05:01 -0500 Subject: [PATCH 27/46] chore: remove duplicated line --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index fcd10e6b..7ec990f1 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,6 @@ require('opencode').setup({ }, debug = { enabled = false, -- Enable debug messages in the output window - enabled = false, capture_streamed_events = false, show_ids = true, quick_chat = { From ce0a37cf3100da206062ecd730600d9f796aa4a3 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 10:10:28 -0500 Subject: [PATCH 28/46] fix(quick_chat_context): don't re-indent selection --- lua/opencode/context/base_context.lua | 6 +++--- lua/opencode/context/quick_chat_context.lua | 6 ++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/lua/opencode/context/base_context.lua b/lua/opencode/context/base_context.lua index 56d662d5..3482dcfb 100644 --- a/lua/opencode/context/base_context.lua +++ b/lua/opencode/context/base_context.lua @@ -231,14 +231,14 @@ end) ---@param file table ---@param content string ---@param lines string +---@param raw_indent? boolean ---@return table -function M.new_selection(file, content, lines) +function M.new_selection(file, content, lines, raw_indent) return { file = file, - content = util.indent_code_block(content), + content = raw_indent and content or util.indent_code_block(content), lines = lines, } end return M - diff --git a/lua/opencode/context/quick_chat_context.lua b/lua/opencode/context/quick_chat_context.lua index 5a02221d..0e2911a0 100644 --- a/lua/opencode/context/quick_chat_context.lua +++ b/lua/opencode/context/quick_chat_context.lua @@ -1,6 +1,3 @@ --- QuickChatContext for quick chat interface --- Outputs plain text formatted context for simple LLM consumption - local base_context = require('opencode.context.base_context') local util = require('opencode.util') local Promise = require('opencode.promise') @@ -144,7 +141,8 @@ M.format_message = Promise.async(function(prompt, opts) extension = vim.fn.fnamemodify(file, ':e'), }, table.concat(vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false), '\n'), - string.format('%d-%d', range.start, range.stop) + string.format('%d-%d', range.start, range.stop), + true ) table.insert(selections, selection) end From 56bfcccb5e028628ce51a670ae4558bf0493150e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 10:12:56 -0500 Subject: [PATCH 29/46] fix(context): don't trim the current line in context --- lua/opencode/context/base_context.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/context/base_context.lua b/lua/opencode/context/base_context.lua index 3482dcfb..abfda95d 100644 --- a/lua/opencode/context/base_context.lua +++ b/lua/opencode/context/base_context.lua @@ -158,7 +158,7 @@ function M.get_current_cursor_data(buf, win, context_config) or 0 local cursor_pos = vim.fn.getcurpos(win) local start_line = (cursor_pos[2] - 1) --[[@as integer]] - local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') + local cursor_content = vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '' local lines_before = vim.api.nvim_buf_get_lines(buf, math.max(0, start_line - num_lines), start_line, false) local lines_after = vim.api.nvim_buf_get_lines(buf, cursor_pos[2], cursor_pos[2] + num_lines, false) return { From 867e430301adb27e246529b0d14eb9997d62a260 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 14:49:39 -0500 Subject: [PATCH 30/46] fix: typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lua/opencode/quick_chat.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 2b82c5e7..c740b226 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -24,7 +24,7 @@ local running_sessions = {} ---@type table local active_global_keymaps = {} ---- Creates an quickchat session title +--- Creates a quick chat session title ---@param buf integer Buffer handle ---@return string title The session title local function create_session_title(buf) From 990776c0a4c6673fbca92a14347ac7af8998d0da Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 14:59:54 -0500 Subject: [PATCH 31/46] fix(quick_chat): improve patch block parsing and application logic - Simplified whitespace normalization and Lua pattern handling. - Improved parsing logic for SEARCH/REPLACE blocks with better error handling. --- lua/opencode/context/quick_chat_context.lua | 9 +- lua/opencode/quick_chat/search_replace.lua | 311 ++++++++++++-------- 2 files changed, 199 insertions(+), 121 deletions(-) diff --git a/lua/opencode/context/quick_chat_context.lua b/lua/opencode/context/quick_chat_context.lua index 0e2911a0..6735d5c8 100644 --- a/lua/opencode/context/quick_chat_context.lua +++ b/lua/opencode/context/quick_chat_context.lua @@ -105,6 +105,13 @@ local function format_buffer(buf, lang) return string.format('[FILE]: %s\n\n```%s\n%s\n```', rel_path, lang, content) end +---@return integer|nil, integer|nil +function M.get_current_buf() + local buf = vim.api.nvim_get_current_buf() + local win = vim.api.nvim_get_current_win() + return buf, win +end + --- Formats context as plain text for LLM consumption (used by quick chat) --- Unlike ChatContext, this outputs human-readable text instead of structured JSON ---@param prompt string The user's instruction/prompt @@ -113,7 +120,7 @@ end M.format_message = Promise.async(function(prompt, opts) opts = opts or {} local context_config = opts.context_config - local buf, win = base_context.get_current_buf() + local buf, win = M.get_current_buf() if not buf or not win then return { diff --git a/lua/opencode/quick_chat/search_replace.lua b/lua/opencode/quick_chat/search_replace.lua index 6be5bc27..51cc6d0d 100644 --- a/lua/opencode/quick_chat/search_replace.lua +++ b/lua/opencode/quick_chat/search_replace.lua @@ -1,134 +1,219 @@ local M = {} ---- Normalizes whitespace for flexible matching ---- Converts all whitespace sequences to single spaces and trims ----@param text string Text to normalize ----@return string normalized Normalized text +local PATTERNS = { + search_start = '<<<<<<<[<]*%s*SEARCH%s*\n', + separator = '=======%s*\n', + replace_end = '\n?>>>>>>>[>]*%s*REPLACE[^\n]*', + code_fence = '```[^\n]*\n(.-)```', + + whitespace = '%s+', + trim_left = '^%s+', + trim_right = '%s+$', +} + +local MAGIC_CHARS = '[%(%)%.%+%-%*%?%[%]%^%$%%]' + +--- Normalize whitespace in text by collapsing multiple spaces and trimming. +--- @param text string The text to normalize +--- @return string The normalized text local function normalize_whitespace(text) - return (text:gsub('%s+', ' '):gsub('^%s+', ''):gsub('%s+$', '')) + return text:gsub(PATTERNS.whitespace, ' '):gsub(PATTERNS.trim_left, ''):gsub(PATTERNS.trim_right, '') end ---- Attempts to find text with flexible whitespace matching ---- First tries exact match, then falls back to normalized whitespace matching ----@param content string Content to search in ----@param search string Text to search for ----@return number|nil start_pos Start position of match ----@return number|nil end_pos End position of match ----@return boolean was_exact Whether the match was exact or flexible -local function find_with_flexible_whitespace(content, search) - -- Try exact match first - local start_pos, end_pos = content:find(search, 1, true) - if start_pos and end_pos then - return start_pos, end_pos, true +--- Escape magic characters in a string for use in Lua patterns. +--- @param text string The text to escape +--- @return string The escaped text +local function escape_pattern(text) + return text:gsub(MAGIC_CHARS, '%%%1') +end + +--- Safely find a pattern in text, handling potential errors. +--- @param text string The text to search in +--- @param pattern string The pattern to search for +--- @return number|nil start Start position of match +--- @return number|nil _end End position of match +local function safe_find(text, pattern) + local ok, start, _end = pcall(text.find, text, pattern) + if ok then + return start, _end end +end + +--- Strip patch block markers from text and return the content. +--- @param text string The text containing patch markers +--- @return string The content with markers stripped +local function strip_markers(text) + local start = text:find(PATTERNS.search_start) + local sep = text:find(PATTERNS.separator) + local _end = text:find(PATTERNS.replace_end) - -- Fall back to flexible whitespace matching - local normalized_search = normalize_whitespace(search) - if normalized_search == '' then - return nil, nil, false + if not (start and sep and _end) then + return text end - local escaped_search = normalized_search:gsub('[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1') + local content_start = text:find('\n', start) + 1 + -- Find the position right before the separator, handling the case where + -- the separator doesn't require a preceding newline + local content_end = sep - 1 + if text:sub(content_end, content_end) == '\n' then + content_end = content_end - 1 + end + + return text:sub(content_start, content_end) +end - local flexible_pattern = escaped_search:gsub(' ', '%%s+') +--- Find a substring in content, ignoring flexible whitespace. +--- @param content string +--- @param search string +--- @return number|nil s Start index of match +--- @return number|nil e End index of match +--- @return boolean|nil exact True if exact match, nil otherwise +local function find_with_flexible_whitespace(content, search) + -- Exact match first + local start, _end = content:find(search, 1, true) + if start then + return start, _end, true + end - local match_start, match_end = content:find(flexible_pattern) - if match_start then - return match_start, match_end, false + local normalized = normalize_whitespace(search) + if normalized == '' then + return end - -- If still no match, try with more flexible approach: match word boundaries - -- Split into words and create a pattern that allows flexible whitespace between them + -- Flexible whitespace pattern + local escaped = escape_pattern(normalized) + local flexible = escaped:gsub(' ', '%%s+') + + start, _end = safe_find(content, flexible) + if start then + return start, _end, false + end + + -- Word-based fallback local words = {} - for word in normalized_search:gmatch('%S+') do - words[#words + 1] = word:gsub('[%(%)%.%+%-%*%?%[%]%^%$%%]', '%%%1') + for w in normalized:gmatch('%S+') do + words[#words + 1] = escape_pattern(w) end if #words > 1 then - local word_pattern = table.concat(words, '%%s+') - local word_start, word_end = content:find(word_pattern) - if word_start then - return word_start, word_end, false + local pattern = table.concat(words, '%%s+') + start, _end = safe_find(content, pattern) + if start then + return start, _end, false end end +end + +--- Find the next patch block in the text after the given position. +---@param text string The text to search in +---@param pos number The position to start searching from +---@return table|nil block The block data, or nil if not found +---@return string|nil error_msg Error message if block is malformed +local function next_block(text, pos) + local start, _end = text:find(PATTERNS.search_start, pos) + if not start then + return + end - return nil, nil, false + local sep_start, sep_end = text:find(PATTERNS.separator, _end + 1) + if not sep_start then + return nil, 'Missing separator (=======)' + end + + local end_s, end_e = text:find(PATTERNS.replace_end, sep_end + 1) + if not end_s then + return nil, 'Missing end marker (>>>>>>> REPLACE)' + end + + -- Find the last newline before the separator to get the exact search content + local search_content_start = _end + 1 + local search_content_end = sep_start - 1 + + -- Check if there's a newline right before the separator + if text:sub(search_content_end, search_content_end) == '\n' then + search_content_end = search_content_end - 1 + end + + local search_content = text:sub(search_content_start, search_content_end) + + return { + search = search_content, + replace = text:sub(sep_end + 1, end_s - 1), + next_pos = end_e + 1, + } end ---- Parses SEARCH/REPLACE blocks from response text ---- Supports both raw and code-fenced formats: ---- <<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE ---- ```\n<<<<<<< SEARCH ... ======= ... >>>>>>> REPLACE\n``` ---- Empty SEARCH sections are valid and indicate "insert at cursor position" ----@param response_text string Response text containing SEARCH/REPLACE blocks ----@return table[] replacements Array of {search=string, replace=string, block_number=number, is_insert=boolean} ----@return string[] warnings Array of warning messages for malformed blocks +---@param response_text string +---@return table replacements, table warnings function M.parse_blocks(response_text) + local text = response_text:gsub('\r\n', '\n'):gsub(PATTERNS.code_fence, '%1') + local replacements = {} local warnings = {} - -- Normalize line endings - local text = response_text:gsub('\r\n', '\n') - - -- Remove code fences if present (```...```) - text = text:gsub('```[^\n]*\n(.-)```', '%1') - - local block_number = 0 local pos = 1 + local block_number = 0 while pos <= #text do - -- Find the start marker (require at least 7 < characters) - local search_start, search_end = text:find('<<<<<<<[<]*[ \t]*SEARCH[ \t]*\n', pos) - if not search_start or not search_end then + local result, err = next_block(text, pos) + + -- Check if we found a search start pattern + local search_start = text:find(PATTERNS.search_start, pos) + if not search_start then break end block_number = block_number + 1 - local content_start = search_end + 1 - -- Find the separator (require exactly 7 = characters) - local separator_start, separator_end = text:find('\n=======%s*\n', content_start) - if not separator_start then - table.insert(warnings, string.format('Block %d: Missing separator (=======)', block_number)) + if err then + warnings[#warnings + 1] = string.format('Block %d: %s', block_number, err) + -- For malformed blocks, advance to next search start + 1 to continue pos = search_start + 1 + elseif result then + local is_insert = result.search:match('^%s*$') ~= nil + + replacements[#replacements + 1] = { + search = is_insert and '' or result.search, + replace = result.replace, + block_number = block_number, + is_insert = is_insert, + } + + pos = result.next_pos else - local search_content = text:sub(content_start, separator_start - 1) - - -- Find the end marker (require at least 7 > characters, newline optional for empty replace) - if separator_end then - local replace_start = separator_end + 1 - local end_marker_start, end_marker_end = text:find('\n?>>>>>>>[>]*%s*REPLACE[^\n]*', replace_start) - if not end_marker_start then - table.insert(warnings, string.format('Block %d: Missing end marker (>>>>>>> REPLACE)', block_number)) - pos = search_start + 1 - else - -- Extract replace content (everything between separator and end marker) - local replace_content = text:sub(replace_start, end_marker_start - 1) - - local is_insert = search_content:match('^%s*$') ~= nil - table.insert(replacements, { - search = is_insert and '' or search_content, - replace = replace_content, - block_number = block_number, - is_insert = is_insert, - }) - pos = end_marker_end and (end_marker_end + 1) or (#text + 1) - end - end + break end end return replacements, warnings end ---- Applies SEARCH/REPLACE blocks to buffer content using exact matching with flexible whitespace fallback ---- Empty SEARCH sections (is_insert=true) will insert at the specified cursor row ----@param buf integer Buffer handle ----@param replacements table[] Array of {search=string, replace=string, block_number=number, is_insert=boolean} ----@param cursor_row? integer Optional cursor row (0-indexed) for insert operations ----@return boolean success Whether any replacements were applied ----@return string[] errors List of error messages for failed replacements ----@return number applied_count Number of successfully applied replacements +---@param buf number +---@param row number +---@param text string +local function apply_insert(buf, row, text) + local lines = vim.split(text, '\n', { plain = true }) + vim.api.nvim_buf_set_lines(buf, row, row, false, lines) +end + +---@param content string +---@param search string +---@param replace string +---@return string|nil, integer|nil, integer|nil +local function apply_replace(content, search, replace) + local cleaned = strip_markers(search) + local start, _end = find_with_flexible_whitespace(content, cleaned) + if not start then + return + end + return content:sub(1, start - 1) .. replace .. content:sub(_end + 1) +end + +---Apply replacements to the buffer content. +---@param buf integer +---@param replacements table +---@param cursor_row integer +---@return boolean, table, integer function M.apply(buf, replacements, cursor_row) if not vim.api.nvim_buf_is_valid(buf) then return false, { 'Buffer is not valid' }, 0 @@ -137,55 +222,41 @@ function M.apply(buf, replacements, cursor_row) local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) local content = table.concat(lines, '\n') - local applied_count = 0 + local applied = 0 local errors = {} - for _, replacement in ipairs(replacements) do - local search = replacement.search - local replace = replacement.replace - local block_num = replacement.block_number or '?' - local is_insert = replacement.is_insert - - if is_insert then - -- Empty SEARCH: insert at cursor row - if not cursor_row then - table.insert(errors, string.format('Block %d: Insert operation requires cursor position', block_num)) + for _, r in ipairs(replacements) do + if r.is_insert then + if cursor_row == nil then + errors[#errors + 1] = 'Insert operation requires cursor position' else - local replace_lines = vim.split(replace, '\n', { plain = true }) - vim.api.nvim_buf_set_lines(buf, cursor_row, cursor_row, false, replace_lines) - applied_count = applied_count + 1 + apply_insert(buf, cursor_row, r.replace) + applied = applied + 1 lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) content = table.concat(lines, '\n') end else - -- Try flexible whitespace matching - local start_pos, end_pos, _was_exact = find_with_flexible_whitespace(content, search) - - if start_pos and end_pos then - local start_int = math.floor(start_pos) - local end_int = math.floor(end_pos) - content = content:sub(1, start_int - 1) .. replace .. content:sub(end_int + 1) - applied_count = applied_count + 1 + local new_content = apply_replace(content, r.search, r.replace) + if new_content then + content = new_content + applied = applied + 1 else - local search_preview = search:sub(1, 60):gsub('\n', '\\n') - if #search > 60 then - search_preview = search_preview .. '...' + local preview = r.search:sub(1, 60):gsub('\n', '\\n') + if #r.search > 60 then + preview = preview .. '...' end - table.insert( - errors, - string.format('Block %d: No match (exact or flexible) for: "%s"', block_num, search_preview) - ) + errors[#errors + 1] = 'No match (exact or flexible)' end end end - if applied_count > 0 then + if applied > 0 then local new_lines = vim.split(content, '\n', { plain = true }) vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) end - return applied_count > 0, errors, applied_count + return applied > 0, errors, applied end return M From 9e313670809d303772347b8a5c2711e378de8502 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 19 Dec 2025 15:51:09 -0500 Subject: [PATCH 32/46] feat: narrower cursor context works better --- lua/opencode/config.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 2f6fa39e..7a897862 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -174,7 +174,7 @@ M.defaults = { enabled = true, cursor_data = { enabled = false, - context_lines = 10, -- Number of lines before and after cursor to include in context + context_lines = 5, -- Number of lines before and after cursor to include in context }, diagnostics = { enabled = true, From 66098536d9ac86cd5726f4b60f82c5d655a087a0 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 22 Dec 2025 10:56:23 -0500 Subject: [PATCH 33/46] fix(api): show selection/line context in Quick Chat input prompt Adds the currently selected range or line number to the Quick Chat input UI prompt, improving user clarity on the chat scope. --- lua/opencode/api.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 8f5f1c0f..a55eb4a3 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -126,7 +126,9 @@ function M.quick_chat(message, range) end if not message or #message == 0 then - vim.ui.input({ prompt = 'Quick Chat Message: ', win = { relative = 'cursor' } }, function(input) + local scope = range and ('[selection: ' .. range.start .. '-' .. range.stop .. ']') + or '[line: ' .. tostring(vim.api.nvim_win_get_cursor(0)[1]) .. ']' + vim.ui.input({ prompt = 'Quick Chat Message: ' .. scope, win = { relative = 'cursor' } }, function(input) if input and input ~= '' then local prompt, ctx = util.parse_quick_context_args(input) quick_chat.quick_chat(prompt, { context_config = ctx }, range) From ed2e6ae3f2733e847ed04686ea975b1a1363bb97 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 22 Dec 2025 11:49:47 -0500 Subject: [PATCH 34/46] feat: replace search/replace with raw code generation in quick chat Remove SEARCH/REPLACE block parsing in favor of direct code output mode for simpler implementation and improved user experience --- lua/opencode/api.lua | 2 + lua/opencode/init.lua | 1 - lua/opencode/quick_chat.lua | 163 ++---- lua/opencode/quick_chat/search_replace.lua | 262 --------- tests/unit/search_replace_spec.lua | 623 --------------------- 5 files changed, 64 insertions(+), 987 deletions(-) delete mode 100644 lua/opencode/quick_chat/search_replace.lua delete mode 100644 tests/unit/search_replace_spec.lua diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index a55eb4a3..f57957c6 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -60,6 +60,8 @@ M.toggle = Promise.async(function(new_session) end end) +---@param new_session boolean? +---@return nil function M.toggle_focus(new_session) if not ui.is_opencode_focused() then local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output' diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 2aacb1ad..8a8a2f2c 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -10,7 +10,6 @@ function M.setup(opts) require('opencode.ui.highlight').setup() require('opencode.core').setup() - require('opencode.quick_chat').setup() require('opencode.api').setup() require('opencode.keymap').setup(config.keymap) require('opencode.ui.completion').setup() diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index c740b226..a1439192 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -5,7 +5,6 @@ local core = require('opencode.core') local util = require('opencode.util') local session = require('opencode.session') local Promise = require('opencode.promise') -local search_replace = require('opencode.quick_chat.search_replace') local CursorSpinner = require('opencode.quick_chat.spinner') local M = {} @@ -16,6 +15,7 @@ local M = {} ---@field col integer Column position for spinner ---@field spinner CursorSpinner Spinner instance ---@field timestamp integer Timestamp when session started +---@field range table|nil Range information ---@type table local running_sessions = {} @@ -130,6 +130,10 @@ end ---@param message OpencodeMessage Message object ---@return string response_text local function extract_response_text(message) + if not message then + return '' + end + local response_text = '' for _, part in ipairs(message.parts or {}) do if part.type == 'text' and part.text then @@ -137,14 +141,45 @@ local function extract_response_text(message) end end + -- Remove code blocks and inline code + response_text = response_text:gsub('```[^`]*```', '') + response_text = response_text:gsub('`[^`]*`', '') + return vim.trim(response_text) end +--- Applies raw code response to buffer (simple replacement) +---@param buf integer Buffer handle +---@param response_text string The raw code response +---@param row integer Row position (0-indexed) +---@param range table|nil Range information { start = number, stop = number } +---@return boolean success Whether the replacement was successful +local function apply_raw_code_response(buf, response_text, row, range) + if response_text == '' then + return false + end + + local lines = vim.split(response_text, '\n') + + if range then + -- Replace the selected range + local start_line = range.start - 1 -- Convert to 0-indexed + local end_line = range.stop - 1 -- Convert to 0-indexed + vim.api.nvim_buf_set_lines(buf, start_line, end_line + 1, false, lines) + else + -- Replace current line + vim.api.nvim_buf_set_lines(buf, row, row + 1, false, lines) + end + + return true +end + --- Processes response from quickchat session ---@param session_info table Session tracking info ---@param messages OpencodeMessage[] Session messages +---@param range table|nil Range information ---@return boolean success Whether the response was processed successfully -local function process_response(session_info, messages) +local function process_response(session_info, messages, range) local response_message = messages[#messages] if #messages < 2 and (not response_message or response_message.info.role ~= 'assistant') then return false @@ -157,39 +192,12 @@ local function process_response(session_info, messages) return false end - local replacements, parse_warnings = search_replace.parse_blocks(response_text) - - -- Show parse warnings - if #parse_warnings > 0 then - for _, warning in ipairs(parse_warnings) do - vim.notify('Quick chat: ' .. warning, vim.log.levels.WARN) - end - end - - if #replacements == 0 then - vim.notify('Quick chat: No valid SEARCH/REPLACE blocks found in response', vim.log.levels.WARN) - return false - end - - local success, errors, applied_count = search_replace.apply(session_info.buf, replacements, session_info.row) - - -- Provide detailed feedback - if applied_count > 0 then - local total_blocks = #replacements - if applied_count == total_blocks then - vim.notify( - string.format('Quick chat: Applied %d change%s', applied_count, applied_count > 1 and 's' or ''), - vim.log.levels.INFO - ) - else - vim.notify(string.format('Quick chat: Applied %d/%d changes', applied_count, total_blocks), vim.log.levels.INFO) - end - end - - if #errors > 0 then - for _, err in ipairs(errors) do - vim.notify('Quick chat: ' .. err, vim.log.levels.WARN) - end + local success = apply_raw_code_response(session_info.buf, response_text, session_info.row, range) + if success then + local target = range and 'selection' or 'current line' + vim.notify(string.format('Quick chat: Replaced %s with generated code', target), vim.log.levels.INFO) + else + vim.notify('Quick chat: Failed to apply raw code response', vim.log.levels.WARN) end return success @@ -213,7 +221,7 @@ local on_done = Promise.async(function(active_session) return end - local success = process_response(running_session, messages) + local success = process_response(running_session, messages, running_session.range) if success then cleanup_session(running_session, active_session.id) else @@ -262,77 +270,28 @@ local function create_context_config(has_range) } end ---- Generates instructions for the LLM to follow the SEARCH/REPLACE format ---- This is inspired from Aider Chat approach +--- Generates instructions for raw code generation mode ---@param context_config OpencodeContextConfig Context configuration ---@return string[] instructions Array of instruction lines -local generate_search_replace_instructions = Promise.async(function(context_config) - local base_instructions = { - 'You are a patch generation engine.', - 'TASK:', - 'Generate search/replace blocks to implement the requested change.', - '', - 'OUTPUT FORMAT (MANDATORY):', - '<<<<<<< SEARCH', - '[exact original code]', - '=======', - '[modified code]', - '>>>>>>> REPLACE', - '', - 'RULES:', - '- Output ONLY RAW patch blocks', - '- Marker lines must match EXACTLY', - '- Include 1-3 lines of context for unique matching', - '- Only REPLACE may differ', - '- Preserve whitespace', - '- NEVER add explanations or extra text', - '', - 'EXAMPLES (use ONLY as reference):', - 'Example 1 - Fix function:', - '<<<<<<< SEARCH', - 'function hello() {', - ' console.log("hello")', - '}', - '=======', - 'function hello() {', - ' console.log("hello");', - '}', - '>>>>>>> REPLACE', - '', - 'Example 2 - Insert at cursor:', - '<<<<<<< SEARCH', - '', - '=======', - 'local new_variable = "value"', - '>>>>>>> REPLACE', - '', - } - - local context_guidance = {} - - -- Check context configuration to determine guidance - if context_config.diagnostics and context_config.diagnostics.enabled then - table.insert(context_guidance, 'Fix [DIAGNOSTICS] only (if asked)') - end +local function generate_raw_code_instructions(context_config) + local context_info = '' - if context_config.selection then - table.insert(context_guidance, 'Modify only [SELECTED RANGE]') + if context_config.selection and context_config.selection.enabled then + context_info = 'You have been provided with a code selection [SELECTED CODE]. ' elseif context_config.cursor_data and context_config.cursor_data.enabled then - table.insert(context_guidance, 'Modify only [CURSOR POSITION]') - end - - if context_config.git_diff and context_config.git_diff.enabled then - table.insert(context_guidance, "Use [GIT DIFF] only as reference (don't copy syntax)") + context_info = 'You have been provided with cursor context. [CURSOR POSITION]' end - if #context_guidance > 0 then - table.insert(base_instructions, 'CONTEXT GUIDANCE: ' .. table.concat(context_guidance, ', ') .. '.') - end - - table.insert(base_instructions, '') + local buf = vim.api.nvim_get_current_buf() + local filetype = vim.api.nvim_buf_get_option(buf, 'filetype') - return base_instructions -end) + return { + 'I want you to act as a senior ' .. filetype .. ' developer. ' .. context_info, + 'I will ask you specific questions and I want you to return raw code only ', + '(no codeblocks, no explanations). ', + "If you can't respond with code, respond with nothing.", + } +end --- Creates message parameters for quick chat ---@param message string The user message @@ -350,7 +309,8 @@ local create_message = Promise.async(function(message, buf, range, context_confi end local result = context.format_quick_chat_message(message, context_config, format_opts):await() - local instructions = quick_chat_config.instructions or generate_search_replace_instructions(context_config):await() + + local instructions = quick_chat_config.instructions or generate_raw_code_instructions(context_config) local parts = { { type = 'text', text = table.concat(instructions, '\n') }, @@ -421,6 +381,7 @@ M.quick_chat = Promise.async(function(message, options, range) col = col, spinner = spinner, timestamp = vim.uv.now(), + range = range, } -- Set up global keymaps for quick chat diff --git a/lua/opencode/quick_chat/search_replace.lua b/lua/opencode/quick_chat/search_replace.lua deleted file mode 100644 index 51cc6d0d..00000000 --- a/lua/opencode/quick_chat/search_replace.lua +++ /dev/null @@ -1,262 +0,0 @@ -local M = {} - -local PATTERNS = { - search_start = '<<<<<<<[<]*%s*SEARCH%s*\n', - separator = '=======%s*\n', - replace_end = '\n?>>>>>>>[>]*%s*REPLACE[^\n]*', - code_fence = '```[^\n]*\n(.-)```', - - whitespace = '%s+', - trim_left = '^%s+', - trim_right = '%s+$', -} - -local MAGIC_CHARS = '[%(%)%.%+%-%*%?%[%]%^%$%%]' - ---- Normalize whitespace in text by collapsing multiple spaces and trimming. ---- @param text string The text to normalize ---- @return string The normalized text -local function normalize_whitespace(text) - return text:gsub(PATTERNS.whitespace, ' '):gsub(PATTERNS.trim_left, ''):gsub(PATTERNS.trim_right, '') -end - ---- Escape magic characters in a string for use in Lua patterns. ---- @param text string The text to escape ---- @return string The escaped text -local function escape_pattern(text) - return text:gsub(MAGIC_CHARS, '%%%1') -end - ---- Safely find a pattern in text, handling potential errors. ---- @param text string The text to search in ---- @param pattern string The pattern to search for ---- @return number|nil start Start position of match ---- @return number|nil _end End position of match -local function safe_find(text, pattern) - local ok, start, _end = pcall(text.find, text, pattern) - if ok then - return start, _end - end -end - ---- Strip patch block markers from text and return the content. ---- @param text string The text containing patch markers ---- @return string The content with markers stripped -local function strip_markers(text) - local start = text:find(PATTERNS.search_start) - local sep = text:find(PATTERNS.separator) - local _end = text:find(PATTERNS.replace_end) - - if not (start and sep and _end) then - return text - end - - local content_start = text:find('\n', start) + 1 - -- Find the position right before the separator, handling the case where - -- the separator doesn't require a preceding newline - local content_end = sep - 1 - if text:sub(content_end, content_end) == '\n' then - content_end = content_end - 1 - end - - return text:sub(content_start, content_end) -end - ---- Find a substring in content, ignoring flexible whitespace. ---- @param content string ---- @param search string ---- @return number|nil s Start index of match ---- @return number|nil e End index of match ---- @return boolean|nil exact True if exact match, nil otherwise -local function find_with_flexible_whitespace(content, search) - -- Exact match first - local start, _end = content:find(search, 1, true) - if start then - return start, _end, true - end - - local normalized = normalize_whitespace(search) - if normalized == '' then - return - end - - -- Flexible whitespace pattern - local escaped = escape_pattern(normalized) - local flexible = escaped:gsub(' ', '%%s+') - - start, _end = safe_find(content, flexible) - if start then - return start, _end, false - end - - -- Word-based fallback - local words = {} - for w in normalized:gmatch('%S+') do - words[#words + 1] = escape_pattern(w) - end - - if #words > 1 then - local pattern = table.concat(words, '%%s+') - start, _end = safe_find(content, pattern) - if start then - return start, _end, false - end - end -end - ---- Find the next patch block in the text after the given position. ----@param text string The text to search in ----@param pos number The position to start searching from ----@return table|nil block The block data, or nil if not found ----@return string|nil error_msg Error message if block is malformed -local function next_block(text, pos) - local start, _end = text:find(PATTERNS.search_start, pos) - if not start then - return - end - - local sep_start, sep_end = text:find(PATTERNS.separator, _end + 1) - if not sep_start then - return nil, 'Missing separator (=======)' - end - - local end_s, end_e = text:find(PATTERNS.replace_end, sep_end + 1) - if not end_s then - return nil, 'Missing end marker (>>>>>>> REPLACE)' - end - - -- Find the last newline before the separator to get the exact search content - local search_content_start = _end + 1 - local search_content_end = sep_start - 1 - - -- Check if there's a newline right before the separator - if text:sub(search_content_end, search_content_end) == '\n' then - search_content_end = search_content_end - 1 - end - - local search_content = text:sub(search_content_start, search_content_end) - - return { - search = search_content, - replace = text:sub(sep_end + 1, end_s - 1), - next_pos = end_e + 1, - } -end - ----@param response_text string ----@return table replacements, table warnings -function M.parse_blocks(response_text) - local text = response_text:gsub('\r\n', '\n'):gsub(PATTERNS.code_fence, '%1') - - local replacements = {} - local warnings = {} - - local pos = 1 - local block_number = 0 - - while pos <= #text do - local result, err = next_block(text, pos) - - -- Check if we found a search start pattern - local search_start = text:find(PATTERNS.search_start, pos) - if not search_start then - break - end - - block_number = block_number + 1 - - if err then - warnings[#warnings + 1] = string.format('Block %d: %s', block_number, err) - -- For malformed blocks, advance to next search start + 1 to continue - pos = search_start + 1 - elseif result then - local is_insert = result.search:match('^%s*$') ~= nil - - replacements[#replacements + 1] = { - search = is_insert and '' or result.search, - replace = result.replace, - block_number = block_number, - is_insert = is_insert, - } - - pos = result.next_pos - else - break - end - end - - return replacements, warnings -end - ----@param buf number ----@param row number ----@param text string -local function apply_insert(buf, row, text) - local lines = vim.split(text, '\n', { plain = true }) - vim.api.nvim_buf_set_lines(buf, row, row, false, lines) -end - ----@param content string ----@param search string ----@param replace string ----@return string|nil, integer|nil, integer|nil -local function apply_replace(content, search, replace) - local cleaned = strip_markers(search) - local start, _end = find_with_flexible_whitespace(content, cleaned) - if not start then - return - end - return content:sub(1, start - 1) .. replace .. content:sub(_end + 1) -end - ----Apply replacements to the buffer content. ----@param buf integer ----@param replacements table ----@param cursor_row integer ----@return boolean, table, integer -function M.apply(buf, replacements, cursor_row) - if not vim.api.nvim_buf_is_valid(buf) then - return false, { 'Buffer is not valid' }, 0 - end - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local content = table.concat(lines, '\n') - - local applied = 0 - local errors = {} - - for _, r in ipairs(replacements) do - if r.is_insert then - if cursor_row == nil then - errors[#errors + 1] = 'Insert operation requires cursor position' - else - apply_insert(buf, cursor_row, r.replace) - applied = applied + 1 - - lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - content = table.concat(lines, '\n') - end - else - local new_content = apply_replace(content, r.search, r.replace) - if new_content then - content = new_content - applied = applied + 1 - else - local preview = r.search:sub(1, 60):gsub('\n', '\\n') - if #r.search > 60 then - preview = preview .. '...' - end - errors[#errors + 1] = 'No match (exact or flexible)' - end - end - end - - if applied > 0 then - local new_lines = vim.split(content, '\n', { plain = true }) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, new_lines) - end - - return applied > 0, errors, applied -end - -return M diff --git a/tests/unit/search_replace_spec.lua b/tests/unit/search_replace_spec.lua deleted file mode 100644 index 52654484..00000000 --- a/tests/unit/search_replace_spec.lua +++ /dev/null @@ -1,623 +0,0 @@ -local search_replace = require('opencode.quick_chat.search_replace') - -describe('search_replace.parse_blocks', function() - it('parses a single SEARCH/REPLACE block', function() - local input = [[ -<<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - assert.equals('local x = 1', replacements[1].search) - assert.equals('local x = 2', replacements[1].replace) - assert.equals(1, replacements[1].block_number) - end) - - it('parses multiple SEARCH/REPLACE blocks', function() - local input = [[ -<<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>>> REPLACE - -<<<<<<< SEARCH -function foo() - return 42 -end -======= -function foo() - return 100 -end ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(2, #replacements) - assert.equals(0, #warnings) - assert.equals('local x = 1', replacements[1].search) - assert.equals('local x = 2', replacements[1].replace) - assert.equals(1, replacements[1].block_number) - assert.equals('function foo()\n return 42\nend', replacements[2].search) - assert.equals('function foo()\n return 100\nend', replacements[2].replace) - assert.equals(2, replacements[2].block_number) - end) - - it('handles code fences around blocks', function() - local input = [[ -``` -<<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>>> REPLACE -``` -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - assert.equals('local x = 1', replacements[1].search) - end) - - it('handles code fences with language specifier', function() - local input = [[ -```lua -<<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>>> REPLACE -``` -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - end) - - it('warns on missing separator', function() - local input = [[ -<<<<<<< SEARCH -local x = 1 ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(0, #replacements) - assert.equals(1, #warnings) - assert.matches('Missing separator', warnings[1]) - end) - - it('warns on missing end marker', function() - local input = [[ -<<<<<<< SEARCH -local x = 1 -======= -local x = 2 -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(0, #replacements) - assert.equals(1, #warnings) - assert.matches('Missing end marker', warnings[1]) - end) - - it('parses empty SEARCH section as insert operation', function() - -- Empty search means "insert at cursor position" - local input = [[ -<<<<<<< SEARCH - -======= -local x = 2 ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - assert.equals('', replacements[1].search) - assert.equals('local x = 2', replacements[1].replace) - assert.is_true(replacements[1].is_insert) - end) - - it('parses whitespace-only SEARCH section as insert operation', function() - -- Note: Empty search with content on same line as separator - -- The parser requires \n======= so whitespace-only search still needs proper structure - local input = [[ -<<<<<<< SEARCH - -======= -local x = 2 ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - assert.equals('', replacements[1].search) - assert.is_true(replacements[1].is_insert) - end) - - it('handles empty REPLACE section (deletion)', function() - -- Note: Empty replace needs proper newline structure - local input = [[ -<<<<<<< SEARCH -local unused = true -======= - ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - assert.equals('local unused = true', replacements[1].search) - -- Replace section contains single empty line - assert.equals('', replacements[1].replace) - end) - - it('normalizes CRLF line endings', function() - local input = "<<<<<<< SEARCH\r\nlocal x = 1\r\n=======\r\nlocal x = 2\r\n>>>>>>> REPLACE\r\n" - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - assert.equals('local x = 1', replacements[1].search) - end) - - it('handles extra angle brackets in markers', function() - local input = [[ -<<<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - end) - - it('ignores text before first block', function() - local input = [[ -Here is my response explaining the changes: - -<<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(1, #replacements) - assert.equals(0, #warnings) - assert.equals('local x = 1', replacements[1].search) - end) - - it('ignores text between blocks', function() - local input = [[ -<<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>>> REPLACE - -And here is another change: - -<<<<<<< SEARCH -local y = 3 -======= -local y = 4 ->>>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(2, #replacements) - assert.equals(0, #warnings) - end) - - it('returns empty array for no blocks', function() - local input = 'Just some regular text with no blocks' - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(0, #replacements) - assert.equals(0, #warnings) - end) - - it('requires at least 7 angle brackets', function() - local input = [[ -<<<<<< SEARCH -local x = 1 -======= -local x = 2 ->>>>>> REPLACE -]] - local replacements, warnings = search_replace.parse_blocks(input) - - assert.equals(0, #replacements) - assert.equals(0, #warnings) - end) -end) - -describe('search_replace.apply', function() - it('applies replacements to buffer', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'local x = 1', 'local y = 2' }) - - local replacements = { - { search = 'local x = 1', replace = 'local x = 100', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.are.same({ 'local x = 100', 'local y = 2' }, lines) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('returns error for invalid buffer', function() - local success, errors, count = search_replace.apply(99999, {}) - - assert.is_false(success) - assert.equals(1, #errors) - assert.matches('Buffer is not valid', errors[1]) - assert.equals(0, count) - end) - - it('does not modify buffer if no matches', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'local x = 1' }) - - local replacements = { - { search = 'local y = 2', replace = 'local y = 200', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_false(success) - assert.equals(1, #errors) - assert.equals(0, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.are.same({ 'local x = 1' }, lines) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('inserts at cursor row when is_insert is true', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', 'line 3' }) - - local replacements = { - { search = '', replace = 'inserted text', block_number = 1, is_insert = true }, - } - - -- Insert before row 1 (0-indexed), so inserts before "line 2" - local success, errors, count = search_replace.apply(buf, replacements, 1) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.are.same({ 'line 1', 'inserted text', 'line 2', 'line 3' }, lines) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('inserts at empty line cursor position', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', '', 'line 3' }) - - local replacements = { - { search = '', replace = 'new content', block_number = 1, is_insert = true }, - } - - -- Insert before row 1 (0-indexed), the empty line - local success, errors, count = search_replace.apply(buf, replacements, 1) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.are.same({ 'line 1', 'new content', '', 'line 3' }, lines) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('inserts multiline content at cursor row', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', '', 'line 3' }) - - local replacements = { - { search = '', replace = 'first\nsecond\nthird', block_number = 1, is_insert = true }, - } - - -- Insert before row 1 (0-indexed) - local success, errors, count = search_replace.apply(buf, replacements, 1) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.are.same({ 'line 1', 'first', 'second', 'third', '', 'line 3' }, lines) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('returns error for insert without cursor_row', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1' }) - - local replacements = { - { search = '', replace = 'inserted text', block_number = 1, is_insert = true }, - } - - -- No cursor_row provided - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_false(success) - assert.equals(1, #errors) - assert.matches('Insert operation requires cursor position', errors[1]) - assert.equals(0, count) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - describe('flexible space matching', function() - it('matches with different whitespace when exact match fails', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'function test()', - ' local x = 1', - 'end' - }) - - -- Search with different whitespace pattern - local replacements = { - { search = 'local x = 1', replace = 'local x = 2', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals(' local x = 2', lines[2]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('matches across multiple whitespace variations', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'if condition then', - ' print("hello")', - 'end' - }) - - -- Search text with single spaces - local replacements = { - { search = 'if condition then', replace = 'if new_condition then', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals('if new_condition then', lines[1]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('matches with tabs and spaces mixed', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - '\tlocal\t\tx\t =\t\t1' - }) - - -- Search with regular spaces - local replacements = { - { search = 'local x = 1', replace = 'local x = 2', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals('\tlocal x = 2', lines[1]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('prefers exact match over flexible match', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'local x = 1', -- exact match - 'local x = 1', -- flexible match candidate - }) - - local replacements = { - { search = 'local x = 1', replace = 'local x = 99', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals('local x = 99', lines[1]) -- exact match replaced - assert.equals('local x = 1', lines[2]) -- flexible match untouched - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('matches with newlines and extra whitespace', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'function test()', - ' return 42', - 'end' - }) - - -- Search for multiline with different whitespace - local replacements = { - { search = 'function test()\n return 42\nend', replace = 'function test()\n return 100\nend', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals('function test()', lines[1]) - assert.equals(' return 100', lines[2]) - assert.equals('end', lines[3]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles word boundary matching for complex patterns', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'const result = calculate( a, b )' - }) - - -- Search with minimal spaces between words - local replacements = { - { search = 'const result = calculate( a, b )', replace = 'const result = compute(a, b)', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals('const result = compute(a, b)', lines[1]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('fails gracefully when no flexible match is possible', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'function test() { return 42; }' - }) - - -- Search for completely different content - local replacements = { - { search = 'class MyClass extends Base', replace = 'class NewClass extends Base', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_false(success) - assert.equals(1, #errors) - assert.equals(0, count) - assert.matches('No match %(exact or flexible%)', errors[1]) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals('function test() { return 42; }', lines[1]) -- unchanged - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles special regex characters in search text', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - 'const pattern = /^test.*$/g' - }) - - -- Search with regex special characters and different whitespace - local replacements = { - { search = 'const pattern = /^test.*$/g', replace = 'const pattern = /^new.*$/g', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals('const pattern = /^new.*$/g', lines[1]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('handles empty normalized search gracefully', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'some content' }) - - -- Search with only whitespace (should be treated as insert) - local replacements = { - { search = ' \t\n ', replace = 'new content', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_false(success) - assert.equals(1, #errors) - assert.equals(0, count) - assert.matches('No match %(exact or flexible%)', errors[1]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - - it('works with indented code blocks', function() - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - ' if (condition) {', - ' console.log("test");', - ' }' - }) - - -- Search with normalized whitespace - local replacements = { - { search = 'if (condition) {\n console.log("test");\n}', replace = 'if (condition) {\n console.log("modified");\n}', block_number = 1 }, - } - - local success, errors, count = search_replace.apply(buf, replacements) - - assert.is_true(success) - assert.equals(0, #errors) - assert.equals(1, count) - - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - assert.equals(' if (condition) {', lines[1]) - assert.equals(' console.log("modified");', lines[2]) - assert.equals('}', lines[3]) - - vim.api.nvim_buf_delete(buf, { force = true }) - end) - end) -end) From 7f92287513a40be78b364cd6532b21ec53d5c962 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 22 Dec 2025 12:41:15 -0500 Subject: [PATCH 35/46] fix(quick_chat): improve code extraction and raw replace instructions - Refactor regex for code fence and inline code removal - Update raw code insertion instructions for clarity and consistency - Fix display label length calculation in file completion --- lua/opencode/quick_chat.lua | 25 ++++++++++++++----------- lua/opencode/ui/completion/files.lua | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index a1439192..a00acca7 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -141,11 +141,12 @@ local function extract_response_text(message) end end - -- Remove code blocks and inline code - response_text = response_text:gsub('```[^`]*```', '') - response_text = response_text:gsub('`[^`]*`', '') + -- Remove code fences + response_text = response_text:gsub('```[^\n]*\n?', '') -- Remove opening code fence + response_text = response_text:gsub('\n?```', '') -- Remove closing code fence + response_text = response_text:gsub('`([^`\n]*)`', '%1') -- Remove inline code backticks but keep content - return vim.trim(response_text) + return response_text end --- Applies raw code response to buffer (simple replacement) @@ -163,8 +164,8 @@ local function apply_raw_code_response(buf, response_text, row, range) if range then -- Replace the selected range - local start_line = range.start - 1 -- Convert to 0-indexed - local end_line = range.stop - 1 -- Convert to 0-indexed + local start_line = math.floor(range.start) - 1 -- Convert to 0-indexed integer + local end_line = math.floor(range.stop) - 1 -- Convert to 0-indexed integer vim.api.nvim_buf_set_lines(buf, start_line, end_line + 1, false, lines) else -- Replace current line @@ -277,18 +278,20 @@ local function generate_raw_code_instructions(context_config) local context_info = '' if context_config.selection and context_config.selection.enabled then - context_info = 'You have been provided with a code selection [SELECTED CODE]. ' + context_info = 'Output ONLY the code to replace the [SELECTED CODE]. ' elseif context_config.cursor_data and context_config.cursor_data.enabled then - context_info = 'You have been provided with cursor context. [CURSOR POSITION]' + context_info = ' Output ONLY the code to insert/append at the [CURRENT LINE]. ' end local buf = vim.api.nvim_get_current_buf() - local filetype = vim.api.nvim_buf_get_option(buf, 'filetype') + local filetype = vim.bo[buf].filetype return { 'I want you to act as a senior ' .. filetype .. ' developer. ' .. context_info, - 'I will ask you specific questions and I want you to return raw code only ', - '(no codeblocks, no explanations). ', + 'I will ask you specific questions.', + 'I want you to ALWAYS return valid raw code ONLY ', + 'CRITICAL: NEVER add (codeblocks, explanations or any additional text). ', + 'Respect the current indentation and formatting of the existing code. ', "If you can't respond with code, respond with nothing.", } end diff --git a/lua/opencode/ui/completion/files.lua b/lua/opencode/ui/completion/files.lua index f37b3cf9..38ae0b70 100644 --- a/lua/opencode/ui/completion/files.lua +++ b/lua/opencode/ui/completion/files.lua @@ -91,7 +91,7 @@ local function create_file_item(file, suffix, priority) local file_config = config.ui.completion.file_sources local max_display_len = file_config.max_display_length or 50 if #display_label > max_display_len then - display_label = '...' .. display_label:sub(-(max_display_len - 3)) + display_label = '...' .. display_label:sub(-(math.floor(max_display_len) - 3)) end local kind = vim.endswith(file, '/') and 'folder' or 'file' return { From 1fdfb401b7414dfb0073a24aa87ac4f58e64040a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 22 Dec 2025 12:59:13 -0500 Subject: [PATCH 36/46] fix: wrong property name in README Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7ec990f1..b967537f 100644 --- a/README.md +++ b/README.md @@ -305,7 +305,7 @@ require('opencode').setup({ quick_chat = { default_model = nil, -- works better with a fast model like gpt-4.1 default_agent = 'plan', -- plan ensure no file modifications by default - default_prompt = nil, -- Use built-in prompt if nil + instructions = nil, -- Use built-in instructions if nil }, }) ``` From 893efbdb41e35da994fb524b64f48df23ca3ad32 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 22 Dec 2025 13:00:44 -0500 Subject: [PATCH 37/46] chore: remove .values for accessing config --- lua/opencode/quick_chat.lua | 2 +- lua/opencode/quick_chat/spinner.lua | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index a00acca7..1e121484 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -66,7 +66,7 @@ local function cancel_all_quick_chat_sessions() session_info.spinner:stop() end - if config.values.debug.quick_chat and not config.values.debug.quick_chat.keep_session then + if config.debug.quick_chat and not config.debug.quick_chat.keep_session then state.api_client:delete_session(session_id):catch(function(err) vim.notify('Error deleting quickchat session: ' .. vim.inspect(err), vim.log.levels.WARN) end) diff --git a/lua/opencode/quick_chat/spinner.lua b/lua/opencode/quick_chat/spinner.lua index e27988cc..fb389f36 100644 --- a/lua/opencode/quick_chat/spinner.lua +++ b/lua/opencode/quick_chat/spinner.lua @@ -56,7 +56,7 @@ function CursorSpinner:create_float() end function CursorSpinner:get_cancel_key() - local quick_chat_keymap = config.values.keymap.quick_chat or {} + local quick_chat_keymap = config.keymap.quick_chat or {} return quick_chat_keymap.cancel and quick_chat_keymap.cancel[1] or '' end From 147768156f25b24b041ed98e31fe944f26625161 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 22 Dec 2025 13:04:18 -0500 Subject: [PATCH 38/46] fix: message condition --- lua/opencode/quick_chat.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 1e121484..8dedaf31 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -241,7 +241,7 @@ local function validate_quick_chat_prerequisites(message) return false, 'Quick chat requires an active file buffer' end - if message and message == '' then + if not message or message == '' then return false, 'Quick chat message cannot be empty' end From f612caa3dbd65bdfdc883085c9cfa6bf5f464973 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 22 Dec 2025 13:48:02 -0500 Subject: [PATCH 39/46] fix: delta context not using current context from state --- lua/opencode/context/chat_context.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/opencode/context/chat_context.lua b/lua/opencode/context/chat_context.lua index a40ab126..b44d3c18 100644 --- a/lua/opencode/context/chat_context.lua +++ b/lua/opencode/context/chat_context.lua @@ -308,9 +308,8 @@ end -- This function creates a context snapshot with delta logic against the last sent context function M.delta_context(opts) local config = require('opencode.config') - local state = require('opencode.state') - opts = opts or config.context + opts = opts or state.current_context_config or config.context if opts.enabled == false then return { current_file = nil, From cf88ab6088e072cc0a269c8276abc3664efb7716 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 23 Dec 2025 10:21:18 -0500 Subject: [PATCH 40/46] feat(context): add sent_at/sent_at_mtime file context state tracking Track when current_file is sent in context to improve status updates, highlights, and delta computation logic. Refactor context read/write to access through get_context(); --- lua/opencode/context.lua | 9 +- lua/opencode/context/chat_context.lua | 131 ++++++++++++++------- lua/opencode/core.lua | 7 +- lua/opencode/quick_chat.lua | 2 +- lua/opencode/types.lua | 1 + lua/opencode/ui/completion/context.lua | 48 ++++---- lua/opencode/ui/context_bar.lua | 9 +- lua/opencode/ui/highlight.lua | 2 + lua/opencode/ui/prompt_guard_indicator.lua | 2 +- test.lua | 35 ++++++ tests/unit/context_bar_spec.lua | 26 ++++ tests/unit/context_completion_spec.lua | 40 ++++--- tests/unit/context_spec.lua | 89 +++++++++++--- 13 files changed, 286 insertions(+), 115 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 911b2bd6..30e9736d 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -12,6 +12,11 @@ local M = {} M.ChatContext = ChatContext M.QuickChatContext = QuickChatContext +-- Provide access to the context state +function M.get_context() + return ChatContext.context +end + --- Formats context for main chat interface (new simplified API) ---@param prompt string The user's instruction/prompt ---@param context_config? OpencodeContextConfig Optional context config @@ -78,8 +83,6 @@ function M.clear_selections() end function M.add_file(file) - ChatContext.context.mentioned_files = ChatContext.context.mentioned_files or {} - local is_file = vim.fn.filereadable(file) == 1 local is_dir = vim.fn.isdirectory(file) == 1 if not is_file and not is_dir then @@ -137,8 +140,6 @@ function M.delta_context(opts) return ChatContext.delta_context(opts) end -M.context = ChatContext.context - ---@param prompt string ---@param opts? OpencodeContextConfig|nil ---@return OpencodeMessagePart[] diff --git a/lua/opencode/context/chat_context.lua b/lua/opencode/context/chat_context.lua index b44d3c18..75d63248 100644 --- a/lua/opencode/context/chat_context.lua +++ b/lua/opencode/context/chat_context.lua @@ -283,20 +283,67 @@ function M.get_mentioned_subagents() return M.context.mentioned_subagents or {} end +---@param current_file table|nil +---@return boolean, boolean -- should_update, is_different_file +function M.should_update_current_file(current_file) + if not M.context.current_file then + return current_file ~= nil, false + end + + if not current_file then + return false, false + end + + -- Different file name means update needed + if M.context.current_file.name ~= current_file.name then + return true, true + end + + local file_path = current_file.path + if not file_path or vim.fn.filereadable(file_path) ~= 1 then + return false, false + end + + local stat = vim.uv.fs_stat(file_path) + if not (stat and stat.mtime and stat.mtime.sec) then + return false, false + end + + local file_mtime_sec = stat.mtime.sec --[[@as number]] + local last_sent_mtime = M.context.current_file.sent_at_mtime or 0 + return file_mtime_sec > last_sent_mtime, false +end + -- Load function that populates the global context state -- This is the core loading logic that was originally in the main context module function M.load() local buf, win = base_context.get_current_buf() - if buf then - local current_file = base_context.get_current_file(buf) - local cursor_data = base_context.get_current_cursor_data(buf, win) + if not buf or not win then + return + end + + local current_file = base_context.get_current_file(buf) + local cursor_data = base_context.get_current_cursor_data(buf, win) + + local should_update_file, is_different_file = M.should_update_current_file(current_file) + + if should_update_file then + if is_different_file then + M.context.selections = {} + end M.context.current_file = current_file - M.context.cursor_data = cursor_data - M.context.linter_errors = base_context.get_diagnostics(buf, nil, nil) + if M.context.current_file then + M.context.current_file.sent_at = nil + M.context.current_file.sent_at_mtime = nil + end end + M.context.cursor_data = cursor_data + M.context.linter_errors = base_context.get_diagnostics(buf, nil, nil) + + -- Handle current selection local current_selection = base_context.get_current_selection() if current_selection and M.context.current_file then local selection = @@ -305,6 +352,18 @@ function M.load() end end +---@param current_file table +local function set_file_sent_timestamps(current_file) + if not current_file then + return + end + current_file.sent_at = vim.uv.now() + local stat = vim.uv.fs_stat(current_file.path) + if stat and stat.mtime and stat.mtime.sec then + current_file.sent_at_mtime = stat.mtime.sec + end +end + -- This function creates a context snapshot with delta logic against the last sent context function M.delta_context(opts) local config = require('opencode.config') @@ -322,37 +381,31 @@ function M.delta_context(opts) end local buf, win = base_context.get_current_buf() - if not buf or not win then + if not buf then return {} end - local ctx = { - current_file = base_context.get_current_file(buf, opts), - cursor_data = base_context.get_current_cursor_data(buf, win, opts), - mentioned_files = M.context.mentioned_files or {}, - selections = M.context.selections or {}, - linter_errors = base_context.get_diagnostics(buf, opts, nil), - mentioned_subagents = M.context.mentioned_subagents or {}, - } + local ctx = vim.deepcopy(M.context) - -- Delta logic against last sent context + if ctx.current_file and M.context.current_file then + set_file_sent_timestamps(M.context.current_file) + set_file_sent_timestamps(ctx.current_file) + end + + -- no need to send subagents again local last_context = state.last_sent_context if last_context then - -- no need to send file context again - if ctx.current_file and last_context.current_file and ctx.current_file.name == last_context.current_file.name then - ctx.current_file = nil - end - - -- no need to send subagents again if ctx.mentioned_subagents and last_context.mentioned_subagents and vim.deep_equal(ctx.mentioned_subagents, last_context.mentioned_subagents) then ctx.mentioned_subagents = nil + M.context.mentioned_subagents = nil end end + state.context_updated_at = vim.uv.now() return ctx end @@ -368,40 +421,42 @@ M.format_message = Promise.async(function(prompt, opts) local range = opts.range local parts = {} - -- Add mentioned files from global state (always process, even without buffer) for _, file_path in ipairs(M.context.mentioned_files or {}) do table.insert(parts, format_file_part(file_path, prompt)) end - -- Add mentioned subagents from global state (always process, even without buffer) for _, agent in ipairs(M.context.mentioned_subagents or {}) do table.insert(parts, format_subagents_part(agent, prompt)) end - if not buf or not win then - -- Add the main prompt + if not buf then table.insert(parts, { type = 'text', text = prompt }) return { parts = parts } end - -- Add selections (both from range and global state) + if M.context.current_file and not M.context.current_file.sent_at then + table.insert(parts, format_file_part(M.context.current_file.path)) + set_file_sent_timestamps(M.context.current_file) + end + if base_context.is_context_enabled('selection', context_config) then local selections = {} - -- Add range selection if specified if range and range.start and range.stop then local file = base_context.get_current_file(buf, context_config) if file then local selection = base_context.new_selection( file, - table.concat(vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false), '\n'), - string.format('%d-%d', range.start, range.stop) + table.concat( + vim.api.nvim_buf_get_lines(buf, math.floor(range.start) - 1, math.floor(range.stop), false), + '\n' + ), + string.format('%d-%d', math.floor(range.start), math.floor(range.stop)) ) table.insert(selections, selection) end end - -- Add current visual selection if available local current_selection = base_context.get_current_selection(context_config) if current_selection then local file = base_context.get_current_file(buf, context_config) @@ -411,7 +466,6 @@ M.format_message = Promise.async(function(prompt, opts) end end - -- Add selections from global state for _, sel in ipairs(M.context.selections or {}) do table.insert(selections, sel) end @@ -421,28 +475,19 @@ M.format_message = Promise.async(function(prompt, opts) end end - -- Add current file if enabled and not already mentioned - local current_file = base_context.get_current_file(buf, context_config) - if current_file and not vim.tbl_contains(M.context.mentioned_files or {}, current_file.path) then - table.insert(parts, format_file_part(current_file.path)) - end - - -- Add buffer content if enabled if base_context.is_context_enabled('buffer', context_config) then table.insert(parts, format_buffer_part(buf)) end - -- Add diagnostics local diag_range = nil if range then - diag_range = { start_line = range.start - 1, end_line = range.stop - 1 } + diag_range = { start_line = math.floor(range.start) - 1, end_line = math.floor(range.stop) - 1 } end local diagnostics = base_context.get_diagnostics(buf, context_config, diag_range) if diagnostics and #diagnostics > 0 then - table.insert(parts, format_diagnostics_part(diagnostics, nil)) -- No need to filter again + table.insert(parts, format_diagnostics_part(diagnostics, diag_range)) end - -- Add cursor data if base_context.is_context_enabled('cursor_data', context_config) then local cursor_data = base_context.get_current_cursor_data(buf, win, context_config) if cursor_data then @@ -455,7 +500,6 @@ M.format_message = Promise.async(function(prompt, opts) end end - -- Add git diff if base_context.is_context_enabled('git_diff', context_config) then local diff_text = base_context.get_git_diff(context_config):await() if diff_text and diff_text ~= '' then @@ -463,7 +507,6 @@ M.format_message = Promise.async(function(prompt, opts) end end - -- Add the main prompt table.insert(parts, { type = 'text', text = prompt }) return { parts = parts } diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index b48d643e..c91ecfd0 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -69,7 +69,7 @@ M.open = Promise.async(function(opts) local are_windows_closed = state.windows == nil if are_windows_closed then -- Check if whether prompting will be allowed - local mentioned_files = context.context.mentioned_files or {} + local mentioned_files = context.get_context().mentioned_files or {} local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files) if not allowed then vim.notify(err_msg or 'Prompts will be denied by prompt_guard', vim.log.levels.WARN) @@ -138,7 +138,7 @@ M.send_message = Promise.async(function(prompt, opts) return false end - local mentioned_files = context.context.mentioned_files or {} + local mentioned_files = context.get_context().mentioned_files or {} local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files) if not allowed then @@ -237,7 +237,8 @@ end) ---@param prompt string function M.after_run(prompt) context.unload_attachments() - state.last_sent_context = vim.deepcopy(context.context) + state.last_sent_context = vim.deepcopy(context.get_context()) + context.delta_context() require('opencode.history').write(prompt) M._abort_count = 0 end diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 8dedaf31..966cf7d9 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -289,7 +289,7 @@ local function generate_raw_code_instructions(context_config) return { 'I want you to act as a senior ' .. filetype .. ' developer. ' .. context_info, 'I will ask you specific questions.', - 'I want you to ALWAYS return valid raw code ONLY ', + 'I want you to ALWAYS return valid RAW code ONLY ', 'CRITICAL: NEVER add (codeblocks, explanations or any additional text). ', 'Respect the current indentation and formatting of the existing code. ', "If you can't respond with code, respond with nothing.", diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 7d5f91ca..1248eb27 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -390,6 +390,7 @@ ---@field path string ---@field name string ---@field extension string +---@field sent_at? number ---@class OpencodeMessagePartSourceText ---@field start number diff --git a/lua/opencode/ui/completion/context.lua b/lua/opencode/ui/completion/context.lua index e8ec2c9a..2656ca37 100644 --- a/lua/opencode/ui/completion/context.lua +++ b/lua/opencode/ui/completion/context.lua @@ -44,27 +44,26 @@ local function format_diagnostics(diagnostics) return 'No diagnostics available.' end - local counts = {} + local by_severity = { Error = {}, Warning = {}, Info = {}, Hint = {} } for _, diag in ipairs(diagnostics) do - counts[diag.severity] = (counts[diag.severity] or 0) + 1 - end - local parts = {} - if counts[vim.diagnostic.severity.ERROR] then - table.insert(parts, string.format('%d Error%s', counts[1], counts[1] > 1 and 's' or '')) - end - if counts[vim.diagnostic.severity.WARN] then - table.insert(parts, string.format('%d Warning%s', counts[2], counts[2] > 1 and 's' or '')) - end - if counts[vim.diagnostic.severity.INFO] then - table.insert(parts, string.format('%d Info%s', counts[3], counts[3] > 1 and 's' or '')) + local severity = diag.severity + local key = severity == 1 and 'Error' or severity == 2 and 'Warning' or severity == 3 and 'Info' or 'Hint' + table.insert(by_severity[key], diag) end - return table.concat(parts, ', ') + local summary = {} + for key, items in pairs(by_severity) do + if #items > 0 then + table.insert(summary, string.format('%d %s%s', #items, key, #items > 1 and 's' or '')) + end + end + return table.concat(summary, ', ') end local function format_selection(selection) - local lang = selection.file and selection.file.extension or '' - return string.format('```%s\n%s\n```', lang, selection.content) + local lang = (selection.file and selection.file.extension) or '' + local content = selection.content or '' + return string.format('```%s\n%s\n```', lang, content) end ---@param cursor_data? OpencodeContextCursorData @@ -77,14 +76,15 @@ local function format_cursor_data(cursor_data) return 'No cursor data available.' end - local filetype = context.context.current_file and context.context.current_file.extension - local parts = { - 'Line: ' .. (cursor_data.line or 'N/A'), - (cursor_data.column or ''), - string.format('```%s \n%s\n```', filetype, cursor_data.line_content or 'N/A'), - } - - return table.concat(parts, '\n') + local filetype = context.get_context().current_file and context.get_context().current_file.extension or '' + local content = cursor_data.line_content or '' + return string.format( + 'Line: %s\nColumn: %s\n```%s\n%s\n```', + tostring(cursor_data.line or 'N/A'), + tostring(cursor_data.column or 'N/A'), + filetype, + content + ) end ---@param ctx OpencodeContext @@ -238,7 +238,7 @@ local context_source = { return {} end - local ctx = context.delta_context() + local ctx = context.get_context() local items = { add_current_file_item(ctx), diff --git a/lua/opencode/ui/context_bar.lua b/lua/opencode/ui/context_bar.lua index 9b6bcd2d..e97f45c0 100644 --- a/lua/opencode/ui/context_bar.lua +++ b/lua/opencode/ui/context_bar.lua @@ -46,15 +46,20 @@ local function format_cursor_data(ctx) end local function create_winbar_segments() - local ctx = context.delta_context() + local ctx = context.get_context() local segments = {} local current_file = get_current_file_info(ctx) if context.is_context_enabled('current_file') and current_file then + local highlight = 'OpencodeContextCurrentFile' + if ctx.current_file and ctx.current_file.sent_at then + highlight = 'OpencodeContextCurrentFileNotUpdated' + end + table.insert(segments, { icon = icons.get('attached_file'), text = current_file.name, - highlight = 'OpencodeContextCurrentFile', + highlight = highlight, }) end diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index f0d87462..1dc4b421 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -26,6 +26,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextBar', { fg = '#3b4261', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextFile', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFile', { link = 'OpencodeContext', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFileNotUpdated', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextAgent', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextSelection', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextError', { link = '@label', default = true }) @@ -59,6 +60,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextBar', { fg = '#3b4261', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextFile', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFile', { link = '@label', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFileNotUpdated', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextAgent', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextSelection', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodeContextError', { link = 'DiagnosticError', default = true }) diff --git a/lua/opencode/ui/prompt_guard_indicator.lua b/lua/opencode/ui/prompt_guard_indicator.lua index 546ab83c..fbdf1653 100644 --- a/lua/opencode/ui/prompt_guard_indicator.lua +++ b/lua/opencode/ui/prompt_guard_indicator.lua @@ -9,7 +9,7 @@ local context = require('opencode.context') ---@return boolean allowed ---@return string|nil error_message function M.get_status() - local mentioned_files = context.context.mentioned_files or {} + local mentioned_files = context.get_context().mentioned_files or {} return util.check_prompt_allowed(config.prompt_guard, mentioned_files) end diff --git a/test.lua b/test.lua index e69de29b..b7a2ed18 100644 --- a/test.lua +++ b/test.lua @@ -0,0 +1,35 @@ +local arr = { + 'bltqsvhw', + 'dmroktuy', + 'ifwxcpza', + 'imcrwsyj', + 'kxgzlfnv', + 'phaekyru', + 'qzuhbpni', + 'twlmeyvs', + 'uqhdoxzp', + 'vjnslgtm', +} + +function class(base) + local c = {} + c.__index = c + setmetatable(c, { __index = base }) + + function c:new(o) + o = o or {} + setmetatable(o, self) + return o + end + + return c +end + +---@param n integer +---@return integer +function fibonacci(n) + if n <= 1 then + return n + end + return fibonacci(n - 1) + fibonacci(n - 2) +end diff --git a/tests/unit/context_bar_spec.lua b/tests/unit/context_bar_spec.lua index 35120b59..587eaf3d 100644 --- a/tests/unit/context_bar_spec.lua +++ b/tests/unit/context_bar_spec.lua @@ -6,6 +6,7 @@ local assert = require('luassert') describe('opencode.ui.context_bar', function() local original_delta_context + local original_get_context local original_is_context_enabled local original_get_icon local original_subscribe @@ -32,6 +33,7 @@ describe('opencode.ui.context_bar', function() before_each(function() original_delta_context = context.delta_context + original_get_context = context.get_context original_is_context_enabled = context.is_context_enabled original_get_icon = icons.get original_subscribe = state.subscribe @@ -55,6 +57,10 @@ describe('opencode.ui.context_bar', function() return mock_context end + context.get_context = function() + return mock_context + end + context.is_context_enabled = function(_) return true -- Enable all context types by default end @@ -96,6 +102,7 @@ describe('opencode.ui.context_bar', function() after_each(function() context.delta_context = original_delta_context + context.get_context = original_get_context context.is_context_enabled = original_is_context_enabled icons.get = original_get_icon state.subscribe = original_subscribe @@ -135,6 +142,25 @@ describe('opencode.ui.context_bar', function() assert.is_not_nil(winbar_capture.value:find(icons.get('attached_file') .. 'test%.lua')) end) + it('renders current file with dimmed highlight when already sent', function() + mock_context.current_file = { + name = 'test.lua', + path = '/tmp/test.lua', + sent_at = 1234567890000, -- File has been sent + } + + local mock_input_win = 2002 + local winbar_capture = create_mock_window(mock_input_win) + + state.windows = { input_win = mock_input_win } + context_bar.render() + + assert.is_string(winbar_capture.value) + assert.is_not_nil(winbar_capture.value:find(icons.get('attached_file') .. 'test%.lua')) + -- Check that Comment highlight is used for already-sent files + assert.is_not_nil(winbar_capture.value:find('%%#OpencodeContextCurrentFileNotUpdated#')) + end) + it('renders winbar with multiple context elements', function() mock_context.current_file = { name = 'main.lua', path = '/src/main.lua' } mock_context.mentioned_files = { '/file1.lua', '/file2.lua' } diff --git a/tests/unit/context_completion_spec.lua b/tests/unit/context_completion_spec.lua index a95de397..5b00b539 100644 --- a/tests/unit/context_completion_spec.lua +++ b/tests/unit/context_completion_spec.lua @@ -41,35 +41,41 @@ describe('context completion', function() } mock_context = { - is_context_enabled = function(context_type) - return mock_config.context[context_type] and mock_config.context[context_type].enabled or false + is_context_enabled = function() + return true end, delta_context = function() + return {} + end, + add_file = function() end, + add_subagent = function() end, + add_selection = function() end, + remove_file = function() end, + remove_subagent = function() end, + remove_selection = function() end, + get_context = function() return { - current_file = { path = '/test/file.lua', name = 'file.lua', extension = 'lua' }, - mentioned_files = { '/test/other.lua', '/test/helper.js' }, - selections = { - { - file = { name = 'test.lua', extension = 'lua' }, + current_file = { extension = 'lua', name = 'test.lua', path = '/test/test.lua' }, + selections = { + { content = 'local x = 1', - lines = '1-1', - }, + file = { extension = 'lua', name = 'test.lua' }, + lines = '1-1' + } }, - mentioned_subagents = { 'review', 'test' }, + mentioned_files = { '/path/to/file1.lua', '/path/to/file2.lua' }, + mentioned_subagents = { 'review', 'analyze' }, linter_errors = { - { severity = _G.vim.diagnostic.severity.ERROR }, - { severity = _G.vim.diagnostic.severity.WARN }, + { severity = 1, msg = 'Test error message', pos = '1:10' }, + { severity = 2, msg = 'Test warning message', pos = '2:15' } }, cursor_data = { line = 42, column = 10, - line_content = 'local test = "hello"', - }, + line_content = 'local test = "hello"' + } } end, - remove_file = function() end, - remove_subagent = function() end, - remove_selection = function() end, context = { current_file = { extension = 'lua' }, }, diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 4aadb683..ddd3998b 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -50,21 +50,34 @@ end) describe('format_message', function() local original_delta_context + local original_get_context + local mock_context + before_each(function() - context.context.current_file = nil - context.context.mentioned_files = nil - context.context.mentioned_subagents = nil - context.context.selections = nil - context.context.linter_errors = nil - context.context.cursor_data = nil + mock_context = { + current_file = nil, + mentioned_files = nil, + mentioned_subagents = nil, + selections = nil, + linter_errors = nil, + cursor_data = nil, + } + original_delta_context = context.delta_context + original_get_context = context.get_context + + context.get_context = function() + return mock_context + end + context.delta_context = function() - return context.context + return context.get_context() end end) after_each(function() context.delta_context = original_delta_context + context.get_context = original_get_context end) it('returns a parts array with prompt as first part', function() @@ -74,8 +87,9 @@ describe('format_message', function() assert.equal('text', parts[1].type) end) it('includes mentioned_files and subagents', function() - context.context.mentioned_files = { '/tmp/foo.lua' } - context.context.mentioned_subagents = { 'agent1' } + local ChatContext = require('opencode.context.chat_context') + ChatContext.context.mentioned_files = { '/tmp/foo.lua' } + ChatContext.context.mentioned_subagents = { 'agent1' } local parts = context.format_message('prompt @foo.lua @agent1'):wait() assert.is_true(#parts > 2) local found_file, found_agent = false, false @@ -93,16 +107,38 @@ describe('format_message', function() end) describe('delta_context', function() + local mock_context + local original_get_context + + before_each(function() + mock_context = { + current_file = nil, + mentioned_files = nil, + mentioned_subagents = nil, + selections = nil, + linter_errors = nil, + cursor_data = nil, + } + + original_get_context = context.get_context + context.get_context = function() + return mock_context + end + end) + + after_each(function() + context.get_context = original_get_context + end) it('removes current_file if unchanged', function() local file = { name = 'foo.lua', path = '/tmp/foo.lua', extension = 'lua' } - context.context.current_file = vim.deepcopy(file) - state.last_sent_context = { current_file = context.context.current_file } + mock_context.current_file = vim.deepcopy(file) + state.last_sent_context = { current_file = mock_context.current_file } local result = context.delta_context() assert.is_nil(result.current_file) end) it('removes mentioned_subagents if unchanged', function() local subagents = { 'a' } - context.context.mentioned_subagents = vim.deepcopy(subagents) + mock_context.mentioned_subagents = vim.deepcopy(subagents) state.last_sent_context = { mentioned_subagents = vim.deepcopy(subagents) } local result = context.delta_context() assert.is_nil(result.mentioned_subagents) @@ -110,11 +146,26 @@ describe('delta_context', function() end) describe('add_file/add_selection/add_subagent', function() + local ChatContext = require('opencode.context.chat_context') + local original_context + before_each(function() - context.context.mentioned_files = nil - context.context.selections = nil + -- Store original context + original_context = vim.deepcopy(ChatContext.context) + + -- Reset to clean state + ChatContext.context.mentioned_files = {} + ChatContext.context.selections = {} + ChatContext.context.mentioned_subagents = {} + context.delta_context() - context.context.mentioned_subagents = nil + end) + + after_each(function() + -- Restore original context + for k, v in pairs(original_context) do + ChatContext.context[k] = v + end end) it('adds a file if filereadable', function() vim.fn.filereadable = function() @@ -127,7 +178,7 @@ describe('add_file/add_selection/add_subagent', function() end context.add_file('/tmp/foo.lua') - assert.same({ '/tmp/foo.lua' }, context.context.mentioned_files) + assert.same({ '/tmp/foo.lua' }, context.get_context().mentioned_files) util.is_path_in_cwd = original_is_path_in_cwd end) @@ -136,15 +187,15 @@ describe('add_file/add_selection/add_subagent', function() return 0 end context.add_file('/tmp/bar.lua') - assert.same({}, context.context.mentioned_files) + assert.same({}, context.get_context().mentioned_files) end) it('adds a selection', function() context.add_selection({ foo = 'bar' }) - assert.same({ { foo = 'bar' } }, context.context.selections) + assert.same({ { foo = 'bar' } }, context.get_context().selections) end) it('adds a subagent', function() context.add_subagent('agentX') - assert.same({ 'agentX' }, context.context.mentioned_subagents) + assert.same({ 'agentX' }, context.get_context().mentioned_subagents) end) end) From b5d86fdb438ba44bbfc2dc911cd474f8a8c31ad3 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 23 Dec 2025 15:57:14 -0500 Subject: [PATCH 41/46] fix: send message not working in last merge --- lua/opencode/core.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index c91ecfd0..8e73e3b8 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -182,7 +182,7 @@ Examples: This matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation. ]] - params.parts = context.format_message(prompt, opts.context) + params.parts = context.format_message(prompt, opts.context):await() M.before_run(opts) local session_id = state.active_session.id From 08bf3f95c7a3535bcb8e6dc2fdde16175d39657b Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 24 Dec 2025 06:43:41 -0500 Subject: [PATCH 42/46] refactor(context): remove unused legacy context management and test files - Deleted legacy context.lua.backup and unrelated test.lua/test.txt - Streamlined project by removing outdated or unnecessary files --- lua/opencode/context.lua.backup | 792 -------------------------------- test.lua | 35 -- test.txt | 1 - 3 files changed, 828 deletions(-) delete mode 100644 lua/opencode/context.lua.backup delete mode 100644 test.lua delete mode 100644 test.txt diff --git a/lua/opencode/context.lua.backup b/lua/opencode/context.lua.backup deleted file mode 100644 index 0f5cd4da..00000000 --- a/lua/opencode/context.lua.backup +++ /dev/null @@ -1,792 +0,0 @@ --- Gathers editor context - -local util = require('opencode.util') -local config = require('opencode.config') -local state = require('opencode.state') - -local M = {} - ----@class ContextInstance ----@field private _context OpencodeContext ----@field private _last_context OpencodeContext|nil -local ContextInstance = {} -ContextInstance.__index = ContextInstance - ---- Creates a new Context instance ----@return ContextInstance -function ContextInstance:new() - local obj = setmetatable({}, self) - obj._context = { - -- current file - current_file = nil, - cursor_data = nil, - - -- attachments - mentioned_files = nil, - selections = {}, - linter_errors = {}, - mentioned_subagents = {}, - } - obj._last_context = nil - return obj -end - ---- Get the internal context data (read-only) ----@return OpencodeContext -function ContextInstance:get_context() - return vim.deepcopy(self._context) -end - ----@type OpencodeContext -M.context = { - -- current file - current_file = nil, - cursor_data = nil, - - -- attachments - mentioned_files = nil, - selections = {}, - linter_errors = {}, - mentioned_subagents = {}, -} - --- Default instance for backward compatibility ----@type ContextInstance -M._default_instance = ContextInstance:new() - -function ContextInstance:unload_attachments() - self._context.mentioned_files = nil - self._context.selections = nil - self._context.linter_errors = nil -end - -function M.unload_attachments() - M._default_instance:unload_attachments() - -- Also update the global context for backward compatibility - M.context.mentioned_files = nil - M.context.selections = nil - M.context.linter_errors = nil -end - -function ContextInstance:get_current_buf() - local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() - if util.is_buf_a_file(curr_buf) then - return curr_buf, state.last_code_win_before_opencode or vim.api.nvim_get_current_win() - end -end - -function M.get_current_buf() - local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() - if util.is_buf_a_file(curr_buf) then - return curr_buf, state.last_code_win_before_opencode or vim.api.nvim_get_current_win() - end -end - -function ContextInstance:load() - local buf, win = self:get_current_buf() - - if buf then - local current_file = self:get_current_file(buf) - local cursor_data = self:get_current_cursor_data(buf, win) - - self._context.current_file = current_file - self._context.cursor_data = cursor_data - self._context.linter_errors = self:get_diagnostics(buf) - end - - local current_selection = self:get_current_selection() - if current_selection then - local selection = self:new_selection(self._context.current_file, current_selection.text, current_selection.lines) - self:add_selection(selection) - end - -- Note: We don't update state.context_updated_at for instance methods -end - -function M.load() - local buf, win = M.get_current_buf() - - if buf then - local current_file = M.get_current_file(buf) - local cursor_data = M.get_current_cursor_data(buf, win) - - M.context.current_file = current_file - M.context.cursor_data = cursor_data - M.context.linter_errors = M.get_diagnostics(buf) - end - - local current_selection = M.get_current_selection() - if current_selection then - local selection = M.new_selection(M.context.current_file, current_selection.text, current_selection.lines) - M.add_selection(selection) - end - state.context_updated_at = vim.uv.now() - - -- Update default instance to keep it in sync - M._default_instance._context = vim.deepcopy(M.context) -end - --- Checks if a context feature is enabled in config or state ----@param context_key string ----@return boolean -function ContextInstance:is_context_enabled(context_key) - local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') - local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') - - if is_state_enabled ~= nil then - return is_state_enabled - else - return is_enabled - end -end - --- Checks if a context feature is enabled in config or state ----@param context_key string ----@return boolean -function M.is_context_enabled(context_key) - local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled') - local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled') - - if is_state_enabled ~= nil then - return is_state_enabled - else - return is_enabled - end -end - ----@return OpencodeDiagnostic[]|nil -function ContextInstance:get_diagnostics(buf) - if not self:is_context_enabled('diagnostics') then - return nil - end - - local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} - if current_conf.enabled == false then - return {} - end - - local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} - local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf) or {} - - local severity_levels = {} - if diagnostic_conf.error then - table.insert(severity_levels, vim.diagnostic.severity.ERROR) - end - if diagnostic_conf.warning then - table.insert(severity_levels, vim.diagnostic.severity.WARN) - end - if diagnostic_conf.info then - table.insert(severity_levels, vim.diagnostic.severity.INFO) - end - - local diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) - if #diagnostics == 0 then - return {} - end - - -- Convert vim.Diagnostic[] to OpencodeDiagnostic[] - local opencode_diagnostics = {} - for _, diag in ipairs(diagnostics) do - table.insert(opencode_diagnostics, { - message = diag.message, - severity = diag.severity, - lnum = diag.lnum, - col = diag.col, - end_lnum = diag.end_lnum, - end_col = diag.end_col, - source = diag.source, - code = diag.code, - user_data = diag.user_data, - }) - end - - return opencode_diagnostics -end - ----@return OpencodeDiagnostic[]|nil -function M.get_diagnostics(buf) - if not M.is_context_enabled('diagnostics') then - return nil - end - - local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {} - if current_conf.enabled == false then - return {} - end - - local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {} - local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf) or {} - - local severity_levels = {} - if diagnostic_conf.error then - table.insert(severity_levels, vim.diagnostic.severity.ERROR) - end - if diagnostic_conf.warning then - table.insert(severity_levels, vim.diagnostic.severity.WARN) - end - if diagnostic_conf.info then - table.insert(severity_levels, vim.diagnostic.severity.INFO) - end - - local diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) - if #diagnostics == 0 then - return {} - end - - -- Convert vim.Diagnostic[] to OpencodeDiagnostic[] - local opencode_diagnostics = {} - for _, diag in ipairs(diagnostics) do - table.insert(opencode_diagnostics, { - message = diag.message, - severity = diag.severity, - lnum = diag.lnum, - col = diag.col, - end_lnum = diag.end_lnum, - end_col = diag.end_col, - source = diag.source, - code = diag.code, - user_data = diag.user_data, - }) - end - - return opencode_diagnostics -end - -function ContextInstance:new_selection(file, content, lines) - return { - file = file, - content = util.indent_code_block(content), - lines = lines, - } -end - -function M.new_selection(file, content, lines) - return { - file = file, - content = util.indent_code_block(content), - lines = lines, - } -end - -function ContextInstance:add_selection(selection) - if not self._context.selections then - self._context.selections = {} - end - - table.insert(self._context.selections, selection) - -- Note: We don't update state.context_updated_at for instance methods -end - -function M.add_selection(selection) - if not M.context.selections then - M.context.selections = {} - end - - table.insert(M.context.selections, selection) - - state.context_updated_at = vim.uv.now() -end - -function ContextInstance:remove_selection(selection) - if not self._context.selections then - return - end - - for i, sel in ipairs(self._context.selections) do - if sel.file.path == selection.file.path and sel.lines == selection.lines then - table.remove(self._context.selections, i) - break - end - end - -- Note: We don't update state.context_updated_at for instance methods -end - -function M.remove_selection(selection) - if not M.context.selections then - return - end - - for i, sel in ipairs(M.context.selections) do - if sel.file.path == selection.file.path and sel.lines == selection.lines then - table.remove(M.context.selections, i) - break - end - end - state.context_updated_at = vim.uv.now() -end - -function ContextInstance:clear_selections() - self._context.selections = nil -end - -function M.clear_selections() - M.context.selections = nil -end - -function M.add_file(file) - if not M.context.mentioned_files then - M.context.mentioned_files = {} - end - - local is_file = vim.fn.filereadable(file) == 1 - local is_dir = vim.fn.isdirectory(file) == 1 - if not is_file and not is_dir then - vim.notify('File not added to context. Could not read.') - return - end - - if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then - vim.notify('File not added to context. Must be inside current working directory.') - return - end - - file = vim.fn.fnamemodify(file, ':p') - - if not vim.tbl_contains(M.context.mentioned_files, file) then - table.insert(M.context.mentioned_files, file) - end - - state.context_updated_at = vim.uv.now() -end - -function M.remove_file(file) - file = vim.fn.fnamemodify(file, ':p') - if not M.context.mentioned_files then - return - end - - for i, f in ipairs(M.context.mentioned_files) do - if f == file then - table.remove(M.context.mentioned_files, i) - break - end - end - state.context_updated_at = vim.uv.now() -end - -function M.clear_files() - M.context.mentioned_files = nil -end - -function M.add_subagent(subagent) - if not M.context.mentioned_subagents then - M.context.mentioned_subagents = {} - end - - if not vim.tbl_contains(M.context.mentioned_subagents, subagent) then - table.insert(M.context.mentioned_subagents, subagent) - end - state.context_updated_at = vim.uv.now() -end - -function M.remove_subagent(subagent) - if not M.context.mentioned_subagents then - return - end - - for i, a in ipairs(M.context.mentioned_subagents) do - if a == subagent then - table.remove(M.context.mentioned_subagents, i) - break - end - end - state.context_updated_at = vim.uv.now() -end - -function M.clear_subagents() - M.context.mentioned_subagents = nil -end - ----@param opts? OpencodeContextConfig ----@return OpencodeContext -function M.delta_context(opts) - opts = opts or config.context - if opts.enabled == false then - return { - current_file = nil, - mentioned_files = nil, - selections = nil, - linter_errors = nil, - cursor_data = nil, - mentioned_subagents = nil, - } - end - - local context = vim.deepcopy(M.context) - local last_context = state.last_sent_context - if not last_context then - return context - end - - -- no need to send file context again - if - context.current_file - and last_context.current_file - and context.current_file.name == last_context.current_file.name - then - context.current_file = nil - end - - -- no need to send subagents again - if - context.mentioned_subagents - and last_context.mentioned_subagents - and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents) - then - context.mentioned_subagents = nil - end - - return context -end - -function M.get_current_file(buf) - if not M.is_context_enabled('current_file') then - return nil - end - local file = vim.api.nvim_buf_get_name(buf) - if not file or file == '' or vim.fn.filereadable(file) ~= 1 then - return nil - end - return { - path = file, - name = vim.fn.fnamemodify(file, ':t'), - extension = vim.fn.fnamemodify(file, ':e'), - } -end - -function M.get_current_cursor_data(buf, win) - if not M.is_context_enabled('cursor_data') then - return nil - end - - local cursor_pos = vim.fn.getcurpos(win) - local start_line = (cursor_pos[2] - 1) --[[@as integer]] - local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '') - return { line = cursor_pos[2], column = cursor_pos[3], line_content = cursor_content } -end - -function M.get_current_selection() - if not M.is_context_enabled('selection') then - return nil - end - - -- Return nil if not in a visual mode - if not vim.fn.mode():match('[vV\022]') then - return nil - end - - -- Save current position and register state - local current_pos = vim.fn.getpos('.') - local old_reg = vim.fn.getreg('x') - local old_regtype = vim.fn.getregtype('x') - - -- Capture selection text and position - vim.cmd('normal! "xy') - local text = vim.fn.getreg('x') - - -- Get line numbers - vim.cmd('normal! `<') - local start_line = vim.fn.line('.') - vim.cmd('normal! `>') - local end_line = vim.fn.line('.') - - -- Restore state - vim.fn.setreg('x', old_reg, old_regtype) - vim.cmd('normal! gv') - vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true) - vim.fn.setpos('.', current_pos) - - if not text or text == '' then - return nil - end - - return { - text = text and text:match('[^%s]') and text or nil, - lines = start_line .. ', ' .. end_line, - } -end - -local function format_file_part(path, prompt) - local rel_path = vim.fn.fnamemodify(path, ':~:.') - local mention = '@' .. rel_path - local pos = prompt and prompt:find(mention) - pos = pos and pos - 1 or 0 -- convert to 0-based index - - local ext = vim.fn.fnamemodify(path, ':e'):lower() - local mime_type = 'text/plain' - if ext == 'png' then - mime_type = 'image/png' - elseif ext == 'jpg' or ext == 'jpeg' then - mime_type = 'image/jpeg' - elseif ext == 'gif' then - mime_type = 'image/gif' - elseif ext == 'webp' then - mime_type = 'image/webp' - end - - local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path } - if prompt then - file_part.source = { - path = path, - type = 'file', - text = { start = pos, value = mention, ['end'] = pos + #mention }, - } - end - return file_part -end - ----@param selection OpencodeContextSelection -local function format_selection_part(selection) - local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or '' - - return { - type = 'text', - text = vim.json.encode({ - context_type = 'selection', - file = selection.file, - content = string.format('`````%s\n%s\n`````', lang, selection.content), - lines = selection.lines, - }), - synthetic = true, - } -end - ----@param diagnostics OpencodeDiagnostic[] -local function format_diagnostics_part(diagnostics) - local diag_list = {} - for _, diag in ipairs(diagnostics) do - local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '') - table.insert( - diag_list, - { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 } - ) - end - return { - type = 'text', - text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }), - synthetic = true, - } -end - -local function format_cursor_data_part(cursor_data) - local buf = (M.get_current_buf() or 0) --[[@as integer]] - local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or '' - return { - type = 'text', - text = vim.json.encode({ - context_type = 'cursor-data', - line = cursor_data.line, - column = cursor_data.column, - line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content), - }), - synthetic = true, - } -end - -local function format_subagents_part(agent, prompt) - local mention = '@' .. agent - local pos = prompt:find(mention) - pos = pos and pos - 1 or 0 -- convert to 0-based index - - return { - type = 'agent', - name = agent, - source = { value = mention, start = pos, ['end'] = pos + #mention }, - } -end - ---- Formats a prompt and context into message with parts for the opencode API ----@param prompt string ----@param opts? OpencodeContextConfig|nil ----@return OpencodeMessagePart[] -function M.format_message(prompt, opts) - opts = opts or config.context - local context = M.delta_context(opts) - - local parts = { { type = 'text', text = prompt } } - - for _, path in ipairs(context.mentioned_files or {}) do - -- don't resend current file if it's also mentioned - if not context.current_file or path ~= context.current_file.path then - table.insert(parts, format_file_part(path, prompt)) - end - end - - for _, sel in ipairs(context.selections or {}) do - table.insert(parts, format_selection_part(sel)) - end - - for _, agent in ipairs(context.mentioned_subagents or {}) do - table.insert(parts, format_subagents_part(agent, prompt)) - end - - if context.current_file then - table.insert(parts, format_file_part(context.current_file.path)) - end - - if context.linter_errors and #context.linter_errors > 0 then - table.insert(parts, format_diagnostics_part(context.linter_errors)) - end - - if context.cursor_data then - table.insert(parts, format_cursor_data_part(context.cursor_data)) - end - - return parts -end - ---- Formats a prompt and context into message without state tracking (bypasses delta) ---- Used for ephemeral sessions like quick chat that don't track context state ----@param prompt string ----@param opts? OpencodeContextConfig|nil ----@return OpencodeMessagePart[] -function M.format_message_stateless(prompt, opts) - opts = opts or config.context - if opts.enabled == false then - return { { type = 'text', text = prompt } } - end - - -- Use full context instead of delta for ephemeral sessions - local context = vim.deepcopy(M.context) - - local parts = { { type = 'text', text = prompt } } - - for _, path in ipairs(context.mentioned_files or {}) do - -- don't resend current file if it's also mentioned - if not context.current_file or path ~= context.current_file.path then - table.insert(parts, format_file_part(path, prompt)) - end - end - - for _, sel in ipairs(context.selections or {}) do - table.insert(parts, format_selection_part(sel)) - end - - for _, agent in ipairs(context.mentioned_subagents or {}) do - table.insert(parts, format_subagents_part(agent, prompt)) - end - - if context.current_file then - table.insert(parts, format_file_part(context.current_file.path)) - end - - if context.linter_errors and #context.linter_errors > 0 then - table.insert(parts, format_diagnostics_part(context.linter_errors)) - end - - if context.cursor_data then - table.insert(parts, format_cursor_data_part(context.cursor_data)) - end - - return parts -end - ----@param text string ----@param context_type string|nil -function M.decode_json_context(text, context_type) - local ok, result = pcall(vim.json.decode, text) - if not ok or (context_type and result.context_type ~= context_type) then - return nil - end - return result -end - ---- Extracts context from an OpencodeMessage (with parts) ----@param message { parts: OpencodeMessagePart[] } ----@return { prompt: string|nil, selected_text: string|nil, current_file: string|nil, mentioned_files: string[]|nil} -function M.extract_from_opencode_message(message) - local ctx = { prompt = nil, selected_text = nil, current_file = nil } - - local handlers = { - text = function(part) - ctx.prompt = ctx.prompt or part.text or '' - end, - text_context = function(part) - local json = M.decode_json_context(part.text, 'selection') - ctx.selected_text = json and json.content or ctx.selected_text - end, - file = function(part) - if not part.source then - ctx.current_file = part.filename - end - end, - } - - for _, part in ipairs(message and message.parts or {}) do - local handler = handlers[part.type .. (part.synthetic and '_context' or '')] - if handler then - handler(part) - end - - if ctx.prompt and ctx.selected_text and ctx.current_file then - break - end - end - - return ctx -end - -function M.extract_from_message_legacy(text) - local current_file = M.extract_legacy_tag('current-file', text) - local context = { - prompt = M.extract_legacy_tag('user-query', text) or text, - selected_text = M.extract_legacy_tag('manually-added-selection', text), - current_file = current_file and current_file:match('Path: (.+)') or nil, - } - return context -end - -function M.extract_legacy_tag(tag, text) - local start_tag = '<' .. tag .. '>' - local end_tag = '' - - local pattern = vim.pesc(start_tag) .. '(.-)' .. vim.pesc(end_tag) - local content = text:match(pattern) - - if content then - return vim.trim(content) - end - - -- Fallback to the original method if pattern matching fails - local query_start = text:find(start_tag) - local query_end = text:find(end_tag) - - if query_start and query_end then - local query_content = text:sub(query_start + #start_tag, query_end - 1) - return vim.trim(query_content) - end - - return nil -end - -function M.setup() - state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function(a) - M.load() - end) - - local augroup = vim.api.nvim_create_augroup('OpenCodeContext', { clear = true }) - vim.api.nvim_create_autocmd('BufWritePost', { - pattern = '*', - group = augroup, - callback = function(args) - local buf = args.buf - local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() - if buf == curr_buf and util.is_buf_a_file(buf) then - M.load() - end - end, - }) - - vim.api.nvim_create_autocmd('DiagnosticChanged', { - pattern = '*', - group = augroup, - callback = function(args) - local buf = args.buf - local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() - if buf == curr_buf and util.is_buf_a_file(buf) and M.is_context_enabled('diagnostics') then - M.load() - end - end, - }) -end - -return M diff --git a/test.lua b/test.lua deleted file mode 100644 index b7a2ed18..00000000 --- a/test.lua +++ /dev/null @@ -1,35 +0,0 @@ -local arr = { - 'bltqsvhw', - 'dmroktuy', - 'ifwxcpza', - 'imcrwsyj', - 'kxgzlfnv', - 'phaekyru', - 'qzuhbpni', - 'twlmeyvs', - 'uqhdoxzp', - 'vjnslgtm', -} - -function class(base) - local c = {} - c.__index = c - setmetatable(c, { __index = base }) - - function c:new(o) - o = o or {} - setmetatable(o, self) - return o - end - - return c -end - ----@param n integer ----@return integer -function fibonacci(n) - if n <= 1 then - return n - end - return fibonacci(n - 1) + fibonacci(n - 2) -end diff --git a/test.txt b/test.txt deleted file mode 100644 index 792b5ae7..00000000 --- a/test.txt +++ /dev/null @@ -1 +0,0 @@ -Received. \ No newline at end of file From 27db2e4f4224d9d226036334c5c838a1a6e251e2 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 24 Dec 2025 09:45:39 -0500 Subject: [PATCH 43/46] feat: add support for multi-range diagnostics with selection-aware filtering - Enhance diagnostics to handle multiple selection ranges simultaneously - Add visual filter indicator in context bar for scoped diagnostic display - Improve chat context to automatically use stored selections for diagnostic filtering --- lua/opencode/context/base_context.lua | 46 +++-- lua/opencode/context/chat_context.lua | 53 ++++- lua/opencode/ui/context_bar.lua | 5 +- lua/opencode/ui/icons.lua | 2 + tests/unit/context_bar_spec.lua | 29 ++- tests/unit/context_spec.lua | 272 ++++++++++++++++++++++++++ 6 files changed, 378 insertions(+), 29 deletions(-) diff --git a/lua/opencode/context/base_context.lua b/lua/opencode/context/base_context.lua index abfda95d..09b9b19a 100644 --- a/lua/opencode/context/base_context.lua +++ b/lua/opencode/context/base_context.lua @@ -1,6 +1,3 @@ --- Base context utilities --- Static methods for gathering context data from the editor - local util = require('opencode.util') local config = require('opencode.config') local state = require('opencode.state') @@ -41,7 +38,7 @@ end ---@param buf integer ---@param context_config? OpencodeContextConfig ----@param range? { start_line: integer, end_line: integer } +---@param range? { start_line: integer, end_line: integer } | { start_line: integer, end_line: integer }[] ---@return OpencodeDiagnostic[]|nil function M.get_diagnostics(buf, context_config, range) if not M.is_context_enabled('diagnostics', context_config) then @@ -69,16 +66,27 @@ function M.get_diagnostics(buf, context_config, range) end local diagnostics = {} + + local ranges = nil + if range then + if range[1] and type(range[1]) == 'table' then + ranges = range + else + ranges = { range } + end + end + if diagnostic_conf.only_closest then - if range then - -- Get diagnostics for the specified range - for line_num = range.start_line, range.end_line do - local line_diagnostics = vim.diagnostic.get(buf, { - lnum = line_num, - severity = severity_levels, - }) - for _, diag in ipairs(line_diagnostics) do - table.insert(diagnostics, diag) + if ranges then + for _, r in ipairs(ranges) do + for line_num = r.start_line, r.end_line do + local line_diagnostics = vim.diagnostic.get(buf, { + lnum = line_num, + severity = severity_levels, + }) + for _, diag in ipairs(line_diagnostics) do + table.insert(diagnostics, diag) + end end end else @@ -92,17 +100,7 @@ function M.get_diagnostics(buf, context_config, range) diagnostics = line_diagnostics end else - -- Get all diagnostics, optionally filtered by range diagnostics = vim.diagnostic.get(buf, { severity = severity_levels }) - if range then - local filtered_diagnostics = {} - for _, diag in ipairs(diagnostics) do - if diag.lnum >= range.start_line and diag.lnum <= range.end_line then - table.insert(filtered_diagnostics, diag) - end - end - diagnostics = filtered_diagnostics - end end if #diagnostics == 0 then @@ -124,7 +122,7 @@ function M.get_diagnostics(buf, context_config, range) }) end - return opencode_diagnostics + return opencode_diagnostics, ranges end ---@param buf integer diff --git a/lua/opencode/context/chat_context.lua b/lua/opencode/context/chat_context.lua index 75d63248..8dfd21ed 100644 --- a/lua/opencode/context/chat_context.lua +++ b/lua/opencode/context/chat_context.lua @@ -283,6 +283,54 @@ function M.get_mentioned_subagents() return M.context.mentioned_subagents or {} end +-- Chat-context-aware get_diagnostics that considers stored selections +---@param buf integer +---@param context_config? OpencodeContextConfig +---@param range? { start_line: integer, end_line: integer } +---@return OpencodeDiagnostic[]|nil +function M.get_diagnostics(buf, context_config, range) + -- Use explicit range if provided + if range then + return base_context.get_diagnostics(buf, context_config, range) + end + + if M.context.selections and #M.context.selections > 0 then + local selection_ranges = {} + + for _, sel in ipairs(M.context.selections) do + if sel.lines then + -- Handle both formats: "1, 5" and "1-5" + local start_line, end_line = sel.lines:match('(%d+)[,%-]%s*(%d+)') + if not start_line then + -- Single line case like "5, 5" or just "5" + start_line = sel.lines:match('(%d+)') + end_line = start_line + end + + if start_line then + local start_num = tonumber(start_line) + local end_num = tonumber(end_line) + + if start_num and end_num then + -- Convert to 0-based + local selection_range = { + start_line = start_num - 1, + end_line = end_num - 1, + } + table.insert(selection_ranges, selection_range) + end + end + end + end + + if #selection_ranges > 0 then + return base_context.get_diagnostics(buf, context_config, selection_ranges) + end + end + + return base_context.get_diagnostics(buf, context_config, nil) +end + ---@param current_file table|nil ---@return boolean, boolean -- should_update, is_different_file function M.should_update_current_file(current_file) @@ -299,6 +347,7 @@ function M.should_update_current_file(current_file) return true, true end + -- Same file, check modification time local file_path = current_file.path if not file_path or vim.fn.filereadable(file_path) ~= 1 then return false, false @@ -341,7 +390,7 @@ function M.load() end M.context.cursor_data = cursor_data - M.context.linter_errors = base_context.get_diagnostics(buf, nil, nil) + M.context.linter_errors = M.get_diagnostics(buf, nil, nil) -- Handle current selection local current_selection = base_context.get_current_selection() @@ -483,7 +532,7 @@ M.format_message = Promise.async(function(prompt, opts) if range then diag_range = { start_line = math.floor(range.start) - 1, end_line = math.floor(range.stop) - 1 } end - local diagnostics = base_context.get_diagnostics(buf, context_config, diag_range) + local diagnostics = M.get_diagnostics(buf, context_config, diag_range) if diagnostics and #diagnostics > 0 then table.insert(parts, format_diagnostics_part(diagnostics, diag_range)) end diff --git a/lua/opencode/ui/context_bar.lua b/lua/opencode/ui/context_bar.lua index e97f45c0..eff0061b 100644 --- a/lua/opencode/ui/context_bar.lua +++ b/lua/opencode/ui/context_bar.lua @@ -3,6 +3,7 @@ local M = {} local context = require('opencode.context') local icons = require('opencode.ui.icons') local state = require('opencode.state') +local config = require('opencode.config') local prompt_guard_indicator = require('opencode.ui.prompt_guard_indicator') local function get_current_file_info(ctx) @@ -111,18 +112,18 @@ local function create_winbar_segments() counts[type_name] = (counts[type_name] or 0) + 1 end + local filter_icon = config.context.diagnostics.only_closest and icons.get('filter') or '' for _, type_name in pairs(severity_types) do local count = counts[type_name] if count and count > 0 then table.insert(segments, { icon = icons.get(type_name), - text = '(' .. count .. ')', + text = '(' .. count .. filter_icon .. ')', highlight = 'OpencodeContext' .. type_name:gsub('^%l', string.upper), }) end end end - return segments end diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua index b110c1d0..53baf91f 100644 --- a/lua/opencode/ui/icons.lua +++ b/lua/opencode/ui/icons.lua @@ -38,6 +38,7 @@ local presets = { error = ' ', warning = ' ', info = ' ', + filter = '/', selection = '󰫙 ', command = ' ', }, @@ -74,6 +75,7 @@ local presets = { error = '[E]', warning = '[W]', info = '[I] ', + filter = '/*', selection = "'<'> ", command = '::', }, diff --git a/tests/unit/context_bar_spec.lua b/tests/unit/context_bar_spec.lua index 587eaf3d..2f4b51c5 100644 --- a/tests/unit/context_bar_spec.lua +++ b/tests/unit/context_bar_spec.lua @@ -2,6 +2,7 @@ local context_bar = require('opencode.ui.context_bar') local context = require('opencode.context') local state = require('opencode.state') local icons = require('opencode.ui.icons') +local config = require('opencode.config') local assert = require('luassert') describe('opencode.ui.context_bar', function() @@ -181,7 +182,9 @@ describe('opencode.ui.context_bar', function() assert.is_not_nil(winbar_capture.value:find('L:10')) -- Cursor data end) - it('renders winbar with diagnostics', function() + it('renders winbar with all diagnostics', function() + local original_only_closest = config.context.diagnostics.only_closest + config.context.diagnostics.only_closest = false mock_context.linter_errors = { { severity = 1 }, -- ERROR { severity = 1 }, -- ERROR @@ -199,6 +202,30 @@ describe('opencode.ui.context_bar', function() assert.is_not_nil(winbar_capture.value:find(icons.get('error') .. '%(2%)')) -- 2 errors assert.is_not_nil(winbar_capture.value:find(icons.get('warning') .. '%(1%)')) -- Warning icon assert.is_not_nil(winbar_capture.value:find(icons.get('info') .. '%(1%)')) -- Info icon + config.context.diagnostics.only_closest = original_only_closest + end) + + it('renders winbar with filtered diagnostics', function() + local original_only_closest = config.context.diagnostics.only_closest + config.context.diagnostics.only_closest = true + mock_context.linter_errors = { + { severity = 1 }, -- ERROR + { severity = 1 }, -- ERROR + { severity = 2 }, -- WARN + { severity = 3 }, -- INFO + } + + local mock_input_win = 2004 + local winbar_capture = create_mock_window(mock_input_win) + + state.windows = { input_win = mock_input_win } + context_bar.render() + + assert.is_string(winbar_capture.value) + assert.is_not_nil(winbar_capture.value:find(icons.get('error') .. '%(2' .. icons.get('filter') .. '%)')) -- 2 errors + assert.is_not_nil(winbar_capture.value:find(icons.get('warning') .. '%(1' .. icons.get('filter') .. '%)')) -- Warning icon + assert.is_not_nil(winbar_capture.value:find(icons.get('info') .. '%(1' .. icons.get('filter') .. '%)')) -- Info icon + config.context.diagnostics.only_closest = original_only_closest end) it('respects context enabled settings', function() diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index ddd3998b..c12d33a3 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -234,3 +234,275 @@ describe('context static API with config override', function() assert.is_not_nil(context.is_context_enabled('diagnostics')) end) end) + +describe('get_diagnostics with chat context selections', function() + local ChatContext + + before_each(function() + ChatContext = require('opencode.context.chat_context') + -- Reset chat context + ChatContext.context = { + mentioned_files = {}, + selections = {}, + mentioned_subagents = {}, + current_file = nil, + cursor_data = nil, + linter_errors = nil, + } + end) + + it('should use chat context selection range when no explicit range provided', function() + -- Add a mock selection to chat context + local mock_selection = { + file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' }, + content = 'print("hello")', + lines = '5, 8' -- Lines 5 to 8 (1-based) + } + ChatContext.add_selection(mock_selection) + + -- Mock the BaseContext.get_diagnostics to capture the range parameter + local BaseContext = require('opencode.context.base_context') + local captured_range = nil + local original_get_diagnostics = BaseContext.get_diagnostics + BaseContext.get_diagnostics = function(buf, context_config, range) + captured_range = range + return {} + end + + -- Call get_diagnostics without an explicit range + ChatContext.get_diagnostics(1, nil, nil) + + -- Verify that a list of ranges was passed to base_context + assert.is_not_nil(captured_range) + assert.equal('table', type(captured_range)) + assert.equal(1, #captured_range) -- Should have one range in the list + assert.equal(4, captured_range[1].start_line) -- 5 - 1 (0-based) + assert.equal(7, captured_range[1].end_line) -- 8 - 1 (0-based) + + -- Restore original function + BaseContext.get_diagnostics = original_get_diagnostics + end) + + it('should prioritize explicit range over chat context selections', function() + -- Add a mock selection to chat context + local mock_selection = { + file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' }, + content = 'print("hello")', + lines = '5, 8' + } + ChatContext.add_selection(mock_selection) + + -- Mock the BaseContext.get_diagnostics to capture the range parameter + local BaseContext = require('opencode.context.base_context') + local captured_range = nil + local original_get_diagnostics = BaseContext.get_diagnostics + BaseContext.get_diagnostics = function(buf, context_config, range) + captured_range = range + return {} + end + + -- Call get_diagnostics with an explicit range + local explicit_range = { start_line = 10, end_line = 15 } + ChatContext.get_diagnostics(1, nil, explicit_range) + + -- Verify that the explicit range was used, not the selection range + assert.is_not_nil(captured_range) + assert.equal(10, captured_range.start_line) + assert.equal(15, captured_range.end_line) + + -- Restore original function + BaseContext.get_diagnostics = original_get_diagnostics + end) + + it('should handle dash-separated line format in selections', function() + -- Add a mock selection with dash format (used by range-based selections) + local mock_selection = { + file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' }, + content = 'print("hello")', + lines = '3-7' -- Lines 3 to 7 with dash separator + } + ChatContext.add_selection(mock_selection) + + -- Mock the BaseContext.get_diagnostics to capture the range parameter + local BaseContext = require('opencode.context.base_context') + local captured_range = nil + local original_get_diagnostics = BaseContext.get_diagnostics + BaseContext.get_diagnostics = function(buf, context_config, range) + captured_range = range + return {} + end + + -- Call get_diagnostics without an explicit range + ChatContext.get_diagnostics(1, nil, nil) + + -- Verify that a list of ranges was passed and parsed correctly from dash format + assert.is_not_nil(captured_range) + assert.equal('table', type(captured_range)) + assert.equal(1, #captured_range) -- Should have one range in the list + assert.equal(2, captured_range[1].start_line) -- 3 - 1 (0-based) + assert.equal(6, captured_range[1].end_line) -- 7 - 1 (0-based) + + -- Restore original function + BaseContext.get_diagnostics = original_get_diagnostics + end) + + it('should fallback to cursor behavior when no selections exist', function() + -- Ensure no selections in chat context + ChatContext.clear_selections() + + -- Mock the BaseContext.get_diagnostics to capture the range parameter + local BaseContext = require('opencode.context.base_context') + local captured_range = nil + local original_get_diagnostics = BaseContext.get_diagnostics + BaseContext.get_diagnostics = function(buf, context_config, range) + captured_range = range + return {} + end + + -- Call get_diagnostics without an explicit range + ChatContext.get_diagnostics(1, nil, nil) + + -- Verify that no range was passed (should fallback to cursor behavior) + assert.is_nil(captured_range) + + -- Restore original function + BaseContext.get_diagnostics = original_get_diagnostics + end) + + it('should collect diagnostics from all selection ranges individually', function() + -- Add multiple selections to chat context + local selection1 = { + file = { path = '/tmp/test1.lua', name = 'test1.lua', extension = 'lua' }, + content = 'print("first")', + lines = '3, 5' + } + local selection2 = { + file = { path = '/tmp/test2.lua', name = 'test2.lua', extension = 'lua' }, + content = 'print("second")', + lines = '10, 12' + } + local selection3 = { + file = { path = '/tmp/test3.lua', name = 'test3.lua', extension = 'lua' }, + content = 'print("third")', + lines = '7, 8' + } + ChatContext.add_selection(selection1) + ChatContext.add_selection(selection2) + ChatContext.add_selection(selection3) + + -- Mock the BaseContext.get_diagnostics to capture the range parameter + local BaseContext = require('opencode.context.base_context') + local captured_range = nil + local original_get_diagnostics = BaseContext.get_diagnostics + BaseContext.get_diagnostics = function(buf, context_config, range) + captured_range = range + -- Return mock diagnostics for all ranges + if range and type(range) == 'table' and range[1] then + local result = {} + for i, r in ipairs(range) do + table.insert(result, { + lnum = r.start_line, + col = 0, + message = 'Mock diagnostic for range ' .. r.start_line .. '-' .. r.end_line, + severity = 1 + }) + end + return result + end + return {} + end + + -- Call get_diagnostics without an explicit range + local result = ChatContext.get_diagnostics(1, nil, nil) + + -- Verify that a single range list was passed containing all selections + assert.is_not_nil(captured_range) + assert.equal('table', type(captured_range)) + assert.equal(3, #captured_range) -- Should have three ranges in the list + + -- Check each range matches the selections + assert.equal(2, captured_range[1].start_line) -- 3 - 1 (0-based) + assert.equal(4, captured_range[1].end_line) -- 5 - 1 (0-based) + + assert.equal(9, captured_range[2].start_line) -- 10 - 1 (0-based) + assert.equal(11, captured_range[2].end_line) -- 12 - 1 (0-based) + + assert.equal(6, captured_range[3].start_line) -- 7 - 1 (0-based) + assert.equal(7, captured_range[3].end_line) -- 8 - 1 (0-based) + + -- Verify that all diagnostics from all ranges are combined in the result + assert.equal(3, #result) + + -- Restore original function + BaseContext.get_diagnostics = original_get_diagnostics + end) + + it('should handle mixed line formats in multiple selection ranges', function() + -- Add selections with different line formats + local selection1 = { + file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' }, + content = 'print("first")', + lines = '15-17' -- Dash format + } + local selection2 = { + file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' }, + content = 'print("second")', + lines = '2, 4' -- Comma format + } + local selection3 = { + file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' }, + content = 'print("third")', + lines = '20' -- Single line + } + + ChatContext.add_selection(selection1) + ChatContext.add_selection(selection2) + ChatContext.add_selection(selection3) + + -- Mock the BaseContext.get_diagnostics to capture the range parameter + local BaseContext = require('opencode.context.base_context') + local captured_range = nil + local original_get_diagnostics = BaseContext.get_diagnostics + BaseContext.get_diagnostics = function(buf, context_config, range) + captured_range = range + -- Return mock diagnostics for all ranges + if range and type(range) == 'table' and range[1] then + local result = {} + for i, r in ipairs(range) do + table.insert(result, { + lnum = r.start_line, + col = 0, + message = 'Mock diagnostic', + severity = 1 + }) + end + return result + end + return {} + end + + -- Call get_diagnostics without an explicit range + local result = ChatContext.get_diagnostics(1, nil, nil) + + -- Verify that a single range list was passed containing all selections + assert.is_not_nil(captured_range) + assert.equal('table', type(captured_range)) + assert.equal(3, #captured_range) -- Should have three ranges in the list + + -- Check ranges for different line formats + assert.equal(14, captured_range[1].start_line) -- 15 - 1 (0-based) + assert.equal(16, captured_range[1].end_line) -- 17 - 1 (0-based) + + assert.equal(1, captured_range[2].start_line) -- 2 - 1 (0-based) + assert.equal(3, captured_range[2].end_line) -- 4 - 1 (0-based) + + assert.equal(19, captured_range[3].start_line) -- 20 - 1 (0-based, single line) + assert.equal(19, captured_range[3].end_line) -- 20 - 1 (0-based, single line) + + -- Verify that all diagnostics from all ranges are combined in the result + assert.equal(3, #result) + + -- Restore original function + BaseContext.get_diagnostics = original_get_diagnostics + end) +end) From ff6c9d15afe06938169f487b1d88a27dd36270c4 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 24 Dec 2025 10:14:24 -0500 Subject: [PATCH 44/46] feat: better errors in promise finally Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lua/opencode/promise.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index beebfb07..345e93ba 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -193,11 +193,11 @@ function Promise:finally(callback) local new_promise = Promise.new() local handle_finally = function() - local ok, _ = pcall(callback) + local ok, err = pcall(callback) -- Ignore callback errors and result, finally doesn't change the promise chain if not ok then -- Log error but don't propagate it - vim.notify('Error in finally callback', vim.log.levels.WARN) + vim.notify('Error in finally callback: ' .. tostring(err), vim.log.levels.WARN) end end From c8863b0ab7fa2e603fb692ff4ecd3baab29c5365 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 24 Dec 2025 11:24:00 -0500 Subject: [PATCH 45/46] perf: debounce context loading to improve performance Add 200ms debounced context loading to prevent excessive reloads during rapid buffer/diagnostic changes and add guard to skip loading when no active session exists --- lua/opencode/context.lua | 10 +++++++--- lua/opencode/context/chat_context.lua | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 30e9736d..6695f548 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -227,8 +227,12 @@ function M.extract_legacy_tag(tag, text) end function M.setup() - state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function() + local debounced_load = util.debounce(function() M.load() + end, 200) + + state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function() + debounced_load() end) local augroup = vim.api.nvim_create_augroup('OpenCodeContext', { clear = true }) @@ -239,7 +243,7 @@ function M.setup() local buf = args.buf local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() if buf == curr_buf and util.is_buf_a_file(buf) then - M.load() + debounced_load() end end, }) @@ -251,7 +255,7 @@ function M.setup() local buf = args.buf local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf() if buf == curr_buf and util.is_buf_a_file(buf) and M.is_context_enabled('diagnostics') then - M.load() + debounced_load() end end, }) diff --git a/lua/opencode/context/chat_context.lua b/lua/opencode/context/chat_context.lua index 8dfd21ed..445feeab 100644 --- a/lua/opencode/context/chat_context.lua +++ b/lua/opencode/context/chat_context.lua @@ -366,6 +366,10 @@ end -- Load function that populates the global context state -- This is the core loading logic that was originally in the main context module function M.load() + if not state.active_session then + return + end + local buf, win = base_context.get_current_buf() if not buf or not win then From a2305a212f2900c61df26df71f9c536fd3a6060b Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 6 Jan 2026 07:43:27 -0500 Subject: [PATCH 46/46] fix: return type --- lua/opencode/context/base_context.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/context/base_context.lua b/lua/opencode/context/base_context.lua index 09b9b19a..18cff5f3 100644 --- a/lua/opencode/context/base_context.lua +++ b/lua/opencode/context/base_context.lua @@ -212,7 +212,7 @@ function M.get_current_selection(context_config) end ---@param context_config? OpencodeContextConfig ----@return Promise +---@return string|nil M.get_git_diff = Promise.async(function(context_config) if not M.is_context_enabled('git_diff', context_config) then return nil