From aac1a2684b6bb6e6daac3d83bc6fa4eff6ff5cae Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 21:15:42 +0200 Subject: [PATCH 01/12] refactor(client): use OrderedMap for provider management (#1295) Refactored provider handling in Client to use OrderedMap, improving consistency and sorting. Added generic type annotations for OrderedMap and introduced a get_cached helper for cache management. This change simplifies provider filtering and caching logic for models and info. --- lua/CopilotChat/client.lua | 143 +++++++++++++++++++++---------------- lua/CopilotChat/utils.lua | 5 +- 2 files changed, 84 insertions(+), 64 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 7955691c..146169fb 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -58,6 +58,22 @@ local RESOURCE_SHORT_FORMAT = '# %s\n```%s start_line=% end_line=%s\n%s\n```' local RESOURCE_LONG_FORMAT = '# %s\n```%s path=%s start_line=%s end_line=%s\n%s\n```' local CACHE_TTL = 300 -- 5 minutes +--- Get a cached value or fill it if not present +--- @param cache table: The cache table to use +--- @param key string: The key to look up in the cache +--- @param filler function: A function that returns the value to cache if not present +local function get_cached(cache, key, filler) + local now = math.floor(os.time()) + if cache and cache[key] and cache[key .. '_expires_at'] > now then + return cache[key] + end + + local value = filler() + cache[key] = value + cache[key .. '_expires_at'] = now + CACHE_TTL + return value +end + --- Generate resource block with line numbers, truncating if necessary ---@param content string ---@param start_line number: The starting line number @@ -174,12 +190,26 @@ local Client = class(function(self) end) --- Get all providers from the client ----@return table -function Client:get_providers() - if self.provider_resolver then - return self.provider_resolver() +---@param supported_method? string: The method to filter providers by (optional) +---@return OrderedMap +function Client:get_providers(supported_method) + local out = utils.ordered_map() + + if not self.provider_resolver then + return out + end + + local providers = self.provider_resolver() + local provider_names = vim.tbl_keys(providers) + table.sort(provider_names) + + for _, provider_name in ipairs(provider_names) do + local provider = providers[provider_name] + if provider and not provider.disabled and (not supported_method or provider[supported_method]) then + out:set(provider_name, provider) + end end - return {} + return out end --- Set a provider resolver on the client @@ -192,8 +222,7 @@ end ---@param provider_name string: The provider to authenticate with ---@return table function Client:authenticate(provider_name) - local providers = self:get_providers() - local provider = providers[provider_name] + local provider = self:get_providers():get(provider_name) local headers = self.provider_cache[provider_name].headers local expires_at = self.provider_cache[provider_name].expires_at @@ -209,80 +238,71 @@ end --- Fetch models from the Copilot API ---@return table function Client:models() - local models = {} - local providers = self:get_providers() - local provider_order = vim.tbl_keys(providers) - table.sort(provider_order) - for _, provider_name in ipairs(provider_order) do - local provider = providers[provider_name] - if not provider.disabled and provider.get_models then - local cache = self.provider_cache[provider_name] - local resolved_models = nil - if cache and cache.models then - resolved_models = cache.models - else + local out = {} + local providers = self:get_providers('get_models') + + for _, provider_name in ipairs(providers:keys()) do + local provider = providers:get(provider_name) + for _, model in + ipairs(get_cached(self.provider_cache[provider_name], 'models', function() notify.publish(notify.STATUS, 'Fetching models from ' .. provider_name) + local ok, headers = pcall(self.authenticate, self, provider_name) if not ok then log.warn('Failed to authenticate with ' .. provider_name .. ': ' .. headers) - goto continue + return {} end - local ok, provider_models = pcall(provider.get_models, headers) + + local ok, models = pcall(provider.get_models, headers) if not ok then - log.warn('Failed to fetch models from ' .. provider_name .. ': ' .. provider_models) - goto continue + log.warn('Failed to fetch models from ' .. provider_name .. ': ' .. models) + return {} end - resolved_models = provider_models - cache.models = resolved_models - end - if resolved_models then - for _, model in ipairs(resolved_models) do - model.provider = provider_name - if models[model.id] then - model.id = model.id .. ':' .. provider_name - end - models[model.id] = model - end + return models or {} + end)) + do + model.provider = provider_name + if out[model.id] then + model.id = model.id .. ':' .. provider_name end - - ::continue:: + out[model.id] = model end end - log.debug('Fetched models:', #vim.tbl_keys(models)) - return models + log.debug('Fetched models:', #vim.tbl_keys(out)) + return out end --- Get information about all providers ---@return table function Client:info() - local infos = {} - local now = math.floor(os.time()) - local providers = self:get_providers() + local out = {} + local providers = self:get_providers('get_info') + + for _, provider_name in ipairs(providers:keys()) do + local provider = providers:get(provider_name) + out[provider_name] = get_cached(self.provider_cache[provider_name], 'infos', function() + notify.publish(notify.STATUS, 'Fetching info from ' .. provider_name) + + local ok, headers = pcall(self.authenticate, self, provider_name) + if not ok then + log.warn('Failed to authenticate with ' .. provider_name .. ': ' .. headers) + return {} + end - for provider_name, provider in pairs(providers) do - if not provider.disabled and provider.get_info then - local cache = self.provider_cache[provider_name] - if cache and cache.info and cache.info_expires_at and cache.info_expires_at > now then - infos[provider_name] = cache.info - else - local ok, info = pcall(provider.get_info, self:authenticate(provider_name)) - if ok then - infos[provider_name] = info - if cache then - cache.info = info - cache.info_expires_at = now + CACHE_TTL - end - else - log.warn('Failed to get info for provider ' .. provider_name .. ': ' .. info) - end + local ok, infos = pcall(provider.get_info, headers) + if not ok then + log.warn('Failed to fetch info from ' .. provider_name .. ': ' .. infos) + return {} end - end + + return infos or {} + end) end - log.debug('Fetched provider infos:', #vim.tbl_keys(infos)) - return infos + log.debug('Fetched provider infos:', #vim.tbl_keys(out)) + return out end --- Ask a question to Copilot @@ -298,7 +318,6 @@ function Client:ask(prompt, opts) log.debug('Resources:', #opts.resources) log.debug('History:', #opts.history) - local providers = self:get_providers() local models = self:models() local model_config = models[opts.model] if not model_config then @@ -309,7 +328,7 @@ function Client:ask(prompt, opts) if not provider_name then error('Provider not found for model: ' .. opts.model) end - local provider = providers[provider_name] + local provider = self:get_providers():get(provider_name) if not provider then error('Provider not found: ' .. provider_name) end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 54f4c6ae..fe2f4773 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -66,14 +66,15 @@ function M.class(fn, parent) return out end ----@class OrderedMap +---@class OrderedMap ---@field set fun(self:OrderedMap, key:any, value:any) ---@field get fun(self:OrderedMap, key:any):any ---@field keys fun(self:OrderedMap):table ---@field values fun(self:OrderedMap):table --- Create an ordered map ----@return OrderedMap +---@generic K, V +---@return OrderedMap function M.ordered_map() return { _keys = {}, From 1b04ddcfe2d04363a3898998a1005ab2f493dff4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 22:18:59 +0200 Subject: [PATCH 02/12] refactor(chat): move completion logic to separate module (#1267) This change refactors the chat completion logic by moving it from init.lua into a new completion.lua module. It also updates mappings and setup to use the new module, improving code organization and maintainability. Autocomplete setup is now handled in the completion module, and prompt listing is extracted to a helper function. Signed-off-by: Tomas Slusny --- README.md | 8 +- lua/CopilotChat/completion.lua | 235 +++++++++++++++++++++++++ lua/CopilotChat/config/mappings.lua | 2 +- lua/CopilotChat/init.lua | 256 +++------------------------- 4 files changed, 265 insertions(+), 236 deletions(-) create mode 100644 lua/CopilotChat/completion.lua diff --git a/README.md b/README.md index 5bdfac2d..4177c1e9 100644 --- a/README.md +++ b/README.md @@ -436,16 +436,10 @@ chat.set_selection(bufnr, start_line, end_line, clear) -- Set or clear selection -- Prompt & Model Management chat.select_prompt(config) -- Open prompt selector with optional config chat.select_model() -- Open model selector -chat.prompts() -- Get all available prompts - --- Completion -chat.trigger_complete() -- Trigger completion in chat window -chat.complete_info() -- Get completion info for custom providers -chat.complete_items() -- Get completion items (WARN: async, requires plenary.async.run) -- History Management -chat.save(name, history_path) -- Save chat history chat.load(name, history_path) -- Load chat history +chat.save(name, history_path) -- Save chat history -- Configuration chat.setup(config) -- Update configuration diff --git a/lua/CopilotChat/completion.lua b/lua/CopilotChat/completion.lua new file mode 100644 index 00000000..61e8bde1 --- /dev/null +++ b/lua/CopilotChat/completion.lua @@ -0,0 +1,235 @@ +local async = require('plenary.async') +local client = require('CopilotChat.client') +local config = require('CopilotChat.config') +local functions = require('CopilotChat.functions') +local utils = require('CopilotChat.utils') + +local M = {} + +--- Get the completion info for the chat window, for use with custom completion providers +---@return table +function M.info() + return { + triggers = { '@', '/', '#', '$' }, + pattern = [[\%(@\|/\|#\|\$\)\S*]], + } +end + +--- Get the completion items for the chat window, for use with custom completion providers +---@return table +---@async +function M.items() + local models = client:models() + local prompts = config.prompts or {} + local items = {} + + for name, prompt in pairs(prompts) do + if type(prompt) == 'string' then + prompt = { + prompt = prompt, + } + end + + local kind = '' + local info = '' + if prompt.prompt then + kind = 'user' + info = prompt.prompt + elseif prompt.system_prompt then + kind = 'system' + info = prompt.system_prompt + end + + items[#items + 1] = { + word = '/' .. name, + abbr = name, + kind = kind, + info = info, + menu = prompt.description or '', + icase = 1, + dup = 0, + empty = 0, + } + end + + for id, model in pairs(models) do + items[#items + 1] = { + word = '$' .. id, + abbr = id, + kind = model.provider, + menu = model.name, + icase = 1, + dup = 0, + empty = 0, + } + end + + local groups = {} + for name, tool in pairs(config.functions) do + if tool.group then + groups[tool.group] = groups[tool.group] or {} + groups[tool.group][name] = tool + end + end + for name, group in pairs(groups) do + local group_tools = vim.tbl_keys(group) + items[#items + 1] = { + word = '@' .. name, + abbr = name, + kind = 'group', + info = table.concat(group_tools, '\n'), + menu = string.format('%s tools', #group_tools), + icase = 1, + dup = 0, + empty = 0, + } + end + for name, tool in pairs(config.functions) do + items[#items + 1] = { + word = '@' .. name, + abbr = name, + kind = 'tool', + info = tool.description, + menu = tool.group or '', + icase = 1, + dup = 0, + empty = 0, + } + end + + local tools_to_use = functions.parse_tools(config.functions) + for _, tool in pairs(tools_to_use) do + local uri = config.functions[tool.name].uri + if uri then + local info = + string.format('%s\n\n%s', tool.description, tool.schema and vim.inspect(tool.schema, { indent = ' ' }) or '') + + items[#items + 1] = { + word = '#' .. tool.name, + abbr = tool.name, + kind = config.functions[tool.name].group or 'resource', + info = info, + menu = uri, + icase = 1, + dup = 0, + empty = 0, + } + end + end + + table.sort(items, function(a, b) + if a.kind == b.kind then + return a.word < b.word + end + return a.kind < b.kind + end) + + return items +end + +--- Trigger the completion for the chat window. +---@param without_input boolean? +function M.complete(without_input) + local source = require('CopilotChat').get_source() + local info = M.info() + local bufnr = vim.api.nvim_get_current_buf() + local line = vim.api.nvim_get_current_line() + local win = vim.api.nvim_get_current_win() + local row, col = unpack(vim.api.nvim_win_get_cursor(win)) + + local prefix, cmp_start = unpack(vim.fn.matchstrpos(line:sub(1, col), info.pattern)) + if not prefix then + return + end + + if not without_input and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then + local found_tool = config.functions[prefix:sub(2, -2)] + local found_schema = found_tool and functions.parse_schema(found_tool) + if found_tool and found_schema then + async.run(function() + local value = functions.enter_input(found_schema, source) + if not value then + return + end + + utils.schedule_main() + vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, { value }) + vim.api.nvim_win_set_cursor(0, { row, col + #value }) + end) + end + + return + end + + utils.debounce('copilot_chat_complete', function() + async.run(function() + local items = M.items() + utils.schedule_main() + + local row_changed = vim.api.nvim_win_get_cursor(win)[1] ~= row + local mode = vim.api.nvim_get_mode().mode + if row_changed or not (mode == 'i' or mode == 'ic') then + return + end + + vim.fn.complete( + cmp_start + 1, + vim.tbl_filter(function(item) + return vim.startswith(item.word:lower(), prefix:lower()) + end, items) + ) + end) + end, 100) +end + +--- Omnifunc for the chat window completion. +---@param findstart integer 0 or 1, decides behavior +---@param base integer findstart=0, text to match against +---@param _ any +---@return number|table +function M.omnifunc(findstart, base) + assert(base) + local bufnr = vim.api.nvim_get_current_buf() + local ft = vim.bo[bufnr].filetype + + if ft ~= 'copilot-chat' then + return findstart == 1 and -1 or {} + end + + M.complete(true) + return -2 -- Return -2 to indicate that we are handling the completion asynchronously +end + +--- Enable the completion for specific buffer. +---@param bufnr number: the buffer number to enable completion for +---@param autocomplete boolean: whether to enable autocomplete +function M.enable(bufnr, autocomplete) + if autocomplete then + vim.api.nvim_create_autocmd('TextChangedI', { + buffer = bufnr, + callback = function() + local completeopt = vim.opt.completeopt:get() + if not vim.tbl_contains(completeopt, 'noinsert') and not vim.tbl_contains(completeopt, 'noselect') then + -- Don't trigger completion if completeopt is not set to noinsert or noselect + return + end + + M.complete(true) + end, + }) + + -- Add noinsert completeopt if not present + if vim.fn.has('nvim-0.11.0') == 1 then + local completeopt = vim.opt.completeopt:get() + if not vim.tbl_contains(completeopt, 'noinsert') then + table.insert(completeopt, 'noinsert') + vim.bo[bufnr].completeopt = table.concat(completeopt, ',') + end + end + else + -- Just set the omnifunc for the buffer + vim.bo[bufnr].omnifunc = [[v:lua.require'CopilotChat.completion'.omnifunc]] + end +end + +return M diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 2421c264..af14f004 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -139,7 +139,7 @@ return { complete = { insert = '', callback = function() - copilot.trigger_complete() + require('CopilotChat.completion').complete() end, }, diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 64558190..0886b412 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -170,6 +170,29 @@ local function list_models() end, result) end +--- List available prompts. +---@return table +local function list_prompts() + local prompts_to_use = {} + + for name, prompt in pairs(M.config.prompts) do + local val = prompt + if type(prompt) == 'string' then + val = { + prompt = prompt, + } + end + + if val.system_prompt and M.config.prompts[val.system_prompt] then + val.system_prompt = M.config.prompts[val.system_prompt].system_prompt + end + + prompts_to_use[name] = val + end + + return prompts_to_use +end + --- Finish writing to chat buffer. ---@param start_of_chat boolean? local function finish(start_of_chat) @@ -456,7 +479,7 @@ function M.resolve_prompt(prompt, config) end end - local prompts_to_use = M.prompts() + local prompts_to_use = list_prompts() local depth = 0 local MAX_DEPTH = 10 @@ -604,198 +627,6 @@ function M.set_selection(bufnr, start_line, end_line, clear) update_highlights() end ---- Trigger the completion for the chat window. ----@param without_input boolean? -function M.trigger_complete(without_input) - local info = M.complete_info() - local bufnr = vim.api.nvim_get_current_buf() - local line = vim.api.nvim_get_current_line() - local cursor = vim.api.nvim_win_get_cursor(0) - local row = cursor[1] - local col = cursor[2] - if col == 0 or #line == 0 then - return - end - - local prefix, cmp_start = unpack(vim.fn.matchstrpos(line:sub(1, col), info.pattern)) - if not prefix then - return - end - - if not without_input and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then - local found_tool = M.config.functions[prefix:sub(2, -2)] - local found_schema = found_tool and functions.parse_schema(found_tool) - if found_tool and found_schema then - async.run(function() - local value = functions.enter_input(found_schema, state.source) - if not value then - return - end - - utils.schedule_main() - vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, { value }) - vim.api.nvim_win_set_cursor(0, { row, col + #value }) - end) - end - - return - end - - async.run(function() - local items = M.complete_items() - utils.schedule_main() - - if vim.fn.mode() ~= 'i' then - return - end - - vim.fn.complete( - cmp_start + 1, - vim.tbl_filter(function(item) - return vim.startswith(item.word:lower(), prefix:lower()) - end, items) - ) - end) -end - ---- Get the completion info for the chat window, for use with custom completion providers ----@return table -function M.complete_info() - return { - triggers = { '@', '/', '#', '$' }, - pattern = [[\%(@\|/\|#\|\$\)\S*]], - } -end - ---- Get the completion items for the chat window, for use with custom completion providers ----@return table ----@async -function M.complete_items() - local models = list_models() - local prompts_to_use = M.prompts() - local items = {} - - for name, prompt in pairs(prompts_to_use) do - local kind = '' - local info = '' - if prompt.prompt then - kind = 'user' - info = prompt.prompt - elseif prompt.system_prompt then - kind = 'system' - info = prompt.system_prompt - end - - items[#items + 1] = { - word = '/' .. name, - abbr = name, - kind = kind, - info = info, - menu = prompt.description or '', - icase = 1, - dup = 0, - empty = 0, - } - end - - for _, model in ipairs(models) do - items[#items + 1] = { - word = '$' .. model.id, - abbr = model.id, - kind = model.provider, - menu = model.name, - icase = 1, - dup = 0, - empty = 0, - } - end - - local groups = {} - for name, tool in pairs(M.config.functions) do - if tool.group then - groups[tool.group] = groups[tool.group] or {} - groups[tool.group][name] = tool - end - end - for name, group in pairs(groups) do - local group_tools = vim.tbl_keys(group) - items[#items + 1] = { - word = '@' .. name, - abbr = name, - kind = 'group', - info = table.concat(group_tools, '\n'), - menu = string.format('%s tools', #group_tools), - icase = 1, - dup = 0, - empty = 0, - } - end - for name, tool in pairs(M.config.functions) do - items[#items + 1] = { - word = '@' .. name, - abbr = name, - kind = 'tool', - info = tool.description, - menu = tool.group or '', - icase = 1, - dup = 0, - empty = 0, - } - end - - local tools_to_use = functions.parse_tools(M.config.functions) - for _, tool in pairs(tools_to_use) do - local uri = M.config.functions[tool.name].uri - if uri then - local info = - string.format('%s\n\n%s', tool.description, tool.schema and vim.inspect(tool.schema, { indent = ' ' }) or '') - - items[#items + 1] = { - word = '#' .. tool.name, - abbr = tool.name, - kind = M.config.functions[tool.name].group or 'resource', - info = info, - menu = uri, - icase = 1, - dup = 0, - empty = 0, - } - end - end - - table.sort(items, function(a, b) - if a.kind == b.kind then - return a.word < b.word - end - return a.kind < b.kind - end) - - return items -end - ---- Get the prompts to use. ----@return table -function M.prompts() - local prompts_to_use = {} - - for name, prompt in pairs(M.config.prompts) do - local val = prompt - if type(prompt) == 'string' then - val = { - prompt = prompt, - } - end - - if val.system_prompt and M.config.prompts[val.system_prompt] then - val.system_prompt = M.config.prompts[val.system_prompt].system_prompt - end - - prompts_to_use[name] = val - end - - return prompts_to_use -end - --- Open the chat window. ---@param config CopilotChat.config.Shared? function M.open(config) @@ -888,7 +719,7 @@ end --- Select a prompt template to use. ---@param config CopilotChat.config.Shared? function M.select_prompt(config) - local prompts = M.prompts() + local prompts = list_prompts() local keys = vim.tbl_keys(prompts) table.sort(keys) @@ -1236,6 +1067,8 @@ function M.setup(config) map_key(name, bufnr) end + require('CopilotChat.completion').enable(bufnr, M.config.chat_autocomplete) + vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { buffer = bufnr, callback = function(ev) @@ -1258,44 +1091,11 @@ function M.setup(config) }) end - if M.config.chat_autocomplete then - vim.api.nvim_create_autocmd('TextChangedI', { - buffer = bufnr, - callback = function() - local completeopt = vim.opt.completeopt:get() - if not vim.tbl_contains(completeopt, 'noinsert') and not vim.tbl_contains(completeopt, 'noselect') then - -- Don't trigger completion if completeopt is not set to noinsert or noselect - return - end - - local line = vim.api.nvim_get_current_line() - local cursor = vim.api.nvim_win_get_cursor(0) - local col = cursor[2] - local char = line:sub(col, col) - - if vim.tbl_contains(M.complete_info().triggers, char) then - utils.debounce('complete', function() - M.trigger_complete(true) - end, 100) - end - end, - }) - - -- Add noinsert completeopt if not present - if vim.fn.has('nvim-0.11.0') == 1 then - local completeopt = vim.opt.completeopt:get() - if not vim.tbl_contains(completeopt, 'noinsert') then - table.insert(completeopt, 'noinsert') - vim.bo[bufnr].completeopt = table.concat(completeopt, ',') - end - end - end - finish(true) end ) - for name, prompt in pairs(M.prompts()) do + for name, prompt in pairs(list_prompts()) do if prompt.prompt then vim.api.nvim_create_user_command('CopilotChat' .. name, function(args) local input = prompt.prompt From c0580d1d043f8222a975f9c049c9bad94dee98a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 Aug 2025 20:19:16 +0000 Subject: [PATCH 03/12] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index c3b52a5c..4b3b0700 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -511,16 +511,10 @@ CORE *CopilotChat-core* -- Prompt & Model Management chat.select_prompt(config) -- Open prompt selector with optional config chat.select_model() -- Open model selector - chat.prompts() -- Get all available prompts - - -- Completion - chat.trigger_complete() -- Trigger completion in chat window - chat.complete_info() -- Get completion info for custom providers - chat.complete_items() -- Get completion items (WARN: async, requires plenary.async.run) -- History Management - chat.save(name, history_path) -- Save chat history chat.load(name, history_path) -- Load chat history + chat.save(name, history_path) -- Save chat history -- Configuration chat.setup(config) -- Update configuration From 90c324177b33aec6d4c2bd5043c26bfc9fbc081f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 23:09:45 +0200 Subject: [PATCH 04/12] fix(info): show resource uri instead of name in preview (#1296) Previously, the resource preview header displayed the resource name, which could be missing. This change updates the header to show the resource URI, providing clearer context. Also, removes unused type annotations for improved code clarity. --- lua/CopilotChat/completion.lua | 1 - lua/CopilotChat/config/mappings.lua | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lua/CopilotChat/completion.lua b/lua/CopilotChat/completion.lua index 61e8bde1..9874999b 100644 --- a/lua/CopilotChat/completion.lua +++ b/lua/CopilotChat/completion.lua @@ -185,7 +185,6 @@ end --- Omnifunc for the chat window completion. ---@param findstart integer 0 or 1, decides behavior ---@param base integer findstart=0, text to match against ----@param _ any ---@return number|table function M.omnifunc(findstart, base) assert(base) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index af14f004..4fcf03c7 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -133,7 +133,6 @@ end ---@field yank_diff CopilotChat.config.mapping.yank_diff|false|nil ---@field show_diff CopilotChat.config.mapping.show_diff|false|nil ---@field show_info CopilotChat.config.mapping|false|nil ----@field show_context CopilotChat.config.mapping|false|nil ---@field show_help CopilotChat.config.mapping|false|nil return { complete = { @@ -513,7 +512,7 @@ return { for _, resource in ipairs(resolved_resources) do local resource_lines = vim.split(resource.data, '\n') local preview = vim.list_slice(resource_lines, 1, math.min(10, #resource_lines)) - local header = string.format('**%s** (%s lines)', resource.name, #resource_lines) + local header = string.format('**%s** (%s lines)', resource.uri, #resource_lines) if #resource_lines > 10 then header = header .. ' (truncated)' end From 27c24c4590ec33d9fa849a96dd95bc1774191be5 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 23:34:32 +0200 Subject: [PATCH 05/12] refactor(chat): unify message/block API for cursor access (#1298) Refactored chat window API to use `get_message(role, cursor)` and `get_block(role, cursor)` for both last and cursor-closest access. Removed legacy `get_closest_message` and `get_closest_block` methods. Updated mappings and documentation to reflect unified API. This simplifies usage and improves consistency for message/block retrieval and removal actions. --- README.md | 8 ++-- lua/CopilotChat/config/mappings.lua | 14 +++---- lua/CopilotChat/ui/chat.lua | 64 ++++++++++++++--------------- 3 files changed, 42 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 4177c1e9..f4111a0f 100644 --- a/README.md +++ b/README.md @@ -458,8 +458,10 @@ window:visible() -- Check if chat window is visible window:focused() -- Check if chat window is focused -- Message Management -window:get_message(role) -- Get last chat message by role (user, assistant, tool) +window:get_message(role, cursor) -- Get chat message by role, either last or closest to cursor window:add_message({ role, content }, replace) -- Add or replace a message in chat +window:remove_message(role, cursor) -- Remove chat message by role, either last or closest to cursor +window:get_block(role, cursor) -- Get code block by role, either last or closest to cursor window:add_sticky(sticky) -- Add sticky prompt to chat message -- Content Management @@ -473,9 +475,7 @@ window:follow() -- Move cursor to end of chat content window:focus() -- Focus the chat window -- Advanced Features -window:get_closest_message(role) -- Get message closest to cursor -window:get_closest_block(role) -- Get code block closest to cursor -window:overlay(opts) -- Show overlay with specified options +window:overlay(opts) -- Show overlay with specified options ``` ## Example Usage diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 4fcf03c7..5f411473 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -162,7 +162,7 @@ return { normal = '', insert = '', callback = function() - local message = copilot.chat:get_closest_message('user') + local message = copilot.chat:get_message('user', true) if not message then return end @@ -234,7 +234,7 @@ return { normal = '', insert = '', callback = function(source) - local diff = get_diff(copilot.chat:get_closest_block()) + local diff = get_diff(copilot.chat:get_block('assistant', true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -249,7 +249,7 @@ return { jump_to_diff = { normal = 'gj', callback = function(source) - local diff = get_diff(copilot.chat:get_closest_block()) + local diff = get_diff(copilot.chat:get_block('assistant', true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -326,7 +326,7 @@ return { normal = 'gy', register = '"', -- Default register to use for yanking callback = function() - local block = copilot.chat:get_closest_block() + local block = copilot.chat:get_block('assistant', true) if not block then return end @@ -339,7 +339,7 @@ return { normal = 'gd', full_diff = false, -- Show full diff instead of unified diff when showing diff window callback = function(source) - local diff = get_diff(copilot.chat:get_closest_block()) + local diff = get_diff(copilot.chat:get_block('assistant', true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -356,7 +356,7 @@ return { -- Apply all diffs from same file if #modified > 0 then -- Find all diffs from the same file in this section - local message = copilot.chat:get_closest_message('assistant') + local message = copilot.chat:get_message('assistant', true) local section = message and message.section local same_file_diffs = {} if section then @@ -429,7 +429,7 @@ return { show_info = { normal = 'gc', callback = function(source) - local message = copilot.chat:get_closest_message('user') + local message = copilot.chat:get_message('user', true) if not message then return end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 2752e6d0..bb4492b1 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -119,35 +119,11 @@ function Chat:focused() return self:visible() and vim.api.nvim_get_current_win() == self.winnr end ---- Get the closest message to the cursor. ----@param role string? If specified, only considers sections of the given role ----@return CopilotChat.ui.chat.Message? -function Chat:get_closest_message(role) - if not self:visible() then - return nil - end - - self:render() - local cursor_pos = vim.api.nvim_win_get_cursor(self.winnr) - local cursor_line = cursor_pos[1] - local closest_message = nil - local max_line_below_cursor = -1 - - for _, message in ipairs(self.messages) do - local section = message.section - local matches_role = not role or message.role == role - if matches_role and section.start_line <= cursor_line and section.start_line > max_line_below_cursor then - max_line_below_cursor = section.start_line - closest_message = message - end - end - - return closest_message -end - --- Get the closest code block to the cursor. +---@param role string? If specified, only considers sections of the given role +---@param cursor boolean? If true, returns the block closest to the cursor position ---@return CopilotChat.ui.chat.Block? -function Chat:get_closest_block() +function Chat:get_block(role, cursor) if not self:visible() then return nil end @@ -172,10 +148,31 @@ function Chat:get_closest_block() end --- Get last message by role in the chat window. +---@param role string? If specified, only considers sections of the given role +---@param cursor boolean? If true, returns the message closest to the cursor position ---@return CopilotChat.ui.chat.Message? -function Chat:get_message(role) - if not self:visible() then - return +function Chat:get_message(role, cursor) + if cursor then + if not self:visible() then + return nil + end + + self:render() + local cursor_pos = vim.api.nvim_win_get_cursor(self.winnr) + local cursor_line = cursor_pos[1] + local closest_message = nil + local max_line_below_cursor = -1 + + for _, message in ipairs(self.messages) do + local section = message.section + local matches_role = not role or message.role == role + if matches_role and section.start_line <= cursor_line and section.start_line > max_line_below_cursor then + max_line_below_cursor = section.start_line + closest_message = message + end + end + + return closest_message end for i = #self.messages, 1, -1 do @@ -475,14 +472,15 @@ function Chat:add_message(message, replace) end --- Remove a message from the chat window by role. ----@param role string -function Chat:remove_message(role) +---@param role string? If specified, only considers sections of the given role +---@param cursor boolean? If true, removes the message closest to the cursor position +function Chat:remove_message(role, cursor) if not self:visible() then return end self:render() - local message = self:get_closest_message(role) + local message = self:get_message(role, cursor) if not message then return end From 77971bb00f057ca15630a96bcdb5f25c5ec61287 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 Aug 2025 21:34:53 +0000 Subject: [PATCH 06/12] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 4b3b0700..ed8df417 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -534,8 +534,10 @@ You can also access the chat window UI methods through the `chat.chat` object: window:focused() -- Check if chat window is focused -- Message Management - window:get_message(role) -- Get last chat message by role (user, assistant, tool) + window:get_message(role, cursor) -- Get chat message by role, either last or closest to cursor window:add_message({ role, content }, replace) -- Add or replace a message in chat + window:remove_message(role, cursor) -- Remove chat message by role, either last or closest to cursor + window:get_block(role, cursor) -- Get code block by role, either last or closest to cursor window:add_sticky(sticky) -- Add sticky prompt to chat message -- Content Management @@ -549,9 +551,7 @@ You can also access the chat window UI methods through the `chat.chat` object: window:focus() -- Focus the chat window -- Advanced Features - window:get_closest_message(role) -- Get message closest to cursor - window:get_closest_block(role) -- Get code block closest to cursor - window:overlay(opts) -- Show overlay with specified options + window:overlay(opts) -- Show overlay with specified options < From 92777fb98ad4de7496188f1e9de336d16871ac43 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 9 Aug 2025 00:01:52 +0200 Subject: [PATCH 07/12] feat(ui): show assistant reasoning as virtual text (#1299) Adds support for displaying the assistant's reasoning above messages in the chat UI as virtual text. Reasoning is now streamed and stored separately from content, and models with reasoning capability are indicated in the model selector. This improves transparency of model responses and debugging. --- lua/CopilotChat/client.lua | 39 +++++++++++++++++++--------- lua/CopilotChat/config/providers.lua | 4 +++ lua/CopilotChat/init.lua | 11 ++++---- lua/CopilotChat/ui/chat.lua | 21 ++++++++++++++- 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 146169fb..abd41872 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -7,11 +7,12 @@ ---@field system_prompt string ---@field model string ---@field temperature number ----@field on_progress? fun(response: string):nil +---@field on_progress fun(response: CopilotChat.client.Message)? ---@class CopilotChat.client.Message ---@field role string ---@field content string +---@field reasoning string? ---@field tool_call_id string? ---@field tool_calls table? @@ -46,6 +47,7 @@ ---@field max_output_tokens number? ---@field streaming boolean? ---@field tools boolean? +---@field reasoning boolean? local log = require('plenary.log') local tiktoken = require('CopilotChat.tiktoken') @@ -402,15 +404,15 @@ function Client:ask(prompt, opts) end end - local errored = false + local errored = nil local finished = false local token_count = 0 - local response_buffer = utils.string_buffer() + local response_content_buffer = utils.string_buffer() + local response_reasoning_buffer = utils.string_buffer() local function finish_stream(err, job) if err then - errored = true - response_buffer:set(err) + errored = err end log.debug('Finishing stream', err) @@ -460,10 +462,19 @@ function Client:ask(prompt, opts) end if out.content then - response_buffer:add(out.content) - if opts.on_progress then - opts.on_progress(out.content) - end + response_content_buffer:add(out.content) + end + + if out.reasoning then + response_reasoning_buffer:add(out.reasoning) + end + + if opts.on_progress then + opts.on_progress({ + role = 'assistant', + content = out.content or '', + reasoning = out.reasoning or '', + }) end if out.finish_reason then @@ -562,12 +573,14 @@ function Client:ask(prompt, opts) return end - local response_text = response_buffer:tostring() if errored then - error(response_text) + error(errored) return end + local response_text = response_content_buffer:tostring() + local response_reasoning = response_reasoning_buffer:tostring() + if response then if is_stream then if utils.empty(response_text) and not finished then @@ -578,13 +591,15 @@ function Client:ask(prompt, opts) else parse_line(response.body) end - response_text = response_buffer:tostring() + response_text = response_content_buffer:tostring() + response_reasoning = response_reasoning_buffer:tostring() end return { message = { role = 'assistant', content = response_text, + reasoning = response_reasoning, tool_calls = #tool_calls:values() > 0 and tool_calls:values() or nil, }, token_count = token_count, diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 5356925a..e08f5fdf 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -191,6 +191,7 @@ end ---@class CopilotChat.config.providers.Output ---@field content string +---@field reasoning string? ---@field finish_reason string? ---@field total_tokens number? ---@field tool_calls table @@ -429,6 +430,7 @@ M.copilot = { local message = choice.message or choice.delta local content = message and message.content + local reasoning = message and (message.reasoning or message.reasoning_content) local usage = choice.usage and choice.usage.total_tokens if not usage then usage = output.usage and output.usage.total_tokens @@ -437,6 +439,7 @@ M.copilot = { return { content = content, + reasoning = reasoning, finish_reason = finish_reason, total_tokens = usage, tool_calls = tool_calls, @@ -480,6 +483,7 @@ M.github_models = { max_output_tokens = max_output_tokens, streaming = vim.tbl_contains(model.capabilities, 'streaming'), tools = vim.tbl_contains(model.capabilities, 'tool-calling'), + reasoning = vim.tbl_contains(model.capabilities, 'reasoning'), version = model.version, } end) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0886b412..c08667af 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -677,6 +677,7 @@ function M.select_model() provider = model.provider, streaming = model.streaming, tools = model.tools, + reasoning = model.reasoning, selected = model.id == M.config.model, } end, models) @@ -701,6 +702,9 @@ function M.select_model() if item.tools then table.insert(indicators, 'tools') end + if item.reasoning then + table.insert(indicators, 'reasoning') + end if #indicators > 0 then out = out .. ' [' .. table.concat(indicators, ', ') .. ']' @@ -865,12 +869,9 @@ function M.ask(prompt, config) system_prompt = system_prompt, model = selected_model, temperature = config.temperature, - on_progress = vim.schedule_wrap(function(token) + on_progress = vim.schedule_wrap(function(message) if not config.headless then - M.chat:add_message({ - content = token, - role = 'assistant', - }) + M.chat:add_message(message) end end), }) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index bb4492b1..34137ec1 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -716,8 +716,8 @@ function Chat:render() -- Replace self.messages with new_messages (preserving tool_calls, etc.) self.messages = new_messages - -- Show tool call details as virt lines for _, message in ipairs(self.messages) do + -- Show tool call details as virt lines if message.tool_calls and #message.tool_calls > 0 then local section = message.section if section and section.end_line then @@ -751,6 +751,25 @@ function Chat:render() }) end end + + -- Show reasoning as virtual text above assistant messages + if + message.role == 'assistant' + and not utils.empty(message.reasoning) + and message.section + and message.section.start_line + then + local virt_lines = {} + for _, line in ipairs(vim.split(message.reasoning, '\n')) do + table.insert(virt_lines, { { 'Reasoning: ' .. line, 'CopilotChatAnnotation' } }) + end + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, message.section.start_line - 1, 0, { + virt_lines = virt_lines, + virt_lines_above = true, + priority = 100, + strict = false, + }) + end end -- Show help as before, using last user message From 7e027df6e95b622da25282285e84a9fc3806dcf1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 9 Aug 2025 03:52:20 +0200 Subject: [PATCH 08/12] fix(chat): correct block selection logic by cursor (#1301) Refactored Chat:get_block to properly select the closest block to the cursor, considering the role filter and ensuring correct fallback to the last matching block. This improves accuracy when interacting with chat blocks in the UI. --- lua/CopilotChat/ui/chat.lua | 45 ++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 34137ec1..4a549c6c 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -124,27 +124,40 @@ end ---@param cursor boolean? If true, returns the block closest to the cursor position ---@return CopilotChat.ui.chat.Block? function Chat:get_block(role, cursor) - if not self:visible() then - return nil - end + if cursor then + if not self:visible() then + return nil + end - self:render() - local cursor_pos = vim.api.nvim_win_get_cursor(self.winnr) - local cursor_line = cursor_pos[1] - local closest_block = nil - local max_line_below_cursor = -1 - - for _, message in pairs(self.messages) do - local section = message.section - for _, block in ipairs(section.blocks) do - if block.start_line <= cursor_line and block.start_line > max_line_below_cursor then - max_line_below_cursor = block.start_line - closest_block = block + self:render() + local cursor_pos = vim.api.nvim_win_get_cursor(self.winnr) + local cursor_line = cursor_pos[1] + local closest_block = nil + local max_line_below_cursor = -1 + + for _, message in ipairs(self.messages) do + local section = message.section + local matches_role = not role or message.role == role + if matches_role and section and section.blocks then + for _, block in ipairs(section.blocks) do + if block.start_line <= cursor_line and block.start_line > max_line_below_cursor then + max_line_below_cursor = block.start_line + closest_block = block + end + end end end + + return closest_block end - return closest_block + for i = #self.messages, 1, -1 do + local message = self.messages[i] + local matches_role = not role or message.role == role + if matches_role and message.section and message.section.blocks and #message.section.blocks > 0 then + return message.section.blocks[#message.section.blocks] + end + end end --- Get last message by role in the chat window. From 45c923d172af6ce32dc7e5f2ed1e328f0875c8e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 9 Aug 2025 01:52:39 +0000 Subject: [PATCH 09/12] chore(doc): auto generate docs --- doc/CopilotChat.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index ed8df417..349cdeeb 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 08 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 6d49da8e211dcb254a10ecd036b1bfdc8838f970 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 9 Aug 2025 04:49:10 +0200 Subject: [PATCH 10/12] refactor(constants): centralize role and plugin name constants (#1302) Move role strings and plugin name to a dedicated constants module. Refactor all usages to reference the new constants. This improves maintainability and reduces duplication across the codebase. --- lua/CopilotChat/client.lua | 15 ++++++------ lua/CopilotChat/completion.lua | 7 +++--- lua/CopilotChat/config/mappings.lua | 21 ++++++++-------- lua/CopilotChat/config/providers.lua | 5 ++-- lua/CopilotChat/constants.lua | 10 ++++++++ lua/CopilotChat/init.lua | 36 ++++++++++++++-------------- lua/CopilotChat/ui/chat.lua | 9 +++---- 7 files changed, 59 insertions(+), 44 deletions(-) create mode 100644 lua/CopilotChat/constants.lua diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index abd41872..f2dba5b0 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -50,8 +50,9 @@ ---@field reasoning boolean? local log = require('plenary.log') -local tiktoken = require('CopilotChat.tiktoken') +local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') +local tiktoken = require('CopilotChat.tiktoken') local utils = require('CopilotChat.utils') local class = utils.class @@ -124,7 +125,7 @@ local function generate_selection_message(selection) selection.start_line, selection.end_line ), - role = 'user', + role = constants.ROLE.USER, } end @@ -140,7 +141,7 @@ local function generate_resource_messages(resources) :map(function(resource) return { content = generate_resource_block(resource.data, resource.mimetype, resource.uri, resource.name, 1, nil), - role = 'user', + role = constants.ROLE.USER, } end) :totable() @@ -160,7 +161,7 @@ local function generate_ask_request(prompt, system_prompt, history, generated_me if not utils.empty(system_prompt) then table.insert(messages, { content = system_prompt, - role = 'system', + role = constants.ROLE.SYSTEM, }) end @@ -172,7 +173,7 @@ local function generate_ask_request(prompt, system_prompt, history, generated_me if not utils.empty(prompt) and utils.empty(history) then table.insert(messages, { content = prompt, - role = 'user', + role = constants.ROLE.USER, }) end @@ -471,7 +472,7 @@ function Client:ask(prompt, opts) if opts.on_progress then opts.on_progress({ - role = 'assistant', + role = constants.ROLE.ASSISTANT, content = out.content or '', reasoning = out.reasoning or '', }) @@ -597,7 +598,7 @@ function Client:ask(prompt, opts) return { message = { - role = 'assistant', + role = constants.ROLE.ASSISTANT, content = response_text, reasoning = response_reasoning, tool_calls = #tool_calls:values() > 0 and tool_calls:values() or nil, diff --git a/lua/CopilotChat/completion.lua b/lua/CopilotChat/completion.lua index 9874999b..a32141eb 100644 --- a/lua/CopilotChat/completion.lua +++ b/lua/CopilotChat/completion.lua @@ -1,5 +1,6 @@ local async = require('plenary.async') local client = require('CopilotChat.client') +local constants = require('CopilotChat.constants') local config = require('CopilotChat.config') local functions = require('CopilotChat.functions') local utils = require('CopilotChat.utils') @@ -33,10 +34,10 @@ function M.items() local kind = '' local info = '' if prompt.prompt then - kind = 'user' + kind = constants.ROLE.USER info = prompt.prompt elseif prompt.system_prompt then - kind = 'system' + kind = constants.ROLE.SYSTEM info = prompt.system_prompt end @@ -88,7 +89,7 @@ function M.items() items[#items + 1] = { word = '@' .. name, abbr = name, - kind = 'tool', + kind = constants.ROLE.TOOL, info = tool.description, menu = tool.group or '', icase = 1, diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 5f411473..4928a876 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -1,6 +1,7 @@ local async = require('plenary.async') local copilot = require('CopilotChat') local client = require('CopilotChat.client') +local constants = require('CopilotChat.constants') local utils = require('CopilotChat.utils') ---@class CopilotChat.config.mappings.Diff @@ -162,7 +163,7 @@ return { normal = '', insert = '', callback = function() - local message = copilot.chat:get_message('user', true) + local message = copilot.chat:get_message(constants.ROLE.USER, true) if not message then return end @@ -174,7 +175,7 @@ return { toggle_sticky = { normal = 'grr', callback = function() - local message = copilot.chat:get_message('user') + local message = copilot.chat:get_message(constants.ROLE.USER) local section = message and message.section if not section then return @@ -205,7 +206,7 @@ return { clear_stickies = { normal = 'grx', callback = function() - local message = copilot.chat:get_message('user') + local message = copilot.chat:get_message(constants.ROLE.USER) local section = message and message.section if not section then return @@ -234,7 +235,7 @@ return { normal = '', insert = '', callback = function(source) - local diff = get_diff(copilot.chat:get_block('assistant', true)) + local diff = get_diff(copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -249,7 +250,7 @@ return { jump_to_diff = { normal = 'gj', callback = function(source) - local diff = get_diff(copilot.chat:get_block('assistant', true)) + local diff = get_diff(copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -264,7 +265,7 @@ return { callback = function() local items = {} for i, message in ipairs(copilot.chat.messages) do - if message.section and message.role == 'assistant' then + if message.section and message.role == constants.ROLE.ASSISTANT then local prev_message = copilot.chat.messages[i - 1] local text = '' if prev_message then @@ -326,7 +327,7 @@ return { normal = 'gy', register = '"', -- Default register to use for yanking callback = function() - local block = copilot.chat:get_block('assistant', true) + local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) if not block then return end @@ -339,7 +340,7 @@ return { normal = 'gd', full_diff = false, -- Show full diff instead of unified diff when showing diff window callback = function(source) - local diff = get_diff(copilot.chat:get_block('assistant', true)) + local diff = get_diff(copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -356,7 +357,7 @@ return { -- Apply all diffs from same file if #modified > 0 then -- Find all diffs from the same file in this section - local message = copilot.chat:get_message('assistant', true) + local message = copilot.chat:get_message(constants.ROLE.ASSISTANT, true) local section = message and message.section local same_file_diffs = {} if section then @@ -429,7 +430,7 @@ return { show_info = { normal = 'gc', callback = function(source) - local message = copilot.chat:get_message('user', true) + local message = copilot.chat:get_message(constants.ROLE.USER, true) if not message then return end diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index e08f5fdf..b2a03eea 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,3 +1,4 @@ +local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local plenary_utils = require('plenary.async.util') @@ -342,8 +343,8 @@ M.copilot = { } if is_o1 then - if input.role == 'system' then - output.role = 'user' + if input.role == constants.ROLE.SYSTEM then + output.role = constants.ROLE.USER end end diff --git a/lua/CopilotChat/constants.lua b/lua/CopilotChat/constants.lua new file mode 100644 index 00000000..7c6f7561 --- /dev/null +++ b/lua/CopilotChat/constants.lua @@ -0,0 +1,10 @@ +return { + PLUGIN_NAME = 'CopilotChat', + + ROLE = { + USER = 'user', + ASSISTANT = 'assistant', + SYSTEM = 'system', + TOOL = 'tool', + }, +} diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index c08667af..90a4209d 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -2,10 +2,10 @@ local async = require('plenary.async') local log = require('plenary.log') local functions = require('CopilotChat.functions') local client = require('CopilotChat.client') +local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') -local PLUGIN_NAME = 'CopilotChat' local WORD = '([^%s:]+)' local WORD_NO_INPUT = '([^%s]+)' local WORD_WITH_INPUT_QUOTED = WORD .. ':`([^`]+)`' @@ -44,7 +44,7 @@ local state = { ---@param prompt string ---@param config CopilotChat.config.Shared local function insert_sticky(prompt, config) - local existing_prompt = M.chat:get_message('user') + local existing_prompt = M.chat:get_message(constants.ROLE.USER) local combined_prompt = (existing_prompt and existing_prompt.content or '') .. '\n' .. (prompt or '') local lines = vim.split(prompt or '', '\n') local stickies = utils.ordered_map() @@ -207,7 +207,7 @@ local function finish(start_of_chat) end local prompt_content = '' - local assistant_message = M.chat:get_message('assistant') + local assistant_message = M.chat:get_message(constants.ROLE.ASSISTANT) local tool_calls = assistant_message and assistant_message.tool_calls or {} if not utils.empty(state.sticky) then @@ -225,7 +225,7 @@ local function finish(start_of_chat) end M.chat:add_message({ - role = 'user', + role = constants.ROLE.USER, content = prompt_content, }) @@ -253,7 +253,7 @@ local function handle_error(config, cb) out = utils.make_string(out) M.chat:add_message({ - role = 'assistant', + role = constants.ROLE.ASSISTANT, content = '\n' .. string.format(BLOCK_OUTPUT_FORMAT, 'error', out) .. '\n', }) @@ -282,7 +282,7 @@ local function map_key(name, bufnr, fn) 'n', key.normal, fn, - { buffer = bufnr, nowait = true, desc = PLUGIN_NAME .. ' ' .. name:gsub('_', ' ') } + { buffer = bufnr, nowait = true, desc = constants.PLUGIN_NAME .. ' ' .. name:gsub('_', ' ') } ) end if key.insert and key.insert ~= '' then @@ -296,7 +296,7 @@ local function map_key(name, bufnr, fn) else fn() end - end, { buffer = bufnr, desc = PLUGIN_NAME .. ' ' .. name:gsub('_', ' ') }) + end, { buffer = bufnr, desc = constants.PLUGIN_NAME .. ' ' .. name:gsub('_', ' ') }) end end @@ -473,7 +473,7 @@ end ---@return CopilotChat.config.prompts.Prompt, string function M.resolve_prompt(prompt, config) if not prompt then - local message = M.chat:get_message('user') + local message = M.chat:get_message(constants.ROLE.USER) if message then prompt = message.content end @@ -636,12 +636,12 @@ function M.open(config) M.chat:open(config) -- Add sticky values from provided config when opening the chat - local message = M.chat:get_message('user') + local message = M.chat:get_message(constants.ROLE.USER) if message then local prompt = insert_sticky(message.content, config) if prompt then M.chat:add_message({ - role = 'user', + role = constants.ROLE.USER, content = '\n' .. prompt, }, true) end @@ -813,7 +813,7 @@ function M.ask(prompt, config) if not config.headless then utils.schedule_main() - local assistant_message = M.chat:get_message('assistant') + local assistant_message = M.chat:get_message(constants.ROLE.ASSISTANT) if assistant_message and assistant_message.tool_calls then local handled_ids = {} for _, tool in ipairs(resolved_tools) do @@ -834,11 +834,11 @@ function M.ask(prompt, config) if not utils.empty(resolved_tools) then -- If we are handling tools, replace user message with tool results - M.chat:remove_message('user') + M.chat:remove_message(constants.ROLE.USER) for _, tool in ipairs(resolved_tools) do M.chat:add_message({ id = tool.id, - role = 'tool', + role = constants.ROLE.TOOL, tool_call_id = tool.id, content = '\n' .. tool.result .. '\n', }) @@ -846,7 +846,7 @@ function M.ask(prompt, config) else -- Otherwise just replace the user message with resolved prompt M.chat:add_message({ - role = 'user', + role = constants.ROLE.USER, content = '\n' .. prompt .. '\n', }, true) end @@ -854,7 +854,7 @@ function M.ask(prompt, config) if utils.empty(prompt) and utils.empty(resolved_tools) then if not config.headless then - M.chat:remove_message('user') + M.chat:remove_message(constants.ROLE.USER) finish() end return @@ -1008,7 +1008,7 @@ function M.log_level(level) M.config.debug = level == 'debug' log.new({ - plugin = PLUGIN_NAME, + plugin = constants.PLUGIN_NAME, level = level, outfile = M.config.log_path, fmt_msg = function(is_console, mode_name, src_path, src_line, msg) @@ -1110,13 +1110,13 @@ function M.setup(config) nargs = '*', force = true, range = true, - desc = prompt.description or (PLUGIN_NAME .. ' ' .. name), + desc = prompt.description or (constants.PLUGIN_NAME .. ' ' .. name), }) if prompt.mapping then vim.keymap.set({ 'n', 'v' }, prompt.mapping, function() M.ask(prompt.prompt, prompt) - end, { desc = prompt.description or (PLUGIN_NAME .. ' ' .. name) }) + end, { desc = prompt.description or (constants.PLUGIN_NAME .. ' ' .. name) }) end end end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 4a549c6c..2e784d33 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -1,5 +1,6 @@ local Overlay = require('CopilotChat.ui.overlay') local Spinner = require('CopilotChat.ui.spinner') +local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local class = utils.class @@ -204,7 +205,7 @@ function Chat:add_sticky(sticky) return end - local prompt = self:get_message('user') + local prompt = self:get_message(constants.ROLE.USER) if not prompt or not prompt.section then return end @@ -667,7 +668,7 @@ function Chat:render() end -- Code blocks - if current_message and current_message.role == 'assistant' then + if current_message and current_message.role == constants.ROLE.ASSISTANT then local filetype, filename, start_line, end_line = match_header(line) if filetype and filename and not current_block then current_block = { @@ -767,7 +768,7 @@ function Chat:render() -- Show reasoning as virtual text above assistant messages if - message.role == 'assistant' + message.role == constants.ROLE.ASSISTANT and not utils.empty(message.reasoning) and message.section and message.section.start_line @@ -787,7 +788,7 @@ function Chat:render() -- Show help as before, using last user message local last_message = self.messages[#self.messages] - if last_message and last_message.role == 'user' then + if last_message and last_message.role == constants.ROLE.USER then local msg = self.config.show_help and self.help or '' if self.token_count and self.token_max_count then if msg ~= '' then From f1d6bb5aa7219cfb426c2f585362b866f1dbb7d9 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 9 Aug 2025 16:45:09 +0200 Subject: [PATCH 11/12] refactor(ui): simplify chat and overlay initialization (#1303) Refactored chat and overlay constructors to remove redundant help argument and use key_to_info for help and close mappings directly. This improves clarity and maintainability by centralizing mapping logic and reducing parameter complexity. Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 54 +++++++++++++++++-------------------- lua/CopilotChat/ui/chat.lua | 32 +++++++++++++--------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 90a4209d..83a3d0e5 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1060,41 +1060,37 @@ function M.setup(config) M.chat:close(state.source and state.source.bufnr or nil) M.chat:delete() end - M.chat = require('CopilotChat.ui.chat')( - M.config, - utils.key_to_info('show_help', M.config.mappings.show_help), - function(bufnr) - for name, _ in pairs(M.config.mappings) do - map_key(name, bufnr) - end + M.chat = require('CopilotChat.ui.chat')(M.config, function(bufnr) + for name, _ in pairs(M.config.mappings) do + map_key(name, bufnr) + end - require('CopilotChat.completion').enable(bufnr, M.config.chat_autocomplete) + require('CopilotChat.completion').enable(bufnr, M.config.chat_autocomplete) - vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { - buffer = bufnr, - callback = function(ev) - if ev.event == 'BufEnter' then - update_source() - end + vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { + buffer = bufnr, + callback = function(ev) + if ev.event == 'BufEnter' then + update_source() + end + + vim.schedule(update_highlights) + end, + }) - vim.schedule(update_highlights) + if M.config.insert_at_end then + vim.api.nvim_create_autocmd({ 'InsertEnter' }, { + buffer = bufnr, + callback = function() + vim.cmd('normal! 0') + vim.cmd('normal! G$') + vim.v.char = 'x' end, }) - - if M.config.insert_at_end then - vim.api.nvim_create_autocmd({ 'InsertEnter' }, { - buffer = bufnr, - callback = function() - vim.cmd('normal! 0') - vim.cmd('normal! G$') - vim.v.char = 'x' - end, - }) - end - - finish(true) end - ) + + finish(true) + end) for name, prompt in pairs(list_prompts()) do if prompt.prompt then diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 2e784d33..e7f0c75c 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -69,8 +69,8 @@ end ---@field private separator string ---@field private spinner CopilotChat.ui.spinner.Spinner ---@field private chat_overlay CopilotChat.ui.overlay.Overlay -local Chat = class(function(self, config, help, on_buf_create) - Overlay.init(self, 'copilot-chat', help, on_buf_create) +local Chat = class(function(self, config, on_buf_create) + Overlay.init(self, 'copilot-chat', utils.key_to_info('show_help', config.mappings.show_help), on_buf_create) self.winnr = nil self.config = config @@ -83,18 +83,24 @@ local Chat = class(function(self, config, help, on_buf_create) self.separator = config.separator self.spinner = Spinner() - self.chat_overlay = Overlay('copilot-overlay', 'q to close', function(bufnr) - vim.keymap.set('n', 'q', function() - self.chat_overlay:restore(self.winnr, self.bufnr) - end) - - vim.api.nvim_create_autocmd({ 'BufHidden', 'BufDelete' }, { - buffer = bufnr, - callback = function() + self.chat_overlay = Overlay( + 'copilot-overlay', + utils.key_to_info('close', { + normal = config.mappings.close.normal, + }), + function(bufnr) + vim.keymap.set('n', config.mappings.close.normal, function() self.chat_overlay:restore(self.winnr, self.bufnr) - end, - }) - end) + end) + + vim.api.nvim_create_autocmd({ 'BufHidden', 'BufDelete' }, { + buffer = bufnr, + callback = function() + self.chat_overlay:restore(self.winnr, self.bufnr) + end, + }) + end + ) notify.listen(notify.MESSAGE, function(msg) utils.schedule_main() From 0553496607717e539f08780a75c292a81619dcb6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 10 Aug 2025 18:40:52 +0200 Subject: [PATCH 12/12] chore(main): release 4.4.0 (#1297) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 ++++++++++++++ version.txt | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0585f66..15f1f334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [4.4.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.3.1...v4.4.0) (2025-08-09) + + +### Features + +* **completion:** add support for omnifunc and move completion logic to separate module ([1b04ddc](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/1b04ddcfe2d04363a3898998a1005ab2f493dff4)) +* **ui:** show assistant reasoning as virtual text ([#1299](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1299)) ([92777fb](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/92777fb98ad4de7496188f1e9de336d16871ac43)) + + +### Bug Fixes + +* **chat:** correct block selection logic by cursor ([#1301](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1301)) ([7e027df](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/7e027df6e95b622da25282285e84a9fc3806dcf1)) +* **info:** show resource uri instead of name in preview ([#1296](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1296)) ([90c3241](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/90c324177b33aec6d4c2bd5043c26bfc9fbc081f)) + ## [4.3.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.3.0...v4.3.1) (2025-08-08) diff --git a/version.txt b/version.txt index f77856a6..fdc66988 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.3.1 +4.4.0