From 8510f30ff8c338482e7c8a2a7d102519cc57315f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 3 Aug 2025 17:34:17 +0200 Subject: [PATCH 001/250] fix(functions): do not filter schema enum when entering input (#1264) It still need to be filtered when preparing tool use as we cant send json functions to API. Closes #1263 Signed-off-by: Tomas Slusny --- lua/CopilotChat/functions.lua | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index 6e936a3d..e63b63a3 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -114,15 +114,16 @@ function M.match_uri(uri, pattern) return result end ----@param tool CopilotChat.config.functions.Function -function M.parse_schema(tool) - local schema = tool.schema +--- Parse function schema and return a JSON schema object +---@param fn CopilotChat.config.functions.Function +function M.parse_schema(fn) + local schema = fn.schema -- If schema is missing but uri is present, generate a default schema from uri - if not schema and tool.uri then + if not schema and fn.uri then -- Extract parameter names from the uri pattern, e.g. file://{path} local param_names = {} - for param in tool.uri:gmatch(URI_PARAM_PATTERN) do + for param in fn.uri:gmatch(URI_PARAM_PATTERN) do table.insert(param_names, param) end if #param_names > 0 then @@ -138,26 +139,22 @@ function M.parse_schema(tool) end end - if schema then - schema = filter_schema(schema, true) - end - return schema end ---- Prepare the schema for use ----@param tools table +--- Prepare functions for tool use +---@param functions table ---@return table -function M.parse_tools(tools) - local tool_names = vim.tbl_keys(tools) +function M.parse_tools(functions) + local tool_names = vim.tbl_keys(functions) table.sort(tool_names) return vim.tbl_map(function(name) - local tool = tools[name] + local tool = functions[name] return { name = name, description = tool.description, - schema = M.parse_schema(tool), + schema = filter_schema(M.parse_schema(tool), true), } end, tool_names) end From 1923ad3d6fd88f756fba46b5c04df4a4addab23f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 3 Aug 2025 17:36:34 +0200 Subject: [PATCH 002/250] chore(main): release 4.1.0 (#1261) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ version.txt | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e31352..d3a05ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [4.1.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.0.0...v4.1.0) (2025-08-03) + + +### Features + +* **ui:** improve keyword highlights accuracy and performance ([#1260](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1260)) ([0d64e26](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/0d64e267a5aef3bd7d580a2c488bcc8b66d374a4)) + + +### Bug Fixes + +* **functions:** do not filter schema enum when entering input ([#1264](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1264)) ([8510f30](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/8510f30ff8c338482e7c8a2a7d102519cc57315f)), closes [#1263](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1263) + ## [4.0.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.12.2...v4.0.0) (2025-08-02) diff --git a/version.txt b/version.txt index fcdb2e10..ee74734a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.0 +4.1.0 From 5c8b457d617dd1e533b826ff9f9b76ddf988756d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 3 Aug 2025 22:17:25 +0200 Subject: [PATCH 003/250] feat(chat): improve error handling (#1265) Gracefully handle errors as they come instead of manually calling show_error method and then just catch all the errors at once place --- lua/CopilotChat/init.lua | 70 ++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 38 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 4e81c934..e72eed5e 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -204,17 +204,32 @@ local function finish(start_of_chat) end --- Show an error in the chat window. ----@param err string|table|nil -local function show_error(err) - err = err or 'Unknown error' - err = utils.make_string(err) +---@param config CopilotChat.config.Shared +---@param cb function +---@return any +local function handle_error(config, cb) + return function() + local ok, out = pcall(cb) + if ok then + return out + end - M.chat:add_message({ - role = 'assistant', - content = '\n' .. string.format(BLOCK_OUTPUT_FORMAT, 'error', err) .. '\n', - }) + log.error(out) + if config.headless then + return + end + + utils.schedule_main() + out = out or 'Unknown error' + out = utils.make_string(out) + + M.chat:add_message({ + role = 'assistant', + content = '\n' .. string.format(BLOCK_OUTPUT_FORMAT, 'error', out) .. '\n', + }) - finish() + finish() + end end --- Map a key to a function. @@ -954,7 +969,7 @@ function M.ask(prompt, config) -- Retrieve the selection local selection = M.get_selection() - local ok, err = pcall(async.run, function() + async.run(handle_error(config, function() local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) @@ -969,9 +984,9 @@ function M.ask(prompt, config) end prompt = vim.trim(prompt) - utils.schedule_main() if not config.headless then + utils.schedule_main() local assistant_message = M.chat:get_message('assistant') if assistant_message and assistant_message.tool_calls then local handled_ids = {} @@ -1019,7 +1034,7 @@ function M.ask(prompt, config) return end - local ask_ok, ask_response = pcall(client.ask, client, prompt, { + local ask_response = client.ask(client, prompt, { headless = config.headless, history = M.chat.messages, selection = selection, @@ -1038,16 +1053,6 @@ function M.ask(prompt, config) end), }) - utils.schedule_main() - - if not ask_ok then - log.error(ask_response) - if not config.headless then - show_error(ask_response) - end - return - end - -- If there was no error and no response, it means job was cancelled if ask_response == nil then return @@ -1059,14 +1064,8 @@ function M.ask(prompt, config) -- Call the callback function if config.callback then - local callback_ok, callback_response = pcall(config.callback, response.content, state.source) - if not callback_ok then - log.error('Callback error: ' .. callback_response) - if not config.headless then - show_error(callback_response) - end - return - end + utils.schedule_main() + config.callback(response.content, state.source) end if not config.headless then @@ -1076,19 +1075,14 @@ function M.ask(prompt, config) else response.content = '\n' .. response.content .. '\n' end + + utils.schedule_main() M.chat:add_message(response, true) M.chat.token_count = token_count M.chat.token_max_count = token_max_count finish() end - end) - - if not ok then - log.error(err) - if not config.headless then - show_error(err) - end - end + end)) end --- Stop current copilot output and optionally reset the chat ten show the help message. From 20b493ea8dee0589703e7e9fe24019f7d2649be1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 02:08:33 +0200 Subject: [PATCH 004/250] chore(main): release 4.2.0 (#1266) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a05ec4..19bf9a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [4.2.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.1.0...v4.2.0) (2025-08-03) + + +### Features + +* **chat:** improve error handling ([#1265](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1265)) ([5c8b457](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/5c8b457d617dd1e533b826ff9f9b76ddf988756d)) + ## [4.1.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.0.0...v4.1.0) (2025-08-03) diff --git a/version.txt b/version.txt index ee74734a..6aba2b24 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.1.0 +4.2.0 From 405728b708cce7359a333b059b744ca176e6c42f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 Aug 2025 00:08:50 +0000 Subject: [PATCH 005/250] 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 f49db7f1..8cfb114b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 03 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 04 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 80f797c3a12e188908e25a3182d1e0a285179abd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 4 Aug 2025 03:02:47 +0200 Subject: [PATCH 006/250] docs: mention selection in core concepts (#1268) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c35320a4..3b54fa3b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ EOF - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks +- **Selection** - Automatically includes current user selection in prompts ## Examples From ffbd2344c99d986b4f415b25e9a60d09f17872a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 Aug 2025 01:03:08 +0000 Subject: [PATCH 007/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 8cfb114b..37524639 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -128,6 +128,7 @@ VIM-PLUG *CopilotChat-vim-plug* - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks +- **Selection** - Automatically includes current user selection in prompts EXAMPLES *CopilotChat-examples* From f38319fd8f3a7aaa1f75b78027032f9c07abc425 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 4 Aug 2025 14:21:33 +0200 Subject: [PATCH 008/250] refactor(core): remove resource processing and embeddings (#1203) This change removes the resource processing and embeddings logic from the plugin, simplifying the codebase and reducing complexity. All related functions, configuration options, and provider logic for embeddings have been deleted. The plugin now relies solely on token count for resource management. This should make maintenance easier and improve user experience by reducing unnecessary processing. BREAKING CHANGE: Resource processing and embeddings support have been removed. Any configuration or usage relying on these features will no longer work. Signed-off-by: Tomas Slusny --- README.md | 4 - lua/CopilotChat/client.lua | 136 -------- lua/CopilotChat/config.lua | 3 - lua/CopilotChat/config/providers.lua | 27 -- lua/CopilotChat/init.lua | 10 - lua/CopilotChat/resources.lua | 460 --------------------------- lua/CopilotChat/utils.lua | 47 --- 7 files changed, 687 deletions(-) diff --git a/README.md b/README.md index 3b54fa3b..d7e7cfb2 100644 --- a/README.md +++ b/README.md @@ -369,9 +369,6 @@ Add custom AI providers: -- Optional: Disable provider disabled?: boolean, - -- Optional: Embeddings provider name or function - embed?: string|function, - -- Optional: Extra info about the provider displayed in info panel get_info?(): string[] @@ -396,7 +393,6 @@ Add custom AI providers: - `copilot` - GitHub Copilot (default) - `github_models` - GitHub Marketplace models (disabled by default) -- `copilot_embeddings` - Copilot embeddings provider # API Reference diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 11c353b6..4fd30197 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -31,17 +31,11 @@ ---@field description string description of the tool ---@field schema table? schema of the tool ----@class CopilotChat.client.Embed ----@field index number ----@field embedding table - ---@class CopilotChat.client.Resource ---@field name string ---@field type string ---@field data string ----@class CopilotChat.client.EmbeddedResource : CopilotChat.client.Resource, CopilotChat.client.Embed - ---@class CopilotChat.client.Model ---@field provider string? ---@field id string @@ -60,44 +54,6 @@ local class = utils.class --- Constants local RESOURCE_FORMAT = '# %s\n```%s\n%s\n```' -local LINE_CHARACTERS = 100 -local BIG_EMBED_THRESHOLD = 200 * LINE_CHARACTERS - ---- Resolve provider function ----@param model string ----@param models table ----@param providers table ----@return string, function -local function resolve_provider_function(name, model, models, providers) - local model_config = models[model] - if not model_config then - error('Model not found: ' .. model) - end - - local provider_name = model_config.provider - if not provider_name then - error('Provider not found for model: ' .. model) - end - local provider = providers[provider_name] - if not provider then - error('Provider not found: ' .. provider_name) - end - - local func = provider[name] - if type(func) == 'string' then - provider_name = func - provider = providers[provider_name] - if not provider then - error('Provider not found: ' .. provider_name) - end - func = provider[name] - end - if not func then - error('Function not found: ' .. name) - end - - return provider_name, func -end --- Generate content block with line numbers, truncating if necessary ---@param content string @@ -202,17 +158,6 @@ local function generate_ask_request(prompt, system_prompt, history, generated_me return messages end ---- Generate embedding request ---- @param inputs table ---- @param threshold number ---- @return table -local function generate_embedding_request(inputs, threshold) - return vim.tbl_map(function(embedding) - local content = generate_content_block(embedding.data, threshold) - return string.format(RESOURCE_FORMAT, embedding.name, embedding.type, content) - end, inputs) -end - ---@class CopilotChat.client.Client : Class ---@field private providers table ---@field private provider_cache table @@ -602,87 +547,6 @@ function Client:ask(prompt, opts) } end ---- Generate embeddings for the given inputs ----@param inputs table: The inputs to embed ----@param model string ----@return table -function Client:embed(inputs, model) - if not inputs or #inputs == 0 then - ---@diagnostic disable-next-line: return-type-mismatch - return inputs - end - - local models = self:models() - local ok, provider_name, embed = pcall(resolve_provider_function, 'embed', model, models, self.providers) - if not ok then - ---@diagnostic disable-next-line: return-type-mismatch - return inputs - end - - notify.publish(notify.STATUS, 'Generating embeddings for ' .. #inputs .. ' inputs') - - -- Initialize essentials - local to_process = inputs - local results = {} - local initial_chunk_size = 10 - - -- Process inputs in batches with adaptive chunk size - while #to_process > 0 do - local chunk_size = initial_chunk_size -- Reset chunk size for each new batch - local threshold = BIG_EMBED_THRESHOLD -- Reset threshold for each new batch - local last_error = nil - - -- Take next chunk - local batch = {} - for _ = 1, math.min(chunk_size, #to_process) do - table.insert(batch, table.remove(to_process, 1)) - end - - -- Try to get embeddings for batch - local success = false - local attempts = 0 - while not success and attempts < 5 do -- Limit total attempts to 5 - local ok, data = pcall(embed, generate_embedding_request(batch, threshold), self:authenticate(provider_name)) - - if not ok then - log.debug('Failed to get embeddings: ', data) - last_error = data - attempts = attempts + 1 - -- If we have few items and the request failed, try reducing threshold first - if #batch <= 5 then - threshold = math.max(5 * LINE_CHARACTERS, math.floor(threshold / 2)) - log.debug(string.format('Reducing threshold to %d and retrying...', threshold)) - else - -- Otherwise reduce batch size first - chunk_size = math.max(1, math.floor(chunk_size / 2)) - -- Put items back in to_process - for i = #batch, 1, -1 do - table.insert(to_process, 1, table.remove(batch, i)) - end - -- Take new smaller batch - batch = {} - for _ = 1, math.min(chunk_size, #to_process) do - table.insert(batch, table.remove(to_process, 1)) - end - log.debug(string.format('Reducing batch size to %d and retrying...', chunk_size)) - end - else - success = true - for _, embedding in ipairs(data) do - local result = vim.tbl_extend('force', batch[embedding.index + 1], embedding) - table.insert(results, result) - end - end - end - - if not success then - error(last_error) - end - end - - return results -end - --- Stop the running job ---@return boolean function Client:stop() diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 30ad2bd8..48f5e96a 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -19,7 +19,6 @@ ---@field tools string|table|nil ---@field sticky string|table|nil ---@field language string? ----@field resource_processing boolean? ---@field temperature number? ---@field headless boolean? ---@field callback nil|fun(response: string, source: CopilotChat.source) @@ -61,8 +60,6 @@ return { sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat (can be specified manually in prompt via >). language = 'English', -- Default language to use for answers - resource_processing = false, -- Enable intelligent resource processing (skips unnecessary resources to save tokens) - temperature = 0.1, -- Result temperature headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) callback = nil, -- Function called when full response is received diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index d2ac7976..5356925a 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -200,7 +200,6 @@ end ---@field get_headers nil|fun():table,number? ---@field get_info nil|fun(headers:table):string[] ---@field get_models nil|fun(headers:table):table ----@field embed nil|string|fun(inputs:table, headers:table):table ---@field prepare_input nil|fun(inputs:table, opts:CopilotChat.config.providers.Options):table ---@field prepare_output nil|fun(output:table, opts:CopilotChat.config.providers.Options):CopilotChat.config.providers.Output ---@field get_url nil|fun(opts:CopilotChat.config.providers.Options):string @@ -209,8 +208,6 @@ end local M = {} M.copilot = { - embed = 'copilot_embeddings', - get_headers = function() local response, err = utils.curl_get('https://api.github.com/copilot_internal/v2/token', { json_response = true, @@ -453,7 +450,6 @@ M.copilot = { M.github_models = { disabled = true, - embed = 'copilot_embeddings', get_headers = function() return { @@ -498,27 +494,4 @@ M.github_models = { end, } -M.copilot_embeddings = { - get_headers = M.copilot.get_headers, - - embed = function(inputs, headers) - local response, err = utils.curl_post('https://api.githubcopilot.com/embeddings', { - headers = headers, - json_request = true, - json_response = true, - body = { - dimensions = 512, - input = inputs, - model = 'text-embedding-3-small', - }, - }) - - if err then - error(err) - end - - return response.body.data - end, -} - return M diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index e72eed5e..10b8bd10 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -973,16 +973,6 @@ function M.ask(prompt, config) local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) - if config.resource_processing then - local query_ok, processed_resources = - pcall(resources.process_resources, prompt, selected_model, resolved_resources) - if query_ok then - resolved_resources = processed_resources - else - log.warn('Failed to process resources', processed_resources) - end - end - prompt = vim.trim(prompt) if not config.headless then diff --git a/lua/CopilotChat/resources.lua b/lua/CopilotChat/resources.lua index da79d6ec..e3ec443a 100644 --- a/lua/CopilotChat/resources.lua +++ b/lua/CopilotChat/resources.lua @@ -8,385 +8,12 @@ ---@field end_col number local async = require('plenary.async') -local log = require('plenary.log') -local client = require('CopilotChat.client') -local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local file_cache = {} local url_cache = {} -local embedding_cache = {} -local outline_cache = {} local M = {} -local OUTLINE_TYPES = { - 'local_function', - 'function_item', - 'arrow_function', - 'function_definition', - 'function_declaration', - 'method_definition', - 'method_declaration', - 'proc_declaration', - 'template_declaration', - 'macro_declaration', - 'constructor_declaration', - 'field_declaration', - 'class_definition', - 'class_declaration', - 'interface_definition', - 'interface_declaration', - 'record_declaration', - 'type_alias_declaration', - 'import_statement', - 'import_from_statement', - 'atx_heading', - 'list_item', -} - -local NAME_TYPES = { - 'name', - 'identifier', - 'heading_content', -} - -local OFF_SIDE_RULE_LANGUAGES = { - 'python', - 'coffeescript', - 'nim', - 'elm', - 'curry', - 'fsharp', -} - -local MULTI_FILE_THRESHOLD = 5 - ---- Compute the cosine similarity between two vectors ----@param a table ----@param b table ----@return number -local function spatial_distance_cosine(a, b) - if not a or not b then - return 0 - end - - local dot_product = 0 - local magnitude_a = 0 - local magnitude_b = 0 - for i = 1, #a do - dot_product = dot_product + a[i] * b[i] - magnitude_a = magnitude_a + a[i] * a[i] - magnitude_b = magnitude_b + b[i] * b[i] - end - magnitude_a = math.sqrt(magnitude_a) - magnitude_b = math.sqrt(magnitude_b) - return dot_product / (magnitude_a * magnitude_b) -end - ---- Rank data by relatedness to the query ----@param query CopilotChat.client.EmbeddedResource ----@param data table ----@return table -local function data_ranked_by_relatedness(query, data) - for _, item in ipairs(data) do - local score = spatial_distance_cosine(item.embedding, query.embedding) - item.score = score or item.score or 0 - end - - table.sort(data, function(a, b) - return a.score > b.score - end) - - -- Apply dynamic filtering for embedding-based ranking - local filtered = {} - - if #data > 0 then - -- Calculate statistics for score distribution - local sum = 0 - local max_score = data[1].score - - for _, item in ipairs(data) do - sum = sum + item.score - end - - local mean = sum / #data - - -- Calculate standard deviation - local sum_squared_diff = 0 - for _, item in ipairs(data) do - sum_squared_diff = sum_squared_diff + ((item.score - mean) * (item.score - mean)) - end - local std_dev = math.sqrt(sum_squared_diff / #data) - - -- Calculate z-scores and use them to determine significance - -- Include items with z-score > -0.5 (meaning within 0.5 std dev below mean) - -- This is a statistical approach to find "significantly" related items - for _, result in ipairs(data) do - local z_score = (result.score - mean) / std_dev - if z_score > -0.5 then - table.insert(filtered, result) - end - end - - -- If we didn't get enough results or the distribution is very tight, - -- use a percentage of max score as fallback - if #filtered < MULTI_FILE_THRESHOLD then - filtered = {} - local adaptive_threshold = max_score * 0.6 -- 60% of max score - - for i, result in ipairs(data) do - if i <= MULTI_FILE_THRESHOLD or result.score >= adaptive_threshold then - table.insert(filtered, result) - end - end - end - end - - return filtered -end - --- Create trigrams from text (e.g., "hello" -> {"hel", "ell", "llo"}) -local function get_trigrams(text) - local trigrams = {} - text = text:lower() - for i = 1, #text - 2 do - trigrams[text:sub(i, i + 2)] = true - end - return trigrams -end - --- Calculate Jaccard similarity between two trigram sets -local function trigram_similarity(set1, set2) - local intersection = 0 - local union = 0 - - -- Count intersection and union - for trigram in pairs(set1) do - if set2[trigram] then - intersection = intersection + 1 - end - union = union + 1 - end - - for trigram in pairs(set2) do - if not set1[trigram] then - union = union + 1 - end - end - - return intersection / union -end - ---- Rank data by symbols and filenames ----@param query string ----@param data table ----@return table -local function data_ranked_by_symbols(query, data) - -- Get query trigrams including compound versions - local query_trigrams = {} - - -- Add trigrams for each word - for term in query:gmatch('%w+') do - for trigram in pairs(get_trigrams(term)) do - query_trigrams[trigram] = true - end - end - - -- Add trigrams for compound query - local compound_query = query:gsub('[^%w]', '') - for trigram in pairs(get_trigrams(compound_query)) do - query_trigrams[trigram] = true - end - - local max_score = 0 - - for _, entry in ipairs(data) do - local basename = utils.filename(entry.name):gsub('%..*$', '') - - -- Get trigrams for basename and compound version - local file_trigrams = get_trigrams(basename) - local compound_trigrams = get_trigrams(basename:gsub('[^%w]', '')) - - -- Calculate similarities - local name_sim = trigram_similarity(query_trigrams, file_trigrams) - local compound_sim = trigram_similarity(query_trigrams, compound_trigrams) - - -- Take best match - local score = (entry.score or 0) + math.max(name_sim, compound_sim) - - -- Add symbol matches - if entry.symbols then - local symbol_score = 0 - for _, symbol in ipairs(entry.symbols) do - if symbol.name then - local symbol_trigrams = get_trigrams(symbol.name) - local sym_sim = trigram_similarity(query_trigrams, symbol_trigrams) - symbol_score = math.max(symbol_score, sym_sim) - end - end - score = score + (symbol_score * 0.5) -- Weight symbol matches less - end - - max_score = math.max(max_score, score) - entry.score = score - end - - -- Normalize scores - for _, entry in ipairs(data) do - entry.score = entry.score / max_score - end - - -- Sort by score first - table.sort(data, function(a, b) - return a.score > b.score - end) - - -- Use elbow method to find natural cutoff point for symbol-based ranking - local filtered_results = {} - - if #data > 0 then - -- Always include at least the top result - table.insert(filtered_results, data[1]) - - -- Find the point of maximum drop-off (the "elbow") - local max_drop = 0 - local cutoff_index = math.min(MULTI_FILE_THRESHOLD, #data) - - for i = 2, math.min(20, #data) do - local drop = data[i - 1].score - data[i].score - if drop > max_drop then - max_drop = drop - cutoff_index = i - end - end - - -- Include everything up to the cutoff point - for i = 2, cutoff_index do - table.insert(filtered_results, data[i]) - end - - -- Also include any remaining items that have scores close to the cutoff - local cutoff_score = data[cutoff_index].score - local threshold = cutoff_score * 0.8 -- Within 80% of the cutoff score - - for i = cutoff_index + 1, #data do - if data[i].score >= threshold then - table.insert(filtered_results, data[i]) - end - end - end - - return filtered_results -end - ---- Get the full signature of a declaration ----@param start_row number ----@param start_col number ----@param lines table ----@return string -local function get_full_signature(start_row, start_col, lines) - local start_line = lines[start_row + 1] - local signature = vim.trim(start_line:sub(start_col + 1)) - - -- Look ahead for opening brace on next line - if not signature:match('{') and (start_row + 2) <= #lines then - local next_line = vim.trim(lines[start_row + 2]) - if next_line:match('^{') then - signature = signature .. ' {' - end - end - - return signature -end - ---- Get the name of a node ----@param node table ----@param content string ----@return string? -local function get_node_name(node, content) - for _, name_type in ipairs(NAME_TYPES) do - local name_field = node:field(name_type) - if name_field and #name_field > 0 then - return vim.treesitter.get_node_text(name_field[1], content) - end - end - - return nil -end - ---- Build an outline and symbols from a string ----@param content string ----@param ft string ----@return string?, table? -local function get_outline(content, ft) - if not ft or ft == '' then - return nil - end - - local lang = vim.treesitter.language.get_lang(ft) - local ok, parser = false, nil - if lang then - ok, parser = pcall(vim.treesitter.get_string_parser, content, lang) - end - if not ok or not parser then - ft = string.gsub(ft, 'react', '') - ok, parser = pcall(vim.treesitter.get_string_parser, content, ft) - if not ok or not parser then - return nil - end - end - - local root = utils.ts_parse(parser) - local lines = vim.split(content, '\n') - local symbols = {} - local outline_lines = {} - local depth = 0 - - local function parse_node(node) - local type = node:type() - local is_outline = vim.tbl_contains(OUTLINE_TYPES, type) - local start_row, start_col, end_row, end_col = node:range() - - if is_outline then - depth = depth + 1 - local name = get_node_name(node, content) - local signature_start = get_full_signature(start_row, start_col, lines) - table.insert(outline_lines, string.rep(' ', depth) .. signature_start) - - -- Store symbol information - table.insert(symbols, { - name = name, - signature = signature_start, - type = type, - start_row = start_row + 1, - start_col = start_col + 1, - end_row = end_row, - end_col = end_col, - }) - end - - for child in node:iter_children() do - parse_node(child) - end - - if is_outline then - if not vim.tbl_contains(OFF_SIDE_RULE_LANGUAGES, ft) then - local end_line = lines[end_row + 1] - local signature_end = vim.trim(end_line:sub(1, end_col)) - table.insert(outline_lines, string.rep(' ', depth) .. signature_end) - end - depth = depth - 1 - end - end - - parse_node(root) - - if #outline_lines == 0 then - return nil - end - return table.concat(outline_lines, '\n'), symbols -end - --- Get data for a file ---@param filename string ---@return string?, string? @@ -492,91 +119,4 @@ function M.to_resource(resource) } end ---- Process resources based on the query ----@param prompt string ----@param model string ----@param resources table ----@return table -function M.process_resources(prompt, model, resources) - -- If we dont need to embed anything, just return directly - if #resources < MULTI_FILE_THRESHOLD then - return resources - end - - notify.publish(notify.STATUS, 'Preparing embedding outline') - - -- Get the outlines for each resource - for _, input in ipairs(resources) do - local hash = input.name .. utils.quick_hash(input.data) - input._hash = hash - - local outline = outline_cache[hash] - if not outline then - local outline_text, symbols = get_outline(input.data, input.type) - if outline_text then - outline = { - outline = outline_text, - symbols = symbols, - } - - outline_cache[hash] = outline - end - end - - if outline then - input.outline = outline.outline - input.symbols = outline.symbols - end - end - - notify.publish(notify.STATUS, 'Ranking embeddings') - - -- Build query from history and prompt - local query = prompt - - -- Rank embeddings by symbols - resources = data_ranked_by_symbols(query, resources) - log.debug('Ranked data:', #resources) - for i, item in ipairs(resources) do - log.debug(string.format('%s: %s - %s', i, item.score, item.name)) - end - - -- Prepare embeddings for processing - local to_process = {} - local results = {} - for _, input in ipairs(resources) do - local hash = input._hash - local embed = embedding_cache[hash] - if embed then - input.embedding = embed - table.insert(results, input) - else - table.insert(to_process, input) - end - end - table.insert(to_process, { - type = 'text', - data = query, - }) - - -- Embed the data and process the results - for _, input in ipairs(client:embed(to_process, model)) do - if input._hash then - embedding_cache[input._hash] = input.embedding - end - table.insert(results, input) - end - - -- Rate embeddings by relatedness to the query - local embedded_query = table.remove(results, #results) - log.debug('Embedded query:', embedded_query.content) - results = data_ranked_by_relatedness(embedded_query, results) - log.debug('Ranked embeddings:', #results) - for i, item in ipairs(results) do - log.debug(string.format('%s: %s - %s', i, item.score, item.filename)) - end - - return results -end - return M diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 251238ca..34e6ab23 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -293,13 +293,6 @@ function M.uuid() ) end ---- Generate a quick hash ----@param str string The string to hash ----@return string -function M.quick_hash(str) - return #str .. str:sub(1, 64) .. str:sub(-64) -end - --- Make a string from arguments ---@vararg any The arguments ---@return string @@ -685,46 +678,6 @@ M.schedule_main = async.wrap(function(callback) end end, 1) ---- Run parse on a treesitter parser asynchronously if possible ----@param parser vim.treesitter.LanguageTree The parser -M.ts_parse = async.wrap(function(parser, callback) - ---@diagnostic disable-next-line: invisible - if not parser._async_parse then - local fn = function() - local trees = parser:parse(false) - if not trees or #trees == 0 then - callback(nil) - return - end - callback(trees[1]:root()) - end - - if vim.in_fast_event() then - vim.schedule(fn) - else - fn() - end - - return - end - - local fn = function() - parser:parse(false, function(err, trees) - if err or not trees or #trees == 0 then - callback(nil) - return - end - callback(trees[1]:root()) - end) - end - - if vim.in_fast_event() then - vim.schedule(fn) - else - fn() - end -end, 2) - --- Wait for a user input M.input = async.wrap(function(opts, callback) local fn = function() From 091bf5455bc5b371bd469e6be1dfcc656524321a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 Aug 2025 12:21:52 +0000 Subject: [PATCH 009/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 37524639..76753cd1 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -443,9 +443,6 @@ Add custom AI providers: -- Optional: Disable provider disabled?: boolean, - -- Optional: Embeddings provider name or function - embed?: string|function, - -- Optional: Extra info about the provider displayed in info panel get_info?(): string[] @@ -470,7 +467,6 @@ Add custom AI providers: - `copilot` - GitHub Copilot (default) - `github_models` - GitHub Marketplace models (disabled by default) -- `copilot_embeddings` - Copilot embeddings provider ============================================================================== From 874c7c56000e2b9d226b26f91929524cd5da1a25 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 4 Aug 2025 16:34:21 +0200 Subject: [PATCH 010/250] refactor(tiktoken): use class-based API and method calls (#1271) Refactored the tiktoken module to use a class-based API instead of a module table. All tiktoken functions are now methods, and the client calls them using the colon syntax. This improves encapsulation and maintainability. Also replaced manual table insertion with vim.list_extend for generated messages and history in the client. No functional changes to token counting logic. --- lua/CopilotChat/client.lua | 30 ++++++++---------- lua/CopilotChat/tiktoken.lua | 60 +++++++++++++++++------------------- 2 files changed, 41 insertions(+), 49 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 4fd30197..c6e02334 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -138,17 +138,11 @@ local function generate_ask_request(prompt, system_prompt, history, generated_me end -- Include generated messages and history - for _, message in ipairs(generated_messages) do - table.insert(messages, { - content = message.content, - role = message.role, - }) - end - for _, message in ipairs(history) do - table.insert(messages, message) - end + vim.list_extend(messages, generated_messages) + vim.list_extend(messages, history) + + -- Include user prompt if we have no history if not utils.empty(prompt) and utils.empty(history) then - -- Include user prompt if we have no history table.insert(messages, { content = prompt, role = 'user', @@ -301,7 +295,7 @@ function Client:ask(prompt, opts) log.debug('Tokenizer:', tokenizer) if max_tokens and tokenizer then - tiktoken.load(tokenizer) + tiktoken:load(tokenizer) end if not opts.headless then @@ -320,29 +314,29 @@ function Client:ask(prompt, opts) if max_tokens then -- Count required tokens that we cannot reduce - local selection_tokens = selection_message and tiktoken.count(selection_message.content) or 0 - local prompt_tokens = tiktoken.count(prompt) - local system_tokens = tiktoken.count(opts.system_prompt) - local resource_tokens = #resource_messages > 0 and tiktoken.count(resource_messages[1].content) or 0 + local selection_tokens = selection_message and tiktoken:count(selection_message.content) or 0 + local prompt_tokens = tiktoken:count(prompt) + local system_tokens = tiktoken:count(opts.system_prompt) + local resource_tokens = #resource_messages > 0 and tiktoken:count(resource_messages[1].content) or 0 local required_tokens = prompt_tokens + system_tokens + selection_tokens + resource_tokens -- Calculate how many tokens we can use for history local history_limit = max_tokens - required_tokens local history_tokens = 0 for _, msg in ipairs(history) do - history_tokens = history_tokens + tiktoken.count(msg.content) + history_tokens = history_tokens + tiktoken:count(msg.content) end -- Remove history messages until we are under the limit while history_tokens > history_limit and #history > 0 do local entry = table.remove(history, 1) - history_tokens = history_tokens - tiktoken.count(entry.content) + history_tokens = history_tokens - tiktoken:count(entry.content) end -- Now add as many files as possible with remaining token budget local remaining_tokens = max_tokens - required_tokens - history_tokens for _, message in ipairs(resource_messages) do - local tokens = tiktoken.count(message.content) + local tokens = tiktoken:count(message.content) if remaining_tokens - tokens >= 0 then remaining_tokens = remaining_tokens - tokens table.insert(generated_messages, message) diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index dde3d2b5..5a6f346b 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,5 +1,6 @@ local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') +local class = utils.class local current_tokenizer = nil --- @return string @@ -13,23 +14,10 @@ local function get_lib_extension() return '.so' end -package.cpath = package.cpath - .. ';' - .. debug.getinfo(1).source:match('@?(.*/)') - .. '../../build/?' - .. get_lib_extension() - -local tiktoken_ok, tiktoken_core = pcall(require, 'tiktoken_core') -if not tiktoken_ok then - tiktoken_core = nil -end - --- Load tiktoken data from cache or download it ---@param tokenizer string The tokenizer to load ---@async local function load_tiktoken_data(tokenizer) - utils.schedule_main() - local tiktoken_url = 'https://openaipublic.blob.core.windows.net/encodings/' .. tokenizer .. '.tiktoken' local cache_dir = vim.fn.stdpath('cache') @@ -49,20 +37,34 @@ local function load_tiktoken_data(tokenizer) return cache_path end -local M = {} +---@class CopilotChat.tiktoken.Tiktoken : Class +---@field private tiktoken_core table? +---@field private tokenizer string? +local Tiktoken = class(function(self) + package.cpath = package.cpath + .. ';' + .. debug.getinfo(1).source:match('@?(.*/)') + .. '../../build/?' + .. get_lib_extension() + + local tiktoken_ok, tiktoken_core = pcall(require, 'tiktoken_core') + self.tiktoken_core = tiktoken_ok and tiktoken_core or nil + self.tokenizer = nil +end) --- Load the tiktoken module ---@param tokenizer string The tokenizer to load ---@async -M.load = function(tokenizer) - if not tiktoken_core then +function Tiktoken:load(tokenizer) + if not self.tiktoken_core then return end - if tokenizer == current_tokenizer then + if tokenizer == self.tokenizer then return end + utils.schedule_main() local path = load_tiktoken_data(tokenizer) local special_tokens = {} special_tokens['<|endoftext|>'] = 100257 @@ -74,26 +76,22 @@ M.load = function(tokenizer) "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}{1,3}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+" utils.schedule_main() - tiktoken_core.new(path, special_tokens, pat_str) - current_tokenizer = tokenizer + self.tiktoken_core.new(path, special_tokens, pat_str) + self.tokenizer = tokenizer end --- Encode a prompt ---@param prompt string The prompt to encode ---@return table? -function M.encode(prompt) - if not tiktoken_core then +function Tiktoken:encode(prompt) + if not self.tiktoken_core then return nil end - if not prompt or prompt == '' then + if not prompt or prompt == '' or type(prompt) ~= 'string' then return nil end - -- Check if prompt is a string - if type(prompt) ~= 'string' then - error('Prompt must be a string') - end - local ok, result = pcall(tiktoken_core.encode, prompt) + local ok, result = pcall(self.tiktoken_core.encode, prompt) if not ok then return nil end @@ -104,16 +102,16 @@ end --- Count the tokens in a prompt ---@param prompt string The prompt to count ---@return number -function M.count(prompt) - if not tiktoken_core then +function Tiktoken:count(prompt) + if not self.tiktoken_core then return math.ceil(#prompt * 0.5) -- Fallback to 1/2 character count end - local tokens = M.encode(prompt) + local tokens = self:encode(prompt) if not tokens then return math.ceil(#prompt * 0.5) -- Fallback to 1/2 character count end return #tokens end -return M +return Tiktoken() From 4d11c49b7a1afb573a3b09be5e10a78a3d41649d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 4 Aug 2025 17:31:01 +0200 Subject: [PATCH 011/250] fix(functions): do not require tool reference in tool prompt, just tool id (#1273) Before it was necessary to include tool @ reference again in tool prompt or make it sticky, but we already know that the user asked for the tool so we do not need to check it again Closes #1269 Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 10b8bd10..0f6e6671 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -178,8 +178,8 @@ local function finish(start_of_chat) end local prompt_content = '' - local last_message = M.chat.messages[#M.chat.messages] - local tool_calls = last_message and last_message.tool_calls or {} + local assistant_message = M.chat:get_message('assistant') + local tool_calls = assistant_message and assistant_message.tool_calls or {} if not utils.empty(state.sticky) then for _, sticky in ipairs(state.sticky) do @@ -316,7 +316,7 @@ function M.resolve_functions(prompt, config) for _, match in ipairs(matches) do for name, tool in pairs(M.config.functions) do if name == match or tool.group == match then - enabled_tools[name] = true + table.insert(enabled_tools, tools[name]) end end end @@ -382,7 +382,7 @@ function M.resolve_functions(prompt, config) if not tool then return nil end - if tool_id and not enabled_tools[name] and not tool.uri then + if not tool_id and not tool.uri then return nil end @@ -435,12 +435,7 @@ function M.resolve_functions(prompt, config) end end - return vim.tbl_map(function(name) - return tools[name] - end, vim.tbl_keys(enabled_tools)), - resolved_resources, - resolved_tools, - prompt + return enabled_tools, resolved_resources, resolved_tools, prompt end --- Resolve the final prompt and config from prompt template. From 93110a5f289aaed20adbbc13ec803f94dc6c63c6 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 4 Aug 2025 17:36:36 +0200 Subject: [PATCH 012/250] fix(ui): prevent italics from breaking glob pattern highlights (#1274) Disables the '@markup.italic.markdown_inline' highlight in the chat UI. This prevents markdown italics from interfering with glob pattern highlighting in chat messages. Signed-off-by: Tomas Slusny --- README.md | 1 - lua/CopilotChat/ui/chat.lua | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d7e7cfb2..a4457147 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,6 @@ You can customize colors by setting highlight groups in your config: -- In your colorscheme or init.lua vim.api.nvim_set_hl(0, 'CopilotChatHeader', { fg = '#7C3AED', bold = true }) vim.api.nvim_set_hl(0, 'CopilotChatSeparator', { fg = '#374151' }) -vim.api.nvim_set_hl(0, 'CopilotChatKeyword', { fg = '#10B981', italic = true }) ``` Types of copilot highlights: diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 8b22c32e..e1d1156e 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -341,7 +341,8 @@ function Chat:open(config) end local ns = vim.api.nvim_create_namespace('copilot-chat-local-hl') - vim.api.nvim_set_hl(ns, '@markup.quote.markdown', {}) + vim.api.nvim_set_hl(ns, '@markup.quote.markdown', {}) -- disable quote block overriding chat keywords + vim.api.nvim_set_hl(ns, '@markup.italic.markdown_inline', {}) -- disable italic messing up glob patterns vim.api.nvim_win_set_hl_ns(self.winnr, ns) vim.api.nvim_win_set_buf(self.winnr, self.bufnr) self:render() From a92ed8157ecfb47054c121d406fd10a588254149 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 Aug 2025 15:36:56 +0000 Subject: [PATCH 013/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 76753cd1..04a95613 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -318,7 +318,6 @@ You can customize colors by setting highlight groups in your config: -- In your colorscheme or init.lua vim.api.nvim_set_hl(0, 'CopilotChatHeader', { fg = '#7C3AED', bold = true }) vim.api.nvim_set_hl(0, 'CopilotChatSeparator', { fg = '#374151' }) - vim.api.nvim_set_hl(0, 'CopilotChatKeyword', { fg = '#10B981', italic = true }) < Types of copilot highlights: From 7576afad950d4258cc7d455d8d42f7dccac4d19b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 4 Aug 2025 17:47:29 +0200 Subject: [PATCH 014/250] chore: mark next release as 4.3.0 (#1275) Release-As: 4.3.0 From 06e54538c24cbeb341301418dabaf598ae8e7e60 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 4 Aug 2025 18:11:52 +0200 Subject: [PATCH 015/250] refactor(client): use provider resolver instead of static table (#1276) Refactors the client to use a provider resolver function instead of a static providers table. This allows providers to be dynamically resolved when needed, improving flexibility and reducing the need to reload providers manually. Updates all internal references and replaces load_providers with add_providers. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 47 ++++++++++++++++++++++++-------------- lua/CopilotChat/init.lua | 4 +++- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index c6e02334..f39953ef 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -54,6 +54,7 @@ local class = utils.class --- Constants local RESOURCE_FORMAT = '# %s\n```%s\n%s\n```' +local CACHE_TTL = 300 -- 5 minutes --- Generate content block with line numbers, truncating if necessary ---@param content string @@ -153,22 +154,40 @@ local function generate_ask_request(prompt, system_prompt, history, generated_me end ---@class CopilotChat.client.Client : Class ----@field private providers table +---@field private provider_resolver function():table ---@field private provider_cache table ---@field private model_cache table? ---@field private current_job string? local Client = class(function(self) - self.providers = {} - self.provider_cache = {} + self.provider_resolver = nil + self.provider_cache = vim.defaulttable(function() + return {} + end) self.model_cache = nil self.current_job = nil end) +--- Get all providers from the client +---@return table +function Client:get_providers() + if self.provider_resolver then + return self.provider_resolver() + end + return {} +end + +--- Set a provider resolver on the client +---@param resolver function: A function that returns a table of providers +function Client:add_providers(resolver) + self.provider_resolver = resolver +end + --- Authenticate with GitHub and get the required headers ---@param provider_name string: The provider to authenticate with ---@return table function Client:authenticate(provider_name) - local provider = self.providers[provider_name] + local providers = self:get_providers() + local provider = providers[provider_name] local headers = self.provider_cache[provider_name].headers local expires_at = self.provider_cache[provider_name].expires_at @@ -189,10 +208,11 @@ function Client:models() end local models = {} - local provider_order = vim.tbl_keys(self.providers) + 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 = self.providers[provider_name] + local provider = providers[provider_name] if not provider.disabled and provider.get_models then notify.publish(notify.STATUS, 'Fetching models from ' .. provider_name) local ok, headers = pcall(self.authenticate, self, provider_name) @@ -228,9 +248,9 @@ end function Client:info() local infos = {} local now = math.floor(os.time()) - local CACHE_TTL = 300 -- 5 minutes + local providers = self:get_providers() - for provider_name, provider in pairs(self.providers) do + 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 @@ -267,6 +287,7 @@ 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 @@ -277,7 +298,7 @@ function Client:ask(prompt, opts) if not provider_name then error('Provider not found for model: ' .. opts.model) end - local provider = self.providers[provider_name] + local provider = providers[provider_name] if not provider then error('Provider not found: ' .. provider_name) end @@ -558,13 +579,5 @@ function Client:running() return self.current_job ~= nil end ---- Load providers to client -function Client:load_providers(providers) - self.providers = providers - for provider_name, _ in pairs(providers) do - self.provider_cache[provider_name] = {} - end -end - --- @type CopilotChat.client.Client return Client() diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0f6e6671..d90b39ff 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1200,7 +1200,9 @@ function M.setup(config) -- Load the providers client:stop() - client:load_providers(M.config.providers) + client:add_providers(function() + return M.config.providers + end) if M.config.debug then M.log_level('debug') From 2eb4dd98ed934bce31b486ab117ea5b7ba875b0f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 6 Aug 2025 20:14:28 +0200 Subject: [PATCH 016/250] refactor(resources): unify resource format and handling (#1279) Simplifies resource representation by removing redundant conversion logic and standardizing resource fields. Updates resource block formatting and selection message generation to use the new structure. Cleans up unused types and improves consistency across resource-related functions. --- lua/CopilotChat/client.lua | 64 ++++++++++++++++------------ lua/CopilotChat/config/functions.lua | 11 +++-- lua/CopilotChat/config/mappings.lua | 10 ++--- lua/CopilotChat/init.lua | 2 +- lua/CopilotChat/resources.lua | 20 --------- 5 files changed, 47 insertions(+), 60 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index f39953ef..95db5bb8 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -32,9 +32,10 @@ ---@field schema table? schema of the tool ---@class CopilotChat.client.Resource ----@field name string ----@field type string ---@field data string +---@field name string? +---@field mimetype string? +---@field uri string? ---@class CopilotChat.client.Model ---@field provider string? @@ -53,49 +54,58 @@ local utils = require('CopilotChat.utils') local class = utils.class --- Constants -local RESOURCE_FORMAT = '# %s\n```%s\n%s\n```' +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 ---- Generate content block with line numbers, truncating if necessary +--- Generate resource block with line numbers, truncating if necessary ---@param content string ----@param start_line number?: The starting line number +---@param start_line number: The starting line number ---@return string -local function generate_content_block(content, start_line) - if start_line ~= nil then - local lines = vim.split(content, '\n') - local total_lines = #lines - local max_length = #tostring(total_lines) - for i, line in ipairs(lines) do - local formatted_line_number = string.format('%' .. max_length .. 'd', i - 1 + (start_line or 1)) - lines[i] = formatted_line_number .. ': ' .. line - end +local function generate_resource_block(content, mimetype, name, path, start_line, end_line) + local lines = vim.split(content, '\n') + local total_lines = #lines + local max_length = #tostring(total_lines) + for i, line in ipairs(lines) do + local formatted_line_number = string.format('%' .. max_length .. 'd', i - 1 + (start_line or 1)) + lines[i] = formatted_line_number .. ': ' .. line + end - return table.concat(lines, '\n') + local updated_content = table.concat(lines, '\n') + local filetype = utils.mimetype_to_filetype(mimetype or 'text') + if not start_line then + start_line = 1 + end + if not end_line then + end_line = start_line and (start_line + total_lines - 1) or 1 end - return content + if path then + return string.format(RESOURCE_LONG_FORMAT, name, filetype, path, start_line, end_line, updated_content) + else + return string.format(RESOURCE_SHORT_FORMAT, name, filetype, start_line, end_line, updated_content) + end end --- Generate messages for the given selection --- @param selection CopilotChat.select.Selection --- @return CopilotChat.client.Message? local function generate_selection_message(selection) - local filename = selection.filename or 'unknown' - local filetype = selection.filetype or 'text' local content = selection.content if not content or content == '' then return nil end - local out = "User's active selection:\n" - if selection.start_line and selection.end_line then - out = out .. string.format('Excerpt from %s, lines %s to %s:\n', filename, selection.start_line, selection.end_line) - end - out = out .. string.format('```%s\n%s\n```', filetype, generate_content_block(content, selection.start_line)) - return { - content = out, + content = generate_resource_block( + content, + selection.filetype, + "User's active selection", + selection.filename, + selection.start_line, + selection.end_line + ), role = 'user', } end @@ -110,10 +120,8 @@ local function generate_resource_messages(resources) return resource.data and resource.data ~= '' end) :map(function(resource) - local content = generate_content_block(resource.data, 1) - return { - content = string.format(RESOURCE_FORMAT, resource.name, resource.type, content), + content = generate_resource_block(resource.data, resource.mimetype, resource.uri, resource.name, 1, nil), role = 'user', } end) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 25c3f6c5..9050d267 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -1,17 +1,12 @@ local resources = require('CopilotChat.resources') local utils = require('CopilotChat.utils') ----@class CopilotChat.config.functions.Result ----@field data string ----@field mimetype string? ----@field uri string? - ---@class CopilotChat.config.functions.Function ---@field description string? ---@field schema table? ---@field group string? ---@field uri string? ----@field resolve fun(input: table, source: CopilotChat.source, prompt: string):table +---@field resolve fun(input: table, source: CopilotChat.source, prompt: string):table ---@type table return { @@ -46,6 +41,7 @@ return { return { { uri = 'file://' .. input.path, + name = input.path, mimetype = mimetype, data = data, }, @@ -163,6 +159,7 @@ return { return { { uri = 'buffer://' .. name, + name = name, mimetype = mimetype, data = data, }, @@ -205,6 +202,7 @@ return { end return { uri = 'buffer://' .. name, + name = name, mimetype = mimetype, data = data, } @@ -258,6 +256,7 @@ return { end return { uri = uri, + name = file, mimetype = mimetype, data = data, } diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index aaaa0ea6..04152721 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -33,6 +33,11 @@ local function get_diff(block) -- If we have header info, use it as source of truth if header.start_line and header.end_line then + filename = utils.uri_to_filename(header.filename) + filetype = header.filetype or utils.filetype(filename) + start_line = header.start_line + end_line = header.end_line + -- Try to find matching buffer and window bufnr = nil for _, win in ipairs(vim.api.nvim_list_wins()) do @@ -43,11 +48,6 @@ local function get_diff(block) end end - filename = header.filename - filetype = header.filetype or utils.filetype(filename) - start_line = header.start_line - end_line = header.end_line - -- If we found a valid buffer, get the reference content if bufnr and utils.buf_valid(bufnr) then reference = table.concat(vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false), '\n') diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index d90b39ff..8b343a75 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -397,7 +397,7 @@ function M.resolve_functions(prompt, config) local content_out = nil if content.uri then content_out = '##' .. content.uri - table.insert(resolved_resources, resources.to_resource(content)) + table.insert(resolved_resources, content) if tool_id then table.insert(state.sticky, content_out) end diff --git a/lua/CopilotChat/resources.lua b/lua/CopilotChat/resources.lua index e3ec443a..a8c1fd78 100644 --- a/lua/CopilotChat/resources.lua +++ b/lua/CopilotChat/resources.lua @@ -1,12 +1,3 @@ ----@class CopilotChat.resources.Symbol ----@field name string? ----@field signature string ----@field type string ----@field start_row number ----@field start_col number ----@field end_row number ----@field end_col number - local async = require('plenary.async') local utils = require('CopilotChat.utils') local file_cache = {} @@ -108,15 +99,4 @@ function M.get_url(url) return content, utils.filetype_to_mimetype(ft) end ---- Transform a resource into a format suitable for the client ----@param resource CopilotChat.config.functions.Result ----@return CopilotChat.client.Resource -function M.to_resource(resource) - return { - name = utils.uri_to_filename(resource.uri), - type = utils.mimetype_to_filetype(resource.mimetype), - data = resource.data, - } -end - return M From 468871cf690d5a9a13ef1b12af394ef988c82489 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 6 Aug 2025 18:14:51 +0000 Subject: [PATCH 017/250] 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 04a95613..ffacf3e0 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 04 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 06 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 59f5b43cdd3d27ab4e033882179d5cf028cf1302 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 7 Aug 2025 02:33:03 +0200 Subject: [PATCH 018/250] feat(keymap): switch back to for completion, add Copilot conflict note (#1280) * feat(keymap): switch back to for completion, add Copilot conflict note Switch completion keymap from to in CopilotChat. Update README to reflect this change and add a warning about potential keymap conflicts with copilot.vim. Provide instructions for disabling Copilot's default mapping and customizing CopilotChat keymaps. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 43 ++++++++++++++++++----------- lua/CopilotChat/config/mappings.lua | 2 +- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a4457147..4198bb70 100644 --- a/README.md +++ b/README.md @@ -136,22 +136,33 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif ## Chat Key Mappings -| Insert | Normal | Action | -| ----------- | ------- | ------------------------------------------ | -| `` | - | Trigger/accept completion menu for tokens | -| `` | `q` | Close the chat window | -| `` | `` | Reset and clear the chat window | -| `` | `` | Submit the current prompt | -| - | `grr` | Toggle sticky prompt for line under cursor | -| - | `grx` | Clear all sticky prompts in prompt | -| `` | `` | Accept nearest diff | -| - | `gj` | Jump to section of nearest diff | -| - | `gqa` | Add all answers from chat to quickfix list | -| - | `gqd` | Add all diffs from chat to quickfix list | -| - | `gy` | Yank nearest diff to register | -| - | `gd` | Show diff between source and nearest diff | -| - | `gc` | Show info about current chat | -| - | `gh` | Show help message | +| Insert | Normal | Action | +| ------- | ------- | ------------------------------------------ | +| `` | - | Trigger/accept completion menu for tokens | +| `` | `q` | Close the chat window | +| `` | `` | Reset and clear the chat window | +| `` | `` | Submit the current prompt | +| - | `grr` | Toggle sticky prompt for line under cursor | +| - | `grx` | Clear all sticky prompts in prompt | +| `` | `` | Accept nearest diff | +| - | `gj` | Jump to section of nearest diff | +| - | `gqa` | Add all answers from chat to quickfix list | +| - | `gqd` | Add all diffs from chat to quickfix list | +| - | `gy` | Yank nearest diff to register | +| - | `gd` | Show diff between source and nearest diff | +| - | `gc` | Show info about current chat | +| - | `gh` | Show help message | + +> [!WARNING] +> Some plugins (e.g. `copilot.vim`) may also map common keys like `` in insert mode. +> To avoid conflicts, disable Copilot's default `` mapping with: +> +> ```lua +> vim.g.copilot_no_tab_map = true +> vim.keymap.set('i', '', 'copilot#Accept("\\")', { expr = true, replace_keycodes = false }) +> ``` +> +> You can also customize CopilotChat keymaps in your config. ## Predefined Functions diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 04152721..552e6c6b 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -137,7 +137,7 @@ end ---@field show_help CopilotChat.config.mapping|false|nil return { complete = { - insert = '', + insert = '', callback = function() copilot.trigger_complete() end, From ba213d39734d026e02af3117a33eba342b13e7e5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 Aug 2025 00:33:23 +0000 Subject: [PATCH 019/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index ffacf3e0..391a7766 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 06 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 07 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -175,22 +175,31 @@ COMMANDS *CopilotChat-commands* CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* - Insert Normal Action - ----------- -------- -------------------------------------------- - - Trigger/accept completion menu for tokens - q Close the chat window - Reset and clear the chat window - Submit the current prompt - - grr Toggle sticky prompt for line under cursor - - grx Clear all sticky prompts in prompt - Accept nearest diff - - gj Jump to section of nearest diff - - gqa Add all answers from chat to quickfix list - - gqd Add all diffs from chat to quickfix list - - gy Yank nearest diff to register - - gd Show diff between source and nearest diff - - gc Show info about current chat - - gh Show help message + Insert Normal Action + -------- -------- -------------------------------------------- + - Trigger/accept completion menu for tokens + q Close the chat window + Reset and clear the chat window + Submit the current prompt + - grr Toggle sticky prompt for line under cursor + - grx Clear all sticky prompts in prompt + Accept nearest diff + - gj Jump to section of nearest diff + - gqa Add all answers from chat to quickfix list + - gqd Add all diffs from chat to quickfix list + - gy Yank nearest diff to register + - gd Show diff between source and nearest diff + - gc Show info about current chat + - gh Show help message + + [!WARNING] Some plugins (e.g. `copilot.vim`) may also map common keys like + `` in insert mode. To avoid conflicts, disable Copilot’s default `` + mapping with: + >lua + vim.g.copilot_no_tab_map = true + vim.keymap.set('i', '', 'copilot#Accept("\\")', { expr = true, replace_keycodes = false }) + < + You can also customize CopilotChat keymaps in your config. PREDEFINED FUNCTIONS *CopilotChat-predefined-functions* From 807c4a179d48aedaf9c5b5dd667486766411d81b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 7 Aug 2025 04:59:41 +0200 Subject: [PATCH 020/250] refactor: clean up annotations and unused code (#1281) Remove unused resources import from init.lua and unnecessary variable from tiktoken.lua. Add and improve function annotations in chat.lua for better documentation and maintainability. --- lua/CopilotChat/init.lua | 1 - lua/CopilotChat/tiktoken.lua | 2 +- lua/CopilotChat/ui/chat.lua | 5 +++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 8b343a75..7791e43a 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1,7 +1,6 @@ local async = require('plenary.async') local log = require('plenary.log') local functions = require('CopilotChat.functions') -local resources = require('CopilotChat.resources') local client = require('CopilotChat.client') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 5a6f346b..3f631428 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,8 +1,8 @@ local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local class = utils.class -local current_tokenizer = nil +--- Get the library extension based on the operating system --- @return string local function get_lib_extension() if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index e1d1156e..2752e6d0 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -424,6 +424,9 @@ function Chat:finish() end end +--- Add a message to the chat window. +---@param message CopilotChat.client.Message +---@param replace boolean? If true, replaces the last message if it has same role function Chat:add_message(message, replace) local current_message = self.messages[#self.messages] local is_new = not current_message @@ -471,6 +474,8 @@ function Chat:add_message(message, replace) end end +--- Remove a message from the chat window by role. +---@param role string function Chat:remove_message(role) if not self:visible() then return From 8e4dc67d822d649280e82561725462e86923695f Mon Sep 17 00:00:00 2001 From: Mihamina Rakotomandimby Date: Thu, 7 Aug 2025 18:49:59 +0300 Subject: [PATCH 021/250] docs: update info about models * #1283 Update the list of models supported * #1283 Update the list of models supported --------- Co-authored-by: Mihamina RKTMB --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4198bb70..52a8d56b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. -- 🤖 **Multiple AI Models** - GitHub Copilot (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash) + custom providers (Ollama, Mistral.ai) +- 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your [GitHub Copilot settings](https://github.com/settings/copilot/features) and the models provided by GitHub's API. - 🔧 **Tool Calling** - LLM can use workspace functions (file reading, git operations, search) with your explicit approval - 🔒 **Explicit Control** - Only shares what you specifically request - no background data collection - 📝 **Interactive Chat** - Rich UI with completion, diffs, and quickfix integration From e81da15603a47117cd6852be1e0a2806ca5795c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 7 Aug 2025 15:50:18 +0000 Subject: [PATCH 022/250] 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 391a7766..e9b4a113 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -39,7 +39,7 @@ Table of Contents *CopilotChat-table-of-contents* CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. -- 🤖 **Multiple AI Models** - GitHub Copilot (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash) + custom providers (Ollama, Mistral.ai) +- 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your GitHub Copilot settings and the models provided by GitHub’s API. - 🔧 **Tool Calling** - LLM can use workspace functions (file reading, git operations, search) with your explicit approval - 🔒 **Explicit Control** - Only shares what you specifically request - no background data collection - 📝 **Interactive Chat** - Rich UI with completion, diffs, and quickfix integration From d4ed3fb40f301fc1ff32e0ff3b437b619112ce14 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 17:53:04 +0200 Subject: [PATCH 023/250] docs: add rakotomandimby as a contributor for doc (#1287) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3e77a246..72ae3b71 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -431,6 +431,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/214497460?v=4", "profile": "https://github.com/danilohorta", "contributions": ["code"] + }, + { + "login": "rakotomandimby", + "name": "Mihamina Rakotomandimby", + "avatar_url": "https://avatars.githubusercontent.com/u/488088?v=4", + "profile": "https://mihamina.rktmb.org", + "contributions": ["doc"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 52a8d56b..5bdfac2d 100644 --- a/README.md +++ b/README.md @@ -632,6 +632,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Aaron D Borden
Aaron D Borden

💻 Md. Iftakhar Awal Chowdhury
Md. Iftakhar Awal Chowdhury

💻 📖 Danilo Horta
Danilo Horta

💻 + Mihamina Rakotomandimby
Mihamina Rakotomandimby

📖 From 1189e376fcad629edf6ffd186aa659f114df0271 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 12:55:57 +0200 Subject: [PATCH 024/250] feat(setup): trigger CopilotChatLoaded user autocommand (#1288) Adds a call to vim.api.nvim_exec_autocmds for the 'User' event with the 'CopilotChatLoaded' pattern at the end of the setup function. This allows external plugins or user configs to react when CopilotChat has finished loading. Also removes unused highlights_loaded state assignment. --- lua/CopilotChat/init.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 7791e43a..0cd052e9 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1189,7 +1189,6 @@ end function M.setup(config) local default_config = require('CopilotChat.config') M.config = vim.tbl_deep_extend('force', default_config, config or {}) - state.highlights_loaded = false -- Save proxy and insecure settings utils.curl_store_args({ @@ -1311,6 +1310,8 @@ function M.setup(config) end end end + + vim.api.nvim_exec_autocmds('User', { pattern = 'CopilotChatLoaded' }) end return M From 6eb4d810bc9f2163ed85922df9f78fea49cd4989 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 8 Aug 2025 10:56:14 +0000 Subject: [PATCH 025/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index e9b4a113..c3b52a5c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 07 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 08 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -634,7 +634,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖This project follows the all-contributors specification. Contributions of any kind are welcome! From 536c5c66e69b113204af1d776b29b86e870471e0 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 14:45:55 +0200 Subject: [PATCH 026/250] refactor(core): improve config handling and setup logic (#1290) - Use metatable for lazy config loading in main module - Update setup to merge config values instead of replacing table - Remove unused autocmd trigger for CopilotChatLoaded - Set default separator directly when empty --- lua/CopilotChat/init.lua | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0cd052e9..64558190 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -15,7 +15,14 @@ local BLOCK_OUTPUT_FORMAT = '```%s\n%s\n```' ---@class CopilotChat ---@field config CopilotChat.config.Config ---@field chat CopilotChat.ui.chat.Chat -local M = {} +local M = setmetatable({}, { + __index = function(t, key) + if key == 'config' then + return require('CopilotChat.config') + end + return rawget(t, key) + end, +}) --- @class CopilotChat.source --- @field bufnr number @@ -1187,8 +1194,10 @@ end --- Set up the plugin ---@param config CopilotChat.config.Config? function M.setup(config) - local default_config = require('CopilotChat.config') - M.config = vim.tbl_deep_extend('force', default_config, config or {}) + -- Little bit of update magic + for k, v in pairs(vim.tbl_deep_extend('force', M.config, config or {})) do + M.config[k] = v + end -- Save proxy and insecure settings utils.curl_store_args({ @@ -1212,7 +1221,7 @@ function M.setup(config) log.warn( 'Empty separator is not allowed, using default separator instead. Set `separator` in config to change this.' ) - M.config.separator = default_config.separator + M.config.separator = '---' end if M.chat then @@ -1310,8 +1319,6 @@ function M.setup(config) end end end - - vim.api.nvim_exec_autocmds('User', { pattern = 'CopilotChatLoaded' }) end return M From 93d3bb93fd6478b25c35acce99815583a79c8079 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:52:53 +0200 Subject: [PATCH 027/250] chore(main): release 4.3.0 (#1270) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ version.txt | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19bf9a6b..82fab37b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [4.3.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.2.0...v4.3.0) (2025-08-08) + + +### ⚠ BREAKING CHANGES + +* **core:** Resource processing and embeddings support have been removed. Any configuration or usage relying on these features will no longer work. + +### Features + +* **keymap:** switch back to <Tab> for completion, add Copilot conflict note ([#1280](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1280)) ([59f5b43](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/59f5b43cdd3d27ab4e033882179d5cf028cf1302)) +* **setup:** trigger CopilotChatLoaded user autocommand ([#1288](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1288)) ([1189e37](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/1189e376fcad629edf6ffd186aa659f114df0271)) + + +### Bug Fixes + +* **functions:** do not require tool reference in tool prompt, just tool id ([#1273](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1273)) ([4d11c49](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/4d11c49b7a1afb573a3b09be5e10a78a3d41649d)), closes [#1269](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1269) +* **ui:** prevent italics from breaking glob pattern highlights ([#1274](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1274)) ([93110a5](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/93110a5f289aaed20adbbc13ec803f94dc6c63c6)) + + +### Miscellaneous Chores + +* mark next release as 4.3.0 ([#1275](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1275)) ([7576afa](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/7576afad950d4258cc7d455d8d42f7dccac4d19b)) + + +### Code Refactoring + +* **core:** remove resource processing and embeddings ([#1203](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1203)) ([f38319f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/f38319fd8f3a7aaa1f75b78027032f9c07abc425)) + ## [4.2.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.1.0...v4.2.0) (2025-08-03) diff --git a/version.txt b/version.txt index 6aba2b24..80895903 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.2.0 +4.3.0 From ffb665919fdafecbfb8dceaf63243d614b50c497 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 15:06:15 +0200 Subject: [PATCH 028/250] fix(client): store models cache per provider (#1291) Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 49 +++++++++++++++-------------- lua/CopilotChat/config/mappings.lua | 2 +- lua/CopilotChat/utils.lua | 3 ++ 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 95db5bb8..7955691c 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -164,14 +164,12 @@ end ---@class CopilotChat.client.Client : Class ---@field private provider_resolver function():table ---@field private provider_cache table ----@field private model_cache table? ---@field private current_job string? local Client = class(function(self) self.provider_resolver = nil self.provider_cache = vim.defaulttable(function() return {} end) - self.model_cache = nil self.current_job = nil end) @@ -211,10 +209,6 @@ end --- Fetch models from the Copilot API ---@return table function Client:models() - if self.model_cache then - return self.model_cache - end - local models = {} local providers = self:get_providers() local provider_order = vim.tbl_keys(providers) @@ -222,24 +216,34 @@ function Client:models() for _, provider_name in ipairs(provider_order) do local provider = providers[provider_name] if not provider.disabled and provider.get_models then - 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 - end - local ok, provider_models = pcall(provider.get_models, headers) - if not ok then - log.warn('Failed to fetch models from ' .. provider_name .. ': ' .. provider_models) - goto continue + local cache = self.provider_cache[provider_name] + local resolved_models = nil + if cache and cache.models then + resolved_models = cache.models + else + 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 + end + local ok, provider_models = pcall(provider.get_models, headers) + if not ok then + log.warn('Failed to fetch models from ' .. provider_name .. ': ' .. provider_models) + goto continue + end + resolved_models = provider_models + cache.models = resolved_models end - for _, model in ipairs(provider_models) do - model.provider = provider_name - if models[model.id] then - model.id = model.id .. ':' .. provider_name + 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 - models[model.id] = model end ::continue:: @@ -247,8 +251,7 @@ function Client:models() end log.debug('Fetched models:', #vim.tbl_keys(models)) - self.model_cache = models - return self.model_cache + return models end --- Get information about all providers diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 552e6c6b..2421c264 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -519,7 +519,7 @@ return { end table.insert(lines, header) - table.insert(lines, '```' .. resource.type) + table.insert(lines, '```' .. utils.mimetype_to_filetype(resource.mimetype)) for _, line in ipairs(preview) do table.insert(lines, line) end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 34e6ab23..54f4c6ae 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -240,6 +240,9 @@ function M.filetype_to_mimetype(filetype) if filetype == 'html' or filetype == 'css' then return 'text/' .. filetype end + if filetype:find('/') then + return filetype + end return 'text/x-' .. filetype end From b1d2b4f3c7cd3fc1e89605ecd070c2670be35a68 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 Aug 2025 15:07:07 +0200 Subject: [PATCH 029/250] chore(main): release 4.3.1 (#1292) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82fab37b..b0585f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [4.3.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.3.0...v4.3.1) (2025-08-08) + + +### Bug Fixes + +* **client:** store models cache per provider ([#1291](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1291)) ([ffb6659](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/ffb665919fdafecbfb8dceaf63243d614b50c497)) + ## [4.3.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.2.0...v4.3.0) (2025-08-08) diff --git a/version.txt b/version.txt index 80895903..f77856a6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.3.0 +4.3.1 From aac1a2684b6bb6e6daac3d83bc6fa4eff6ff5cae Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 8 Aug 2025 21:15:42 +0200 Subject: [PATCH 030/250] 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 031/250] 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 032/250] 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 033/250] 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 034/250] 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 035/250] 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 036/250] 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 037/250] 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 038/250] 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 039/250] 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 040/250] 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 041/250] 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 From f5fd1a7ead5ccdd240fc3ef6e740fb49f74a1294 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 10 Aug 2025 16:41:17 +0000 Subject: [PATCH 042/250] 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 349cdeeb..6d02c33f 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 10 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 5e091bf1bf11827bec5130edc8d4f87fdd243716 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 11 Aug 2025 20:21:15 +0200 Subject: [PATCH 043/250] fix(prompts): update tool instructions for system prompt (#1304) Change references from "system context" to "system prompt" in tool use instructions for clarity and consistency. This improves documentation accuracy and helps prevent confusion when defining tool usage. --- lua/CopilotChat/config/prompts.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 8764f914..44b2f8bd 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -24,7 +24,7 @@ Think creatively to provide complete solutions based on the information availabl Never fabricate or hallucinate file contents you haven't actually seen. -If tools are explicitly defined in your system context: +If tools are explicitly defined in your system prompt: - Follow JSON schema precisely when using tools, including all required properties and outputting valid JSON. - Use appropriate tools for tasks rather than asking for manual actions. - Execute actions directly when you indicate you'll do so, without asking for permission. @@ -33,7 +33,7 @@ If tools are explicitly defined in your system context: 1. Resources shared via "# " headers and referenced via "##" links 2. Code blocks with file path labels 3. Other contextual sharing like selected text or conversation history -- If you don't have explicit tool definitions in your system context, assume NO tools are available and clearly state this limitation when asked. NEVER pretend to retrieve content you cannot access. +- If you don't have explicit tool definitions in your system prompt, assume NO tools are available and clearly state this limitation when asked. NEVER pretend to retrieve content you cannot access. You will receive code snippets that include line number prefixes - use these to maintain correct position references but remove them when generating output. From fa8bb09decb927a092788e327b0d3ce4bbcd35e1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 11 Aug 2025 18:21:33 +0000 Subject: [PATCH 044/250] 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 6d02c33f..6bafa23b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 10 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 11 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 15eebed57156c3ae6a6bb6f73692dbf0547ba9e4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 12 Aug 2025 02:30:04 +0200 Subject: [PATCH 045/250] fix(chat): schedule chat initialization after window opens (#1308) Ensure chat initialization and prompt resolution are scheduled after opening the chat window to avoid race conditions and ensure proper state. This fixes issues where chat state was not fully resolved before processing user prompts or tool calls. Closes #1307 --- lua/CopilotChat/init.lua | 209 ++++++++++++++++++++------------------- 1 file changed, 108 insertions(+), 101 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 83a3d0e5..c97359dd 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -764,6 +764,9 @@ function M.ask(prompt, config) vim.diagnostic.reset(vim.api.nvim_create_namespace('copilot-chat-diagnostics')) config = vim.tbl_deep_extend('force', M.config, config or {}) + local schedule = function(cb) + return cb() + end -- Stop previous conversation and open window if not config.headless then @@ -774,6 +777,7 @@ function M.ask(prompt, config) end if not M.chat:focused() then M.open(config) + schedule = vim.schedule end else update_source() @@ -783,129 +787,132 @@ function M.ask(prompt, config) prompt = insert_sticky(prompt, config) prompt = vim.trim(prompt) - -- Prepare chat - if not config.headless then - store_sticky(prompt) - M.chat:start() - M.chat:append('\n') - end + -- After opening window we need to schedule to next cycle so everything properly resolves + schedule(function() + -- Prepare chat + if not config.headless then + store_sticky(prompt) + M.chat:start() + M.chat:append('\n') + end - -- Resolve prompt references - config, prompt = M.resolve_prompt(prompt, config) - local system_prompt = config.system_prompt or '' + -- Resolve prompt references + config, prompt = M.resolve_prompt(prompt, config) + local system_prompt = config.system_prompt or '' - -- Remove sticky prefix - prompt = table.concat( - vim.tbl_map(function(l) - return l:gsub('^>%s+', '') - end, vim.split(prompt, '\n')), - '\n' - ) + -- Remove sticky prefix + prompt = table.concat( + vim.tbl_map(function(l) + return l:gsub('^>%s+', '') + end, vim.split(prompt, '\n')), + '\n' + ) - -- Retrieve the selection - local selection = M.get_selection() + -- Retrieve the selection + local selection = M.get_selection() - async.run(handle_error(config, function() - local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) - local selected_model, prompt = M.resolve_model(prompt, config) + async.run(handle_error(config, function() + local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) + local selected_model, prompt = M.resolve_model(prompt, config) - prompt = vim.trim(prompt) + prompt = vim.trim(prompt) - if not config.headless then - utils.schedule_main() - 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 - handled_ids[tool.id] = true - end + if not config.headless then + utils.schedule_main() + 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 + handled_ids[tool.id] = true + end - -- If we skipped any tool calls, send that as result - for _, tool_call in ipairs(assistant_message.tool_calls) do - if not handled_ids[tool_call.id] then - table.insert(resolved_tools, { - id = tool_call.id, - result = string.format(BLOCK_OUTPUT_FORMAT, 'error', 'User skipped this function call.'), - }) - handled_ids[tool_call.id] = true + -- If we skipped any tool calls, send that as result + for _, tool_call in ipairs(assistant_message.tool_calls) do + if not handled_ids[tool_call.id] then + table.insert(resolved_tools, { + id = tool_call.id, + result = string.format(BLOCK_OUTPUT_FORMAT, 'error', 'User skipped this function call.'), + }) + handled_ids[tool_call.id] = true + end end end - end - if not utils.empty(resolved_tools) then - -- If we are handling tools, replace user message with tool results - M.chat:remove_message(constants.ROLE.USER) - for _, tool in ipairs(resolved_tools) do + if not utils.empty(resolved_tools) then + -- If we are handling tools, replace user message with tool results + M.chat:remove_message(constants.ROLE.USER) + for _, tool in ipairs(resolved_tools) do + M.chat:add_message({ + id = tool.id, + role = constants.ROLE.TOOL, + tool_call_id = tool.id, + content = '\n' .. tool.result .. '\n', + }) + end + else + -- Otherwise just replace the user message with resolved prompt M.chat:add_message({ - id = tool.id, - role = constants.ROLE.TOOL, - tool_call_id = tool.id, - content = '\n' .. tool.result .. '\n', - }) + role = constants.ROLE.USER, + content = '\n' .. prompt .. '\n', + }, true) end - else - -- Otherwise just replace the user message with resolved prompt - M.chat:add_message({ - role = constants.ROLE.USER, - content = '\n' .. prompt .. '\n', - }, true) end - end - if utils.empty(prompt) and utils.empty(resolved_tools) then - if not config.headless then - M.chat:remove_message(constants.ROLE.USER) - finish() - end - return - end - - local ask_response = client.ask(client, prompt, { - headless = config.headless, - history = M.chat.messages, - selection = selection, - resources = resolved_resources, - tools = selected_tools, - system_prompt = system_prompt, - model = selected_model, - temperature = config.temperature, - on_progress = vim.schedule_wrap(function(message) + if utils.empty(prompt) and utils.empty(resolved_tools) then if not config.headless then - M.chat:add_message(message) + M.chat:remove_message(constants.ROLE.USER) + finish() end - end), - }) + return + end - -- If there was no error and no response, it means job was cancelled - if ask_response == nil then - return - end + local ask_response = client.ask(client, prompt, { + headless = config.headless, + history = M.chat.messages, + selection = selection, + resources = resolved_resources, + tools = selected_tools, + system_prompt = system_prompt, + model = selected_model, + temperature = config.temperature, + on_progress = vim.schedule_wrap(function(message) + if not config.headless then + M.chat:add_message(message) + end + end), + }) - local response = ask_response.message - local token_count = ask_response.token_count - local token_max_count = ask_response.token_max_count + -- If there was no error and no response, it means job was cancelled + if ask_response == nil then + return + end - -- Call the callback function - if config.callback then - utils.schedule_main() - config.callback(response.content, state.source) - end + local response = ask_response.message + local token_count = ask_response.token_count + local token_max_count = ask_response.token_max_count - if not config.headless then - response.content = vim.trim(response.content) - if utils.empty(response.content) then - response.content = '' - else - response.content = '\n' .. response.content .. '\n' + -- Call the callback function + if config.callback then + utils.schedule_main() + config.callback(response.content, state.source) end - utils.schedule_main() - M.chat:add_message(response, true) - M.chat.token_count = token_count - M.chat.token_max_count = token_max_count - finish() - end - end)) + if not config.headless then + response.content = vim.trim(response.content) + if utils.empty(response.content) then + response.content = '' + else + response.content = '\n' .. response.content .. '\n' + end + + utils.schedule_main() + M.chat:add_message(response, true) + M.chat.token_count = token_count + M.chat.token_max_count = token_max_count + finish() + end + end)) + end) end --- Stop current copilot output and optionally reset the chat ten show the help message. From 925342b5964bfd6f5b0e7333f62333f3b0590bfb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 Aug 2025 00:30:50 +0000 Subject: [PATCH 046/250] 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 6bafa23b..2c3086ef 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 11 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 12 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From f22747ae5a4c88b4aa519e4638fe89b274ea28a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 Aug 2025 02:31:38 +0200 Subject: [PATCH 047/250] chore(main): release 4.4.1 (#1305) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ version.txt | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f1f334..1df5c92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [4.4.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.4.0...v4.4.1) (2025-08-12) + + +### Bug Fixes + +* **chat:** schedule chat initialization after window opens ([#1308](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1308)) ([15eebed](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/15eebed57156c3ae6a6bb6f73692dbf0547ba9e4)), closes [#1307](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1307) +* **prompts:** update tool instructions for system prompt ([#1304](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1304)) ([5e091bf](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/5e091bf1bf11827bec5130edc8d4f87fdd243716)) + ## [4.4.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.3.1...v4.4.0) (2025-08-09) diff --git a/version.txt b/version.txt index fdc66988..cca25a93 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.4.0 +4.4.1 From 081d4c20242140bb185ebee142a65454ad375f7d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 12 Aug 2025 16:04:22 +0200 Subject: [PATCH 048/250] refactor(prompts)!: support template substitution in system_prompt (#1312) Refactor prompt configuration to allow template substitution in system_prompt strings using curly braces (e.g. {COPILOT_BASE}). This enables more flexible and composable prompt definitions. Also update callback signature to use the full response object. Update README to reflect new prompt usage. BREAKING CHANGE: callback receives the full response object instead of just content. --- README.md | 2 +- lua/CopilotChat/config.lua | 2 +- lua/CopilotChat/config/prompts.lua | 64 ++++++++++++++---------------- lua/CopilotChat/init.lua | 15 ++++--- 4 files changed, 39 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f4111a0f..c766fbd4 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Define your own prompts in the configuration: system_prompt = 'You are fascinated by pirates, so please respond in pirate speak.', }, NiceInstructions = { - system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner.' .. require('CopilotChat.config.prompts').COPILOT_BASE.system_prompt, + system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner. {BASE_INSTRUCTIONS}', } } } diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 48f5e96a..f3a0d291 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -21,7 +21,7 @@ ---@field language string? ---@field temperature number? ---@field headless boolean? ----@field callback nil|fun(response: string, source: CopilotChat.source) +---@field callback nil|fun(response: CopilotChat.client.Message, source: CopilotChat.source) ---@field remember_as_sticky boolean? ---@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.Selection? ---@field window CopilotChat.config.Window? diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 44b2f8bd..559740e1 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -1,4 +1,12 @@ -local COPILOT_BASE = [[ +---@class CopilotChat.config.prompts.Prompt : CopilotChat.config.Shared +---@field prompt string? +---@field description string? +---@field mapping string? + +---@type table +return { + COPILOT_BASE = { + system_prompt = [[ When asked for your name, you must respond with "GitHub Copilot". Follow the user's requirements carefully & to the letter. Keep your answers short and impersonal. @@ -72,15 +80,20 @@ When presenting code changes: 4. Address any diagnostics issues when fixing code. 5. If multiple changes are needed, present them as separate code blocks. -]] +]], + }, -local COPILOT_INSTRUCTIONS = [[ + COPILOT_INSTRUCTIONS = { + system_prompt = [[ You are a code-focused AI programming assistant that specializes in practical software engineering solutions. -]] .. COPILOT_BASE -local COPILOT_EXPLAIN = [[ +{COPILOT_BASE} +]], + }, + + COPILOT_EXPLAIN = { + system_prompt = [[ You are a programming instructor focused on clear, practical explanations. -]] .. COPILOT_BASE .. [[ When explaining code: - Provide concise high-level overview first @@ -90,11 +103,16 @@ When explaining code: - Focus on complex parts rather than basic syntax - Use short paragraphs with clear structure - Mention performance considerations where relevant -]] -local COPILOT_REVIEW = [[ +{COPILOT_BASE} +]], + }, + + COPILOT_REVIEW = { + system_prompt = [[ You are a code reviewer focused on improving code quality and maintainability. -]] .. COPILOT_BASE .. [[ + +{COPILOT_BASE} Format each issue you find precisely as: line=: @@ -117,39 +135,17 @@ Multiple issues on one line should be separated by semicolons. End with: "**`To clear buffer highlights, please ask a different question.`**" If no issues found, confirm the code is well-written and explain why. -]] - ----@class CopilotChat.config.prompts.Prompt : CopilotChat.config.Shared ----@field prompt string? ----@field description string? ----@field mapping string? - ----@type table -return { - COPILOT_BASE = { - system_prompt = COPILOT_BASE, - }, - - COPILOT_INSTRUCTIONS = { - system_prompt = COPILOT_INSTRUCTIONS, - }, - - COPILOT_EXPLAIN = { - system_prompt = COPILOT_EXPLAIN, - }, - - COPILOT_REVIEW = { - system_prompt = COPILOT_REVIEW, +]], }, Explain = { prompt = 'Write an explanation for the selected code as paragraphs of text.', - system_prompt = 'COPILOT_EXPLAIN', + system_prompt = '{COPILOT_EXPLAIN}', }, Review = { prompt = 'Review the selected code.', - system_prompt = 'COPILOT_REVIEW', + system_prompt = '{COPILOT_REVIEW}', callback = function(response, source) local diagnostics = {} for line in response:gmatch('[^\r\n]+') do diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index c97359dd..48703c74 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -183,10 +183,6 @@ local function list_prompts() } 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 @@ -506,11 +502,14 @@ function M.resolve_prompt(prompt, config) config = vim.tbl_deep_extend('force', M.config, config or {}) config, prompt = resolve(config, prompt or '') - if prompts_to_use[config.system_prompt] then - config.system_prompt = prompts_to_use[config.system_prompt].system_prompt - end if config.system_prompt then + for name, prompt in pairs(prompts_to_use) do + if prompt.system_prompt then + config.system_prompt = config.system_prompt:gsub('{' .. name .. '}', prompt.system_prompt) + end + end + config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) if state.source then @@ -894,7 +893,7 @@ function M.ask(prompt, config) -- Call the callback function if config.callback then utils.schedule_main() - config.callback(response.content, state.source) + config.callback(response, state.source) end if not config.headless then From 918e4d1078655ab966a6b69010d77192e69a1eb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 Aug 2025 14:04:44 +0000 Subject: [PATCH 049/250] 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 2c3086ef..90c7a034 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -361,7 +361,7 @@ Define your own prompts in the configuration: system_prompt = 'You are fascinated by pirates, so please respond in pirate speak.', }, NiceInstructions = { - system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner.' .. require('CopilotChat.config.prompts').COPILOT_BASE.system_prompt, + system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner. {BASE_INSTRUCTIONS}', } } } From 957e0a88c7d7df706380e09412c0b3f24af534ad Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 12 Aug 2025 16:18:15 +0200 Subject: [PATCH 050/250] fix(utils): always exit insert mode in return_to_normal_mode (#1313) Previously, return_to_normal_mode only exited insert mode if not already in normal mode. This change ensures that 'stopinsert' is always called, making the function more reliable when switching modes, especially after visual selections. This improves consistency in mode transitions. Fixes #1307 --- lua/CopilotChat/utils.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index fe2f4773..a221b749 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -174,9 +174,8 @@ function M.return_to_normal_mode() local mode = vim.fn.mode():lower() if mode:find('v') then vim.cmd([[execute "normal! \"]]) - elseif mode ~= 'n' then - vim.cmd('stopinsert') end + vim.cmd('stopinsert') end --- Debounce a function From d12f6dff0e1641f933f9941b843d094bf505a82e Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 12 Aug 2025 18:16:07 +0200 Subject: [PATCH 051/250] chore: mark next release as 4.5.0 (#1315) Release-As: 4.5.0 Signed-off-by: Tomas Slusny From 33e6ffc63b77b0340731f2b50bd962045adf9366 Mon Sep 17 00:00:00 2001 From: Mihamina Rakotomandimby Date: Wed, 13 Aug 2025 07:58:21 +0300 Subject: [PATCH 052/250] feat(prompts): add support for providing system prompt as function (#1318) * use a function to dynamically look for sytem instructions * remove unused parameter * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Mihamina RKTMB Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- lua/CopilotChat/config.lua | 2 +- lua/CopilotChat/init.lua | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index f3a0d291..1f35f33f 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -14,7 +14,7 @@ ---@field blend number? ---@class CopilotChat.config.Shared ----@field system_prompt string? +---@field system_prompt string|fun(source: CopilotChat.source):string|nil ---@field model string? ---@field tools string|table|nil ---@field sticky string|table|nil diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 48703c74..8a660183 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -189,6 +189,26 @@ local function list_prompts() return prompts_to_use end +--- Resolve system prompt - handle both string and function types +---@param system_prompt string|function|nil +---@return string? +local function resolve_system_prompt(system_prompt) + if not system_prompt then + return nil + end + + if type(system_prompt) == 'function' then + local ok, result = pcall(system_prompt) + if not ok then + log.warn('Failed to resolve system prompt function: ' .. result) + return nil + end + return result + end + + return system_prompt +end + --- Finish writing to chat buffer. ---@param start_of_chat boolean? local function finish(start_of_chat) @@ -503,6 +523,9 @@ function M.resolve_prompt(prompt, config) config = vim.tbl_deep_extend('force', M.config, config or {}) config, prompt = resolve(config, prompt or '') + -- Resolve system prompt (handle functions) + config.system_prompt = resolve_system_prompt(config.system_prompt, state.source) + if config.system_prompt then for name, prompt in pairs(prompts_to_use) do if prompt.system_prompt then From 8a5e5e77c64bc8b266c73db33d0954df5facab20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 Aug 2025 04:58:47 +0000 Subject: [PATCH 053/250] 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 90c7a034..f308336d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 12 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 13 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 44f37584bdd2d1b984e4ce64f5378a773ba0fa1e Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 13 Aug 2025 06:59:06 +0200 Subject: [PATCH 054/250] docs: add rakotomandimby as a contributor for code (#1319) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.all-contributorsrc b/.all-contributorsrc index 72ae3b71..e8546bbf 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -437,7 +437,7 @@ "name": "Mihamina Rakotomandimby", "avatar_url": "https://avatars.githubusercontent.com/u/488088?v=4", "profile": "https://mihamina.rktmb.org", - "contributions": ["doc"] + "contributions": ["doc", "code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index c766fbd4..5f2c8374 100644 --- a/README.md +++ b/README.md @@ -626,7 +626,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Aaron D Borden
Aaron D Borden

💻 Md. Iftakhar Awal Chowdhury
Md. Iftakhar Awal Chowdhury

💻 📖 Danilo Horta
Danilo Horta

💻 - Mihamina Rakotomandimby
Mihamina Rakotomandimby

📖 + Mihamina Rakotomandimby
Mihamina Rakotomandimby

📖 💻 From 26f7b4f157ec75b168c05dc826b5fa3106cfc351 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 13 Aug 2025 10:40:36 +0200 Subject: [PATCH 055/250] fix(prompt): recursive system prompt expansion (#1324) Refactor system prompt expansion to support recursive placeholders. The system prompt now uses curly braces for placeholders and expands nested prompts up to a maximum depth. This improves flexibility for prompt composition and avoids incomplete substitutions. Signed-off-by: Tomas Slusny Closes #1323 --- lua/CopilotChat/config.lua | 2 +- lua/CopilotChat/init.lua | 24 ++++++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 1f35f33f..6a37eaa0 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -53,7 +53,7 @@ return { -- Shared config starts here (can be passed to functions at runtime and configured via setup function) - system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). + system_prompt = '{COPILOT_INSTRUCTIONS}', -- System prompt to use (can be specified manually in prompt via /). model = 'gpt-4.1', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). tools = nil, -- Default tool or array of tools (or groups) to share with LLM (can be specified manually in prompt via @). diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 8a660183..dc868b0d 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -520,6 +520,23 @@ function M.resolve_prompt(prompt, config) return inner_config, inner_prompt end + local function expand_system_prompt(system_prompt, prompts_to_use) + local prev, curr = nil, system_prompt + local depth = 0 + repeat + prev = curr + curr = curr:gsub('{([%w_]+)}', function(name) + local prompt = prompts_to_use[name] + if prompt and prompt.system_prompt then + return prompt.system_prompt + end + return '{' .. name .. '}' + end) + depth = depth + 1 + until prev == curr or depth >= MAX_DEPTH + return curr + end + config = vim.tbl_deep_extend('force', M.config, config or {}) config, prompt = resolve(config, prompt or '') @@ -527,12 +544,7 @@ function M.resolve_prompt(prompt, config) config.system_prompt = resolve_system_prompt(config.system_prompt, state.source) if config.system_prompt then - for name, prompt in pairs(prompts_to_use) do - if prompt.system_prompt then - config.system_prompt = config.system_prompt:gsub('{' .. name .. '}', prompt.system_prompt) - end - end - + config.system_prompt = expand_system_prompt(config.system_prompt, prompts_to_use) config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) if state.source then From 02b97d8b3f54ed93f3c41091e578e0ac4a8abeaa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 Aug 2025 08:40:55 +0000 Subject: [PATCH 056/250] 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 f308336d..844b5890 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -628,7 +628,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻This project follows the all-contributors specification. Contributions of any kind are welcome! From f99f1cdef151ac1c950850cdcc0dbeefad00603c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 13 Aug 2025 10:43:47 +0200 Subject: [PATCH 057/250] fix(config): correct system_prompt type and callback usage (#1325) Update the type annotation for system_prompt to allow nil and fix its initialization to use the shared prompt. Also, update the callback in prompts.lua to use response.content for line parsing, ensuring correct diagnostics extraction. --- lua/CopilotChat/config.lua | 4 ++-- lua/CopilotChat/config/prompts.lua | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 6a37eaa0..a3fce3a9 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -14,7 +14,7 @@ ---@field blend number? ---@class CopilotChat.config.Shared ----@field system_prompt string|fun(source: CopilotChat.source):string|nil +---@field system_prompt nil|string|fun(source: CopilotChat.source):string ---@field model string? ---@field tools string|table|nil ---@field sticky string|table|nil @@ -53,7 +53,7 @@ return { -- Shared config starts here (can be passed to functions at runtime and configured via setup function) - system_prompt = '{COPILOT_INSTRUCTIONS}', -- System prompt to use (can be specified manually in prompt via /). + system_prompt = require('CopilotChat.config.prompts').COPILOT_INSTRUCTIONS.system_prompt, -- System prompt to use (can be specified manually in prompt via /). model = 'gpt-4.1', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). tools = nil, -- Default tool or array of tools (or groups) to share with LLM (can be specified manually in prompt via @). diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 559740e1..d01db26d 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -148,7 +148,7 @@ If no issues found, confirm the code is well-written and explain why. system_prompt = '{COPILOT_REVIEW}', callback = function(response, source) local diagnostics = {} - for line in response:gmatch('[^\r\n]+') do + for line in response.content:gmatch('[^\r\n]+') do if line:find('^line=') then local start_line = nil local end_line = nil From f62eaf3447530b34e92ba7c11d9cd7f980e088ed Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 13 Aug 2025 18:18:47 +0200 Subject: [PATCH 058/250] refactor(prompt): simplify system prompt resolution logic (#1327) Remove recursive prompt expansion and placeholder substitution for system prompts. Now, system prompts reference other prompts by name directly, and the base instructions are appended automatically. This improves clarity and maintainability of prompt configuration. Signed-off-by: Tomas Slusny --- README.md | 2 +- lua/CopilotChat/config/prompts.lua | 12 ++----- lua/CopilotChat/init.lua | 58 ++++++++++-------------------- 3 files changed, 23 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 5f2c8374..ef385174 100644 --- a/README.md +++ b/README.md @@ -292,7 +292,7 @@ Define your own prompts in the configuration: system_prompt = 'You are fascinated by pirates, so please respond in pirate speak.', }, NiceInstructions = { - system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner. {BASE_INSTRUCTIONS}', + system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner.', } } } diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index d01db26d..203997a7 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -7,7 +7,7 @@ return { COPILOT_BASE = { system_prompt = [[ -When asked for your name, you must respond with "GitHub Copilot". +When asked for your name, you must respond with "Copilot". Follow the user's requirements carefully & to the letter. Keep your answers short and impersonal. Always answer in {LANGUAGE} unless explicitly asked otherwise. @@ -86,8 +86,6 @@ When presenting code changes: COPILOT_INSTRUCTIONS = { system_prompt = [[ You are a code-focused AI programming assistant that specializes in practical software engineering solutions. - -{COPILOT_BASE} ]], }, @@ -103,8 +101,6 @@ When explaining code: - Focus on complex parts rather than basic syntax - Use short paragraphs with clear structure - Mention performance considerations where relevant - -{COPILOT_BASE} ]], }, @@ -112,8 +108,6 @@ When explaining code: system_prompt = [[ You are a code reviewer focused on improving code quality and maintainability. -{COPILOT_BASE} - Format each issue you find precisely as: line=: OR @@ -140,12 +134,12 @@ If no issues found, confirm the code is well-written and explain why. Explain = { prompt = 'Write an explanation for the selected code as paragraphs of text.', - system_prompt = '{COPILOT_EXPLAIN}', + system_prompt = 'COPILOT_EXPLAIN', }, Review = { prompt = 'Review the selected code.', - system_prompt = '{COPILOT_REVIEW}', + system_prompt = 'COPILOT_REVIEW', callback = function(response, source) local diagnostics = {} for line in response.content:gmatch('[^\r\n]+') do diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index dc868b0d..38ee5568 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -189,26 +189,6 @@ local function list_prompts() return prompts_to_use end ---- Resolve system prompt - handle both string and function types ----@param system_prompt string|function|nil ----@return string? -local function resolve_system_prompt(system_prompt) - if not system_prompt then - return nil - end - - if type(system_prompt) == 'function' then - local ok, result = pcall(system_prompt) - if not ok then - log.warn('Failed to resolve system prompt function: ' .. result) - return nil - end - return result - end - - return system_prompt -end - --- Finish writing to chat buffer. ---@param start_of_chat boolean? local function finish(start_of_chat) @@ -520,31 +500,31 @@ function M.resolve_prompt(prompt, config) return inner_config, inner_prompt end - local function expand_system_prompt(system_prompt, prompts_to_use) - local prev, curr = nil, system_prompt - local depth = 0 - repeat - prev = curr - curr = curr:gsub('{([%w_]+)}', function(name) - local prompt = prompts_to_use[name] - if prompt and prompt.system_prompt then - return prompt.system_prompt - end - return '{' .. name .. '}' - end) - depth = depth + 1 - until prev == curr or depth >= MAX_DEPTH - return curr + local function resolve_system_prompt(system_prompt) + if type(system_prompt) == 'function' then + local ok, result = pcall(system_prompt) + if not ok then + log.warn('Failed to resolve system prompt function: ' .. result) + return nil + end + return result + end + + return system_prompt end config = vim.tbl_deep_extend('force', M.config, config or {}) config, prompt = resolve(config, prompt or '') - -- Resolve system prompt (handle functions) - config.system_prompt = resolve_system_prompt(config.system_prompt, state.source) - if config.system_prompt then - config.system_prompt = expand_system_prompt(config.system_prompt, prompts_to_use) + config.system_prompt = resolve_system_prompt(config.system_prompt) + + if M.config.prompts[config.system_prompt] then + -- Name references are good for making system prompt auto sticky + config.system_prompt = M.config.prompts[config.system_prompt].system_prompt + end + + config.system_prompt = config.system_prompt .. '\n' .. M.config.prompts.COPILOT_BASE.system_prompt config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) if state.source then From f60803fca0b5b305a2d56361a732a07fb3c38c39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 Aug 2025 16:19:04 +0000 Subject: [PATCH 059/250] 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 844b5890..b063c54e 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -361,7 +361,7 @@ Define your own prompts in the configuration: system_prompt = 'You are fascinated by pirates, so please respond in pirate speak.', }, NiceInstructions = { - system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner. {BASE_INSTRUCTIONS}', + system_prompt = 'You are a nice coding tutor, so please respond in a friendly and helpful manner.', } } } From 76cc41653d63cfdb653f584624b4bf5e721f9514 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 13 Aug 2025 19:17:33 +0200 Subject: [PATCH 060/250] fix(completion): require tool uri for input completion (#1328) Previously, input completion was triggered for tools without a defined `uri`, which could lead to errors or unexpected behavior. This change ensures that input completion only occurs when both the tool and its schema are present and the tool has a valid `uri` property. --- lua/CopilotChat/completion.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/completion.lua b/lua/CopilotChat/completion.lua index a32141eb..6c65a18d 100644 --- a/lua/CopilotChat/completion.lua +++ b/lua/CopilotChat/completion.lua @@ -146,7 +146,7 @@ function M.complete(without_input) 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 + if found_tool and found_schema and found_tool.uri then async.run(function() local value = functions.enter_input(found_schema, source) if not value then From 6d6e39088bd21087ba7ca6e9943c9ee10bda1f9b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 14 Aug 2025 18:34:58 +0200 Subject: [PATCH 061/250] refactor(diff): improve line matching and replacement logic (#1329) Refactors diff application logic to use a new utils.split_lines function for consistent line splitting. Applies diffs from bottom to top to preserve line numbering and ensures correct replacement of lines in buffers. This improves accuracy when applying multiple diffs to the same file and enhances code readability. --- lua/CopilotChat/config/mappings.lua | 56 ++++++++++++++++------------- lua/CopilotChat/utils.lua | 11 ++++++ 2 files changed, 43 insertions(+), 24 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 4928a876..e3e2e248 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -51,7 +51,8 @@ local function get_diff(block) -- If we found a valid buffer, get the reference content if bufnr and utils.buf_valid(bufnr) then - reference = table.concat(vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false), '\n') + local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) + reference = table.concat(lines, '\n') filetype = vim.bo[bufnr].filetype end end @@ -212,7 +213,7 @@ return { return end - local lines = vim.split(message.content, '\n') + local lines = utils.split_lines(message.content) local new_lines = {} local changed = false @@ -241,9 +242,9 @@ return { return end - local lines = vim.split(diff.change, '\n', { trimempty = false }) + local lines = utils.split_lines(diff.change) vim.api.nvim_buf_set_lines(diff.bufnr, diff.start_line - 1, diff.end_line, false, lines) - copilot.set_selection(diff.bufnr, diff.start_line, diff.start_line + #lines - 1) + copilot.set_selection(diff.bufnr, diff.start_line, diff.end_line) end, }, @@ -352,10 +353,9 @@ return { } if copilot.config.mappings.show_diff.full_diff then - local modified = utils.buf_valid(diff.bufnr) and vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) or {} + local original = utils.buf_valid(diff.bufnr) and vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) or {} - -- Apply all diffs from same file - if #modified > 0 then + if #original > 0 then -- Find all diffs from the same file in this section local message = copilot.chat:get_message(constants.ROLE.ASSISTANT, true) local section = message and message.section @@ -367,30 +367,38 @@ return { table.insert(same_file_diffs, block_diff) end end + end - -- Sort diffs bottom to top to preserve line numbering - table.sort(same_file_diffs, function(a, b) - return a.start_line > b.start_line - end) + -- Ensure we at least apply the current diff + if #same_file_diffs == 0 then + table.insert(same_file_diffs, diff) end - for _, file_diff in ipairs(same_file_diffs) do - local start_idx = file_diff.start_line - local end_idx = file_diff.end_line - for _ = start_idx, end_idx do - table.remove(modified, start_idx) + -- Sort diffs by start_line in descending order (apply from bottom to top) + table.sort(same_file_diffs, function(a, b) + return a.start_line > b.start_line + end) + + local result = vim.deepcopy(original) + + -- Apply diffs from bottom to top so line numbers remain valid + for _, d in ipairs(same_file_diffs) do + local change_lines = utils.split_lines(d.change) + + -- Remove original lines (from end to start to avoid index shifting) + for i = d.end_line, d.start_line, -1 do + if result[i] then + table.remove(result, i) + end end - local change_lines = vim.split(file_diff.change, '\n') - for i, line in ipairs(change_lines) do - table.insert(modified, start_idx + i, line) + + -- Insert replacement lines at start_line + for i = #change_lines, 1, -1 do + table.insert(result, d.start_line, change_lines[i]) end end - modified = vim.tbl_filter(function(line) - return line ~= nil - end, modified) - - opts.text = table.concat(modified, '\n') + opts.text = table.concat(result, '\n') else opts.text = diff.change end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index a221b749..a6517f35 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -772,6 +772,17 @@ function M.empty(v) return false end +--- Split text into lines +---@param text string The text to split +---@return string[] A table of lines +function M.split_lines(text) + if not text or text == '' then + return {} + end + + return vim.split(text, '\r?\n', { trimempty = false }) +end + --- Convert glob pattern to regex pattern --- https://github.com/davidm/lua-glob-pattern/blob/master/lua/globtopattern.lua ---@param g string The glob pattern From 5f3c57083515ea511deda291ae72434db568ee6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 14 Aug 2025 16:35:15 +0000 Subject: [PATCH 062/250] 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 b063c54e..b3b7c88c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 13 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 14 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From b3b7a3c8d3f9c083f6e3d0f079072b61bd057183 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 15 Aug 2025 18:34:05 +0200 Subject: [PATCH 063/250] test: migrate to MiniTest and add utils tests (#1333) Replaces vusted with MiniTest for running tests and updates CI workflow to use Neovim for testing. Adds initial tests for utils.glob_to_pattern. Removes old plugin_spec.lua and updates .luarc.json for new globals. Signed-off-by: Tomas Slusny --- .github/workflows/ci.yml | 6 +---- .gitignore | 2 ++ .luarc.json | 9 ++++++- Makefile | 18 +------------- README.md | 1 - scripts/minimal.lua | 16 +++++++++++++ scripts/test.lua | 14 +++++++++++ test/plugin_spec.lua | 18 -------------- tests/test_init.lua | 9 +++++++ tests/test_utils.lua | 52 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 103 insertions(+), 42 deletions(-) create mode 100644 scripts/minimal.lua create mode 100644 scripts/test.lua delete mode 100644 test/plugin_spec.lua create mode 100644 tests/test_init.lua create mode 100644 tests/test_utils.lua diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e722714f..9ce3f370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,8 +64,4 @@ jobs: luarocksVersion: "3.12.2" - name: run test - shell: bash - run: | - luarocks install luacheck - luarocks install vusted - vusted ./test + run: make test diff --git a/.gitignore b/.gitignore index 94e6f763..fc3fe2ac 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,5 @@ cython_debug/ # (neo)vim helptags /doc/tags + +.dependencies/ diff --git a/.luarc.json b/.luarc.json index b97a9f11..ad90d858 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,4 +1,11 @@ { - "diagnostics.globals": ["describe", "it"], + "runtime.version": "LuaJIT", + "diagnostics.globals": [ + "describe", + "it", + "MiniTest", + "before_each", + "after_each" + ], "diagnostics.disable": ["redefined-local"] } diff --git a/Makefile b/Makefile index c5d53c52..240be629 100644 --- a/Makefile +++ b/Makefile @@ -19,28 +19,12 @@ BUILD_DIR := build .PHONY: help install-cli install-pre-commit install test tiktoken clean -help: - @echo "Available commands:" - @echo " install-cli - Install Lua and Luarocks using Homebrew" - @echo " install-pre-commit - Install pre-commit using pip" - @echo " install - Install vusted using Luarocks" - @echo " test - Run tests using vusted" - @echo " tiktoken - Download tiktoken_core library" - @echo " clean - Remove build directory" - -install-cli: - brew install luarocks - brew install lua - install-pre-commit: pip install pre-commit pre-commit install -install: - luarocks install vusted - test: - vusted test + nvim --headless --noplugin -u ./scripts/test.lua -c "lua MiniTest.run()" all: luajit diff --git a/README.md b/README.md index ef385174..62d03ee5 100644 --- a/README.md +++ b/README.md @@ -519,7 +519,6 @@ cd CopilotChat.nvim 2. Install development dependencies: ```bash -# Install pre-commit hooks make install-pre-commit ``` diff --git a/scripts/minimal.lua b/scripts/minimal.lua new file mode 100644 index 00000000..69c5cefb --- /dev/null +++ b/scripts/minimal.lua @@ -0,0 +1,16 @@ +-- https://github.com/neovim/neovim/blob/master/contrib/minimal.lua +vim.opt.runtimepath:append(vim.fn.getcwd()) + +for name, url in pairs({ + 'https://github.com/nvim-lua/plenary.nvim', +}) do + local install_path = vim.fn.fnamemodify('.dependencies/' .. name, ':p') + if vim.fn.isdirectory(install_path) == 0 then + vim.fn.system({ 'git', 'clone', '--depth=1', url, install_path }) + end + vim.opt.runtimepath:append(install_path) +end + +require('CopilotChat').setup({ + -- Add your configuration here +}) diff --git a/scripts/test.lua b/scripts/test.lua new file mode 100644 index 00000000..fd391b37 --- /dev/null +++ b/scripts/test.lua @@ -0,0 +1,14 @@ +vim.opt.runtimepath:append(vim.fn.getcwd()) + +for name, url in pairs({ + 'https://github.com/nvim-lua/plenary.nvim', + 'https://github.com/echasnovski/mini.test', +}) do + local install_path = vim.fn.fnamemodify('.dependencies/' .. name, ':p') + if vim.fn.isdirectory(install_path) == 0 then + vim.fn.system({ 'git', 'clone', '--depth=1', url, install_path }) + end + vim.opt.runtimepath:append(install_path) +end + +require('mini.test').setup() diff --git a/test/plugin_spec.lua b/test/plugin_spec.lua deleted file mode 100644 index 9497f016..00000000 --- a/test/plugin_spec.lua +++ /dev/null @@ -1,18 +0,0 @@ --- Mock packages -package.loaded['plenary.async'] = { - wrap = function(fn) - return function(...) - return fn(...) - end - end, -} -package.loaded['plenary.curl'] = {} -package.loaded['plenary.log'] = {} -package.loaded['plenary.scandir'] = {} -package.loaded['plenary.filetype'] = {} - -describe('CopilotChat plugin', function() - it('should be able to load', function() - assert.truthy(require('CopilotChat')) - end) -end) diff --git a/tests/test_init.lua b/tests/test_init.lua new file mode 100644 index 00000000..e4329607 --- /dev/null +++ b/tests/test_init.lua @@ -0,0 +1,9 @@ +local T = MiniTest.new_set() + +T['should be able to load'] = function() + MiniTest.expect.no_error(function() + require('CopilotChat') + end) +end + +return T diff --git a/tests/test_utils.lua b/tests/test_utils.lua new file mode 100644 index 00000000..ca23a4bb --- /dev/null +++ b/tests/test_utils.lua @@ -0,0 +1,52 @@ +local T = MiniTest.new_set() + +local cases = { + { glob = '', expected = '^$' }, + { glob = 'abc', expected = '^abc$' }, + { glob = 'ab#/.', expected = '^ab%#%/%.$' }, + { glob = '\\\\\\ab\\c\\', expected = '^%\\abc\\$' }, + + { glob = 'abc.*', expected = '^abc%..*$', matches = { 'abc.txt', 'abc.' }, not_matches = { 'abc' } }, + { glob = '??.txt', expected = '^..%.txt$' }, + + { glob = 'a[]', expected = '[^]' }, + { glob = 'a[^]b', expected = '^ab$' }, + { glob = 'a[!]b', expected = '^ab$' }, + { glob = 'a[a][b]z', expected = '^a[a][b]z$' }, + { glob = 'a[a-f]z', expected = '^a[a-f]z$' }, + { glob = 'a[a-f0-9]z', expected = '^a[a-f0-9]z$' }, + { glob = 'a[a-f0-]z', expected = '^a[a-f0%-]z$' }, + { glob = 'a[!a-f]z', expected = '^a[^a-f]z$' }, + { glob = 'a[^a-f]z', expected = '^a[^a-f]z$' }, + { glob = 'a[\\!\\^\\-z\\]]z', expected = '^a[%!%^%-z%]]z$' }, + { glob = 'a[\\a-\\f]z', expected = '^a[a-f]z$' }, + + { glob = 'a[', expected = '[^]' }, + { glob = 'a[a-', expected = '[^]' }, + { glob = 'a[a-b', expected = '[^]' }, + { glob = 'a[!', expected = '[^]' }, + { glob = 'a[!a', expected = '[^]' }, + { glob = 'a[!a-', expected = '[^]' }, + { glob = 'a[!a-b', expected = '[^]' }, + { glob = 'a[!a-b\\]', expected = '[^]' }, +} + +for _, case in ipairs(cases) do + T['glob_to_pattern: ' .. case.glob] = function() + local utils = require('CopilotChat.utils') + local pattern = utils.glob_to_pattern(case.glob) + MiniTest.expect.equality(pattern, case.expected) + if case.matches then + for _, str in ipairs(case.matches) do + MiniTest.expect.equality(str:match(pattern) ~= nil, true) + end + end + if case.not_matches then + for _, str in ipairs(case.not_matches) do + MiniTest.expect.equality(str:match(pattern) ~= nil, false) + end + end + end +end + +return T From d7d808b14a73e17c71fe6f153017a411f36cd3cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 15 Aug 2025 16:34:25 +0000 Subject: [PATCH 064/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index b3b7c88c..2d64906e 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 14 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 15 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -600,7 +600,6 @@ To set up the environment: 1. Install development dependencies: >bash - # Install pre-commit hooks make install-pre-commit < From c5057d3bb6d87e9b117b4f37162409d4c2c74e31 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 15 Aug 2025 18:41:01 +0200 Subject: [PATCH 065/250] fix(test): run tests automatically in test script (#1334) Previously, the Makefile required a command to run tests after loading the test script. Now, the test script itself runs the tests automatically, simplifying the Makefile and ensuring consistent test execution. Signed-off-by: Tomas Slusny --- Makefile | 2 +- scripts/test.lua | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 240be629..04756dda 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ install-pre-commit: pre-commit install test: - nvim --headless --noplugin -u ./scripts/test.lua -c "lua MiniTest.run()" + nvim --headless --noplugin -u ./scripts/test.lua all: luajit diff --git a/scripts/test.lua b/scripts/test.lua index fd391b37..fdb0cdec 100644 --- a/scripts/test.lua +++ b/scripts/test.lua @@ -11,4 +11,6 @@ for name, url in pairs({ vim.opt.runtimepath:append(install_path) end -require('mini.test').setup() +local minitest = require('mini.test') +minitest.setup() +minitest.run() From b479d60f6e9df1186f33f2bbe8bfb8069ed5fc85 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 15 Aug 2025 19:11:46 +0200 Subject: [PATCH 066/250] test: migrate to plenary.nvim for unit testing (#1335) Replace mini.test with plenary.nvim for running unit tests. Update test scripts and test files to use plenary's test harness and assertion methods. Remove mini.test references from configuration and codebase. This simplifies dependencies and aligns with common Neovim testing practices. --- .luarc.json | 1 - lua/CopilotChat/utils.lua | 2 +- scripts/test.lua | 5 +-- tests/init_spec.lua | 7 ++++ tests/test_init.lua | 9 ---- tests/test_utils.lua | 52 ----------------------- tests/utils_spec.lua | 88 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 67 deletions(-) create mode 100644 tests/init_spec.lua delete mode 100644 tests/test_init.lua delete mode 100644 tests/test_utils.lua create mode 100644 tests/utils_spec.lua diff --git a/.luarc.json b/.luarc.json index ad90d858..4a3cf0b6 100644 --- a/.luarc.json +++ b/.luarc.json @@ -3,7 +3,6 @@ "diagnostics.globals": [ "describe", "it", - "MiniTest", "before_each", "after_each" ], diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index a6517f35..c829d40e 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -157,7 +157,7 @@ end --- Writes text to a temporary file and returns path ---@param text string The text to write ----@return string? +---@return string function M.temp_file(text) local temp_file = os.tmpname() local f = io.open(temp_file, 'w+') diff --git a/scripts/test.lua b/scripts/test.lua index fdb0cdec..5da43da3 100644 --- a/scripts/test.lua +++ b/scripts/test.lua @@ -2,7 +2,6 @@ vim.opt.runtimepath:append(vim.fn.getcwd()) for name, url in pairs({ 'https://github.com/nvim-lua/plenary.nvim', - 'https://github.com/echasnovski/mini.test', }) do local install_path = vim.fn.fnamemodify('.dependencies/' .. name, ':p') if vim.fn.isdirectory(install_path) == 0 then @@ -11,6 +10,4 @@ for name, url in pairs({ vim.opt.runtimepath:append(install_path) end -local minitest = require('mini.test') -minitest.setup() -minitest.run() +require('plenary.test_harness').test_directory('tests') diff --git a/tests/init_spec.lua b/tests/init_spec.lua new file mode 100644 index 00000000..23102727 --- /dev/null +++ b/tests/init_spec.lua @@ -0,0 +1,7 @@ +describe('CopilotChat module', function() + it('should be able to load', function() + assert.has_no.errors(function() + require('CopilotChat') + end) + end) +end) diff --git a/tests/test_init.lua b/tests/test_init.lua deleted file mode 100644 index e4329607..00000000 --- a/tests/test_init.lua +++ /dev/null @@ -1,9 +0,0 @@ -local T = MiniTest.new_set() - -T['should be able to load'] = function() - MiniTest.expect.no_error(function() - require('CopilotChat') - end) -end - -return T diff --git a/tests/test_utils.lua b/tests/test_utils.lua deleted file mode 100644 index ca23a4bb..00000000 --- a/tests/test_utils.lua +++ /dev/null @@ -1,52 +0,0 @@ -local T = MiniTest.new_set() - -local cases = { - { glob = '', expected = '^$' }, - { glob = 'abc', expected = '^abc$' }, - { glob = 'ab#/.', expected = '^ab%#%/%.$' }, - { glob = '\\\\\\ab\\c\\', expected = '^%\\abc\\$' }, - - { glob = 'abc.*', expected = '^abc%..*$', matches = { 'abc.txt', 'abc.' }, not_matches = { 'abc' } }, - { glob = '??.txt', expected = '^..%.txt$' }, - - { glob = 'a[]', expected = '[^]' }, - { glob = 'a[^]b', expected = '^ab$' }, - { glob = 'a[!]b', expected = '^ab$' }, - { glob = 'a[a][b]z', expected = '^a[a][b]z$' }, - { glob = 'a[a-f]z', expected = '^a[a-f]z$' }, - { glob = 'a[a-f0-9]z', expected = '^a[a-f0-9]z$' }, - { glob = 'a[a-f0-]z', expected = '^a[a-f0%-]z$' }, - { glob = 'a[!a-f]z', expected = '^a[^a-f]z$' }, - { glob = 'a[^a-f]z', expected = '^a[^a-f]z$' }, - { glob = 'a[\\!\\^\\-z\\]]z', expected = '^a[%!%^%-z%]]z$' }, - { glob = 'a[\\a-\\f]z', expected = '^a[a-f]z$' }, - - { glob = 'a[', expected = '[^]' }, - { glob = 'a[a-', expected = '[^]' }, - { glob = 'a[a-b', expected = '[^]' }, - { glob = 'a[!', expected = '[^]' }, - { glob = 'a[!a', expected = '[^]' }, - { glob = 'a[!a-', expected = '[^]' }, - { glob = 'a[!a-b', expected = '[^]' }, - { glob = 'a[!a-b\\]', expected = '[^]' }, -} - -for _, case in ipairs(cases) do - T['glob_to_pattern: ' .. case.glob] = function() - local utils = require('CopilotChat.utils') - local pattern = utils.glob_to_pattern(case.glob) - MiniTest.expect.equality(pattern, case.expected) - if case.matches then - for _, str in ipairs(case.matches) do - MiniTest.expect.equality(str:match(pattern) ~= nil, true) - end - end - if case.not_matches then - for _, str in ipairs(case.not_matches) do - MiniTest.expect.equality(str:match(pattern) ~= nil, false) - end - end - end -end - -return T diff --git a/tests/utils_spec.lua b/tests/utils_spec.lua new file mode 100644 index 00000000..2e45c83d --- /dev/null +++ b/tests/utils_spec.lua @@ -0,0 +1,88 @@ +local utils = require('CopilotChat.utils') + +describe('CopilotChat.utils', function() + local cases = { + { glob = '', expected = '^$' }, + { glob = 'abc', expected = '^abc$' }, + { glob = 'ab#/.', expected = '^ab%#%/%.$' }, + { glob = '\\\\\\ab\\c\\', expected = '^%\\abc\\$' }, + + { glob = 'abc.*', expected = '^abc%..*$', matches = { 'abc.txt', 'abc.' }, not_matches = { 'abc' } }, + { glob = '??.txt', expected = '^..%.txt$' }, + + { glob = 'a[]', expected = '[^]' }, + { glob = 'a[^]b', expected = '^ab$' }, + { glob = 'a[!]b', expected = '^ab$' }, + { glob = 'a[a][b]z', expected = '^a[a][b]z$' }, + { glob = 'a[a-f]z', expected = '^a[a-f]z$' }, + { glob = 'a[a-f0-9]z', expected = '^a[a-f0-9]z$' }, + { glob = 'a[a-f0-]z', expected = '^a[a-f0%-]z$' }, + { glob = 'a[!a-f]z', expected = '^a[^a-f]z$' }, + { glob = 'a[^a-f]z', expected = '^a[^a-f]z$' }, + { glob = 'a[\\!\\^\\-z\\]]z', expected = '^a[%!%^%-z%]]z$' }, + { glob = 'a[\\a-\\f]z', expected = '^a[a-f]z$' }, + + { glob = 'a[', expected = '[^]' }, + { glob = 'a[a-', expected = '[^]' }, + { glob = 'a[a-b', expected = '[^]' }, + { glob = 'a[!', expected = '[^]' }, + { glob = 'a[!a', expected = '[^]' }, + { glob = 'a[!a-', expected = '[^]' }, + { glob = 'a[!a-b', expected = '[^]' }, + { glob = 'a[!a-b\\]', expected = '[^]' }, + } + + for _, case in ipairs(cases) do + it('glob_to_pattern: ' .. case.glob, function() + local pattern = utils.glob_to_pattern(case.glob) + assert.equals(case.expected, pattern) + if case.matches then + for _, str in ipairs(case.matches) do + assert.is_true(str:match(pattern) ~= nil) + end + end + if case.not_matches then + for _, str in ipairs(case.not_matches) do + assert.is_false(str:match(pattern) ~= nil) + end + end + end) + end + + it('empty', function() + assert.is_true(utils.empty(nil)) + assert.is_true(utils.empty('')) + assert.is_true(utils.empty(' ')) + assert.is_true(utils.empty({})) + assert.is_false(utils.empty({ 1 })) + assert.is_false(utils.empty('abc')) + assert.is_false(utils.empty(0)) + end) + + it('split_lines', function() + assert.are.same(utils.split_lines(''), {}) + assert.are.same(utils.split_lines('a\nb'), { 'a', 'b' }) + assert.are.same(utils.split_lines('a\r\nb'), { 'a', 'b' }) + assert.are.same(utils.split_lines('a\nb\n'), { 'a', 'b', '' }) + end) + + it('make_string', function() + assert.equals('a b 1', utils.make_string('a', 'b', 1)) + assert.equals(vim.inspect({ x = 1 }), utils.make_string({ x = 1 })) + assert.equals('msg', utils.make_string('error:1: msg')) + end) + + it('uuid', function() + local uuid1 = utils.uuid() + local uuid2 = utils.uuid() + assert.equals('string', type(uuid1)) + assert.not_equals(uuid1, uuid2) + assert.equals(36, #uuid1) + end) + + it('to_table', function() + assert.are.same({ 1, 2, 3 }, utils.to_table(1, 2, 3)) + assert.are.same({ 1, 2, 3 }, utils.to_table({ 1, 2 }, 3)) + assert.are.same({ 1 }, utils.to_table(nil, 1)) + end) +end) From 97cc5143f07f3106b2dd4116105384866e4e7a27 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 15 Aug 2025 20:19:32 +0200 Subject: [PATCH 067/250] test: add setup test for CopilotChat module (#1336) * test: add setup test for CopilotChat module Added a test to verify that CopilotChat can be set up without errors and that the chat module is initialized. Also updated .luarc.json to include additional globals used in tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .luarc.json | 6 +++++- tests/init_spec.lua | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.luarc.json b/.luarc.json index 4a3cf0b6..c4cebd58 100644 --- a/.luarc.json +++ b/.luarc.json @@ -3,8 +3,12 @@ "diagnostics.globals": [ "describe", "it", + "pending", "before_each", - "after_each" + "after_each", + "clear", + "assert", + "print" ], "diagnostics.disable": ["redefined-local"] } diff --git a/tests/init_spec.lua b/tests/init_spec.lua index 23102727..59a42f62 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -4,4 +4,10 @@ describe('CopilotChat module', function() require('CopilotChat') end) end) + it('should be able to set up', function() + assert.has_no.errors(function() + require('CopilotChat').setup({}) + end) + assert.is_not_nil(require('CopilotChat').chat) + end) end) From 07d43b41d31c60d7b8f4afbc05b1a77e0239856e Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 15 Aug 2025 20:33:24 +0200 Subject: [PATCH 068/250] test(functions): add unit tests for functions module (#1337) * test(functions): add unit tests for functions module Add comprehensive unit tests for CopilotChat.functions covering uri_to_url, match_uri, parse_schema, and parse_input. This improves test coverage and ensures correct behavior for URI parsing and schema handling. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/functions_spec.lua | 62 ++++++++++++++++++++++++++++++++++++++++ tests/init_spec.lua | 1 + 2 files changed, 63 insertions(+) create mode 100644 tests/functions_spec.lua diff --git a/tests/functions_spec.lua b/tests/functions_spec.lua new file mode 100644 index 00000000..93939cdb --- /dev/null +++ b/tests/functions_spec.lua @@ -0,0 +1,62 @@ +local functions = require('CopilotChat.functions') + +describe('CopilotChat.functions', function() + describe('uri_to_url', function() + it('replaces parameters in uri template', function() + local uri = 'file://{path}' + local input = { path = '/tmp/test.txt' } + assert.equals('file:///tmp/test.txt', functions.uri_to_url(uri, input)) + end) + it('leaves missing params empty', function() + local uri = 'file://{path}/{id}' + local input = { path = '/tmp' } + assert.equals('file:///tmp/', functions.uri_to_url(uri, input)) + end) + end) + + describe('match_uri', function() + it('matches uri and extracts parameters', function() + local uri = 'file:///tmp/test.txt' + local pattern = 'file://{path}' + local result = functions.match_uri(uri, pattern) + assert.are.same({ path = '/tmp/test.txt' }, result) + end) + it('returns nil for non-matching uri', function() + assert.is_nil(functions.match_uri('abc', 'file://{path}')) + end) + it('returns empty table for exact match with no params', function() + assert.are.same({}, functions.match_uri('abc', 'abc')) + end) + end) + + describe('parse_schema', function() + it('returns schema if present', function() + local fn = { schema = { type = 'object', properties = { foo = { type = 'string' } } } } + assert.equals(fn.schema, functions.parse_schema(fn)) + end) + it('generates schema from uri if missing', function() + local fn = { uri = 'file://{path}/{id}' } + local schema = functions.parse_schema(fn) + assert.are.same({ + type = 'object', + properties = { path = { type = 'string' }, id = { type = 'string' } }, + required = { 'path', 'id' }, + }, schema) + end) + end) + + describe('parse_input', function() + it('parses input string into table', function() + local schema = { properties = { a = {}, b = {} }, required = { 'a', 'b' } } + local input = 'foo;;bar' + assert.are.same({ a = 'foo', b = 'bar' }, functions.parse_input(input, schema)) + end) + it('returns input if already table', function() + local input = { a = 1 } + assert.equals(input, functions.parse_input(input)) + end) + it('returns empty table if no schema', function() + assert.are.same({}, functions.parse_input('foo')) + end) + end) +end) diff --git a/tests/init_spec.lua b/tests/init_spec.lua index 59a42f62..995a84c3 100644 --- a/tests/init_spec.lua +++ b/tests/init_spec.lua @@ -4,6 +4,7 @@ describe('CopilotChat module', function() require('CopilotChat') end) end) + it('should be able to set up', function() assert.has_no.errors(function() require('CopilotChat').setup({}) From 7b15d0350d1d96e5651961130287dbbb22397e9f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 15 Aug 2025 21:39:01 +0200 Subject: [PATCH 069/250] test(makefile): use --clean for isolated test runs (#1338) Switches the test target to use `nvim --headless --clean` instead of `--noplugin` to ensure tests run in a fully isolated environment, avoiding interference from user or system configuration. This improves test reliability and consistency. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 04756dda..98cba272 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ install-pre-commit: pre-commit install test: - nvim --headless --noplugin -u ./scripts/test.lua + nvim --headless --clean -u ./scripts/test.lua all: luajit From a6434d56b5ff5344c20772c04cfdf66416ffe03f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 17 Aug 2025 10:58:25 +0200 Subject: [PATCH 070/250] refactor(utils): remove custom glob to pattern logic (#1339) Replace custom glob pattern conversion and scandir fallback with native vim.lpeg and vim.uv.fs_scandir for file matching. This simplifies code, removes redundant logic, and improves compatibility. Also remove related unit tests for glob_to_pattern. Closes #1331 --- lua/CopilotChat/utils.lua | 211 ++++++++++++-------------------------- tests/utils_spec.lua | 48 --------- 2 files changed, 64 insertions(+), 195 deletions(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index c829d40e..b9d1ade0 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -1,6 +1,5 @@ local async = require('plenary.async') local curl = require('plenary.curl') -local scandir = require('plenary.scandir') local log = require('plenary.log') local M = {} @@ -479,7 +478,7 @@ end ---@class CopilotChat.utils.ScanOpts ---@field max_count number? The maximum number of files to scan ---@field max_depth number? The maximum depth to scan ----@field glob? string The glob pattern to match files +---@field pattern? string The glob pattern to match files ---@field hidden? boolean Whether to include hidden files ---@field no_ignore? boolean Whether to respect or ignore .gitignore @@ -527,19 +526,69 @@ M.glob = async.wrap(function(path, opts, callback) return end - -- Fall back to scandir if rg is not available or fails - scandir.scan_dir_async( - path, - vim.tbl_deep_extend('force', opts, { - depth = opts.max_depth, - add_dirs = false, - search_pattern = opts.glob and M.glob_to_pattern(opts.glob) or nil, - respect_gitignore = not opts.no_ignore, - on_exit = function(files) - callback(filter_files(files, opts.max_count)) - end, - }) - ) + -- Fallback to vim.uv.fs_scandir + local matchers = {} + if opts.pattern then + local file_pattern = vim.glob.to_lpeg(opts.pattern) + local path_pattern = vim.lpeg.P(path .. '/') * file_pattern + + table.insert(matchers, function(name, dir) + return file_pattern:match(name) or path_pattern:match(dir .. '/' .. name) + end) + end + + if not opts.hidden then + table.insert(matchers, function(name) + return not name:match('^%.') + end) + end + + local data = {} + local next_dir = { path } + local current_depths = { [path] = 1 } + + local function read_dir(err, fd) + local current_dir = table.remove(next_dir, 1) + local depth = current_depths[current_dir] or 1 + + if not err and fd then + while true do + local name, typ = vim.uv.fs_scandir_next(fd) + if name == nil then + break + end + + local full_path = current_dir .. '/' .. name + + if typ == 'directory' and not name:match('^%.git') then + if not opts.max_depth or depth < opts.max_depth then + table.insert(next_dir, full_path) + current_depths[full_path] = depth + 1 + end + else + local match = true + for _, matcher in ipairs(matchers) do + if not matcher(name, current_dir) then + match = false + break + end + end + + if match then + table.insert(data, full_path) + end + end + end + end + + if #next_dir == 0 then + callback(data) + else + vim.uv.fs_scandir(next_dir[1], read_dir) + end + end + + vim.uv.fs_scandir(path, read_dir) end, 3) --- Grep a directory @@ -783,136 +832,4 @@ function M.split_lines(text) return vim.split(text, '\r?\n', { trimempty = false }) end ---- Convert glob pattern to regex pattern ---- https://github.com/davidm/lua-glob-pattern/blob/master/lua/globtopattern.lua ----@param g string The glob pattern ----@return string -function M.glob_to_pattern(g) - local p = '^' -- pattern being built - local i = 0 -- index in g - local c -- char at index i in g. - - -- unescape glob char - local function unescape() - if c == '\\' then - i = i + 1 - c = g:sub(i, i) - if c == '' then - p = '[^]' - return false - end - end - return true - end - - -- escape pattern char - local function escape(c) - return c:match('^%w$') and c or '%' .. c - end - - -- Convert tokens at end of charset. - local function charset_end() - while 1 do - if c == '' then - p = '[^]' - return false - elseif c == ']' then - p = p .. ']' - break - else - if not unescape() then - break - end - local c1 = c - i = i + 1 - c = g:sub(i, i) - if c == '' then - p = '[^]' - return false - elseif c == '-' then - i = i + 1 - c = g:sub(i, i) - if c == '' then - p = '[^]' - return false - elseif c == ']' then - p = p .. escape(c1) .. '%-]' - break - else - if not unescape() then - break - end - p = p .. escape(c1) .. '-' .. escape(c) - end - elseif c == ']' then - p = p .. escape(c1) .. ']' - break - else - p = p .. escape(c1) - i = i - 1 -- put back - end - end - i = i + 1 - c = g:sub(i, i) - end - return true - end - - -- Convert tokens in charset. - local function charset() - i = i + 1 - c = g:sub(i, i) - if c == '' or c == ']' then - p = '[^]' - return false - elseif c == '^' or c == '!' then - i = i + 1 - c = g:sub(i, i) - if c == ']' then - -- ignored - else - p = p .. '[^' - if not charset_end() then - return false - end - end - else - p = p .. '[' - if not charset_end() then - return false - end - end - return true - end - - -- Convert tokens. - while 1 do - i = i + 1 - c = g:sub(i, i) - if c == '' then - p = p .. '$' - break - elseif c == '?' then - p = p .. '.' - elseif c == '*' then - p = p .. '.*' - elseif c == '[' then - if not charset() then - break - end - elseif c == '\\' then - i = i + 1 - c = g:sub(i, i) - if c == '' then - p = p .. '\\$' - break - end - p = p .. escape(c) - else - p = p .. escape(c) - end - end - return p -end - return M diff --git a/tests/utils_spec.lua b/tests/utils_spec.lua index 2e45c83d..5352395d 100644 --- a/tests/utils_spec.lua +++ b/tests/utils_spec.lua @@ -1,54 +1,6 @@ local utils = require('CopilotChat.utils') describe('CopilotChat.utils', function() - local cases = { - { glob = '', expected = '^$' }, - { glob = 'abc', expected = '^abc$' }, - { glob = 'ab#/.', expected = '^ab%#%/%.$' }, - { glob = '\\\\\\ab\\c\\', expected = '^%\\abc\\$' }, - - { glob = 'abc.*', expected = '^abc%..*$', matches = { 'abc.txt', 'abc.' }, not_matches = { 'abc' } }, - { glob = '??.txt', expected = '^..%.txt$' }, - - { glob = 'a[]', expected = '[^]' }, - { glob = 'a[^]b', expected = '^ab$' }, - { glob = 'a[!]b', expected = '^ab$' }, - { glob = 'a[a][b]z', expected = '^a[a][b]z$' }, - { glob = 'a[a-f]z', expected = '^a[a-f]z$' }, - { glob = 'a[a-f0-9]z', expected = '^a[a-f0-9]z$' }, - { glob = 'a[a-f0-]z', expected = '^a[a-f0%-]z$' }, - { glob = 'a[!a-f]z', expected = '^a[^a-f]z$' }, - { glob = 'a[^a-f]z', expected = '^a[^a-f]z$' }, - { glob = 'a[\\!\\^\\-z\\]]z', expected = '^a[%!%^%-z%]]z$' }, - { glob = 'a[\\a-\\f]z', expected = '^a[a-f]z$' }, - - { glob = 'a[', expected = '[^]' }, - { glob = 'a[a-', expected = '[^]' }, - { glob = 'a[a-b', expected = '[^]' }, - { glob = 'a[!', expected = '[^]' }, - { glob = 'a[!a', expected = '[^]' }, - { glob = 'a[!a-', expected = '[^]' }, - { glob = 'a[!a-b', expected = '[^]' }, - { glob = 'a[!a-b\\]', expected = '[^]' }, - } - - for _, case in ipairs(cases) do - it('glob_to_pattern: ' .. case.glob, function() - local pattern = utils.glob_to_pattern(case.glob) - assert.equals(case.expected, pattern) - if case.matches then - for _, str in ipairs(case.matches) do - assert.is_true(str:match(pattern) ~= nil) - end - end - if case.not_matches then - for _, str in ipairs(case.not_matches) do - assert.is_false(str:match(pattern) ~= nil) - end - end - end) - end - it('empty', function() assert.is_true(utils.empty(nil)) assert.is_true(utils.empty('')) From 28e13a37eb31a667ed94379d3c249788eb59fb38 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 17 Aug 2025 08:58:47 +0000 Subject: [PATCH 071/250] 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 2d64906e..09e08ff9 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 15 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 17 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 7993e6d2a97cb851b8b3a4087005cfaf8427dbf3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 19 Aug 2025 22:41:22 +0200 Subject: [PATCH 072/250] fix(utils): avoid vim.filetype.match in fast event (#1344) Signed-off-by: Tomas Slusny --- lua/CopilotChat/utils.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index b9d1ade0..e8735a69 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -219,7 +219,7 @@ function M.filetype(filename) fs_access = false, }) - if ft == '' or not ft then + if ft == '' or not ft and not vim.in_fast_event() then return vim.filetype.match({ filename = filename }) end From f7bb32dbbe2ff5e26f5033e2142b5920cf427236 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 19 Aug 2025 20:41:40 +0000 Subject: [PATCH 073/250] 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 09e08ff9..bbc0cd58 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 17 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 19 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 9769bf9a1d215cf0dc22874712d5dcda53a075ee Mon Sep 17 00:00:00 2001 From: Ajmal S <23806715+AjmalShajahan@users.noreply.github.com> Date: Fri, 22 Aug 2025 01:55:28 +0530 Subject: [PATCH 074/250] fix(makefile): handle MSYS_NT as a valid Windows environment (#1347) --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 98cba272..ebfe5768 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,9 @@ else ifeq ($(UNAME), Darwin) else ifeq ($(UNAME), Windows_NT) OS := windows EXT := dll +else ifneq ($(findstring MSYS_NT,$(UNAME)),) + OS := windows + EXT := dll else $(error Unsupported operating system: $(UNAME)) endif From c62077134be1c3bd0bda52c9b1b5a7b7739fac27 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 21 Aug 2025 20:25:48 +0000 Subject: [PATCH 075/250] 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 bbc0cd58..2546dbe0 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 19 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 21 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 3496a487caf5776f33617648e06bf641f49d09fc Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 22:26:21 +0200 Subject: [PATCH 076/250] docs: add AjmalShajahan as a contributor for code (#1348) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index e8546bbf..3f6268f0 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -438,6 +438,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/488088?v=4", "profile": "https://mihamina.rktmb.org", "contributions": ["doc", "code"] + }, + { + "login": "AjmalShajahan", + "name": "Ajmal S", + "avatar_url": "https://avatars.githubusercontent.com/u/23806715?v=4", + "profile": "http://ajmalshajahan.me", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 62d03ee5..f45e06cc 100644 --- a/README.md +++ b/README.md @@ -626,6 +626,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Md. Iftakhar Awal Chowdhury
Md. Iftakhar Awal Chowdhury

💻 📖 Danilo Horta
Danilo Horta

💻 Mihamina Rakotomandimby
Mihamina Rakotomandimby

📖 💻 + Ajmal S
Ajmal S

💻 From 80a0994f01096705e0c24dd7ed09032594689e01 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 24 Aug 2025 00:01:02 +0200 Subject: [PATCH 077/250] feat(ui): add auto_fold option for chat messages (#1354) Introduce the `auto_fold` config option to automatically fold non-assistant messages in the chat window and unfold assistant messages. This improves readability and navigation in long chat sessions. Closes #1300 Signed-off-by: Tomas Slusny --- lua/CopilotChat/config.lua | 2 ++ lua/CopilotChat/ui/chat.lua | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index a3fce3a9..c9a86dbb 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -31,6 +31,7 @@ ---@field highlight_headers boolean? ---@field auto_follow_cursor boolean? ---@field auto_insert_mode boolean? +---@field auto_fold boolean? ---@field insert_at_end boolean? ---@field clear_chat_on_new_prompt boolean? @@ -90,6 +91,7 @@ return { highlight_headers = true, -- Highlight headers in chat auto_follow_cursor = true, -- Auto-follow cursor in chat auto_insert_mode = false, -- Automatically enter insert mode when opening window and on new prompt + auto_fold = false, -- Automatically non-assistant messages in chat insert_at_end = false, -- Move cursor to end of buffer when inserting text clear_chat_on_new_prompt = false, -- Clears chat on every new prompt diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index e7f0c75c..25d875d5 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -450,6 +450,18 @@ function Chat:add_message(message, replace) or current_message.role ~= message.role or (message.id and current_message.id ~= message.id) + if + self.config.auto_fold + and current_message + and current_message.role ~= constants.ROLE.ASSISTANT + and message.role ~= constants.ROLE.USER + and self:visible() + then + vim.api.nvim_win_call(self.winnr, function() + vim.cmd('normal! zc') + end) + end + if is_new then -- Add appropriate header based on role and generate a new ID if not provided message.id = message.id or utils.uuid() @@ -489,6 +501,12 @@ function Chat:add_message(message, replace) current_message.content = current_message.content .. message.content self:append(message.content) end + + if self.config.auto_fold and message.role == constants.ROLE.ASSISTANT and self:visible() then + vim.api.nvim_win_call(self.winnr, function() + vim.cmd('normal! zo') + end) + end end --- Remove a message from the chat window by role. From 81a7992584bcf4b634ab87d8fa98bef3905a937c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 23 Aug 2025 22:01:21 +0000 Subject: [PATCH 078/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 2546dbe0..b0bb1c68 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 21 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 23 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -627,7 +627,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻This project follows the all-contributors specification. Contributions of any kind are welcome! From f30698d0163a7ce7c8d43a638d76a65fa354c9c7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 24 Aug 2025 00:20:50 +0200 Subject: [PATCH 079/250] docs: update README with nicer window config (#1355) Signed-off-by: Tomas Slusny --- README.md | 9 +++++---- lua/CopilotChat/config.lua | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f45e06cc..7dbba797 100644 --- a/README.md +++ b/README.md @@ -228,12 +228,13 @@ Most users only need to configure a few options: }, headers = { - user = '👤 You: ', - assistant = '🤖 Copilot: ', - tool = '🔧 Tool: ', + user = '👤 You', + assistant = '🤖 Copilot', + tool = '🔧 Tool', }, + separator = '━━', - show_folds = false, -- Disable folding for cleaner look + auto_fold = true, -- Automatically folds non-assistant messages } ``` diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index c9a86dbb..1a739c67 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -87,11 +87,11 @@ return { show_help = true, -- Shows help message as virtual lines when waiting for user input show_folds = true, -- Shows folds for sections in chat + auto_fold = false, -- Automatically non-assistant messages in chat (requires 'show_folds' to be true) highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat auto_follow_cursor = true, -- Auto-follow cursor in chat auto_insert_mode = false, -- Automatically enter insert mode when opening window and on new prompt - auto_fold = false, -- Automatically non-assistant messages in chat insert_at_end = false, -- Move cursor to end of buffer when inserting text clear_chat_on_new_prompt = false, -- Clears chat on every new prompt From f79f2928a3d2cb1ca5b7bc3c71bbf79d485c00cf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 23 Aug 2025 22:21:08 +0000 Subject: [PATCH 080/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index b0bb1c68..ce0418c3 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -294,12 +294,13 @@ WINDOW & APPEARANCE *CopilotChat-window-&-appearance* }, headers = { - user = '👤 You: ', - assistant = '🤖 Copilot: ', - tool = '🔧 Tool: ', + user = '👤 You', + assistant = '🤖 Copilot', + tool = '🔧 Tool', }, + separator = '━━', - show_folds = false, -- Disable folding for cleaner look + auto_fold = true, -- Automatically folds non-assistant messages } < From a7679e118af8038046b2fc4c841406db7fe71216 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 25 Aug 2025 15:31:39 +0200 Subject: [PATCH 081/250] feat(ui): improve auto folding logic in chat window (#1356) Refactors auto folding to occur during chat rendering instead of message addition. This ensures more consistent folding behavior for non-assistant messages and improves code clarity. Removes redundant fold open/close commands from add_message. No breaking changes. Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 25d875d5..670ed2b4 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -450,18 +450,6 @@ function Chat:add_message(message, replace) or current_message.role ~= message.role or (message.id and current_message.id ~= message.id) - if - self.config.auto_fold - and current_message - and current_message.role ~= constants.ROLE.ASSISTANT - and message.role ~= constants.ROLE.USER - and self:visible() - then - vim.api.nvim_win_call(self.winnr, function() - vim.cmd('normal! zc') - end) - end - if is_new then -- Add appropriate header based on role and generate a new ID if not provided message.id = message.id or utils.uuid() @@ -501,12 +489,6 @@ function Chat:add_message(message, replace) current_message.content = current_message.content .. message.content self:append(message.content) end - - if self.config.auto_fold and message.role == constants.ROLE.ASSISTANT and self:visible() then - vim.api.nvim_win_call(self.winnr, function() - vim.cmd('normal! zo') - end) - end end --- Remove a message from the chat window by role. @@ -754,7 +736,7 @@ function Chat:render() -- Replace self.messages with new_messages (preserving tool_calls, etc.) self.messages = new_messages - for _, message in ipairs(self.messages) do + for i, 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 @@ -808,6 +790,16 @@ function Chat:render() strict = false, }) end + + if self.config.auto_fold and self:visible() then + if message.role ~= constants.ROLE.ASSISTANT and message.section and i < #self.messages then + vim.api.nvim_win_call(self.winnr, function() + if vim.fn.foldclosed(message.section.start_line) == -1 then + vim.api.nvim_cmd({ cmd = 'foldclose', range = { message.section.start_line } }, {}) + end + end) + end + end end -- Show help as before, using last user message From 98b50a6143ba2dedb545203e9b50eb36703def00 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 25 Aug 2025 13:36:59 +0000 Subject: [PATCH 082/250] 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 ce0418c3..ced4f2be 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 23 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 25 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From a2429ed44438f694f1fca60429a7984022d4a9f0 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 25 Aug 2025 19:45:40 +0200 Subject: [PATCH 083/250] refactor(core): remove selection API in favor of resources (#1340) Replaces the selection API with a unified resources mechanism for sharing context with the LLM. Updates config, mappings, and internal logic to use resources instead of selection. Deprecates selection-related functions and highlights. Improves flexibility and future extensibility for context injection. --- README.md | 28 +---- lua/CopilotChat/client.lua | 48 +++------ lua/CopilotChat/config.lua | 6 +- lua/CopilotChat/config/functions.lua | 29 +++++- lua/CopilotChat/config/mappings.lua | 77 +++++++------- lua/CopilotChat/init.lua | 125 ++++++---------------- lua/CopilotChat/select.lua | 149 ++++++++++++++------------- plugin/CopilotChat.lua | 26 ++--- 8 files changed, 214 insertions(+), 274 deletions(-) diff --git a/README.md b/README.md index 7dbba797..2be9d184 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,6 @@ EOF - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks -- **Selection** - Automatically includes current user selection in prompts ## Examples @@ -266,6 +265,7 @@ Types of copilot highlights: - `CopilotChatHeader` - Header highlight in chat buffer - `CopilotChatSeparator` - Separator highlight in chat buffer +- `CopilotChatSelection` - Selection highlight in source buffer - `CopilotChatStatus` - Status and spinner in chat buffer - `CopilotChatHelp` - Help text in chat buffer - `CopilotChatResource` - Resource highlight in chat buffer (e.g. `#file`, `#gitdiff`) @@ -273,7 +273,6 @@ Types of copilot highlights: - `CopilotChatPrompt` - Prompt highlight in chat buffer (e.g. `/Explain`, `/Review`) - `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) - `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) -- `CopilotChatSelection` - Selection highlight in source buffer - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) ## Prompts @@ -334,27 +333,6 @@ Define your own functions in the configuration with input handling and schema: } ``` -## Selections - -Control what content is automatically included: - -```lua -{ - -- Use visual selection, fallback to current line - selection = function(source) - return require('CopilotChat.select').visual(source) or - require('CopilotChat.select').line(source) - end, -} -``` - -**Available selections:** - -- `require('CopilotChat.select').visual` - Current visual selection -- `require('CopilotChat.select').buffer` - Entire buffer content -- `require('CopilotChat.select').line` - Current line content -- `require('CopilotChat.select').unnamed` - Unnamed register (last deleted/changed/yanked) - ## Providers Add custom AI providers: @@ -430,10 +408,6 @@ chat.stop() -- Stop current output chat.get_source() -- Get the current source buffer and window chat.set_source(winnr) -- Set the source window --- Selection Management -chat.get_selection() -- Get the current selection -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 diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index f2dba5b0..6121bdfe 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -1,7 +1,6 @@ ---@class CopilotChat.client.AskOptions ---@field headless boolean ---@field history table ----@field selection CopilotChat.select.Selection? ---@field tools table? ---@field resources table? ---@field system_prompt string @@ -32,11 +31,16 @@ ---@field description string description of the tool ---@field schema table? schema of the tool +---@class CopilotChat.client.ResourceAnnotations +---@field start_line number? +---@field end_line number? + ---@class CopilotChat.client.Resource ---@field data string ---@field name string? ---@field mimetype string? ---@field uri string? +---@field annotations CopilotChat.client.ResourceAnnotations? ---@class CopilotChat.client.Model ---@field provider string? @@ -106,29 +110,6 @@ local function generate_resource_block(content, mimetype, name, path, start_line end end ---- Generate messages for the given selection ---- @param selection CopilotChat.select.Selection ---- @return CopilotChat.client.Message? -local function generate_selection_message(selection) - local content = selection.content - - if not content or content == '' then - return nil - end - - return { - content = generate_resource_block( - content, - selection.filetype, - "User's active selection", - selection.filename, - selection.start_line, - selection.end_line - ), - role = constants.ROLE.USER, - } -end - --- Generate messages for the given resources --- @param resources CopilotChat.client.Resource[] --- @return table @@ -139,8 +120,17 @@ local function generate_resource_messages(resources) return resource.data and resource.data ~= '' end) :map(function(resource) + local start_line = resource.annotations and resource.annotations.start_line or 1 + local end_line = resource.annotations and resource.annotations.end_line or nil return { - content = generate_resource_block(resource.data, resource.mimetype, resource.uri, resource.name, 1, nil), + content = generate_resource_block( + resource.data, + resource.mimetype, + resource.uri, + resource.name, + start_line, + end_line + ), role = constants.ROLE.USER, } end) @@ -359,20 +349,14 @@ function Client:ask(prompt, opts) local history = not opts.headless and vim.deepcopy(opts.history) or {} local tool_calls = utils.ordered_map() local generated_messages = {} - local selection_message = opts.selection and generate_selection_message(opts.selection) local resource_messages = generate_resource_messages(opts.resources) - if selection_message then - table.insert(generated_messages, selection_message) - end - if max_tokens then -- Count required tokens that we cannot reduce - local selection_tokens = selection_message and tiktoken:count(selection_message.content) or 0 local prompt_tokens = tiktoken:count(prompt) local system_tokens = tiktoken:count(opts.system_prompt) local resource_tokens = #resource_messages > 0 and tiktoken:count(resource_messages[1].content) or 0 - local required_tokens = prompt_tokens + system_tokens + selection_tokens + resource_tokens + local required_tokens = prompt_tokens + system_tokens + resource_tokens -- Calculate how many tokens we can use for history local history_limit = max_tokens - required_tokens diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 1a739c67..d3f2b8cb 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -17,13 +17,13 @@ ---@field system_prompt nil|string|fun(source: CopilotChat.source):string ---@field model string? ---@field tools string|table|nil +---@field resources string|table|nil ---@field sticky string|table|nil ---@field language string? ---@field temperature number? ---@field headless boolean? ---@field callback nil|fun(response: CopilotChat.client.Message, source: CopilotChat.source) ---@field remember_as_sticky boolean? ----@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.Selection? ---@field window CopilotChat.config.Window? ---@field show_help boolean? ---@field show_folds boolean? @@ -58,6 +58,7 @@ return { model = 'gpt-4.1', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). tools = nil, -- Default tool or array of tools (or groups) to share with LLM (can be specified manually in prompt via @). + resources = 'selection', -- Default resources to share with LLM (can be specified manually in prompt via #). sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat (can be specified manually in prompt via >). language = 'English', -- Default language to use for answers @@ -66,9 +67,6 @@ return { callback = nil, -- Function called when full response is received remember_as_sticky = true, -- Remember config as sticky prompts when asking questions - -- default selection - selection = require('CopilotChat.select').visual, - -- default window options window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace', or a function that returns the layout diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 9050d267..eb067376 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -6,7 +6,7 @@ local utils = require('CopilotChat.utils') ---@field schema table? ---@field group string? ---@field uri string? ----@field resolve fun(input: table, source: CopilotChat.source, prompt: string):table +---@field resolve fun(input: table, source: CopilotChat.source):CopilotChat.client.Resource[] ---@type table return { @@ -214,6 +214,33 @@ return { end, }, + selection = { + group = 'copilot', + uri = 'neovim://selection', + description = 'Includes the content of the current visual selection. Useful for discussing specific code snippets or text blocks.', + + resolve = function(_, source) + utils.schedule_main() + local selection = require('CopilotChat.select').get(source.bufnr) + if not selection then + return {} + end + + return { + { + uri = 'neovim://selection', + name = selection.filename, + mimetype = utils.mimetype_to_filetype(selection.filetype), + data = selection.content, + annotations = { + start_line = selection.start_line, + end_line = selection.end_line, + }, + }, + } + end, + }, + quickfix = { group = 'copilot', uri = 'neovim://quickfix', diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index e3e2e248..ec42be85 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -2,6 +2,7 @@ local async = require('plenary.async') local copilot = require('CopilotChat') local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') +local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') ---@class CopilotChat.config.mappings.Diff @@ -14,23 +15,33 @@ local utils = require('CopilotChat.utils') ---@field bufnr number? --- Get diff data from a block +---@param bufnr number ---@param block CopilotChat.ui.chat.Block? ---@return CopilotChat.config.mappings.Diff? -local function get_diff(block) +local function get_diff(bufnr, block) -- If no block found, return nil if not block then return nil end - -- Initialize variables with selection if available local header = block.header - local selection = copilot.get_selection() - local reference = selection and selection.content - local start_line = selection and selection.start_line - local end_line = selection and selection.end_line - local filename = selection and selection.filename - local filetype = selection and selection.filetype - local bufnr = selection and selection.bufnr + local selection = select.get(bufnr) + local filename = nil + local filetype = nil + local start_line = nil + local end_line = nil + local reference = nil + local bufnr = nil + + if selection then + -- If we have a selection, use it as default source of truth + filename = selection.filename + filetype = selection.filetype + start_line = selection.start_line + end_line = selection.end_line + reference = selection.content + bufnr = selection.bufnr + end -- If we have header info, use it as source of truth if header.start_line and header.end_line then @@ -236,7 +247,7 @@ return { normal = '', insert = '', callback = function(source) - local diff = get_diff(copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) + local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -244,20 +255,22 @@ return { local lines = utils.split_lines(diff.change) vim.api.nvim_buf_set_lines(diff.bufnr, diff.start_line - 1, diff.end_line, false, lines) - copilot.set_selection(diff.bufnr, diff.start_line, diff.end_line) + select.set(source.bufnr, source.winnr, diff.start_line, diff.start_line + #lines - 1) + select.highlight(source.bufnr) end, }, jump_to_diff = { normal = 'gj', callback = function(source) - local diff = get_diff(copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) + local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) diff = prepare_diff_buffer(diff, source) if not diff then return end - copilot.set_selection(diff.bufnr, diff.start_line, diff.end_line) + select.set(source.bufnr, source.winnr, diff.start_line, diff.end_line) + select.highlight(source.bufnr) end, }, @@ -289,32 +302,26 @@ return { quickfix_diffs = { normal = 'gqd', - callback = function() - local selection = copilot.get_selection() + callback = function(source) local items = {} for _, message in ipairs(copilot.chat.messages) do if message.section then for _, block in ipairs(message.section.blocks) do - local header = block.header - - if not header.start_line and selection then - header.filename = selection.filename .. ' (selection)' - header.start_line = selection.start_line - header.end_line = selection.end_line - end + local diff = get_diff(source.bufnr, block) + if diff then + local text = string.format('%s (%s)', diff.filename, diff.filetype) + if diff.start_line and diff.end_line then + text = text .. string.format(' [lines %d-%d]', diff.start_line, diff.end_line) + end - local text = string.format('%s (%s)', header.filename, header.filetype) - if header.start_line and header.end_line then - text = text .. string.format(' [lines %d-%d]', header.start_line, header.end_line) + table.insert(items, { + bufnr = copilot.chat.bufnr, + lnum = block.start_line, + end_lnum = block.end_line, + text = text, + }) end - - table.insert(items, { - bufnr = copilot.chat.bufnr, - lnum = block.start_line, - end_lnum = block.end_line, - text = text, - }) end end @@ -341,7 +348,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(constants.ROLE.ASSISTANT, true)) + local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) diff = prepare_diff_buffer(diff, source) if not diff then return @@ -362,7 +369,7 @@ return { local same_file_diffs = {} if section then for _, block in ipairs(section.blocks) do - local block_diff = get_diff(block) + local block_diff = get_diff(source.bufnr, block) if block_diff and block_diff.bufnr == diff.bufnr then table.insert(same_file_diffs, block_diff) end @@ -497,7 +504,7 @@ return { table.insert(lines, '') end - local selection = copilot.get_selection() + local selection = select.get(source.bufnr) if selection then table.insert(lines, '**Selection**') table.insert(lines, '') diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 38ee5568..ba32542e 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -4,6 +4,7 @@ local functions = require('CopilotChat.functions') local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') +local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') local WORD = '([^%s:]+)' @@ -25,18 +26,22 @@ local M = setmetatable({}, { }) --- @class CopilotChat.source ---- @field bufnr number ---- @field winnr number +--- @field bufnr number? +--- @field winnr number? --- @field cwd fun():string --- @class CopilotChat.state --- @field source CopilotChat.source? --- @field sticky string[]? local state = { - -- Current state tracking - source = nil, + source = { + bufnr = nil, + winnr = nil, + cwd = function() + return '.' + end, + }, - -- Last state tracking sticky = nil, } @@ -80,6 +85,12 @@ local function insert_sticky(prompt, config) end end + if config.remember_as_sticky and config.resources and not vim.deep_equal(config.resources, M.config.resources) then + for _, resource in ipairs(utils.to_table(config.resources)) do + stickies:set('#' .. resource, true) + end + end + if config.remember_as_sticky and config.system_prompt @@ -129,27 +140,6 @@ local function store_sticky(prompt) state.sticky = sticky end ---- Update the highlights for chat buffer -local function update_highlights() - local selection_ns = vim.api.nvim_create_namespace('copilot-chat-selection') - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - vim.api.nvim_buf_clear_namespace(buf, selection_ns, 0, -1) - end - - if M.chat.config.highlight_selection and M.chat:focused() then - local selection = M.get_selection() - if not selection or not utils.buf_valid(selection.bufnr) or not selection.start_line or not selection.end_line then - return - end - - vim.api.nvim_buf_set_extmark(selection.bufnr, selection_ns, selection.start_line - 1, 0, { - hl_group = 'CopilotChatSelection', - end_row = selection.end_line, - strict = false, - }) - end -end - --- List available models. --- @return CopilotChat.client.Model[] local function list_models() @@ -315,6 +305,16 @@ function M.resolve_functions(prompt, config) tools[tool.name] = tool end + if config.resources then + local resources = utils.to_table(config.resources) + local lines = utils.split_lines(prompt) + for i = #resources, 1, -1 do + local resource = resources[i] + table.insert(lines, 1, '#' .. resource) + end + prompt = table.concat(lines, '\n') + end + local enabled_tools = {} local resolved_resources = {} local resolved_tools = {} @@ -413,7 +413,7 @@ function M.resolve_functions(prompt, config) local schema = tools[name] and tools[name].schema or nil local result = '' - local ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source or {}, prompt) + local ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source) if not ok then result = string.format(BLOCK_OUTPUT_FORMAT, 'error', utils.make_string(output)) else @@ -527,9 +527,7 @@ function M.resolve_prompt(prompt, config) config.system_prompt = config.system_prompt .. '\n' .. M.config.prompts.COPILOT_BASE.system_prompt config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) - if state.source then - config.system_prompt = config.system_prompt:gsub('{DIR}', state.source.cwd()) - end + config.system_prompt = config.system_prompt:gsub('{DIR}', state.source.cwd()) end return config, prompt @@ -592,55 +590,6 @@ function M.set_source(source_winnr) return false end ---- Get the selection from the source buffer. ----@return CopilotChat.select.Selection? -function M.get_selection() - local config = vim.tbl_deep_extend('force', M.config, M.chat.config) - local selection = config.selection - local bufnr = state.source and state.source.bufnr - local winnr = state.source and state.source.winnr - - if selection and utils.buf_valid(bufnr) and winnr and vim.api.nvim_win_is_valid(winnr) then - return selection(state.source) - end - - return nil -end - ---- Sets the selection to specific lines in buffer. ----@param bufnr number ----@param start_line number ----@param end_line number ----@param clear boolean? -function M.set_selection(bufnr, start_line, end_line, clear) - if not utils.buf_valid(bufnr) then - return - end - - if clear then - for _, mark in ipairs({ '<', '>', '[', ']' }) do - pcall(vim.api.nvim_buf_del_mark, bufnr, mark) - end - update_highlights() - return - end - - local winnr = vim.fn.win_findbuf(bufnr)[1] - if not winnr and state.source then - winnr = state.source.winnr - end - if not winnr then - return - end - - pcall(vim.api.nvim_buf_set_mark, bufnr, '<', start_line, 0, {}) - pcall(vim.api.nvim_buf_set_mark, bufnr, '>', end_line, 0, {}) - pcall(vim.api.nvim_buf_set_mark, bufnr, '[', start_line, 0, {}) - pcall(vim.api.nvim_buf_set_mark, bufnr, ']', end_line, 0, {}) - pcall(vim.api.nvim_win_set_cursor, winnr, { start_line, 0 }) - update_highlights() -end - --- Open the chat window. ---@param config CopilotChat.config.Shared? function M.open(config) @@ -667,7 +616,7 @@ end --- Close the chat window. function M.close() - M.chat:close(state.source and state.source.bufnr or nil) + M.chat:close(state.source.bufnr) end --- Toggle the chat window. @@ -822,9 +771,6 @@ function M.ask(prompt, config) '\n' ) - -- Retrieve the selection - local selection = M.get_selection() - async.run(handle_error(config, function() local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) @@ -883,7 +829,6 @@ function M.ask(prompt, config) local ask_response = client.ask(client, prompt, { headless = config.headless, history = M.chat.messages, - selection = selection, resources = resolved_resources, tools = selected_tools, system_prompt = system_prompt, @@ -937,11 +882,7 @@ function M.stop(reset) if reset then M.chat:clear() vim.diagnostic.reset(vim.api.nvim_create_namespace('copilot-chat-diagnostics')) - - -- Clear the selection - if state.source then - M.set_selection(state.source.bufnr, 0, 0, true) - end + select.set(state.source.bufnr) end if stopped or reset then @@ -1078,7 +1019,7 @@ function M.setup(config) end if M.chat then - M.chat:close(state.source and state.source.bufnr or nil) + M.chat:close(state.source.bufnr) M.chat:delete() end M.chat = require('CopilotChat.ui.chat')(M.config, function(bufnr) @@ -1095,7 +1036,9 @@ function M.setup(config) update_source() end - vim.schedule(update_highlights) + vim.schedule(function() + select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) + end) end, }) diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index 8bef366c..1d2b8bcb 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -6,90 +6,70 @@ ---@field filetype string ---@field bufnr number +local constants = require('CopilotChat.constants') +local utils = require('CopilotChat.utils') + local M = {} ---- Select and process current visual selection ---- @param source CopilotChat.source ---- @return CopilotChat.select.Selection|nil -function M.visual(source) - local bufnr = source.bufnr - local start_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '<')) - local finish_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '>')) - if start_line == 0 or finish_line == 0 then - return nil - end - if start_line > finish_line then - start_line, finish_line = finish_line, start_line - end +---@deprecated +function M.visual(_) + vim.deprecate('CopilotChat.select.visual', '#selection', '5.0.0', constants.PLUGIN_NAME) + return nil +end - local ok, lines = pcall(vim.api.nvim_buf_get_lines, bufnr, start_line - 1, finish_line, false) - if not ok then - return nil - end - local lines_content = table.concat(lines, '\n') - if vim.trim(lines_content) == '' then - return nil - end +---@deprecated +function M.buffer(_) + vim.deprecate('CopilotChat.select.buffer', '#selection', '5.0.0', constants.PLUGIN_NAME) + return nil +end - return { - content = lines_content, - filename = vim.api.nvim_buf_get_name(bufnr), - filetype = vim.bo[bufnr].filetype, - start_line = start_line, - end_line = finish_line, - bufnr = bufnr, - } +---@deprecated +function M.line(_) + vim.deprecate('CopilotChat.select.line', '#selection', '5.0.0', constants.PLUGIN_NAME) + return nil end ---- Select and process whole buffer ---- @param source CopilotChat.source ---- @return CopilotChat.select.Selection|nil -function M.buffer(source) - local bufnr = source.bufnr - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - if not lines or #lines == 0 then - return nil +---@deprecated +function M.unnamed(_) + vim.deprecate('CopilotChat.select.unnamed', '#selection', '5.0.0', constants.PLUGIN_NAME) + return nil +end + +--- Highlight selection in target buffer or clear it +---@param bufnr number +---@param clear boolean? +function M.highlight(bufnr, clear) + local selection_ns = vim.api.nvim_create_namespace('copilot-chat-selection') + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + vim.api.nvim_buf_clear_namespace(buf, selection_ns, 0, -1) end - return { - content = table.concat(lines, '\n'), - filename = vim.api.nvim_buf_get_name(bufnr), - filetype = vim.bo[bufnr].filetype, - start_line = 1, - end_line = #lines, - bufnr = bufnr, - } -end + if clear then + return + end ---- Select and process current line ---- @param source CopilotChat.source ---- @return CopilotChat.select.Selection|nil -function M.line(source) - local bufnr = source.bufnr - local winnr = source.winnr - local cursor = vim.api.nvim_win_get_cursor(winnr) - local line = vim.api.nvim_buf_get_lines(bufnr, cursor[1] - 1, cursor[1], false)[1] - if not line then - return nil + local selection = M.get(bufnr) + if not selection then + return end - return { - content = line, - filename = vim.api.nvim_buf_get_name(bufnr), - filetype = vim.bo[bufnr].filetype, - start_line = cursor[1], - end_line = cursor[1], - bufnr = bufnr, - } + vim.api.nvim_buf_set_extmark(selection.bufnr, selection_ns, selection.start_line - 1, 0, { + hl_group = 'CopilotChatSelection', + end_row = selection.end_line, + strict = false, + }) end ---- Select and process contents of unnamed register ("). This register contains last deleted, changed or yanked content. ---- @param source CopilotChat.source ---- @return CopilotChat.select.Selection|nil -function M.unnamed(source) - local bufnr = source.bufnr - local start_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '[')) - local finish_line = unpack(vim.api.nvim_buf_get_mark(bufnr, ']')) +--- Get the selection from the target buffer +---@param bufnr number +---@return CopilotChat.select.Selection? +function M.get(bufnr) + if not utils.buf_valid(bufnr) then + return nil + end + + local start_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '<')) + local finish_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '>')) if start_line == 0 or finish_line == 0 then return nil end @@ -116,4 +96,31 @@ function M.unnamed(source) } end +--- Sets the selection to specific lines in buffer or clears it +---@param bufnr number +---@param winnr number? +---@param start_line number? +---@param end_line number? +function M.set(bufnr, winnr, start_line, end_line) + if not utils.buf_valid(bufnr) then + return + end + + if not start_line or not end_line then + for _, mark in ipairs({ '<', '>', '[', ']' }) do + pcall(vim.api.nvim_buf_del_mark, bufnr, mark) + end + return + end + + pcall(vim.api.nvim_buf_set_mark, bufnr, '<', start_line, 0, {}) + pcall(vim.api.nvim_buf_set_mark, bufnr, '>', end_line, 0, {}) + pcall(vim.api.nvim_buf_set_mark, bufnr, '[', start_line, 0, {}) + pcall(vim.api.nvim_buf_set_mark, bufnr, ']', end_line, 0, {}) + + if winnr and vim.api.nvim_win_is_valid(winnr) then + pcall(vim.api.nvim_win_set_cursor, winnr, { start_line, 0 }) + end +end + return M diff --git a/plugin/CopilotChat.lua b/plugin/CopilotChat.lua index e59ee49e..db83d5b2 100644 --- a/plugin/CopilotChat.lua +++ b/plugin/CopilotChat.lua @@ -14,6 +14,7 @@ local group = vim.api.nvim_create_augroup('CopilotChat', {}) local function setup_highlights() vim.api.nvim_set_hl(0, 'CopilotChatHeader', { link = '@markup.heading.2.markdown', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatSeparator', { link = '@punctuation.special.markdown', default = true }) + vim.api.nvim_set_hl(0, 'CopilotChatSelection', { link = 'Visual', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatStatus', { link = 'DiagnosticHint', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatHelp', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatResource', { link = 'Constant', default = true }) @@ -21,7 +22,6 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'CopilotChatPrompt', { link = 'Statement', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatModel', { link = 'Type', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatUri', { link = 'Underlined', default = true }) - vim.api.nvim_set_hl(0, 'CopilotChatSelection', { link = 'Visual', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatAnnotation', { link = 'ColorColumn', default = true }) local fg = vim.api.nvim_get_hl(0, { name = 'CopilotChatStatus', link = false }).fg @@ -36,6 +36,18 @@ vim.api.nvim_create_autocmd('ColorScheme', { }) setup_highlights() +vim.api.nvim_create_autocmd('FileType', { + pattern = 'copilot-chat', + group = group, + callback = vim.schedule_wrap(function() + vim.cmd.syntax('match CopilotChatResource "#\\S\\+"') + vim.cmd.syntax('match CopilotChatTool "@\\S\\+"') + vim.cmd.syntax('match CopilotChatPrompt "/\\S\\+"') + vim.cmd.syntax('match CopilotChatModel "\\$\\S\\+"') + vim.cmd.syntax('match CopilotChatUri "##\\S\\+"') + end), +}) + -- Setup commands vim.api.nvim_create_user_command('CopilotChat', function(args) local chat = require('CopilotChat') @@ -79,18 +91,6 @@ vim.api.nvim_create_user_command('CopilotChatReset', function() chat.reset() end, { force = true }) -vim.api.nvim_create_autocmd('FileType', { - pattern = 'copilot-chat', - group = group, - callback = vim.schedule_wrap(function() - vim.cmd.syntax('match CopilotChatResource "#\\S\\+"') - vim.cmd.syntax('match CopilotChatTool "@\\S\\+"') - vim.cmd.syntax('match CopilotChatPrompt "/\\S\\+"') - vim.cmd.syntax('match CopilotChatModel "\\$\\S\\+"') - vim.cmd.syntax('match CopilotChatUri "##\\S\\+"') - end), -}) - local function complete_load() local chat = require('CopilotChat') local options = vim.tbl_map(function(file) From a7d95ffebf14ddb6709c328754c4164337dc70d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 25 Aug 2025 17:46:05 +0000 Subject: [PATCH 084/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 30 +----------------------------- 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index ced4f2be..e9de6f50 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -23,7 +23,6 @@ Table of Contents *CopilotChat-table-of-contents* - Highlights |CopilotChat-highlights| - Prompts |CopilotChat-prompts| - Functions |CopilotChat-functions| - - Selections |CopilotChat-selections| - Providers |CopilotChat-providers| 5. API Reference |CopilotChat-api-reference| - Core |CopilotChat-core| @@ -128,7 +127,6 @@ VIM-PLUG *CopilotChat-vim-plug* - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks -- **Selection** - Automatically includes current user selection in prompts EXAMPLES *CopilotChat-examples* @@ -334,6 +332,7 @@ Types of copilot highlights: - `CopilotChatHeader` - Header highlight in chat buffer - `CopilotChatSeparator` - Separator highlight in chat buffer +- `CopilotChatSelection` - Selection highlight in source buffer - `CopilotChatStatus` - Status and spinner in chat buffer - `CopilotChatHelp` - Help text in chat buffer - `CopilotChatResource` - Resource highlight in chat buffer (e.g. `#file`, `#gitdiff`) @@ -341,7 +340,6 @@ Types of copilot highlights: - `CopilotChatPrompt` - Prompt highlight in chat buffer (e.g. `/Explain`, `/Review`) - `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) - `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) -- `CopilotChatSelection` - Selection highlight in source buffer - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) @@ -405,28 +403,6 @@ Define your own functions in the configuration with input handling and schema: < -SELECTIONS *CopilotChat-selections* - -Control what content is automatically included: - ->lua - { - -- Use visual selection, fallback to current line - selection = function(source) - return require('CopilotChat.select').visual(source) or - require('CopilotChat.select').line(source) - end, - } -< - -**Available selections:** - -- `require('CopilotChat.select').visual` - Current visual selection -- `require('CopilotChat.select').buffer` - Entire buffer content -- `require('CopilotChat.select').line` - Current line content -- `require('CopilotChat.select').unnamed` - Unnamed register (last deleted/changed/yanked) - - PROVIDERS *CopilotChat-providers* Add custom AI providers: @@ -505,10 +481,6 @@ CORE *CopilotChat-core* chat.get_source() -- Get the current source buffer and window chat.set_source(winnr) -- Set the source window - -- Selection Management - chat.get_selection() -- Get the current selection - 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 From 05708d30f763084619914852d1d531cbaf4a7117 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 25 Aug 2025 19:48:45 +0200 Subject: [PATCH 085/250] refactor(prompts): use resources instead of sticky for Commit (#1357) Replaces the 'sticky' property with 'resources' in the Commit prompt configuration to improve clarity and maintain consistency with other prompt definitions. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/prompts.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 203997a7..073a8e8b 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -195,6 +195,6 @@ If no issues found, confirm the code is well-written and explain why. Commit = { prompt = 'Write commit message for the change with commitizen convention. Keep the title under 50 characters and wrap message at 72 characters. Format as a gitcommit code block.', - sticky = '#gitdiff:staged', + resources = 'gitdiff:staged', }, } From c7d85478f775a65ca777cb9b2f685911cbcd8def Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 25 Aug 2025 20:01:36 +0200 Subject: [PATCH 086/250] feat(docs): add selection source to function table (#1358) Documented the new `selection` source in the function table, clarifying its usage for including the current visual selection. This improves discoverability and helps users understand available sources. Signed-off-by: Tomas Slusny --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2be9d184..ad3cd3ef 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ All predefined functions belong to the `copilot` group. | `grep` | Searches for a pattern across files in workspace | `#grep:TODO` | | `quickfix` | Includes content of files in quickfix list | `#quickfix` | | `register` | Provides access to specified Vim register | `#register:+` | +| `selection` | Includes the current visual selection | `#selection` | | `url` | Fetches content from a specified URL | `#url:https://...` | ## Predefined Prompts From 8e2c2e2d792e9b68dc5a229f097ca3c61fb1d047 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 25 Aug 2025 18:01:53 +0000 Subject: [PATCH 087/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index e9de6f50..e489a6c6 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -230,6 +230,8 @@ All predefined functions belong to the `copilot` group. register Provides access to specified Vim register #register:+ + selection Includes the current visual selection #selection + url Fetches content from a specified URL #url:https://... ------------------------------------------------------------------------------ From c37ec3cbdb2c29be73d7d0c48057d64306aa185f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 25 Aug 2025 20:09:46 +0200 Subject: [PATCH 088/250] feat(config): add back selection source config option (#1360) Reintroduce `selection` config option to choose between 'visual' and 'unnamed' selection sources. Update selection mark handling in select.lua to respect the configured source, improving flexibility for user workflows. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config.lua | 2 ++ lua/CopilotChat/select.lua | 26 +++++++++++++++++++------- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index d3f2b8cb..ba24edb1 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -20,6 +20,7 @@ ---@field resources string|table|nil ---@field sticky string|table|nil ---@field language string? +---@field selection 'visual'|'unnamed'|nil ---@field temperature number? ---@field headless boolean? ---@field callback nil|fun(response: CopilotChat.client.Message, source: CopilotChat.source) @@ -62,6 +63,7 @@ return { sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat (can be specified manually in prompt via >). language = 'English', -- Default language to use for answers + selection = 'visual', -- Selection source temperature = 0.1, -- Result temperature headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) callback = nil, -- Function called when full response is received diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index 1d2b8bcb..669876df 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -8,6 +8,7 @@ local constants = require('CopilotChat.constants') local utils = require('CopilotChat.utils') +local config = require('CopilotChat.config') local M = {} @@ -35,6 +36,16 @@ function M.unnamed(_) return nil end +--- Get the marks used for selection +---@return string[] +function M.marks() + local marks = { '<', '>' } + if config.selection == 'unnamed' then + marks = { '[', ']' } + end + return marks +end + --- Highlight selection in target buffer or clear it ---@param bufnr number ---@param clear boolean? @@ -68,8 +79,9 @@ function M.get(bufnr) return nil end - local start_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '<')) - local finish_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '>')) + local marks = M.marks() + local start_line = unpack(vim.api.nvim_buf_get_mark(bufnr, marks[1])) + local finish_line = unpack(vim.api.nvim_buf_get_mark(bufnr, marks[2])) if start_line == 0 or finish_line == 0 then return nil end @@ -106,17 +118,17 @@ function M.set(bufnr, winnr, start_line, end_line) return end + local marks = M.marks() + if not start_line or not end_line then - for _, mark in ipairs({ '<', '>', '[', ']' }) do + for _, mark in ipairs(marks) do pcall(vim.api.nvim_buf_del_mark, bufnr, mark) end return end - pcall(vim.api.nvim_buf_set_mark, bufnr, '<', start_line, 0, {}) - pcall(vim.api.nvim_buf_set_mark, bufnr, '>', end_line, 0, {}) - pcall(vim.api.nvim_buf_set_mark, bufnr, '[', start_line, 0, {}) - pcall(vim.api.nvim_buf_set_mark, bufnr, ']', end_line, 0, {}) + pcall(vim.api.nvim_buf_set_mark, bufnr, marks[1], start_line, 0, {}) + pcall(vim.api.nvim_buf_set_mark, bufnr, marks[2], end_line, 0, {}) if winnr and vim.api.nvim_win_is_valid(winnr) then pcall(vim.api.nvim_win_set_cursor, winnr, { start_line, 0 }) From 19a38dd34e1b61c49349552598e43b2559be2fc7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 25 Aug 2025 20:15:33 +0200 Subject: [PATCH 089/250] fix(select): move config inside of marks function to prevent import loop (#1361) Signed-off-by: Tomas Slusny --- lua/CopilotChat/select.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index 669876df..e177aff0 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -8,7 +8,6 @@ local constants = require('CopilotChat.constants') local utils = require('CopilotChat.utils') -local config = require('CopilotChat.config') local M = {} @@ -39,6 +38,7 @@ end --- Get the marks used for selection ---@return string[] function M.marks() + local config = require('CopilotChat.config') local marks = { '<', '>' } if config.selection == 'unnamed' then marks = { '[', ']' } From fdac67ab62085436b60003f420ae45f104bdf935 Mon Sep 17 00:00:00 2001 From: Samiul Islam Date: Tue, 26 Aug 2025 00:24:31 +0600 Subject: [PATCH 090/250] fix(completion.lua): check if window is valid before calling get_cursor (#1359) Signed-off-by: sami --- lua/CopilotChat/completion.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/CopilotChat/completion.lua b/lua/CopilotChat/completion.lua index 6c65a18d..97b3e9d4 100644 --- a/lua/CopilotChat/completion.lua +++ b/lua/CopilotChat/completion.lua @@ -167,6 +167,10 @@ function M.complete(without_input) local items = M.items() utils.schedule_main() + if not vim.api.nvim_win_is_valid(win) then + return + end + 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 From e879659ed67be1c774d4598bce4a119a200f6622 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 20:25:10 +0200 Subject: [PATCH 091/250] docs: add samiulsami as a contributor for code (#1362) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 3 +++ 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3f6268f0..3dee6f1f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -445,6 +445,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/23806715?v=4", "profile": "http://ajmalshajahan.me", "contributions": ["code"] + }, + { + "login": "samiulsami", + "name": "Samiul Islam", + "avatar_url": "https://avatars.githubusercontent.com/u/33352407?v=4", + "profile": "https://github.com/samiulsami", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index ad3cd3ef..79e8d7e1 100644 --- a/README.md +++ b/README.md @@ -604,6 +604,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Mihamina Rakotomandimby
Mihamina Rakotomandimby

📖 💻 Ajmal S
Ajmal S

💻 + + Samiul Islam
Samiul Islam

💻 + From 8d8f1e7ea594b2db3368e1fa62dd7d0d128e8860 Mon Sep 17 00:00:00 2001 From: Mihamina Rakotomandimby Date: Mon, 25 Aug 2025 22:48:58 +0300 Subject: [PATCH 092/250] feat(functions): add configuration parameter to stop on tool failure (#1364) Co-authored-by: Mihamina RKTMB --- lua/CopilotChat/config.lua | 2 ++ lua/CopilotChat/init.lua | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index ba24edb1..49205784 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -35,6 +35,7 @@ ---@field auto_fold boolean? ---@field insert_at_end boolean? ---@field clear_chat_on_new_prompt boolean? +---@field stop_on_tool_failure boolean? --- CopilotChat default configuration ---@class CopilotChat.config.Config : CopilotChat.config.Shared @@ -94,6 +95,7 @@ return { auto_insert_mode = false, -- Automatically enter insert mode when opening window and on new prompt insert_at_end = false, -- Move cursor to end of buffer when inserting text clear_chat_on_new_prompt = false, -- Clears chat on every new prompt + stop_on_tool_failure = false, -- Stop processing prompt if any tool fails (preserves quota) -- Static config starts here (can be configured only via setup function) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index ba32542e..2e62f4d9 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -413,7 +413,13 @@ function M.resolve_functions(prompt, config) local schema = tools[name] and tools[name].schema or nil local result = '' - local ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source) + local ok, output + if config.stop_on_tool_failure then + output = tool.resolve(functions.parse_input(input, schema), state.source) + ok = true + else + ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source) + end if not ok then result = string.format(BLOCK_OUTPUT_FORMAT, 'error', utils.make_string(output)) else From e75d50358fd7cfd82ed98b5dea95ac33925a026f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 25 Aug 2025 19:49:24 +0000 Subject: [PATCH 093/250] 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 e489a6c6..9ac94fd3 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -602,7 +602,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 3ab915042a118f22c4730dff4b20cb7d7220fd64 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 25 Aug 2025 21:54:32 +0200 Subject: [PATCH 094/250] refactor(config): rename tool failure option for clarity (#1365) Renames `stop_on_tool_failure` to `stop_on_function_failure` in config and related code for improved clarity and consistency. Updates type annotations and usage to reflect the new naming. No functional changes. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config.lua | 8 ++++---- lua/CopilotChat/init.lua | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 49205784..b525e995 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -20,7 +20,6 @@ ---@field resources string|table|nil ---@field sticky string|table|nil ---@field language string? ----@field selection 'visual'|'unnamed'|nil ---@field temperature number? ---@field headless boolean? ---@field callback nil|fun(response: CopilotChat.client.Message, source: CopilotChat.source) @@ -35,7 +34,7 @@ ---@field auto_fold boolean? ---@field insert_at_end boolean? ---@field clear_chat_on_new_prompt boolean? ----@field stop_on_tool_failure boolean? +---@field stop_on_function_failure boolean? --- CopilotChat default configuration ---@class CopilotChat.config.Config : CopilotChat.config.Shared @@ -43,6 +42,7 @@ ---@field log_level 'trace'|'debug'|'info'|'warn'|'error'|'fatal'? ---@field proxy string? ---@field allow_insecure boolean? +---@field selection 'visual'|'unnamed'|nil ---@field chat_autocomplete boolean? ---@field log_path string? ---@field history_path string? @@ -64,7 +64,6 @@ return { sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat (can be specified manually in prompt via >). language = 'English', -- Default language to use for answers - selection = 'visual', -- Selection source temperature = 0.1, -- Result temperature headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) callback = nil, -- Function called when full response is received @@ -95,7 +94,7 @@ return { auto_insert_mode = false, -- Automatically enter insert mode when opening window and on new prompt insert_at_end = false, -- Move cursor to end of buffer when inserting text clear_chat_on_new_prompt = false, -- Clears chat on every new prompt - stop_on_tool_failure = false, -- Stop processing prompt if any tool fails (preserves quota) + stop_on_function_failure = false, -- Stop processing prompt if any function fails (preserves quota) -- Static config starts here (can be configured only via setup function) @@ -104,6 +103,7 @@ return { proxy = nil, -- [protocol://]host[:port] Use this proxy allow_insecure = false, -- Allow insecure server connections + selection = 'visual', -- Selection source chat_autocomplete = true, -- Enable chat autocompletion (when disabled, requires manual `mappings.complete` trigger) log_path = vim.fn.stdpath('state') .. '/CopilotChat.log', -- Default path to log file diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 2e62f4d9..d7b111f8 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -412,14 +412,15 @@ function M.resolve_functions(prompt, config) end local schema = tools[name] and tools[name].schema or nil - local result = '' local ok, output - if config.stop_on_tool_failure then + if config.stop_on_function_failure then output = tool.resolve(functions.parse_input(input, schema), state.source) ok = true else ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source) end + + local result = '' if not ok then result = string.format(BLOCK_OUTPUT_FORMAT, 'error', utils.make_string(output)) else From 6bdac49da12b0ac2ddbc1a59997a872ea5396112 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 26 Aug 2025 01:11:00 +0200 Subject: [PATCH 095/250] refactor(utils): split class, ordered map, string buffer (#1366) Move class, ordered map, and string buffer utilities into separate modules for better organization and maintainability. Update all references to use the new modules. This change improves code clarity and makes future extensions easier. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 16 ++-- lua/CopilotChat/init.lua | 5 +- lua/CopilotChat/tiktoken.lua | 2 +- lua/CopilotChat/ui/chat.lua | 2 +- lua/CopilotChat/ui/overlay.lua | 2 +- lua/CopilotChat/ui/spinner.lua | 3 +- lua/CopilotChat/utils.lua | 108 ------------------------- lua/CopilotChat/utils/class.lua | 38 +++++++++ lua/CopilotChat/utils/orderedmap.lua | 39 +++++++++ lua/CopilotChat/utils/stringbuffer.lua | 46 +++++++++++ 10 files changed, 140 insertions(+), 121 deletions(-) create mode 100644 lua/CopilotChat/utils/class.lua create mode 100644 lua/CopilotChat/utils/orderedmap.lua create mode 100644 lua/CopilotChat/utils/stringbuffer.lua diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 6121bdfe..a78fd3e9 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -58,7 +58,9 @@ local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local tiktoken = require('CopilotChat.tiktoken') local utils = require('CopilotChat.utils') -local class = utils.class +local class = require('CopilotChat.utils.class') +local orderedmap = require('CopilotChat.utils.orderedmap') +local stringbuffer = require('CopilotChat.utils.stringbuffer') --- Constants local RESOURCE_SHORT_FORMAT = '# %s\n```%s start_line=% end_line=%s\n%s\n```' @@ -186,7 +188,7 @@ end) ---@param supported_method? string: The method to filter providers by (optional) ---@return OrderedMap function Client:get_providers(supported_method) - local out = utils.ordered_map() + local out = orderedmap() if not self.provider_resolver then return out @@ -347,7 +349,7 @@ function Client:ask(prompt, opts) end local history = not opts.headless and vim.deepcopy(opts.history) or {} - local tool_calls = utils.ordered_map() + local tool_calls = orderedmap() local generated_messages = {} local resource_messages = generate_resource_messages(opts.resources) @@ -392,8 +394,8 @@ function Client:ask(prompt, opts) local errored = nil local finished = false local token_count = 0 - local response_content_buffer = utils.string_buffer() - local response_reasoning_buffer = utils.string_buffer() + local response_content_buffer = stringbuffer() + local response_reasoning_buffer = stringbuffer() local function finish_stream(err, job) if err then @@ -447,11 +449,11 @@ function Client:ask(prompt, opts) end if out.content then - response_content_buffer:add(out.content) + response_content_buffer:put(out.content) end if out.reasoning then - response_reasoning_buffer:add(out.reasoning) + response_reasoning_buffer:put(out.reasoning) end if opts.on_progress then diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index d7b111f8..6c56a380 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -6,6 +6,7 @@ local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') +local orderedmap = require('CopilotChat.utils.orderedmap') local WORD = '([^%s:]+)' local WORD_NO_INPUT = '([^%s]+)' @@ -52,7 +53,7 @@ local function insert_sticky(prompt, config) 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() + local stickies = orderedmap() local sticky_indices = {} local in_code_block = false @@ -346,7 +347,7 @@ function M.resolve_functions(prompt, config) end end - local matches = utils.ordered_map() + local matches = orderedmap() -- Check for #word:`input` pattern for word, input in prompt:gmatch('#' .. WORD_WITH_INPUT_QUOTED) do diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 3f631428..652196eb 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,6 +1,6 @@ local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') -local class = utils.class +local class = require('CopilotChat.utils.class') --- Get the library extension based on the operating system --- @return string diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 670ed2b4..2de10bde 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -3,7 +3,7 @@ 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 +local class = require('CopilotChat.utils.class') function CopilotChatFoldExpr(lnum, separator) local to_match = separator .. '$' diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index a23c022e..298bfcb2 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -1,5 +1,5 @@ local utils = require('CopilotChat.utils') -local class = utils.class +local class = require('CopilotChat.utils.class') ---@class CopilotChat.ui.overlay.Overlay : Class ---@field bufnr number? diff --git a/lua/CopilotChat/ui/spinner.lua b/lua/CopilotChat/ui/spinner.lua index 0f582032..44c77b80 100644 --- a/lua/CopilotChat/ui/spinner.lua +++ b/lua/CopilotChat/ui/spinner.lua @@ -1,6 +1,7 @@ local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') -local class = utils.class +local class = require('CopilotChat.utils.class') + local spinner_frames = { '⠋', '⠙', diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index e8735a69..f0579669 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -28,81 +28,6 @@ M.curl_args = { }, } ----@class Class ----@field new fun(...):table ----@field init fun(self, ...) - ---- Create class ----@param fn function The class constructor ----@param parent table? The parent class ----@return Class -function M.class(fn, parent) - local out = {} - out.__index = out - - local mt = { - __call = function(cls, ...) - return cls.new(...) - end, - } - - if parent then - mt.__index = parent - end - - setmetatable(out, mt) - - function out.new(...) - local self = setmetatable({}, out) - fn(self, ...) - return self - end - - function out.init(self, ...) - fn(self, ...) - end - - return out -end - ----@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 ----@generic K, V ----@return OrderedMap -function M.ordered_map() - return { - _keys = {}, - _data = {}, - set = function(self, key, value) - if not self._data[key] then - table.insert(self._keys, key) - end - self._data[key] = value - end, - - get = function(self, key) - return self._data[key] - end, - - keys = function(self) - return self._keys - end, - - values = function(self) - local result = {} - for _, key in ipairs(self._keys) do - table.insert(result, self._data[key]) - end - return result - end, - } -end - --- Convert arguments to a table ---@param ... any The arguments ---@return table @@ -121,39 +46,6 @@ function M.to_table(...) return result end ----@class StringBuffer ----@field add fun(self:StringBuffer, s:string) ----@field set fun(self:StringBuffer, s:string) ----@field tostring fun(self:StringBuffer):string - ---- Create a string buffer for efficient string concatenation ----@return StringBuffer -function M.string_buffer() - return { - _buf = { '' }, - - add = function(self, s) - table.insert(self._buf, s) - -- Keep track of lengths to know when to merge - for i = #self._buf - 1, 1, -1 do - if #self._buf[i] > #self._buf[i + 1] then - break - end - self._buf[i] = self._buf[i] .. table.remove(self._buf) - end - end, - - set = function(self, s) - self._buf = { s } - end, - - -- Get final string - tostring = function(self) - return table.concat(self._buf) - end, - } -end - --- Writes text to a temporary file and returns path ---@param text string The text to write ---@return string diff --git a/lua/CopilotChat/utils/class.lua b/lua/CopilotChat/utils/class.lua new file mode 100644 index 00000000..b8dfce83 --- /dev/null +++ b/lua/CopilotChat/utils/class.lua @@ -0,0 +1,38 @@ +---@class Class +---@field new fun(...):table +---@field init fun(self, ...) + +--- Create class +---@param fn function The class constructor +---@param parent table? The parent class +---@return Class +local function class(fn, parent) + local out = {} + out.__index = out + + local mt = { + __call = function(cls, ...) + return cls.new(...) + end, + } + + if parent then + mt.__index = parent + end + + setmetatable(out, mt) + + function out.new(...) + local self = setmetatable({}, out) + fn(self, ...) + return self + end + + function out.init(self, ...) + fn(self, ...) + end + + return out +end + +return class diff --git a/lua/CopilotChat/utils/orderedmap.lua b/lua/CopilotChat/utils/orderedmap.lua new file mode 100644 index 00000000..778c686d --- /dev/null +++ b/lua/CopilotChat/utils/orderedmap.lua @@ -0,0 +1,39 @@ +---@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 ordered map +---@generic K, V +---@return OrderedMap +local function orderedmap() + return { + _keys = {}, + _data = {}, + set = function(self, key, value) + if not self._data[key] then + table.insert(self._keys, key) + end + self._data[key] = value + end, + + get = function(self, key) + return self._data[key] + end, + + keys = function(self) + return self._keys + end, + + values = function(self) + local result = {} + for _, key in ipairs(self._keys) do + table.insert(result, self._data[key]) + end + return result + end, + } +end + +return orderedmap diff --git a/lua/CopilotChat/utils/stringbuffer.lua b/lua/CopilotChat/utils/stringbuffer.lua new file mode 100644 index 00000000..de89f2db --- /dev/null +++ b/lua/CopilotChat/utils/stringbuffer.lua @@ -0,0 +1,46 @@ +local ok, jit_buffer = pcall(require, 'string.buffer') + +---@class StringBuffer +---@field put fun(self:StringBuffer, s:string) +---@field set fun(self:StringBuffer, s:string) +---@field tostring fun(self:StringBuffer):string + +--- Create a string buffer for efficient string concatenation +---@return StringBuffer +local function stringbuffer() + if ok and jit_buffer then + return { + _buf = jit_buffer.new(), + put = function(self, s) + self._buf:put(s) + end, + set = function(self, s) + self._buf:set(s) + end, + tostring = function(self) + return self._buf:tostring() + end, + } + end + + return { + _buf = { '' }, + put = function(self, s) + table.insert(self._buf, s) + for i = #self._buf - 1, 1, -1 do + if #self._buf[i] > #self._buf[i + 1] then + break + end + self._buf[i] = self._buf[i] .. table.remove(self._buf) + end + end, + set = function(self, s) + self._buf = { s } + end, + tostring = function(self) + return table.concat(self._buf) + end, + } +end + +return stringbuffer From 7b4a56b29ed926b680ea936bd29fc8568b909d97 Mon Sep 17 00:00:00 2001 From: Rui Costa Date: Tue, 26 Aug 2025 00:21:06 +0100 Subject: [PATCH 096/250] feat(functions): add scope=selection to diagnostics (#1351) * feat(functions): add scope=selection to diagnostics Adds a new scope to the diagnostics function where the user specifies that only diagnostics found inside the visual selection are to be included in the prompt's context. * fix(functions) use new selection API on diagnostics:selection * fix(functions) address review comments - no need for scope conditional bc we're using the whole buffer as default selection - pass buffer id when using selection.get - return early if mode is selection but there's nothing selected * fix(functions) flip conditional to avoid empty block --- lua/CopilotChat/config/functions.lua | 49 +++++++++++++++++++--------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index eb067376..7d7be042 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -307,7 +307,7 @@ return { scope = { type = 'string', description = 'Scope of buffers to use for retrieving diagnostics.', - enum = { 'current', 'listed', 'visible' }, + enum = { 'current', 'listed', 'visible', 'selection' }, default = 'current', }, severity = { @@ -326,7 +326,7 @@ return { local buffers = {} -- Get buffers based on scope - if scope == 'current' then + if scope == 'current' or scope == 'selection' then if source and source.bufnr and utils.buf_valid(source.bufnr) then buffers = { source.bufnr } end @@ -344,6 +344,21 @@ return { end, vim.api.nvim_list_bufs()) end + -- By default, collect from the whole buffer + local selection_start_line = 1 + local selection_end_line = vim.api.nvim_buf_line_count(source.bufnr) + -- Determine selection range if scope is 'selection' + if scope == 'selection' then + local select = require('CopilotChat.select') + local selection = select.get(source.bufnr) + if selection then + selection_start_line = selection.start_line + selection_end_line = selection.end_line + else + return out + end + end + -- Collect diagnostics for each buffer for _, bufnr in ipairs(buffers) do local name = vim.api.nvim_buf_get_name(bufnr) @@ -356,20 +371,24 @@ return { if #diagnostics > 0 then local diag_lines = {} for _, diag in ipairs(diagnostics) do - local severity = vim.diagnostic.severity[diag.severity] or 'UNKNOWN' - local line_text = vim.api.nvim_buf_get_lines(bufnr, diag.lnum, diag.lnum + 1, false)[1] or '' - - table.insert( - diag_lines, - string.format( - '%s line=%d-%d: %s\n > %s', - severity, - diag.lnum + 1, - diag.end_lnum and (diag.end_lnum + 1) or (diag.lnum + 1), - diag.message, - line_text + -- Diagnostics.lnum are 0-indexed, so add 1 for comparison + local diag_lnum = diag.lnum + 1 + if diag_lnum >= selection_start_line and diag_lnum <= selection_end_line then + local severity = vim.diagnostic.severity[diag.severity] or 'UNKNOWN' + local line_text = vim.api.nvim_buf_get_lines(bufnr, diag.lnum, diag.lnum + 1, false)[1] or '' + + table.insert( + diag_lines, + string.format( + '%s line=%d-%d: %s\n > %s', + severity, + diag.lnum + 1, + diag.end_lnum and (diag.end_lnum + 1) or (diag.lnum + 1), + diag.message, + line_text + ) ) - ) + end end table.insert(out, { From f4ff91e32893c89e5970c1a9cd5196c57596321a Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 01:22:21 +0200 Subject: [PATCH 097/250] docs: add ruicsh as a contributor for code (#1368) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 3dee6f1f..933a828e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -452,6 +452,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/33352407?v=4", "profile": "https://github.com/samiulsami", "contributions": ["code"] + }, + { + "login": "ruicsh", + "name": "Rui Costa", + "avatar_url": "https://avatars.githubusercontent.com/u/8294038?v=4", + "profile": "https://ruicsh.github.io", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 79e8d7e1..07f43d6c 100644 --- a/README.md +++ b/README.md @@ -606,6 +606,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Samiul Islam
Samiul Islam

💻 + Rui Costa
Rui Costa

💻 From 43b1ac09347d956896fd57ce19ae9f02cd15e1c4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 26 Aug 2025 07:56:57 +0200 Subject: [PATCH 098/250] refactor(utils): split file operation to utils.files (#1367) Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 3 +- lua/CopilotChat/config/functions.lua | 13 +- lua/CopilotChat/config/mappings.lua | 11 +- lua/CopilotChat/config/providers.lua | 9 +- lua/CopilotChat/init.lua | 3 +- lua/CopilotChat/resources.lua | 18 +- lua/CopilotChat/utils.lua | 371 +-------------------------- lua/CopilotChat/utils/files.lua | 335 ++++++++++++++++++++++++ 8 files changed, 374 insertions(+), 389 deletions(-) create mode 100644 lua/CopilotChat/utils/files.lua diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index a78fd3e9..39f6c13e 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -59,6 +59,7 @@ local notify = require('CopilotChat.notify') local tiktoken = require('CopilotChat.tiktoken') local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') +local files = require('CopilotChat.utils.files') local orderedmap = require('CopilotChat.utils.orderedmap') local stringbuffer = require('CopilotChat.utils.stringbuffer') @@ -97,7 +98,7 @@ local function generate_resource_block(content, mimetype, name, path, start_line end local updated_content = table.concat(lines, '\n') - local filetype = utils.mimetype_to_filetype(mimetype or 'text') + local filetype = files.mimetype_to_filetype(mimetype or 'text') if not start_line then start_line = 1 end diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 7d7be042..2f085705 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -1,5 +1,6 @@ local resources = require('CopilotChat.resources') local utils = require('CopilotChat.utils') +local files = require('CopilotChat.utils.files') ---@class CopilotChat.config.functions.Function ---@field description string? @@ -23,7 +24,7 @@ return { type = 'string', description = 'Path to file to include in chat context.', enum = function(source) - return utils.glob(source.cwd(), { + return files.glob(source.cwd(), { max_count = 0, }) end, @@ -67,7 +68,7 @@ return { }, resolve = function(input, source) - local files = utils.glob(source.cwd(), { + local out = files.glob(source.cwd(), { pattern = input.pattern, }) @@ -75,7 +76,7 @@ return { { uri = 'files://glob/' .. input.pattern, mimetype = 'text/plain', - data = table.concat(files, '\n'), + data = table.concat(out, '\n'), }, } end, @@ -98,7 +99,7 @@ return { }, resolve = function(input, source) - local files = utils.grep(source.cwd(), { + local out = files.grep(source.cwd(), { pattern = input.pattern, }) @@ -106,7 +107,7 @@ return { { uri = 'files://grep/' .. input.pattern, mimetype = 'text/plain', - data = table.concat(files, '\n'), + data = table.concat(out, '\n'), }, } end, @@ -230,7 +231,7 @@ return { { uri = 'neovim://selection', name = selection.filename, - mimetype = utils.mimetype_to_filetype(selection.filetype), + mimetype = files.mimetype_to_filetype(selection.filetype), data = selection.content, annotations = { start_line = selection.start_line, diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index ec42be85..3b0d06b9 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -4,6 +4,7 @@ local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') +local files = require('CopilotChat.utils.files') ---@class CopilotChat.config.mappings.Diff ---@field change string @@ -45,8 +46,8 @@ local function get_diff(bufnr, block) -- If we have header info, use it as source of truth if header.start_line and header.end_line then - filename = utils.uri_to_filename(header.filename) - filetype = header.filetype or utils.filetype(filename) + filename = files.uri_to_filename(header.filename) + filetype = header.filetype or files.filetype(filename) start_line = header.start_line end_line = header.end_line @@ -54,7 +55,7 @@ local function get_diff(bufnr, block) bufnr = nil for _, win in ipairs(vim.api.nvim_list_wins()) do local win_buf = vim.api.nvim_win_get_buf(win) - if utils.filename_same(vim.api.nvim_buf_get_name(win_buf), header.filename) then + if files.filename_same(vim.api.nvim_buf_get_name(win_buf), header.filename) then bufnr = win_buf break end @@ -99,7 +100,7 @@ local function prepare_diff_buffer(diff, source) if not diff_bufnr then -- Try to find matching buffer first for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if utils.filename_same(vim.api.nvim_buf_get_name(buf), diff.filename) then + if files.filename_same(vim.api.nvim_buf_get_name(buf), diff.filename) then diff_bufnr = buf break end @@ -534,7 +535,7 @@ return { end table.insert(lines, header) - table.insert(lines, '```' .. utils.mimetype_to_filetype(resource.mimetype)) + table.insert(lines, '```' .. files.mimetype_to_filetype(resource.mimetype)) for _, line in ipairs(preview) do table.insert(lines, line) end diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index b2a03eea..d75da5ad 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,7 +1,8 @@ +local plenary_utils = require('plenary.async.util') local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') -local plenary_utils = require('plenary.async.util') +local files = require('CopilotChat.utils.files') local EDITOR_VERSION = 'Neovim/' .. vim.version().major .. '.' .. vim.version().minor .. '.' .. vim.version().patch @@ -14,7 +15,7 @@ local function load_tokens() local config_path = vim.fs.normalize(vim.fn.stdpath('data') .. '/copilot_chat') local cache_file = config_path .. '/tokens.json' - local file = utils.read_file(cache_file) + local file = files.read_file(cache_file) if file then token_cache = vim.json.decode(file) else @@ -42,7 +43,7 @@ local function set_token(tag, token, save) local tokens = load_tokens() tokens[tag] = token local config_path = vim.fs.normalize(vim.fn.stdpath('data') .. '/copilot_chat') - utils.write_file(config_path .. '/tokens.json', vim.json.encode(tokens)) + files.write_file(config_path .. '/tokens.json', vim.json.encode(tokens)) return token end @@ -141,7 +142,7 @@ local function get_github_copilot_token(tag) } for _, file_path in ipairs(file_paths) do - local file_data = utils.read_file(file_path) + local file_data = files.read_file(file_path) if file_data then local parsed_data = utils.json_decode(file_data) if parsed_data then diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 6c56a380..2e14226e 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -7,6 +7,7 @@ local notify = require('CopilotChat.notify') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') local orderedmap = require('CopilotChat.utils.orderedmap') +local files = require('CopilotChat.utils.files') local WORD = '([^%s:]+)' local WORD_NO_INPUT = '([^%s]+)' @@ -435,7 +436,7 @@ function M.resolve_functions(prompt, config) table.insert(state.sticky, content_out) end else - content_out = string.format(BLOCK_OUTPUT_FORMAT, utils.mimetype_to_filetype(content.mimetype), content.data) + content_out = string.format(BLOCK_OUTPUT_FORMAT, files.mimetype_to_filetype(content.mimetype), content.data) end if not utils.empty(result) then diff --git a/lua/CopilotChat/resources.lua b/lua/CopilotChat/resources.lua index a8c1fd78..d1b588f7 100644 --- a/lua/CopilotChat/resources.lua +++ b/lua/CopilotChat/resources.lua @@ -1,5 +1,6 @@ local async = require('plenary.async') local utils = require('CopilotChat.utils') +local files = require('CopilotChat.utils.files') local file_cache = {} local url_cache = {} @@ -9,18 +10,19 @@ local M = {} ---@param filename string ---@return string?, string? function M.get_file(filename) - local filetype = utils.filetype(filename) + local filetype = files.filetype(filename) if not filetype then return nil end - local modified = utils.file_mtime(filename) - if not modified then + local err, stat = async.uv.fs_stat(filename) + if err or not stat then return nil end + local modified = stat.mtime.sec local data = file_cache[filename] if not data or data._modified < modified then - local content = utils.read_file(filename) + local content = files.read_file(filename) if not content or content == '' then return nil end @@ -31,7 +33,7 @@ function M.get_file(filename) file_cache[filename] = data end - return data.content, utils.filetype_to_mimetype(filetype) + return data.content, files.filetype_to_mimetype(filetype) end --- Get data for a buffer @@ -47,7 +49,7 @@ function M.get_buffer(bufnr) return nil end - return table.concat(content, '\n'), utils.filetype_to_mimetype(vim.bo[bufnr].filetype) + return table.concat(content, '\n'), files.filetype_to_mimetype(vim.bo[bufnr].filetype) end --- Get the content of an URL @@ -58,7 +60,7 @@ function M.get_url(url) return nil end - local ft = utils.filetype(url) + local ft = files.filetype(url) local content = url_cache[url] if not content then local ok, out = async.util.apcall(utils.system, { 'lynx', '-dump', url }) @@ -96,7 +98,7 @@ function M.get_url(url) url_cache[url] = content end - return content, utils.filetype_to_mimetype(ft) + return content, files.filetype_to_mimetype(ft) end return M diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index f0579669..4b56579c 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -5,12 +5,6 @@ local log = require('plenary.log') local M = {} M.timers = {} -M.scan_args = { - max_count = 2500, - max_depth = 50, - no_ignore = false, -} - M.curl_args = { timeout = 30000, raw = { @@ -46,20 +40,6 @@ function M.to_table(...) return result end ---- Writes text to a temporary file and returns path ----@param text string The text to write ----@return string -function M.temp_file(text) - local temp_file = os.tmpname() - local f = io.open(temp_file, 'w+') - if f == nil then - error('Could not open file: ' .. temp_file) - end - f:write(text) - f:close() - return temp_file -end - --- Return to normal mode function M.return_to_normal_mode() local mode = vim.fn.mode():lower() @@ -90,91 +70,6 @@ function M.buf_valid(bufnr) or false end ---- Check if file paths are the same ----@param file1 string? The first file path ----@param file2 string? The second file path ----@return boolean -function M.filename_same(file1, file2) - if not file1 or not file2 then - return false - end - return vim.fn.fnamemodify(file1, ':p') == vim.fn.fnamemodify(file2, ':p') -end - ---- Get the filetype of a file ----@param filename string The file name ----@return string|nil -function M.filetype(filename) - local filetype = require('plenary.filetype') - - local ft = filetype.detect(filename, { - fs_access = false, - }) - - if ft == '' or not ft and not vim.in_fast_event() then - return vim.filetype.match({ filename = filename }) - end - - return ft -end - ---- Get the mimetype from filetype ----@param filetype string? ----@return string -function M.filetype_to_mimetype(filetype) - if not filetype or filetype == '' then - return 'text/plain' - end - if filetype == 'json' or filetype == 'yaml' then - return 'application/' .. filetype - end - if filetype == 'html' or filetype == 'css' then - return 'text/' .. filetype - end - if filetype:find('/') then - return filetype - end - return 'text/x-' .. filetype -end - ---- Get the filetype from mimetype ----@param mimetype string? ----@return string -function M.mimetype_to_filetype(mimetype) - if not mimetype or mimetype == '' then - return 'text' - end - - local out = mimetype:gsub('^text/x%-', '') - out = out:gsub('^text/', '') - out = out:gsub('^application/', '') - out = out:gsub('^image/', '') - out = out:gsub('^video/', '') - out = out:gsub('^audio/', '') - return out -end - ---- Convert a URI to a file name ----@param uri string The URI ----@return string -function M.uri_to_filename(uri) - if not uri or uri == '' then - return uri - end - local ok, fname = pcall(vim.uri_to_fname, uri) - if not ok or M.empty(fname) then - return uri - end - return fname -end - ---- Get the file name ----@param filepath string The file path ----@return string -function M.filename(filepath) - return vim.fs.basename(filepath) -end - --- Generate a UUID ---@return string function M.uuid() @@ -335,271 +230,19 @@ M.curl_post = async.wrap(function(url, opts, callback) ['Content-Type'] = 'application/json', }) - temp_file_path = M.temp_file(vim.json.encode(args.body)) + temp_file_path = os.tmpname() + local f = io.open(temp_file_path, 'w+') + if f == nil then + error('Could not open file: ' .. temp_file_path) + end + f:write(vim.json.encode(args.body)) + f:close() args.body = temp_file_path end curl.post(url, args) end, 3) -local function filter_files(files, max_count) - local filetype = require('plenary.filetype') - - files = vim.tbl_filter(function(file) - if file == nil or file == '' then - return false - end - - local ft = filetype.detect(file, { - fs_access = false, - }) - - if ft == '' or not ft then - return false - end - - return true - end, files) - if max_count and max_count > 0 then - files = vim.list_slice(files, 1, max_count) - end - - return files -end - ----@class CopilotChat.utils.ScanOpts ----@field max_count number? The maximum number of files to scan ----@field max_depth number? The maximum depth to scan ----@field pattern? string The glob pattern to match files ----@field hidden? boolean Whether to include hidden files ----@field no_ignore? boolean Whether to respect or ignore .gitignore - ---- Scan a directory ----@param path string ----@param opts CopilotChat.utils.ScanOpts? ----@async -M.glob = async.wrap(function(path, opts, callback) - opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) - - -- Use ripgrep if available - if vim.fn.executable('rg') == 1 then - local cmd = { 'rg' } - - if opts.pattern then - table.insert(cmd, '-g') - table.insert(cmd, opts.pattern) - end - - if opts.max_depth then - table.insert(cmd, '--max-depth') - table.insert(cmd, tostring(opts.max_depth)) - end - - if opts.no_ignore then - table.insert(cmd, '--no-ignore') - end - - if opts.hidden then - table.insert(cmd, '--hidden') - end - - table.insert(cmd, '--files') - table.insert(cmd, path) - - vim.system(cmd, { text = true }, function(result) - local files = {} - if result and result.code == 0 and result.stdout ~= '' then - files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) - end - - callback(files) - end) - - return - end - - -- Fallback to vim.uv.fs_scandir - local matchers = {} - if opts.pattern then - local file_pattern = vim.glob.to_lpeg(opts.pattern) - local path_pattern = vim.lpeg.P(path .. '/') * file_pattern - - table.insert(matchers, function(name, dir) - return file_pattern:match(name) or path_pattern:match(dir .. '/' .. name) - end) - end - - if not opts.hidden then - table.insert(matchers, function(name) - return not name:match('^%.') - end) - end - - local data = {} - local next_dir = { path } - local current_depths = { [path] = 1 } - - local function read_dir(err, fd) - local current_dir = table.remove(next_dir, 1) - local depth = current_depths[current_dir] or 1 - - if not err and fd then - while true do - local name, typ = vim.uv.fs_scandir_next(fd) - if name == nil then - break - end - - local full_path = current_dir .. '/' .. name - - if typ == 'directory' and not name:match('^%.git') then - if not opts.max_depth or depth < opts.max_depth then - table.insert(next_dir, full_path) - current_depths[full_path] = depth + 1 - end - else - local match = true - for _, matcher in ipairs(matchers) do - if not matcher(name, current_dir) then - match = false - break - end - end - - if match then - table.insert(data, full_path) - end - end - end - end - - if #next_dir == 0 then - callback(data) - else - vim.uv.fs_scandir(next_dir[1], read_dir) - end - end - - vim.uv.fs_scandir(path, read_dir) -end, 3) - ---- Grep a directory ----@param path string The path to search ----@param opts CopilotChat.utils.ScanOpts? -M.grep = async.wrap(function(path, opts, callback) - opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) - local cmd = {} - - if vim.fn.executable('rg') == 1 then - table.insert(cmd, 'rg') - - if opts.max_depth then - table.insert(cmd, '--max-depth') - table.insert(cmd, tostring(opts.max_depth)) - end - - if opts.no_ignore then - table.insert(cmd, '--no-ignore') - end - - if opts.hidden then - table.insert(cmd, '--hidden') - end - - table.insert(cmd, '--files-with-matches') - table.insert(cmd, '--ignore-case') - - if opts.pattern then - table.insert(cmd, '-e') - table.insert(cmd, "'" .. opts.pattern .. "'") - end - - table.insert(cmd, path) - elseif vim.fn.executable('grep') == 1 then - table.insert(cmd, 'grep') - table.insert(cmd, '-rli') - - if opts.pattern then - table.insert(cmd, '-e') - table.insert(cmd, "'" .. opts.pattern .. "'") - end - - table.insert(cmd, path) - end - - if M.empty(cmd) then - error('No executable found for grep') - return - end - - vim.system(cmd, { text = true }, function(result) - local files = {} - if result and result.code == 0 and result.stdout ~= '' then - files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) - end - - callback(files) - end) -end, 3) - ---- Get last modified time of a file ----@param path string The file path ----@return number? ----@async -function M.file_mtime(path) - local err, stat = async.uv.fs_stat(path) - if err or not stat then - return nil - end - return stat.mtime.sec -end - ---- Read a file ----@param path string The file path ----@async -function M.read_file(path) - local err, fd = async.uv.fs_open(path, 'r', 438) - if err or not fd then - return nil - end - - local err, stat = async.uv.fs_fstat(fd) - if err or not stat then - async.uv.fs_close(fd) - return nil - end - - local err, data = async.uv.fs_read(fd, stat.size, 0) - async.uv.fs_close(fd) - if err or not data then - return nil - end - return data -end - ---- Write data to a file ----@param path string The file path ----@param data string The data to write ----@return boolean -function M.write_file(path, data) - M.schedule_main() - vim.fn.mkdir(vim.fn.fnamemodify(path, ':p:h'), 'p') - - local err, fd = async.uv.fs_open(path, 'w', 438) - if err or not fd then - return false - end - - local err = async.uv.fs_write(fd, data, 0) - if err then - async.uv.fs_close(fd) - return false - end - - async.uv.fs_close(fd) - return true -end - --- Call a system command ---@param cmd table The command ---@async diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua new file mode 100644 index 00000000..2a7704eb --- /dev/null +++ b/lua/CopilotChat/utils/files.lua @@ -0,0 +1,335 @@ +local async = require('plenary.async') + +local M = {} + +M.scan_args = { + max_count = 2500, + max_depth = 50, + no_ignore = false, +} + +local function filter_files(files, max_count) + local filetype = require('plenary.filetype') + + files = vim.tbl_filter(function(file) + if file == nil or file == '' then + return false + end + + local ft = filetype.detect(file, { + fs_access = false, + }) + + if ft == '' or not ft then + return false + end + + return true + end, files) + if max_count and max_count > 0 then + files = vim.list_slice(files, 1, max_count) + end + + return files +end + +---@class CopilotChat.utils.ScanOpts +---@field max_count number? The maximum number of files to scan +---@field max_depth number? The maximum depth to scan +---@field pattern? string The glob pattern to match files +---@field hidden? boolean Whether to include hidden files +---@field no_ignore? boolean Whether to respect or ignore .gitignore + +--- Scan a directory +---@param path string +---@param opts CopilotChat.utils.ScanOpts? +---@async +M.glob = async.wrap(function(path, opts, callback) + opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) + + -- Use ripgrep if available + if vim.fn.executable('rg') == 1 then + local cmd = { 'rg' } + + if opts.pattern then + table.insert(cmd, '-g') + table.insert(cmd, opts.pattern) + end + + if opts.max_depth then + table.insert(cmd, '--max-depth') + table.insert(cmd, tostring(opts.max_depth)) + end + + if opts.no_ignore then + table.insert(cmd, '--no-ignore') + end + + if opts.hidden then + table.insert(cmd, '--hidden') + end + + table.insert(cmd, '--files') + table.insert(cmd, path) + + vim.system(cmd, { text = true }, function(result) + local files = {} + if result and result.code == 0 and result.stdout ~= '' then + files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) + end + + callback(files) + end) + + return + end + + -- Fallback to vim.uv.fs_scandir + local matchers = {} + if opts.pattern then + local file_pattern = vim.glob.to_lpeg(opts.pattern) + local path_pattern = vim.lpeg.P(path .. '/') * file_pattern + + table.insert(matchers, function(name, dir) + return file_pattern:match(name) or path_pattern:match(dir .. '/' .. name) + end) + end + + if not opts.hidden then + table.insert(matchers, function(name) + return not name:match('^%.') + end) + end + + local data = {} + local next_dir = { path } + local current_depths = { [path] = 1 } + + local function read_dir(err, fd) + local current_dir = table.remove(next_dir, 1) + local depth = current_depths[current_dir] or 1 + + if not err and fd then + while true do + local name, typ = vim.uv.fs_scandir_next(fd) + if name == nil then + break + end + + local full_path = current_dir .. '/' .. name + + if typ == 'directory' and not name:match('^%.git') then + if not opts.max_depth or depth < opts.max_depth then + table.insert(next_dir, full_path) + current_depths[full_path] = depth + 1 + end + else + local match = true + for _, matcher in ipairs(matchers) do + if not matcher(name, current_dir) then + match = false + break + end + end + + if match then + table.insert(data, full_path) + end + end + end + end + + if #next_dir == 0 then + callback(data) + else + vim.uv.fs_scandir(next_dir[1], read_dir) + end + end + + vim.uv.fs_scandir(path, read_dir) +end, 3) + +--- Grep a directory +---@param path string The path to search +---@param opts CopilotChat.utils.ScanOpts? +M.grep = async.wrap(function(path, opts, callback) + opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) + local cmd = {} + + if vim.fn.executable('rg') == 1 then + table.insert(cmd, 'rg') + + if opts.max_depth then + table.insert(cmd, '--max-depth') + table.insert(cmd, tostring(opts.max_depth)) + end + + if opts.no_ignore then + table.insert(cmd, '--no-ignore') + end + + if opts.hidden then + table.insert(cmd, '--hidden') + end + + table.insert(cmd, '--files-with-matches') + table.insert(cmd, '--ignore-case') + + if opts.pattern then + table.insert(cmd, '-e') + table.insert(cmd, "'" .. opts.pattern .. "'") + end + + table.insert(cmd, path) + elseif vim.fn.executable('grep') == 1 then + table.insert(cmd, 'grep') + table.insert(cmd, '-rli') + + if opts.pattern then + table.insert(cmd, '-e') + table.insert(cmd, "'" .. opts.pattern .. "'") + end + + table.insert(cmd, path) + end + + if M.empty(cmd) then + error('No executable found for grep') + return + end + + vim.system(cmd, { text = true }, function(result) + local files = {} + if result and result.code == 0 and result.stdout ~= '' then + files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) + end + + callback(files) + end) +end, 3) + +--- Read a file +---@param path string The file path +---@async +function M.read_file(path) + local err, fd = async.uv.fs_open(path, 'r', 438) + if err or not fd then + return nil + end + + local err, stat = async.uv.fs_fstat(fd) + if err or not stat then + async.uv.fs_close(fd) + return nil + end + + local err, data = async.uv.fs_read(fd, stat.size, 0) + async.uv.fs_close(fd) + if err or not data then + return nil + end + return data +end + +--- Write data to a file +---@param path string The file path +---@param data string The data to write +---@return boolean +function M.write_file(path, data) + M.schedule_main() + vim.fn.mkdir(vim.fn.fnamemodify(path, ':p:h'), 'p') + + local err, fd = async.uv.fs_open(path, 'w', 438) + if err or not fd then + return false + end + + local err = async.uv.fs_write(fd, data, 0) + if err then + async.uv.fs_close(fd) + return false + end + + async.uv.fs_close(fd) + return true +end + +--- Check if file paths are the same +---@param file1 string? The first file path +---@param file2 string? The second file path +---@return boolean +function M.filename_same(file1, file2) + if not file1 or not file2 then + return false + end + return vim.fn.fnamemodify(file1, ':p') == vim.fn.fnamemodify(file2, ':p') +end + +--- Get the filetype of a file +---@param filename string The file name +---@return string|nil +function M.filetype(filename) + local filetype = require('plenary.filetype') + + local ft = filetype.detect(filename, { + fs_access = false, + }) + + if ft == '' or not ft and not vim.in_fast_event() then + return vim.filetype.match({ filename = filename }) + end + + return ft +end + +--- Get the mimetype from filetype +---@param filetype string? +---@return string +function M.filetype_to_mimetype(filetype) + if not filetype or filetype == '' then + return 'text/plain' + end + if filetype == 'json' or filetype == 'yaml' then + return 'application/' .. filetype + end + if filetype == 'html' or filetype == 'css' then + return 'text/' .. filetype + end + if filetype:find('/') then + return filetype + end + return 'text/x-' .. filetype +end + +--- Get the filetype from mimetype +---@param mimetype string? +---@return string +function M.mimetype_to_filetype(mimetype) + if not mimetype or mimetype == '' then + return 'text' + end + + local out = mimetype:gsub('^text/x%-', '') + out = out:gsub('^text/', '') + out = out:gsub('^application/', '') + out = out:gsub('^image/', '') + out = out:gsub('^video/', '') + out = out:gsub('^audio/', '') + return out +end + +--- Convert a URI to a file name +---@param uri string The URI +---@return string +function M.uri_to_filename(uri) + if not uri or uri == '' then + return uri + end + local ok, fname = pcall(vim.uri_to_fname, uri) + if not ok or M.empty(fname) then + return uri + end + return fname +end + +return M From d7d65d6c4fd14f0ec704e3fa32939082ebb98e22 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 26 Aug 2025 05:57:18 +0000 Subject: [PATCH 099/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 9ac94fd3..edc10e56 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 25 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 26 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -602,7 +602,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻This project follows the all-contributors specification. Contributions of any kind are welcome! From afafec51d2657cdde4fa839bac9cc203037ff60b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 26 Aug 2025 20:34:11 +0200 Subject: [PATCH 100/250] feat(prompts): support buffer replacement in commit messages (#1370) Extend Commit prompt to handle buffer replacement when COMMIT_EDITMSG is opened. This allows generating commit messages that can fully replace the buffer, improving workflow for staged changes. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/prompts.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 073a8e8b..929ebee7 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -194,7 +194,10 @@ If no issues found, confirm the code is well-written and explain why. }, Commit = { - prompt = 'Write commit message for the change with commitizen convention. Keep the title under 50 characters and wrap message at 72 characters. Format as a gitcommit code block.', - resources = 'gitdiff:staged', + prompt = 'Write commit message for the change with commitizen convention. Keep the title under 50 characters and wrap message at 72 characters. Format as a gitcommit code block. If user has COMMIT_EDITMSG opened, generate replacement block for whole buffer.', + resources = { + 'gitdiff:staged', + 'buffer', + }, }, } From a5ac084d54be9314f0d04cec05518654aced0081 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 26 Aug 2025 22:58:55 +0200 Subject: [PATCH 101/250] refactor(utils): split curl logic into separate module (#1371) Move all curl-related logic from utils.lua to a new utils/curl.lua module. Update all internal references to use the new module. Mark old curl functions in utils.lua as deprecated. This improves code organization and makes curl logic easier to maintain and extend. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 3 +- lua/CopilotChat/config/providers.lua | 17 ++-- lua/CopilotChat/init.lua | 3 +- lua/CopilotChat/resources.lua | 3 +- lua/CopilotChat/select.lua | 20 ++-- lua/CopilotChat/tiktoken.lua | 3 +- lua/CopilotChat/utils.lua | 147 +++------------------------ lua/CopilotChat/utils/curl.lua | 142 ++++++++++++++++++++++++++ 8 files changed, 184 insertions(+), 154 deletions(-) create mode 100644 lua/CopilotChat/utils/curl.lua diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 39f6c13e..fb2929a0 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -58,6 +58,7 @@ local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local tiktoken = require('CopilotChat.tiktoken') local utils = require('CopilotChat.utils') +local curl = require('CopilotChat.utils.curl') local class = require('CopilotChat.utils.class') local files = require('CopilotChat.utils.files') local orderedmap = require('CopilotChat.utils.orderedmap') @@ -530,7 +531,7 @@ function Client:ask(prompt, opts) args.stream = stream_func end - local response, err = utils.curl_post(provider.get_url(options), args) + local response, err = curl.post(provider.get_url(options), args) if not opts.headless then if self.current_job ~= job_id then diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index d75da5ad..f2f99b94 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -2,6 +2,7 @@ local plenary_utils = require('plenary.async.util') local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') +local curl = require('CopilotChat.utils.curl') local files = require('CopilotChat.utils.files') local EDITOR_VERSION = 'Neovim/' .. vim.version().major .. '.' .. vim.version().minor .. '.' .. vim.version().patch @@ -51,7 +52,7 @@ end ---@return string local function github_device_flow(tag, client_id, scope) local function request_device_code() - local res = utils.curl_post('https://github.com/login/device/code', { + local res = curl.post('https://github.com/login/device/code', { body = { client_id = client_id, scope = scope, @@ -67,7 +68,7 @@ local function github_device_flow(tag, client_id, scope) while true do plenary_utils.sleep(interval * 1000) - local res = utils.curl_post('https://github.com/login/oauth/access_token', { + local res = curl.post('https://github.com/login/oauth/access_token', { body = { client_id = client_id, device_code = device_code, @@ -212,7 +213,7 @@ local M = {} M.copilot = { get_headers = function() - local response, err = utils.curl_get('https://api.github.com/copilot_internal/v2/token', { + local response, err = curl.get('https://api.github.com/copilot_internal/v2/token', { json_response = true, headers = { ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), @@ -232,8 +233,8 @@ M.copilot = { response.body.expires_at end, - get_info = function(headers) - local response, err = utils.curl_get('https://api.github.com/copilot_internal/user', { + get_info = function() + local response, err = curl.get('https://api.github.com/copilot_internal/user', { json_response = true, headers = { ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), @@ -283,7 +284,7 @@ M.copilot = { end, get_models = function(headers) - local response, err = utils.curl_get('https://api.githubcopilot.com/models', { + local response, err = curl.get('https://api.githubcopilot.com/models', { json_response = true, headers = headers, }) @@ -323,7 +324,7 @@ M.copilot = { for _, model in ipairs(models) do if not model.policy then - utils.curl_post('https://api.githubcopilot.com/models/' .. model.id .. '/policy', { + curl.post('https://api.githubcopilot.com/models/' .. model.id .. '/policy', { headers = headers, json_request = true, body = { state = 'enabled' }, @@ -463,7 +464,7 @@ M.github_models = { end, get_models = function(headers) - local response, err = utils.curl_get('https://models.github.ai/catalog/models', { + local response, err = curl.get('https://models.github.ai/catalog/models', { json_response = true, headers = headers, }) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 2e14226e..e40969e9 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -6,6 +6,7 @@ local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') +local curl = require('CopilotChat.utils.curl') local orderedmap = require('CopilotChat.utils.orderedmap') local files = require('CopilotChat.utils.files') @@ -1003,7 +1004,7 @@ function M.setup(config) end -- Save proxy and insecure settings - utils.curl_store_args({ + curl.store_args({ insecure = M.config.allow_insecure, proxy = M.config.proxy, }) diff --git a/lua/CopilotChat/resources.lua b/lua/CopilotChat/resources.lua index d1b588f7..57e12e33 100644 --- a/lua/CopilotChat/resources.lua +++ b/lua/CopilotChat/resources.lua @@ -1,5 +1,6 @@ local async = require('plenary.async') local utils = require('CopilotChat.utils') +local curl = require('CopilotChat.utils.curl') local files = require('CopilotChat.utils.files') local file_cache = {} local url_cache = {} @@ -69,7 +70,7 @@ function M.get_url(url) content = out.stdout else -- Fallback to curl if lynx fails - local response = utils.curl_get(url, { raw = { '-L' } }) + local response = curl.get(url, { raw = { '-L' } }) if not response or not response.body then return nil end diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index e177aff0..425bf2a5 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -6,32 +6,36 @@ ---@field filetype string ---@field bufnr number -local constants = require('CopilotChat.constants') +local log = require('plenary.log') local utils = require('CopilotChat.utils') local M = {} +--- Use #selection instead ---@deprecated function M.visual(_) - vim.deprecate('CopilotChat.select.visual', '#selection', '5.0.0', constants.PLUGIN_NAME) + log.warn('CopilotChat.select.visual is deprecated, use #selection instead') return nil end ----@deprecated +--- Use #selection instead +---@deprecated use #selection instead function M.buffer(_) - vim.deprecate('CopilotChat.select.buffer', '#selection', '5.0.0', constants.PLUGIN_NAME) + log.warn('CopilotChat.select.buffer is deprecated, use #selection instead') return nil end ----@deprecated +--- Use #selection instead +---@deprecated use #selection instead function M.line(_) - vim.deprecate('CopilotChat.select.line', '#selection', '5.0.0', constants.PLUGIN_NAME) + log.warn('CopilotChat.select.line is deprecated, use #selection instead') return nil end ----@deprecated +--- Use #selection instead +---@deprecated use #selection instead function M.unnamed(_) - vim.deprecate('CopilotChat.select.unnamed', '#selection', '5.0.0', constants.PLUGIN_NAME) + log.warn('CopilotChat.select.unnamed is deprecated, use #selection instead') return nil end diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 652196eb..4066388a 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,5 +1,6 @@ local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') +local curl = require('CopilotChat.utils.curl') local class = require('CopilotChat.utils.class') --- Get the library extension based on the operating system @@ -30,7 +31,7 @@ local function load_tiktoken_data(tokenizer) notify.publish(notify.STATUS, 'Downloading tiktoken data from ' .. tiktoken_url) - utils.curl_get(tiktoken_url, { + curl.get(tiktoken_url, { output = cache_path, }) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 4b56579c..8b32a85b 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -1,26 +1,22 @@ local async = require('plenary.async') -local curl = require('plenary.curl') local log = require('plenary.log') local M = {} M.timers = {} -M.curl_args = { - timeout = 30000, - raw = { - '--retry', - '2', - '--retry-delay', - '1', - '--keepalive-time', - '60', - '--no-compressed', - '--connect-timeout', - '10', - '--tcp-nodelay', - '--no-buffer', - }, -} +--- Use CopilotChat.utils.curl.get instead +---@deprecated +function M.curl_get(url, opts) + log.warn('M.curl_get is deprecated, use CopilotChat.utils.curl.get instead') + return require('CopilotChat.utils.curl').get(url, opts) +end + +--- Use CopilotChat.utils.curl.post instead +---@deprecated +function M.curl_post(url, opts) + log.warn('M.curl_post is deprecated, use CopilotChat.utils.curl.post instead') + return require('CopilotChat.utils.curl').post(url, opts) +end --- Convert arguments to a table ---@param ... any The arguments @@ -126,123 +122,6 @@ function M.json_decode(body) return {}, data end ---- Store curl global arguments ----@param args table The arguments ----@return table -function M.curl_store_args(args) - M.curl_args = vim.tbl_deep_extend('force', M.curl_args, args) - return M.curl_args -end - ---- Send curl get request ----@param url string The url ----@param opts table? The options ----@async -M.curl_get = async.wrap(function(url, opts, callback) - log.debug('GET request:', url, opts) - local args = { - on_error = function(err) - log.debug('GET error:', err) - callback(nil, err and err.stderr or err) - end, - } - - args = vim.tbl_deep_extend('force', M.curl_args, args) - args = vim.tbl_deep_extend('force', args, opts or {}) - - args.callback = function(response) - log.debug('GET response:', response) - if response and not vim.startswith(tostring(response.status), '20') then - callback(response, response.body) - return - end - - if not args.json_response then - callback(response) - return - end - - local body, err = M.json_decode(tostring(response.body)) - if err then - callback(response, err) - else - response.body = body - callback(response) - end - end - - curl.get(url, args) -end, 3) - ---- Send curl post request ----@param url string The url ----@param opts table? The options ----@async -M.curl_post = async.wrap(function(url, opts, callback) - log.debug('POST request:', url, opts) - local args = { - on_error = function(err) - log.debug('POST error:', err) - callback(nil, err and err.stderr or err) - end, - } - - args = vim.tbl_deep_extend('force', M.curl_args, args) - args = vim.tbl_deep_extend('force', args, opts or {}) - - local temp_file_path = nil - - args.callback = function(response) - log.debug('POST response:', url, response) - if temp_file_path then - local ok, err = pcall(os.remove, temp_file_path) - if not ok then - log.debug('Failed to remove temp file:', temp_file_path, err) - end - end - if response and not vim.startswith(tostring(response.status), '20') then - callback(response, response.body) - return - end - - if not args.json_response then - callback(response) - return - end - - local body, err = M.json_decode(tostring(response.body)) - if err then - callback(response, err) - else - response.body = body - callback(response) - end - end - - if args.json_response then - args.headers = vim.tbl_deep_extend('force', args.headers or {}, { - Accept = 'application/json', - }) - end - - if args.json_request then - args.headers = vim.tbl_deep_extend('force', args.headers or {}, { - ['Content-Type'] = 'application/json', - }) - - temp_file_path = os.tmpname() - local f = io.open(temp_file_path, 'w+') - if f == nil then - error('Could not open file: ' .. temp_file_path) - end - f:write(vim.json.encode(args.body)) - f:close() - args.body = temp_file_path - end - - curl.post(url, args) -end, 3) - --- Call a system command ---@param cmd table The command ---@async diff --git a/lua/CopilotChat/utils/curl.lua b/lua/CopilotChat/utils/curl.lua new file mode 100644 index 00000000..87e9b89d --- /dev/null +++ b/lua/CopilotChat/utils/curl.lua @@ -0,0 +1,142 @@ +local async = require('plenary.async') +local curl = require('plenary.curl') +local log = require('plenary.log') +local utils = require('CopilotChat.utils') + +local M = {} + +M.args = { + timeout = 30000, + raw = { + '--retry', + '2', + '--retry-delay', + '1', + '--keepalive-time', + '60', + '--no-compressed', + '--connect-timeout', + '10', + '--tcp-nodelay', + '--no-buffer', + }, +} + +--- Store curl global arguments +---@param args table The arguments +---@return table +function M.store_args(args) + M.args = vim.tbl_deep_extend('force', M.args, args) + return M.args +end + +--- Send curl get request +---@param url string The url +---@param opts table? The options +---@async +M.get = async.wrap(function(url, opts, callback) + log.debug('GET request:', url, opts) + local args = { + on_error = function(err) + log.debug('GET error:', err) + callback(nil, err and err.stderr or err) + end, + } + + args = vim.tbl_deep_extend('force', M.args, args) + args = vim.tbl_deep_extend('force', args, opts or {}) + + args.callback = function(response) + log.debug('GET response:', response) + if response and not vim.startswith(tostring(response.status), '20') then + callback(response, response.body) + return + end + + if not args.json_response then + callback(response) + return + end + + local body, err = utils.json_decode(tostring(response.body)) + if err then + callback(response, err) + else + response.body = body + callback(response) + end + end + + curl.get(url, args) +end, 3) + +--- Send curl post request +---@param url string The url +---@param opts table? The options +---@async +M.post = async.wrap(function(url, opts, callback) + log.debug('POST request:', url, opts) + local args = { + on_error = function(err) + log.debug('POST error:', err) + callback(nil, err and err.stderr or err) + end, + } + + args = vim.tbl_deep_extend('force', M.args, args) + args = vim.tbl_deep_extend('force', args, opts or {}) + + local temp_file_path = nil + + args.callback = function(response) + log.debug('POST response:', url, response) + if temp_file_path then + local ok, err = pcall(os.remove, temp_file_path) + if not ok then + log.debug('Failed to remove temp file:', temp_file_path, err) + end + end + if response and not vim.startswith(tostring(response.status), '20') then + callback(response, response.body) + return + end + + if not args.json_response then + callback(response) + return + end + + local body, err = utils.json_decode(tostring(response.body)) + if err then + callback(response, err) + else + response.body = body + callback(response) + end + end + + if args.json_response then + args.headers = vim.tbl_deep_extend('force', args.headers or {}, { + Accept = 'application/json', + }) + end + + if args.json_request then + args.headers = vim.tbl_deep_extend('force', args.headers or {}, { + ['Content-Type'] = 'application/json', + }) + + temp_file_path = os.tmpname() + local f = io.open(temp_file_path, 'w+') + if f == nil then + error('Could not open file: ' .. temp_file_path) + end + f:write(vim.json.encode(args.body)) + f:close() + args.body = temp_file_path + end + + curl.post(url, args) +end, 3) + +return M From db2581c5f100ccfc63b55c671cdfeec06209ddd4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 26 Aug 2025 23:02:41 +0200 Subject: [PATCH 102/250] test: add unit tests for class, orderedmap, stringbuffer (#1372) * test: add unit tests for class, orderedmap, stringbuffer Add new unit tests for CopilotChat.utils.class, orderedmap, and stringbuffer modules. These tests cover class creation, inheritance, ordered map behavior, and string buffer operations to improve code reliability and coverage. Signed-off-by: Tomas Slusny * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Signed-off-by: Tomas Slusny Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/class_spec.lua | 33 +++++++++++++++++++++++++++++++++ tests/orderedmap_spec.lua | 28 ++++++++++++++++++++++++++++ tests/stringbuffer_spec.lua | 23 +++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 tests/class_spec.lua create mode 100644 tests/orderedmap_spec.lua create mode 100644 tests/stringbuffer_spec.lua diff --git a/tests/class_spec.lua b/tests/class_spec.lua new file mode 100644 index 00000000..ef2f1657 --- /dev/null +++ b/tests/class_spec.lua @@ -0,0 +1,33 @@ +local class = require('CopilotChat.utils.class') + +describe('CopilotChat.utils.class', function() + it('creates a simple class', function() + local Foo = class(function(self, x) + self.x = x + end) + local obj = Foo(42) + assert.equals(42, obj.x) + end) + + it('supports init method', function() + local Bar = class(function(self, y) + self.y = y + end) + local obj = Bar.new(7) + assert.equals(7, obj.y) + obj:init(8) + assert.equals(8, obj.y) + end) + + it('supports inheritance', function() + local Parent = class(function(self) + self.val = 1 + end) + local Child = class(function(self) + self.val = 2 + end, Parent) + local obj = Child() + assert.equals(2, obj.val) + assert.equals(Parent, getmetatable(Child).__index) + end) +end) diff --git a/tests/orderedmap_spec.lua b/tests/orderedmap_spec.lua new file mode 100644 index 00000000..9000915c --- /dev/null +++ b/tests/orderedmap_spec.lua @@ -0,0 +1,28 @@ +local orderedmap = require('CopilotChat.utils.orderedmap') + +describe('CopilotChat.utils.orderedmap', function() + it('sets and gets values', function() + local map = orderedmap() + map:set('a', 1) + map:set('b', 2) + assert.equals(1, map:get('a')) + assert.equals(2, map:get('b')) + end) + + it('preserves insertion order', function() + local map = orderedmap() + map:set('x', 10) + map:set('y', 20) + map:set('z', 30) + assert.are.same({ 'x', 'y', 'z' }, map:keys()) + assert.are.same({ 10, 20, 30 }, map:values()) + end) + + it('overwrites value but not order', function() + local map = orderedmap() + map:set('a', 1) + map:set('a', 2) + assert.are.same({ 'a' }, map:keys()) + assert.are.same({ 2 }, map:values()) + end) +end) diff --git a/tests/stringbuffer_spec.lua b/tests/stringbuffer_spec.lua new file mode 100644 index 00000000..d491fd43 --- /dev/null +++ b/tests/stringbuffer_spec.lua @@ -0,0 +1,23 @@ +local stringbuffer = require('CopilotChat.utils.stringbuffer') + +describe('CopilotChat.utils.stringbuffer', function() + it('concatenates strings with put', function() + local buf = stringbuffer() + buf:put('hello') + buf:put(' ') + buf:put('world') + assert.equals('hello world', buf:tostring()) + end) + + it('sets buffer with set', function() + local buf = stringbuffer() + buf:put('foo') + buf:set('bar') + assert.equals('bar', buf:tostring()) + end) + + it('handles empty buffer', function() + local buf = stringbuffer() + assert.equals('', buf:tostring()) + end) +end) From 72216c06fa2ce82406c3406d898a83c02db412a7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 26 Aug 2025 23:28:20 +0200 Subject: [PATCH 103/250] feat(functions): use cwd for file and grep commands (#1373) Refactor file and grep picker utilities to use the working directory (cwd) instead of passing the path as a command argument. This improves the picker display. Also, update chat auto-fold logic to only close folds if a fold exists at the target line, preventing unnecessary foldclose calls. Closes #1108 Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 3 ++- lua/CopilotChat/utils/files.lua | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 2de10bde..d6c1e283 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -794,7 +794,8 @@ function Chat:render() if self.config.auto_fold and self:visible() then if message.role ~= constants.ROLE.ASSISTANT and message.section and i < #self.messages then vim.api.nvim_win_call(self.winnr, function() - if vim.fn.foldclosed(message.section.start_line) == -1 then + local fold_level = vim.fn.foldlevel(message.section.start_line) + if fold_level > 0 and vim.fn.foldclosed(message.section.start_line) == -1 then vim.api.nvim_cmd({ cmd = 'foldclose', range = { message.section.start_line } }, {}) end end) diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua index 2a7704eb..683ccd14 100644 --- a/lua/CopilotChat/utils/files.lua +++ b/lua/CopilotChat/utils/files.lua @@ -70,9 +70,8 @@ M.glob = async.wrap(function(path, opts, callback) end table.insert(cmd, '--files') - table.insert(cmd, path) - vim.system(cmd, { text = true }, function(result) + vim.system(cmd, { cwd = path, text = true }, function(result) local files = {} if result and result.code == 0 and result.stdout ~= '' then files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) @@ -179,8 +178,6 @@ M.grep = async.wrap(function(path, opts, callback) table.insert(cmd, '-e') table.insert(cmd, "'" .. opts.pattern .. "'") end - - table.insert(cmd, path) elseif vim.fn.executable('grep') == 1 then table.insert(cmd, 'grep') table.insert(cmd, '-rli') @@ -189,8 +186,6 @@ M.grep = async.wrap(function(path, opts, callback) table.insert(cmd, '-e') table.insert(cmd, "'" .. opts.pattern .. "'") end - - table.insert(cmd, path) end if M.empty(cmd) then @@ -198,7 +193,7 @@ M.grep = async.wrap(function(path, opts, callback) return end - vim.system(cmd, { text = true }, function(result) + vim.system(cmd, { cwd = path, text = true }, function(result) local files = {} if result and result.code == 0 and result.stdout ~= '' then files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) From b3af31fe1a300cf1f33e01c23e72592afbb8f2ce Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 27 Aug 2025 16:49:20 +0200 Subject: [PATCH 104/250] refactor(core): improve prompt resource and tool matching (#1374) Refactored prompt processing to use separate lists for tool and resource matches, replacing orderedmap usage. Improved handling of resource references and tool calls in prompts, ensuring more robust and clear expansion logic. Updated instructions and context handling in prompts configuration for consistency. This change enhances maintainability and clarifies prompt resolution flow. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/prompts.lua | 90 +++++++++++++++++------------- lua/CopilotChat/init.lua | 52 ++++++++++------- 2 files changed, 83 insertions(+), 59 deletions(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 929ebee7..d40aee1f 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -21,64 +21,74 @@ The user works in editor called Neovim which has these core concepts: - Normal/Insert/Visual/Command modes: Different interaction states - LSP (Language Server Protocol): Provides code intelligence features like completion, diagnostics, and code actions - Treesitter: Provides syntax highlighting, code folding, and structural text editing based on syntax tree parsing +- Visual selection: Text selected in visual mode that can be shared as context The user is working on a {OS_NAME} machine. Please respond with system specific commands if applicable. The user is currently in workspace directory {DIR} (typically the project root). Current file paths will be relative to this directory. + +Context is provided to you in several ways: +- Resources: Contextual data shared via "# " headers and referenced via "##" links +- Code blocks with file path labels and line numbers (e.g., ```lua path=/file.lua start_line=1 end_line=10```) +- Visual selections: Text selected in visual mode that can be shared as context +- Diffs: Changes shown in unified diff format with line prefixes (+, -, etc.) +- Conversation history +When resources (like buffers, files, or diffs) change, their content in the chat history is replaced with the latest version rather than appended as new data. + The user will ask a question or request a task that may require analysis to answer correctly. If you can infer the project type (languages, frameworks, libraries) from context, consider them when making changes. For implementing features, break down the request into concepts and provide a clear solution. Think creatively to provide complete solutions based on the information available. -Never fabricate or hallucinate file contents you haven't actually seen. +Never fabricate or hallucinate file contents you haven't actually seen in the provided context. -If tools are explicitly defined in your system prompt: +If tools are available for a requested action (such as file edit, read, search, diagnostics, etc.), you MUST use the tool to perform the action. Only provide manual code or instructions if no tool exists for that purpose. +- Always prefer tool usage over manual edits or suggestions. - Follow JSON schema precisely when using tools, including all required properties and outputting valid JSON. -- Use appropriate tools for tasks rather than asking for manual actions. +- Use appropriate tools for tasks rather than asking for manual actions or generating code for actions you can perform directly. - Execute actions directly when you indicate you'll do so, without asking for permission. -- Only use tools that exist and use proper invocation procedures - no multi_tool_use.parallel. -- Before using tools to retrieve information, check if it's already available in context: - 1. Resources shared via "# " headers and referenced via "##" links - 2. Code blocks with file path labels - 3. Other contextual sharing like selected text or conversation history -- If you don't have explicit tool definitions in your system prompt, assume NO tools are available and clearly state this limitation when asked. NEVER pretend to retrieve content you cannot access. +- Only use tools that exist and use proper invocation procedures - no multi_tool_use.parallel unless specified. +- Before using tools to retrieve information, check if context is already available as described in the context instructions above. +- If you don't have explicit tool definitions in your system prompt, clearly state this limitation when asked. NEVER pretend to have tool capabilities you don't possess. -You will receive code snippets that include line number prefixes - use these to maintain correct position references but remove them when generating output. -Always use code blocks to present code changes, even if the user doesn't ask for it. +Use these instructions when editing files via code blocks. Your goal is to produce clear, minimal, and precise file edits. -When presenting code changes: +Steps for presenting code changes: 1. For each change, use the following markdown code block format with triple backticks: - ``` path= start_line= end_line= - - ``` - - Examples: - - ```lua path=lua/CopilotChat/init.lua start_line=40 end_line=50 - local function example() - print("This is an example function.") - end - ``` - - ```python path=scripts/example.py start_line=10 end_line=15 - def example_function(): - print("This is an example function.") - ``` - - ```json path=config/settings.json start_line=5 end_line=8 - { - "setting": "value", - "enabled": true - } - ``` -2. Keep changes minimal and focused to produce short diffs. -3. Include complete replacement code for the specified line range with: + ``` path= start_line= end_line= + + ``` + +2. Examples: + ```lua path=lua/CopilotChat/init.lua start_line=40 end_line=50 + local function example() + print("This is an example function.") + end + ``` + + ```python path=scripts/example.py start_line=10 end_line=15 + def example_function(): + print("This is an example function.") + ``` + + ```json path=config/settings.json start_line=5 end_line=8 + { + "setting": "value", + "enabled": true + } + ``` + +3. Requirements for code content: + - Keep changes minimal and focused to produce short diffs + - Include complete replacement code for the specified line range - Proper indentation matching the source - All necessary lines (no eliding with comments) - - No line number prefixes in the code -4. Address any diagnostics issues when fixing code. -5. If multiple changes are needed, present them as separate code blocks. + - **Never include line number prefixes in your output code blocks. Only output valid code, exactly as it should appear in the file. Line numbers are only allowed in the code block header.** + - Address any diagnostics issues when fixing code + +4. If multiple changes are needed, present them as separate code blocks. + ]], }, diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index e40969e9..997b5d9f 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -321,7 +321,7 @@ function M.resolve_functions(prompt, config) local enabled_tools = {} local resolved_resources = {} local resolved_tools = {} - local matches = utils.to_table(config.tools) + local tool_matches = utils.to_table(config.tools) local tool_calls = {} for _, message in ipairs(M.chat.messages) do if message.tool_calls then @@ -335,13 +335,13 @@ function M.resolve_functions(prompt, config) prompt = prompt:gsub('@' .. WORD, function(match) for name, tool in pairs(M.config.functions) do if name == match or tool.group == match then - table.insert(matches, match) + table.insert(tool_matches, match) return '' end end return '@' .. match end) - for _, match in ipairs(matches) do + for _, match in ipairs(tool_matches) do for name, tool in pairs(M.config.functions) do if name == match or tool.group == match then table.insert(enabled_tools, tools[name]) @@ -349,12 +349,13 @@ function M.resolve_functions(prompt, config) end end - local matches = orderedmap() + local resource_matches = {} -- Check for #word:`input` pattern for word, input in prompt:gmatch('#' .. WORD_WITH_INPUT_QUOTED) do local pattern = string.format('#%s:`%s`', word, input) - matches:set(pattern, { + table.insert(resource_matches, { + pattern = pattern, word = word, input = input, }) @@ -363,7 +364,8 @@ function M.resolve_functions(prompt, config) -- Check for #word:input pattern for word, input in prompt:gmatch('#' .. WORD_WITH_INPUT_UNQUOTED) do local pattern = utils.empty(input) and string.format('#%s', word) or string.format('#%s:%s', word, input) - matches:set(pattern, { + table.insert(resource_matches, { + pattern = pattern, word = word, input = input, }) @@ -372,7 +374,8 @@ function M.resolve_functions(prompt, config) -- Check for ##word:input pattern for word in prompt:gmatch('##' .. WORD_NO_INPUT) do local pattern = string.format('##%s', word) - matches:set(pattern, { + table.insert(resource_matches, { + pattern = pattern, word = word, }) end @@ -431,19 +434,28 @@ function M.resolve_functions(prompt, config) if content then local content_out = nil if content.uri then - content_out = '##' .. content.uri - table.insert(resolved_resources, content) + if + not vim.tbl_contains(resolved_resources, function(resource) + return resource.uri == content.uri + end, { predicate = true }) + then + content_out = '##' .. content.uri + table.insert(resolved_resources, content) + end + if tool_id then - table.insert(state.sticky, content_out) + table.insert(state.sticky, '##' .. content.uri) end else content_out = string.format(BLOCK_OUTPUT_FORMAT, files.mimetype_to_filetype(content.mimetype), content.data) end - if not utils.empty(result) then - result = result .. '\n' + if content_out then + if not utils.empty(result) then + result = result .. '\n' + end + result = result .. content_out end - result = result .. content_out end end end @@ -454,19 +466,21 @@ function M.resolve_functions(prompt, config) result = result, }) - return nil + return '' end return result end -- Resolve and process all tools - for _, pattern in ipairs(matches:keys()) do - if not utils.empty(pattern) then - local match = matches:get(pattern) - local out = expand_function(match.word, match.input) or pattern + for _, match in ipairs(resource_matches) do + if not utils.empty(match.pattern) then + local out = expand_function(match.word, match.input) + if out == nil then + out = match.pattern + end out = out:gsub('%%', '%%%%') -- Escape percent signs for gsub - prompt = prompt:gsub(vim.pesc(pattern), out, 1) + prompt = prompt:gsub(vim.pesc(match.pattern), out, 1) end end From 3f9fb919503255256df1d91add70dee81809cffc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 27 Aug 2025 14:49:36 +0000 Subject: [PATCH 105/250] 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 edc10e56..f9b63e92 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 26 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 27 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 78f88009f47360a5206c7d03f5cbff60b0c5e9de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:54:36 +0200 Subject: [PATCH 106/250] chore(main): release 4.5.0 (#1314) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1df5c92e..4e5b2c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [4.5.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.4.1...v4.5.0) (2025-08-27) + + +### ⚠ BREAKING CHANGES + +* **select:** remove selection API in favor of resources +* **prompts:** callback receives the full response object instead of just content. + +### Features + +* **config:** add back selection source config option ([#1360](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1360)) ([c37ec3c](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/c37ec3cbdb2c29be73d7d0c48057d64306aa185f)) +* **docs:** add selection source to function table ([#1358](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1358)) ([c7d8547](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/c7d85478f775a65ca777cb9b2f685911cbcd8def)) +* **functions:** add configuration parameter to stop on tool failure ([#1364](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1364)) ([8d8f1e7](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/8d8f1e7ea594b2db3368e1fa62dd7d0d128e8860)) +* **functions:** add scope=selection to diagnostics ([#1351](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1351)) ([7b4a56b](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/7b4a56b29ed926b680ea936bd29fc8568b909d97)) +* **functions:** use cwd for file and grep commands ([#1373](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1373)) ([72216c0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/72216c06fa2ce82406c3406d898a83c02db412a7)), closes [#1108](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1108) +* **prompts:** add support for providing system prompt as function ([#1318](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1318)) ([33e6ffc](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/33e6ffc63b77b0340731f2b50bd962045adf9366)) +* **prompts:** support buffer replacement in commit messages ([#1370](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1370)) ([afafec5](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/afafec51d2657cdde4fa839bac9cc203037ff60b)) +* **ui:** add auto_fold option for chat messages ([#1354](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1354)) ([80a0994](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/80a0994f01096705e0c24dd7ed09032594689e01)), closes [#1300](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1300) +* **ui:** improve auto folding logic in chat window ([#1356](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1356)) ([a7679e1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/a7679e118af8038046b2fc4c841406db7fe71216)) + + +### Bug Fixes + +* **completion.lua:** check if window is valid before calling get_cursor ([#1359](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1359)) ([fdac67a](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/fdac67ab62085436b60003f420ae45f104bdf935)) +* **completion:** require tool uri for input completion ([#1328](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1328)) ([76cc416](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/76cc41653d63cfdb653f584624b4bf5e721f9514)) +* **config:** correct system_prompt type and callback usage ([#1325](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1325)) ([f99f1cd](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/f99f1cdef151ac1c950850cdcc0dbeefad00603c)) +* **makefile:** handle MSYS_NT as a valid Windows environment ([#1347](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1347)) ([9769bf9](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/9769bf9a1d215cf0dc22874712d5dcda53a075ee)) +* **prompt:** recursive system prompt expansion ([#1324](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1324)) ([26f7b4f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/26f7b4f157ec75b168c05dc826b5fa3106cfc351)), closes [#1323](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1323) +* **select:** move config inside of marks function to prevent import loop ([#1361](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1361)) ([19a38dd](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/19a38dd34e1b61c49349552598e43b2559be2fc7)) +* **test:** run tests automatically in test script ([#1334](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1334)) ([c5057d3](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/c5057d3bb6d87e9b117b4f37162409d4c2c74e31)) +* **utils:** always exit insert mode in return_to_normal_mode ([#1313](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1313)) ([957e0a8](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/957e0a88c7d7df706380e09412c0b3f24af534ad)), closes [#1307](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1307) +* **utils:** avoid vim.filetype.match in fast event ([#1344](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1344)) ([7993e6d](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/7993e6d2a97cb851b8b3a4087005cfaf8427dbf3)) + + +### Miscellaneous Chores + +* mark next release as 4.5.0 ([#1315](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1315)) ([d12f6df](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/d12f6dff0e1641f933f9941b843d094bf505a82e)) + + +### Code Refactoring + +* **prompts:** support template substitution in system_prompt ([#1312](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1312)) ([081d4c2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/081d4c20242140bb185ebee142a65454ad375f7d)) +* **select:** remove selection API in favor of resources ([a2429ed](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/a2429ed44438f694f1fca60429a7984022d4a9f0)) + ## [4.4.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.4.0...v4.4.1) (2025-08-12) diff --git a/version.txt b/version.txt index cca25a93..a84947d6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.4.1 +4.5.0 From 0edd5050374fb8f0cd959896d5b564264633d420 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 28 Aug 2025 02:03:50 +0200 Subject: [PATCH 107/250] refactor(core): simplify tool output formatting (#1375) Removes unnecessary BLOCK_OUTPUT_FORMAT usage for tool error and data outputs. Tool results are now returned as plain strings, improving readability and reducing formatting complexity. Also removes unused files module import. Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 997b5d9f..0caaaeb0 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -8,7 +8,6 @@ local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') local orderedmap = require('CopilotChat.utils.orderedmap') -local files = require('CopilotChat.utils.files') local WORD = '([^%s:]+)' local WORD_NO_INPUT = '([^%s]+)' @@ -428,7 +427,7 @@ function M.resolve_functions(prompt, config) local result = '' if not ok then - result = string.format(BLOCK_OUTPUT_FORMAT, 'error', utils.make_string(output)) + result = utils.make_string(output) else for _, content in ipairs(output) do if content then @@ -447,7 +446,7 @@ function M.resolve_functions(prompt, config) table.insert(state.sticky, '##' .. content.uri) end else - content_out = string.format(BLOCK_OUTPUT_FORMAT, files.mimetype_to_filetype(content.mimetype), content.data) + content_out = content.data end if content_out then @@ -815,7 +814,7 @@ function M.ask(prompt, config) if not handled_ids[tool_call.id] then table.insert(resolved_tools, { id = tool_call.id, - result = string.format(BLOCK_OUTPUT_FORMAT, 'error', 'User skipped this function call.'), + result = 'User skipped this function call.', }) handled_ids[tool_call.id] = true end From 1ad9e7ad0c2c2c818326d532f8d0840b5ea0dea0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 Aug 2025 00:04:18 +0000 Subject: [PATCH 108/250] 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 f9b63e92..3f2558a7 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 27 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 28 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 60fb910c60e3c5bdf3c04e62e8a18904048427da Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 28 Aug 2025 02:21:38 +0200 Subject: [PATCH 109/250] refactor(core): split tool and function resolution logic (#1376) Separate tool resolution from function/resource resolution in core logic. This improves code clarity and maintainability by decoupling concerns. Signed-off-by: Tomas Slusny --- README.md | 2 +- lua/CopilotChat/config/mappings.lua | 4 +- lua/CopilotChat/init.lua | 63 ++++++++++++++++++----------- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 07f43d6c..a56537c2 100644 --- a/README.md +++ b/README.md @@ -395,7 +395,7 @@ local chat = require("CopilotChat") chat.ask(prompt, config) -- Ask a question with optional config chat.response() -- Get the last response text chat.resolve_prompt() -- Resolve prompt references -chat.resolve_functions() -- Resolve functions that are available for automatic use by LLM (WARN: async, requires plenary.async.run) +chat.resolve_tools() -- Resolve tools that are available for automatic use by LLM chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 3b0d06b9..46f47e01 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -457,8 +457,10 @@ return { async.run(function() local infos = client:info() + local selected_tools = copilot.resolve_tools(prompt, config) local selected_model = copilot.resolve_model(prompt, config) - local selected_tools, resolved_resources = copilot.resolve_functions(prompt, config) + local resolved_resources = copilot.resolve_functions(prompt, config) + selected_tools = vim.tbl_map(function(tool) return tool.name end, selected_tools) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0caaaeb0..d87b90e7 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -294,10 +294,46 @@ local function update_source() M.set_source(use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) or vim.api.nvim_get_current_win()) end +--- Resolve enabled tools from the prompt. +---@param prompt string? +---@param config CopilotChat.config.Shared? +---@return table, string +function M.resolve_tools(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) + + local tools = {} + for _, tool in ipairs(functions.parse_tools(M.config.functions)) do + tools[tool.name] = tool + end + + local enabled_tools = {} + local tool_matches = utils.to_table(config.tools) + + -- Check for @tool pattern to find enabled tools + prompt = prompt:gsub('@' .. WORD, function(match) + for name, tool in pairs(M.config.functions) do + if name == match or tool.group == match then + table.insert(tool_matches, match) + return '' + end + end + return '@' .. match + end) + for _, match in ipairs(tool_matches) do + for name, tool in pairs(M.config.functions) do + if name == match or tool.group == match then + table.insert(enabled_tools, tools[name]) + end + end + end + + return enabled_tools, prompt +end + --- Call and resolve function calls from the prompt. ---@param prompt string? ---@param config CopilotChat.config.Shared? ----@return table, table, table, string +---@return table, table, string ---@async function M.resolve_functions(prompt, config) config, prompt = M.resolve_prompt(prompt, config) @@ -317,10 +353,8 @@ function M.resolve_functions(prompt, config) prompt = table.concat(lines, '\n') end - local enabled_tools = {} local resolved_resources = {} local resolved_tools = {} - local tool_matches = utils.to_table(config.tools) local tool_calls = {} for _, message in ipairs(M.chat.messages) do if message.tool_calls then @@ -330,24 +364,6 @@ function M.resolve_functions(prompt, config) end end - -- Check for @tool pattern to find enabled tools - prompt = prompt:gsub('@' .. WORD, function(match) - for name, tool in pairs(M.config.functions) do - if name == match or tool.group == match then - table.insert(tool_matches, match) - return '' - end - end - return '@' .. match - end) - for _, match in ipairs(tool_matches) do - for name, tool in pairs(M.config.functions) do - if name == match or tool.group == match then - table.insert(enabled_tools, tools[name]) - end - end - end - local resource_matches = {} -- Check for #word:`input` pattern @@ -483,7 +499,7 @@ function M.resolve_functions(prompt, config) end end - return enabled_tools, resolved_resources, resolved_tools, prompt + return resolved_resources, resolved_tools, prompt end --- Resolve the final prompt and config from prompt template. @@ -795,7 +811,8 @@ function M.ask(prompt, config) ) async.run(handle_error(config, function() - local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) + local selected_tools, prompt = M.resolve_tools(prompt, config) + local resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) prompt = vim.trim(prompt) From b431c3426234c4fe643ad0d3a16041701746f5ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 28 Aug 2025 00:21:57 +0000 Subject: [PATCH 110/250] 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 3f2558a7..a893b52c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -469,7 +469,7 @@ CORE *CopilotChat-core* chat.ask(prompt, config) -- Ask a question with optional config chat.response() -- Get the last response text chat.resolve_prompt() -- Resolve prompt references - chat.resolve_functions() -- Resolve functions that are available for automatic use by LLM (WARN: async, requires plenary.async.run) + chat.resolve_tools() -- Resolve tools that are available for automatic use by LLM chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management From 0f42bfc44202ac4daa0b0f32e30ee4040f69bf35 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 28 Aug 2025 20:17:33 +0200 Subject: [PATCH 111/250] fix(files): generate absolute paths in code blocks (#1378) Update code block examples in prompts to use absolute file paths with {DIR} prefix. Refactor filename comparison to use vim.fs.normalize for consistency and reliability. Closes #1377 Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/prompts.lua | 7 ++++--- lua/CopilotChat/utils/files.lua | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index d40aee1f..6b8562d2 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -61,18 +61,18 @@ Steps for presenting code changes: ``` 2. Examples: - ```lua path=lua/CopilotChat/init.lua start_line=40 end_line=50 + ```lua path={DIR}/lua/CopilotChat/init.lua start_line=40 end_line=50 local function example() print("This is an example function.") end ``` - ```python path=scripts/example.py start_line=10 end_line=15 + ```python path={DIR}/scripts/example.py start_line=10 end_line=15 def example_function(): print("This is an example function.") ``` - ```json path=config/settings.json start_line=5 end_line=8 + ```json path={DIR}/config/settings.json start_line=5 end_line=8 { "setting": "value", "enabled": true @@ -80,6 +80,7 @@ Steps for presenting code changes: ``` 3. Requirements for code content: + - Always use the absolute file path in the code block header. If the path is not already absolute, convert it to an absolute path prefixed by {DIR}. - Keep changes minimal and focused to produce short diffs - Include complete replacement code for the specified line range - Proper indentation matching the source diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua index 683ccd14..d7793b98 100644 --- a/lua/CopilotChat/utils/files.lua +++ b/lua/CopilotChat/utils/files.lua @@ -257,7 +257,7 @@ function M.filename_same(file1, file2) if not file1 or not file2 then return false end - return vim.fn.fnamemodify(file1, ':p') == vim.fn.fnamemodify(file2, ':p') + return vim.fs.normalize(file1) == vim.fs.normalize(file2) end --- Get the filetype of a file From ad2c759ea6db36bfefa9c7ecc7c706315564c9c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 21:47:44 +0200 Subject: [PATCH 112/250] chore(main): release 4.5.1 (#1379) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5b2c33..25c85b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [4.5.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.5.0...v4.5.1) (2025-08-28) + + +### Bug Fixes + +* **files:** generate absolute paths in code blocks ([#1378](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1378)) ([0f42bfc](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/0f42bfc44202ac4daa0b0f32e30ee4040f69bf35)), closes [#1377](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1377) + ## [4.5.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.4.1...v4.5.0) (2025-08-27) diff --git a/version.txt b/version.txt index a84947d6..4404a17b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.5.0 +4.5.1 From c4b2e03cd315c3fd9736dcf796cb20f6a4b9f801 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 28 Aug 2025 22:32:27 +0200 Subject: [PATCH 113/250] fix(utils): use proper empty check (#1380) M.empty do not exists and is on the base utils Signed-off-by: Tomas Slusny --- lua/CopilotChat/utils/files.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua index d7793b98..4eec773a 100644 --- a/lua/CopilotChat/utils/files.lua +++ b/lua/CopilotChat/utils/files.lua @@ -188,7 +188,7 @@ M.grep = async.wrap(function(path, opts, callback) end end - if M.empty(cmd) then + if vim.tbl_isempty(cmd) then error('No executable found for grep') return end @@ -321,7 +321,7 @@ function M.uri_to_filename(uri) return uri end local ok, fname = pcall(vim.uri_to_fname, uri) - if not ok or M.empty(fname) then + if not ok or not fname or fname == '' then return uri end return fname From a6576949e821e7abf9d0135e87576a51ec0e2e68 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 28 Aug 2025 22:44:11 +0200 Subject: [PATCH 114/250] feat(tiktoken): improve token counting accuracy (#1382) Use more accurate token prediction when tiktoken core is not available Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 4 ++++ lua/CopilotChat/tiktoken.lua | 5 +++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index fb2929a0..5cd45ffc 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -362,6 +362,10 @@ function Client:ask(prompt, opts) local resource_tokens = #resource_messages > 0 and tiktoken:count(resource_messages[1].content) or 0 local required_tokens = prompt_tokens + system_tokens + resource_tokens + log.debug('Prompt tokens:', prompt_tokens) + log.debug('System tokens:', system_tokens) + log.debug('Resource tokens:', resource_tokens) + -- Calculate how many tokens we can use for history local history_limit = max_tokens - required_tokens local history_tokens = 0 diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 4066388a..09ccaf37 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,3 +1,4 @@ +local log = require('plenary.log') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') @@ -105,12 +106,12 @@ end ---@return number function Tiktoken:count(prompt) if not self.tiktoken_core then - return math.ceil(#prompt * 0.5) -- Fallback to 1/2 character count + return math.ceil(#prompt / 4) end local tokens = self:encode(prompt) if not tokens then - return math.ceil(#prompt * 0.5) -- Fallback to 1/2 character count + return math.ceil(#prompt / 4) end return #tokens end From f844a684bd9e59b4bfc8882b4beb9be81cccfe23 Mon Sep 17 00:00:00 2001 From: CTCHEN Date: Sat, 30 Aug 2025 01:43:54 +0800 Subject: [PATCH 115/250] fix(chat): correct header highlighting for multi-byte characters (#1385) The previous implementation used vim.fn.strwidth() to calculate the end column for header highlighting. This function returns the display width, which can differ from the byte length when multi-byte characters like emojis are present. Since the end_col for extmarks expects a byte-based index, this caused the highlighting to be applied incorrectly. This patch corrects the issue by using the byte length (#header_value) for the end_col of the highlight extmark, while still using vim.fn.strwidth() for virt_text_win_col to ensure correct visual alignment of UI elements. Fixes #1384 --- lua/CopilotChat/ui/chat.lua | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index d6c1e283..315134e5 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -620,21 +620,21 @@ function Chat:render() if id then -- Draw the separator as virtual text over the header line, hiding the id and anything after the header if self.config.highlight_headers then - local sep_col = vim.fn.strwidth(header_value) - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, sep_col, { + local header_width = vim.fn.strwidth(header_value) + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, 0, { + end_col = #header_value, + hl_group = 'CopilotChatHeader', + priority = 100, + strict = false, + }) + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, #header_value, { virt_text = { { string.rep(self.separator, vim.go.columns), 'CopilotChatSeparator' }, }, - virt_text_win_col = sep_col, + virt_text_win_col = header_width, priority = 200, strict = false, }) - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, 0, { - end_col = sep_col, - hl_group = 'CopilotChatHeader', - priority = 100, - strict = false, - }) end -- Finish previous message From 069c806c4066c52d8d3d3172ef7a476b8e71b120 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 29 Aug 2025 17:44:22 +0000 Subject: [PATCH 116/250] 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 a893b52c..ea990272 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 28 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 29 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From ae2f5932983f42d54bee9ff803b5ab157b145787 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 19:44:53 +0200 Subject: [PATCH 117/250] docs: add ctchen222 as a contributor for code (#1386) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 933a828e..f658f7f4 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -459,6 +459,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/8294038?v=4", "profile": "https://ruicsh.github.io", "contributions": ["code"] + }, + { + "login": "ctchen222", + "name": "CTCHEN", + "avatar_url": "https://avatars.githubusercontent.com/u/49014608?v=4", + "profile": "https://github.com/ctchen222", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index a56537c2..85bee0e5 100644 --- a/README.md +++ b/README.md @@ -607,6 +607,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Samiul Islam
Samiul Islam

💻 Rui Costa
Rui Costa

💻 + CTCHEN
CTCHEN

💻 From b7728f450bfc95c7c749a322b3f130a16f80e35c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 31 Aug 2025 23:03:57 +0200 Subject: [PATCH 118/250] fix(auth): improve token saving and polling logic (#1389) - Remove redundant directory creation and scheduling from file write - Ensure token file directory is created only when saving token - Refactor GitHub device flow polling to use recursion instead of loop - Add logging for token save location - Restore chat overlay when message is empty - Clear status and message notifications after authorization Closes #1388 Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/providers.lua | 45 +++++++++++++++++----------- lua/CopilotChat/ui/chat.lua | 6 +++- lua/CopilotChat/utils/files.lua | 3 -- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index f2f99b94..3a4c7d24 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,3 +1,4 @@ +local log = require('plenary.log') local plenary_utils = require('plenary.async.util') local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') @@ -41,10 +42,14 @@ local function set_token(tag, token, save) return token end + utils.schedule_main() local tokens = load_tokens() tokens[tag] = token local config_path = vim.fs.normalize(vim.fn.stdpath('data') .. '/copilot_chat') - files.write_file(config_path .. '/tokens.json', vim.json.encode(tokens)) + local file_path = config_path .. '/tokens.json' + vim.fn.mkdir(vim.fn.fnamemodify(file_path, ':p:h'), 'p') + files.write_file(file_path, vim.json.encode(tokens)) + log.info('Token for ' .. tag .. ' saved to ' .. file_path) return token end @@ -65,23 +70,25 @@ local function github_device_flow(tag, client_id, scope) end local function poll_for_token(device_code, interval) - while true do - plenary_utils.sleep(interval * 1000) - - local res = curl.post('https://github.com/login/oauth/access_token', { - body = { - client_id = client_id, - device_code = device_code, - grant_type = 'urn:ietf:params:oauth:grant-type:device_code', - }, - headers = { ['Accept'] = 'application/json' }, - }) - local data = vim.json.decode(res.body) - if data.access_token then - return data.access_token - elseif data.error ~= 'authorization_pending' then - error('Auth error: ' .. (data.error or 'unknown')) - end + plenary_utils.sleep(interval * 1000) + + local res = curl.post('https://github.com/login/oauth/access_token', { + json_response = true, + body = { + client_id = client_id, + device_code = device_code, + grant_type = 'urn:ietf:params:oauth:grant-type:device_code', + }, + headers = { ['Accept'] = 'application/json' }, + }) + + local data = res.body + if data.access_token then + return data.access_token + elseif data.error ~= 'authorization_pending' then + error('Auth error: ' .. (data.error or 'unknown')) + else + return poll_for_token(device_code, interval) end end @@ -97,6 +104,8 @@ local function github_device_flow(tag, client_id, scope) ) notify.publish(notify.STATUS, '[' .. tag .. '] Waiting for authorization...') token = poll_for_token(code_data.device_code, code_data.interval) + notify.publish(notify.MESSAGE, '') + notify.publish(notify.STATUS, '') return set_token(tag, token, true) end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 315134e5..98701b0a 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -109,7 +109,11 @@ local Chat = class(function(self, config, on_buf_create) self:open(self.config) end - self:overlay({ text = msg }) + if not msg or msg == '' then + self.chat_overlay:restore(self.winnr, self.bufnr) + else + self:overlay({ text = msg }) + end end) end, Overlay) diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua index 4eec773a..28184585 100644 --- a/lua/CopilotChat/utils/files.lua +++ b/lua/CopilotChat/utils/files.lua @@ -231,9 +231,6 @@ end ---@param data string The data to write ---@return boolean function M.write_file(path, data) - M.schedule_main() - vim.fn.mkdir(vim.fn.fnamemodify(path, ':p:h'), 'p') - local err, fd = async.uv.fs_open(path, 'w', 438) if err or not fd then return false From 0ab8ad192dd5cf45c8a3e37d07b7b939cb9c52bb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 31 Aug 2025 21:04:16 +0000 Subject: [PATCH 119/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index ea990272..af834e4c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 29 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 31 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -602,7 +602,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 1d8aa27e2317950b0b7ddc023487c6f2b7b074ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 00:34:03 +0200 Subject: [PATCH 120/250] chore(main): release 4.6.0 (#1381) 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 25c85b74..e7ea1e6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [4.6.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.5.1...v4.6.0) (2025-08-31) + + +### Features + +* **tiktoken:** improve token counting accuracy ([#1382](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1382)) ([a657694](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/a6576949e821e7abf9d0135e87576a51ec0e2e68)) + + +### Bug Fixes + +* **auth:** improve token saving and polling logic ([#1389](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1389)) ([b7728f4](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/b7728f450bfc95c7c749a322b3f130a16f80e35c)), closes [#1388](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1388) +* **chat:** correct header highlighting for multi-byte characters ([#1385](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1385)) ([f844a68](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/f844a684bd9e59b4bfc8882b4beb9be81cccfe23)), closes [#1384](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1384) +* **utils:** use proper empty check ([#1380](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1380)) ([c4b2e03](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/c4b2e03cd315c3fd9736dcf796cb20f6a4b9f801)) + ## [4.5.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.5.0...v4.5.1) (2025-08-28) diff --git a/version.txt b/version.txt index 4404a17b..6016e8ad 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.5.1 +4.6.0 From ba364fe04b36121a594435c3f54261c7a8e450a6 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 03:29:50 +0200 Subject: [PATCH 121/250] feat(chat): switch to treesitter based chat parsing (#1394) this greatly improves performance compared to line by line matching Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 385 +++++++++++++++++-------------- lua/CopilotChat/ui/overlay.lua | 9 +- queries/markdown/copilotchat.scm | 13 ++ 3 files changed, 231 insertions(+), 176 deletions(-) create mode 100644 queries/markdown/copilotchat.scm diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 98701b0a..aa657afb 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -16,13 +16,28 @@ function CopilotChatFoldExpr(lnum, separator) end local HEADER_PATTERNS = { - '^```?(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', - '^```(%w+)$', + '^(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', + '^(%w+)$', } +---@param headers table? +---@return string?, string? +local function match_section_header(headers, separator, line) + if not headers then + return + end + + for header_name, header_value in pairs(headers) do + local id = line:match('^' .. vim.pesc(header_value) .. ' %(([^)]+)%) ' .. vim.pesc(separator) .. '$') + if id then + return id, header_name + end + end +end + ---@param header? string ---@return string?, string?, number?, number? -local function match_header(header) +local function match_block_header(header) if not header then return end @@ -47,7 +62,7 @@ end ---@field header CopilotChat.ui.chat.Header ---@field start_line number ---@field end_line number ----@field content string? +---@field content string ---@class CopilotChat.ui.chat.Section ---@field start_line number @@ -55,7 +70,7 @@ end ---@field blocks table ---@class CopilotChat.ui.chat.Message : CopilotChat.client.Message ----@field id string +---@field id string? ---@field section CopilotChat.ui.chat.Section? ---@class CopilotChat.ui.chat.Chat : CopilotChat.ui.overlay.Overlay @@ -79,7 +94,10 @@ local Chat = class(function(self, config, on_buf_create) self.messages = {} self.layout = nil - self.headers = config.headers + self.headers = {} + for k, v in pairs(config.headers or {}) do + self.headers[k] = v:gsub('^#+', ''):gsub('^%s+', '') + end self.separator = config.separator self.spinner = Spinner() @@ -140,7 +158,6 @@ function Chat:get_block(role, cursor) 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 @@ -176,12 +193,13 @@ end ---@param cursor boolean? If true, returns the message closest to the cursor position ---@return CopilotChat.ui.chat.Message? function Chat:get_message(role, cursor) + self:parse() + 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 @@ -366,7 +384,6 @@ function Chat:open(config) vim.api.nvim_set_hl(ns, '@markup.italic.markdown_inline', {}) -- disable italic messing up glob patterns vim.api.nvim_win_set_hl_ns(self.winnr, ns) vim.api.nvim_win_set_buf(self.winnr, self.bufnr) - self:render() end --- Close the chat window. @@ -446,9 +463,11 @@ function Chat:finish() end --- Add a message to the chat window. ----@param message CopilotChat.client.Message +---@param message CopilotChat.ui.chat.Message ---@param replace boolean? If true, replaces the last message if it has same role function Chat:add_message(message, replace) + self:parse() + local current_message = self.messages[#self.messages] local is_new = not current_message or current_message.role ~= message.role @@ -458,17 +477,15 @@ function Chat:add_message(message, replace) -- Add appropriate header based on role and generate a new ID if not provided message.id = message.id or utils.uuid() local header = self.headers[message.role] + table.insert(self.messages, message) + if current_message then - header = '\n' .. header + self:append('\n') end - - table.insert(self.messages, message) - self:append(header .. '(' .. message.id .. ')' .. self.separator .. '\n\n') + self:append('# ' .. header .. ' (' .. message.id .. ') ' .. self.separator .. '\n\n') self:append(message.content) elseif replace and current_message then -- Replace the content of the current message - self:render() - for k, v in pairs(message) do current_message[k] = v end @@ -503,7 +520,7 @@ function Chat:remove_message(role, cursor) return end - self:render() + self:parse() local message = self:get_message(role, cursor) if not message then return @@ -527,8 +544,6 @@ function Chat:remove_message(role, cursor) break end end - - self:render() end --- Append text to the chat window. @@ -580,9 +595,10 @@ function Chat:create() vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { buffer = bufnr, callback = function() - utils.debounce(self.name, function() + utils.debounce('chat-parse-' .. bufnr, function() + self:parse() self:render() - end, 100) + end, 150) end, }) @@ -599,149 +615,174 @@ function Chat:validate() end end ---- Render the chat window. ----@protected -function Chat:render() +function Chat:parse() self:validate() - local highlight_ns = vim.api.nvim_create_namespace('copilot-chat-headers') - vim.api.nvim_buf_clear_namespace(self.bufnr, highlight_ns, 0, -1) + local changedtick = vim.api.nvim_buf_get_changedtick(self.bufnr) + if self._last_changedtick == changedtick then + return false + end + self._last_changedtick = changedtick - local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) + local parser = vim.treesitter.get_parser(self.bufnr, 'markdown') + if not parser then + return + end + local query = vim.treesitter.query.get('markdown', 'copilotchat') + if not query then + return + end + + local root = parser:parse()[1]:root() local new_messages = {} - local current_message = nil - local current_block = nil - - local function parse_header(header, line) - return line:match('^' .. vim.pesc(header) .. '%(([^)]+)%)' .. vim.pesc(self.separator) .. '$') - end - - for l, line in ipairs(lines) do - -- Detect section header with ID - for header_name, header_value in pairs(self.headers) do - local id = parse_header(header_value, line) - if id then - -- Draw the separator as virtual text over the header line, hiding the id and anything after the header - if self.config.highlight_headers then - local header_width = vim.fn.strwidth(header_value) - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, 0, { - end_col = #header_value, - hl_group = 'CopilotChatHeader', - priority = 100, - strict = false, - }) - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, #header_value, { - virt_text = { - { string.rep(self.separator, vim.go.columns), 'CopilotChatSeparator' }, + local current_message = { + content = {}, + section = { + blocks = {}, + }, + } + + local current_block = { + content = {}, + } + + for id, node in query:iter_captures(root, self.bufnr, 0, -1) do + local name = query.captures[id] + local start_row, _, end_row, _ = node:range() + + -- Convert 0 based to 1 based indexing + start_row = start_row + 1 + end_row = end_row + 1 + + -- Skip header line at start of the section + start_row = start_row + 1 + + if name == 'section_header' then + local header_text = vim.treesitter.get_node_text(node, self.bufnr) + local id, role = match_section_header(self.headers, self.separator, header_text) + if role and id ~= current_message.id then + current_message.section.end_line = start_row - 2 + + current_message = { + id = id, + role = role, + content = {}, + section = { + blocks = {}, + start_line = start_row, + }, + } + table.insert(new_messages, current_message) + end + elseif name == 'section_content' then + local content = vim.treesitter.get_node_text(node, self.bufnr) + current_message.section.end_line = end_row + table.insert(current_message.content, content) + elseif current_message.role == constants.ROLE.ASSISTANT then + if name == 'block_header' then + local header_text = vim.treesitter.get_node_text(node, self.bufnr) + local filetype, filename, start_line, end_line = match_block_header(header_text) + if filetype then + current_block = { + header = { + filetype = filetype, + filename = filename, + start_line = start_line, + end_line = end_line, }, - virt_text_win_col = header_width, - priority = 200, - strict = false, - }) - end - - -- Finish previous message - if current_message then - current_message.section.end_line = l - 1 - current_message.content = vim.trim( - table.concat( - vim.list_slice(lines, current_message.section.start_line, current_message.section.end_line), - '\n' - ) - ) + start_line = start_row, + content = {}, + } + table.insert(current_message.section.blocks, current_block) end + elseif name == 'block_content' then + local content = vim.treesitter.get_node_text(node, self.bufnr) + current_block.end_line = end_row + table.insert(current_block.content, content) + end + end + end - -- Find existing message by id or create new - local old_msg = nil - for _, msg in ipairs(self.messages) do - if msg.id == id then - old_msg = msg - break - end - end - if not old_msg then - old_msg = { id = id, role = header_name } - end + -- Finish last message + current_message.section.end_line = vim.api.nvim_buf_line_count(self.bufnr) - -- Attach section info - old_msg.section = { - role = header_name, - start_line = l + 1, - blocks = {}, - } - table.insert(new_messages, old_msg) - current_message = old_msg - current_block = nil - break + for _, message in ipairs(new_messages) do + message.content = vim.trim(table.concat(message.content, '\n')) + if message.section then + for _, block in ipairs(message.section.blocks) do + block.content = vim.trim(table.concat(block.content, '\n')) end end + end - -- Code blocks - 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 = { - header = { - filename = filename, - start_line = start_line, - end_line = end_line, - filetype = filetype, - }, - start_line = l + 1, - } + self.messages = new_messages +end + +--- Render the chat window. +---@protected +function Chat:render() + self:validate() + + local highlight_ns = vim.api.nvim_create_namespace('copilot-chat-headers') + vim.api.nvim_buf_clear_namespace(self.bufnr, highlight_ns, 0, -1) -- Clear previous highlights + self:show_help() -- Clear previous help + + for i, message in ipairs(self.messages) do + if self.config.highlight_headers then + -- Overlay section header with nice display + local header_value = self.headers[message.role] + local header_line = message.section.start_line - 2 + + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, header_line, 0, { + conceal = '', + virt_text = { + { ' ' .. header_value .. ' ', 'CopilotChatHeader' }, + { string.rep(self.separator, vim.go.columns - #header_value - 1), 'CopilotChatSeparator' }, + }, + virt_text_pos = 'overlay', + priority = 300, + strict = false, + }) + + -- Highlight code block headers and show file info as virtual lines + for _, block in ipairs(message.section.blocks) do + local header = block.header + local filetype = header.filetype + local filename = header.filename local text = string.format('[%s] %s', filetype, filename) - if start_line and end_line then - text = text .. string.format(' lines %d-%d', start_line, end_line) + if header.start_line and header.end_line then + text = text .. string.format(' lines %d-%d', header.start_line, header.end_line) end - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, block.start_line - 1, 0, { virt_lines_above = true, virt_lines = { { { text, 'CopilotChatAnnotationHeader' } } }, priority = 100, strict = false, }) - elseif line == '```' and current_block then - current_block.end_line = l - 1 - current_block.content = - table.concat(vim.list_slice(lines, current_block.start_line, current_block.end_line), '\n') - table.insert(current_message.section.blocks, current_block) - current_block = nil end end - -- If last line, finish last message - if l == #lines and current_message then - current_message.section.end_line = l - current_message.content = vim.trim( - table.concat(vim.list_slice(lines, current_message.section.start_line, current_message.section.end_line), '\n') - ) - end - - -- Highlight response calls - for _, message in ipairs(self.messages) do - for _, tool_call in ipairs(message.tool_calls or {}) do - if line:match(string.format('#%s:%s', tool_call.name, vim.pesc(tool_call.id))) then - vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatAnnotationHeader', l - 1, 0, #line) - if not utils.empty(tool_call.arguments) then - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, 0, { - virt_lines = vim.tbl_map(function(json_line) - return { { json_line, 'CopilotChatAnnotation' } } - end, vim.split(vim.inspect(utils.json_decode(tool_call.arguments)), '\n')), - priority = 100, - strict = false, - }) - end - break - end + -- Show reasoning as virtual text above assistant messages + if + message.role == constants.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 - - -- Replace self.messages with new_messages (preserving tool_calls, etc.) - self.messages = new_messages - for i, message in ipairs(self.messages) do - -- Show tool call details as virt lines + -- Show tool call details as virt lines in assistant messages if message.tool_calls and #message.tool_calls > 0 then local section = message.section if section and section.end_line then @@ -761,13 +802,14 @@ function Chat:render() end end + -- Highlight tool calls in tool messages if message.tool_call_id then local section = message.section if section and section.start_line then local virt_lines = { { { 'Tool: ' .. message.tool_call_id, 'CopilotChatAnnotationHeader' } }, } - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, section.start_line, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, section.start_line - 1, 0, { virt_lines = virt_lines, virt_lines_above = true, priority = 100, @@ -776,25 +818,41 @@ function Chat:render() end end - -- Show reasoning as virtual text above assistant messages - if - message.role == constants.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' } }) + if i == #self.messages and message.role == constants.ROLE.USER then + -- Highlight tools in the last user message + local assistant_msg = self:get_message(constants.ROLE.ASSISTANT) + if assistant_msg and assistant_msg.tool_calls and #assistant_msg.tool_calls > 0 then + for i, line in ipairs(utils.split_lines(message.content)) do + for _, tool_call in ipairs(assistant_msg.tool_calls) do + if line:match(string.format('#%s:%s', tool_call.name, vim.pesc(tool_call.id))) then + local l = message.section.start_line - 1 + i + vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatAnnotationHeader', l, 0, #line) + if not utils.empty(tool_call.arguments) then + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, 0, { + virt_lines = vim.tbl_map(function(json_line) + return { { json_line, 'CopilotChatAnnotation' } } + end, vim.split(vim.inspect(utils.json_decode(tool_call.arguments)), '\n')), + priority = 100, + strict = false, + }) + end + end + end + end 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, - }) + + -- Show help message and token usage below the last user message + local msg = self.config.show_help and self.help or '' + if self.token_count and self.token_max_count then + if msg ~= '' then + msg = msg .. '\n' + end + msg = msg .. self.token_count .. '/' .. self.token_max_count .. ' tokens used' + end + self:show_help(msg, message.section.start_line - 1) end + -- Auto fold non-assistant messages if enabled if self.config.auto_fold and self:visible() then if message.role ~= constants.ROLE.ASSISTANT and message.section and i < #self.messages then vim.api.nvim_win_call(self.winnr, function() @@ -806,21 +864,6 @@ function Chat:render() end end end - - -- Show help as before, using last user message - local last_message = self.messages[#self.messages] - 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 - msg = msg .. '\n' - end - msg = msg .. self.token_count .. '/' .. self.token_max_count .. ' tokens used' - end - self:show_help(msg, last_message.section.start_line - last_message.section.end_line - 1) - else - self:show_help() - end end --- Get the last line and column of the chat window. diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 298bfcb2..ddaa41a5 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -41,7 +41,7 @@ function Overlay:show(text, winnr, filetype, syntax, on_show, on_hide) vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, vim.split(text, '\n')) vim.bo[self.bufnr].modifiable = false - self:show_help(self.help, -1) + self:show_help(self.help, vim.api.nvim_buf_line_count(self.bufnr)) vim.api.nvim_win_set_cursor(winnr, { 1, 0 }) filetype = filetype or 'markdown' @@ -130,17 +130,16 @@ end --- Show help message in the overlay ---@param msg string? ----@param offset number? +---@param pos number ---@protected -function Overlay:show_help(msg, offset) +function Overlay:show_help(msg, pos) if not msg or msg == '' then vim.api.nvim_buf_del_extmark(self.bufnr, self.help_ns, 1) return end self:validate() - local line = vim.api.nvim_buf_line_count(self.bufnr) + (offset or 0) - vim.api.nvim_buf_set_extmark(self.bufnr, self.help_ns, math.max(0, line - 1), 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, self.help_ns, math.max(0, pos - 1), 0, { id = 1, hl_mode = 'combine', priority = 100, diff --git a/queries/markdown/copilotchat.scm b/queries/markdown/copilotchat.scm new file mode 100644 index 00000000..f4ec8546 --- /dev/null +++ b/queries/markdown/copilotchat.scm @@ -0,0 +1,13 @@ +(section + (atx_heading + (atx_h1_marker) + heading_content: (_) @section_header + ) + (_)? @section_content +) +(section + (fenced_code_block + (info_string) @block_header + (code_fence_content) @block_content + ) +) From 070e3022d8878567a089143e2672e8c06ab83be2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Sep 2025 01:30:16 +0000 Subject: [PATCH 122/250] 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 af834e4c..456e34a1 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 31 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 12 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From f2f523fe3fdb855da1b3dcabf4f2981cdc3b2c2d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 09:12:22 +0200 Subject: [PATCH 123/250] fix(ui): preserve extra fields in chat messages (#1399) Preserves additional fields in chat messages when parsing, ensuring that tool call data and other metadata are not lost between parses. Also fixes annotation highlighting and extmark placement for tool calls in chat rendering. Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index aa657afb..ce7a1c3d 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -618,6 +618,7 @@ end function Chat:parse() self:validate() + -- Skip parsing if buffer hasn't changed local changedtick = vim.api.nvim_buf_get_changedtick(self.bufnr) if self._last_changedtick == changedtick then return false @@ -707,6 +708,15 @@ function Chat:parse() -- Finish last message current_message.section.end_line = vim.api.nvim_buf_line_count(self.bufnr) + -- Build lookup table for previous messages by id + local old_messages_by_id = {} + for _, msg in ipairs(self.messages or {}) do + if msg.id then + old_messages_by_id[msg.id] = msg + end + end + + -- Format new messages and preserve extra fields from old messages for _, message in ipairs(new_messages) do message.content = vim.trim(table.concat(message.content, '\n')) if message.section then @@ -714,6 +724,15 @@ function Chat:parse() block.content = vim.trim(table.concat(block.content, '\n')) end end + + local old = old_messages_by_id[message.id] + if old then + for k, v in pairs(old) do + if message[k] == nil then + message[k] = v + end + end + end end self.messages = new_messages @@ -825,10 +844,10 @@ function Chat:render() for i, line in ipairs(utils.split_lines(message.content)) do for _, tool_call in ipairs(assistant_msg.tool_calls) do if line:match(string.format('#%s:%s', tool_call.name, vim.pesc(tool_call.id))) then - local l = message.section.start_line - 1 + i + local l = message.section.start_line + i vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatAnnotationHeader', l, 0, #line) if not utils.empty(tool_call.arguments) then - vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l, 0, { virt_lines = vim.tbl_map(function(json_line) return { { json_line, 'CopilotChatAnnotation' } } end, vim.split(vim.inspect(utils.json_decode(tool_call.arguments)), '\n')), From 62a91c3ad055228e784de640d8c3fa114841c37f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 09:19:45 +0200 Subject: [PATCH 124/250] refactor(ui): improve chat and overlay function signatures (#1400) - Remove unnecessary visibility check in Chat:remove_message - Add @protected annotation to Chat:parse for clarity - Make Overlay:show_help pos parameter optional for flexibility These changes enhance code readability and maintainability by refining function signatures and documentation. --- lua/CopilotChat/ui/chat.lua | 7 +++---- lua/CopilotChat/ui/overlay.lua | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index ce7a1c3d..2f97fe74 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -516,11 +516,8 @@ end ---@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:parse() + local message = self:get_message(role, cursor) if not message then return @@ -615,6 +612,8 @@ function Chat:validate() end end +--- Parse the chat window buffer into structured messages. +---@protected function Chat:parse() self:validate() diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index ddaa41a5..7a56c33b 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -130,7 +130,7 @@ end --- Show help message in the overlay ---@param msg string? ----@param pos number +---@param pos number? ---@protected function Overlay:show_help(msg, pos) if not msg or msg == '' then From f49df19d5a8925d295ac6472c30b36584bd10d93 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 09:40:30 +0200 Subject: [PATCH 125/250] feat(health): require markdown parser and copilotchat query (#1401) Update health checks to require the markdown treesitter parser and copilotchat query for chat parsing. Errors are now shown if either is missing, with instructions for installation. This ensures proper chat highlighting and parsing functionality. Signed-off-by: Tomas Slusny --- lua/CopilotChat/health.lua | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index 1c8bc3b4..0c3bcfe9 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -38,6 +38,15 @@ local function treesitter_parser_available(ft) return res and parser ~= nil end +--- Check if a treesitter query is available +---@param ft string +---@param query_name string +---@return boolean +local function treesitter_query_available(ft, query_name) + local query = vim.treesitter.query.get(ft, query_name) + return query ~= nil +end + function M.check() start('CopilotChat.nvim [core]') @@ -145,8 +154,16 @@ function M.check() if treesitter_parser_available('markdown') then ok('treesitter[markdown]: installed') else - warn( - 'treesitter[markdown]: missing, optional for better chat highlighting. Install `nvim-treesitter/nvim-treesitter` plugin and run `:TSInstall markdown`.' + error( + 'treesitter[markdown]: missing, required for chat parsing. Install `nvim-treesitter/nvim-treesitter` plugin and run `:TSInstall markdown`.' + ) + end + + if treesitter_query_available('markdown', 'copilotchat') then + ok('treesitter[markdown/copilotchat]: found') + else + error( + 'treesitter[markdown/copilotchat]: missing, required for chat parsing. See `:h CopilotChat-installation` for instructions.' ) end From 4a45e69de8ad2b72ef62ede5a554c68c9632e718 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 10:03:50 +0200 Subject: [PATCH 126/250] perf(chat): simplify last line/column calculation (#1402) Replaces Chat:last() method with a standalone last() function for retrieving the last line and column of the chat buffer. Updates references to use the new function and removes redundant logic. This improves code clarity and maintainability. --- lua/CopilotChat/ui/chat.lua | 41 +++++++++++++++---------------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 2f97fe74..4681779b 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -52,6 +52,21 @@ local function match_block_header(header) end end +--- Get the last line and column of the chat window. +---@param bufnr number +---@return number, number +---@protected +local function last(bufnr) + local line_count = vim.api.nvim_buf_line_count(bufnr) + if line_count == 0 then + return 0, 0 + end + local last_line = line_count - 1 + local last_line_content = vim.api.nvim_buf_get_lines(bufnr, last_line, last_line + 1, false) + local last_column = last_line_content[1] and #last_line_content[1] or 0 + return last_line, last_column +end + ---@class CopilotChat.ui.chat.Header ---@field filename string ---@field start_line number @@ -426,11 +441,7 @@ function Chat:follow() return end - local last_line, last_column, line_count = self:last() - if line_count == 0 then - return - end - + local last_line, last_column = last(self.bufnr) vim.api.nvim_win_set_cursor(self.winnr, { last_line + 1, last_column }) end @@ -557,7 +568,7 @@ function Chat:append(str) should_follow_cursor = current_pos[1] >= line_count - 1 end - local last_line, last_column, _ = self:last() + local last_line, last_column, _ = last(self.bufnr) local modifiable = vim.bo[self.bufnr].modifiable vim.bo[self.bufnr].modifiable = true @@ -884,22 +895,4 @@ function Chat:render() end end ---- Get the last line and column of the chat window. ----@return number, number, number ----@protected -function Chat:last() - self:validate() - local line_count = vim.api.nvim_buf_line_count(self.bufnr) - local last_line = line_count - 1 - if last_line < 0 then - return 0, 0, line_count - end - local last_line_content = vim.api.nvim_buf_get_lines(self.bufnr, -2, -1, false) - if not last_line_content or #last_line_content == 0 then - return last_line, 0, line_count - end - local last_column = #last_line_content[1] - return last_line, last_column, line_count -end - return Chat From 1041ad0034e65e4a63859172d31e7045c8975d87 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 12:14:08 +0200 Subject: [PATCH 127/250] perf(chat): optimize message storage and access (#1403) Switch chat message storage to OrderedMap for improved performance and consistency. Refactor all message access to use get_messages() and update related logic for adding, removing, and parsing messages. Adds remove() method to OrderedMap utility. This change improves efficiency and prepares for future scalability. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 9 ++-- lua/CopilotChat/init.lua | 6 +-- lua/CopilotChat/ui/chat.lua | 65 +++++++++++++++------------- lua/CopilotChat/utils/orderedmap.lua | 13 ++++++ tests/orderedmap_spec.lua | 9 ++++ 5 files changed, 65 insertions(+), 37 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 46f47e01..23867f61 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -279,9 +279,10 @@ return { normal = 'gqa', callback = function() local items = {} - for i, message in ipairs(copilot.chat.messages) do + local messages = copilot.chat:get_messages() + for i, message in ipairs(messages) do if message.section and message.role == constants.ROLE.ASSISTANT then - local prev_message = copilot.chat.messages[i - 1] + local prev_message = messages[i - 1] local text = '' if prev_message then text = prev_message.content @@ -305,8 +306,8 @@ return { normal = 'gqd', callback = function(source) local items = {} - - for _, message in ipairs(copilot.chat.messages) do + local messages = copilot.chat:get_messages() + for _, message in ipairs(messages) do if message.section then for _, block in ipairs(message.section.blocks) do local diff = get_diff(source.bufnr, block) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index d87b90e7..69d6ac77 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -356,7 +356,7 @@ function M.resolve_functions(prompt, config) local resolved_resources = {} local resolved_tools = {} local tool_calls = {} - for _, message in ipairs(M.chat.messages) do + for _, message in ipairs(M.chat:get_messages()) do if message.tool_calls then for _, tool_call in ipairs(message.tool_calls) do table.insert(tool_calls, tool_call) @@ -868,7 +868,7 @@ function M.ask(prompt, config) local ask_response = client.ask(client, prompt, { headless = config.headless, - history = M.chat.messages, + history = M.chat:get_messages(), resources = resolved_resources, tools = selected_tools, system_prompt = system_prompt, @@ -948,7 +948,7 @@ function M.save(name, history_path) return end - local history = vim.deepcopy(M.chat.messages) + local history = vim.deepcopy(M.chat:get_messages()) for _, message in ipairs(history) do message.section = nil end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 4681779b..95d7fc1e 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -4,6 +4,7 @@ local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') +local orderedmap = require('CopilotChat.utils.orderedmap') function CopilotChatFoldExpr(lnum, separator) local to_match = separator .. '$' @@ -93,7 +94,7 @@ end ---@field config CopilotChat.config.Shared ---@field token_count number? ---@field token_max_count number? ----@field messages table +---@field private messages OrderedMap ---@field private layout CopilotChat.config.Layout? ---@field private headers table ---@field private separator string @@ -106,7 +107,7 @@ local Chat = class(function(self, config, on_buf_create) self.config = config self.token_count = nil self.token_max_count = nil - self.messages = {} + self.messages = orderedmap() self.layout = nil self.headers = {} @@ -168,6 +169,9 @@ 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) + self:parse() + local messages = self:get_messages() + if cursor then if not self:visible() then return nil @@ -178,7 +182,7 @@ function Chat:get_block(role, cursor) local closest_block = nil local max_line_below_cursor = -1 - for _, message in ipairs(self.messages) do + for _, message in ipairs(messages) do local section = message.section local matches_role = not role or message.role == role if matches_role and section and section.blocks then @@ -194,8 +198,8 @@ function Chat:get_block(role, cursor) return closest_block end - for i = #self.messages, 1, -1 do - local message = self.messages[i] + for i = #messages, 1, -1 do + local message = 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] @@ -203,12 +207,19 @@ function Chat:get_block(role, cursor) end end +--- Get list of all chat messages +---@return table +function Chat:get_messages() + return self.messages:values() +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, cursor) self:parse() + local messages = self:get_messages() if cursor then if not self:visible() then @@ -220,7 +231,7 @@ function Chat:get_message(role, cursor) local closest_message = nil local max_line_below_cursor = -1 - for _, message in ipairs(self.messages) do + for _, message in ipairs(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 @@ -232,8 +243,8 @@ function Chat:get_message(role, cursor) return closest_message end - for i = #self.messages, 1, -1 do - local message = self.messages[i] + for i = #messages, 1, -1 do + local message = messages[i] local matches_role = not role or message.role == role if matches_role then return message @@ -479,7 +490,8 @@ end function Chat:add_message(message, replace) self:parse() - local current_message = self.messages[#self.messages] + local messages = self:get_messages() + local current_message = messages[#messages] local is_new = not current_message or current_message.role ~= message.role or (message.id and current_message.id ~= message.id) @@ -488,7 +500,7 @@ function Chat:add_message(message, replace) -- Add appropriate header based on role and generate a new ID if not provided message.id = message.id or utils.uuid() local header = self.headers[message.role] - table.insert(self.messages, message) + self.messages:set(message.id, message) if current_message then self:append('\n') @@ -546,12 +558,7 @@ function Chat:remove_message(role, cursor) vim.bo[self.bufnr].modifiable = modifiable -- Remove the message from the messages list - for i, msg in ipairs(self.messages) do - if msg.id == message.id then - table.remove(self.messages, i) - break - end - end + self.messages:remove(message.id) end --- Append text to the chat window. @@ -585,7 +592,7 @@ function Chat:clear() self:validate() self.token_count = nil self.token_max_count = nil - self.messages = {} + self.messages = orderedmap() local modifiable = vim.bo[self.bufnr].modifiable vim.bo[self.bufnr].modifiable = true @@ -718,15 +725,8 @@ function Chat:parse() -- Finish last message current_message.section.end_line = vim.api.nvim_buf_line_count(self.bufnr) - -- Build lookup table for previous messages by id - local old_messages_by_id = {} - for _, msg in ipairs(self.messages or {}) do - if msg.id then - old_messages_by_id[msg.id] = msg - end - end - -- Format new messages and preserve extra fields from old messages + local messages = orderedmap() for _, message in ipairs(new_messages) do message.content = vim.trim(table.concat(message.content, '\n')) if message.section then @@ -735,7 +735,7 @@ function Chat:parse() end end - local old = old_messages_by_id[message.id] + local old = self.messages:get(message.id) if old then for k, v in pairs(old) do if message[k] == nil then @@ -743,9 +743,12 @@ function Chat:parse() end end end + + messages:set(message.id, message) end - self.messages = new_messages + -- Update messages + self.messages = messages end --- Render the chat window. @@ -757,7 +760,9 @@ function Chat:render() vim.api.nvim_buf_clear_namespace(self.bufnr, highlight_ns, 0, -1) -- Clear previous highlights self:show_help() -- Clear previous help - for i, message in ipairs(self.messages) do + local messages = self:get_messages() + + for i, message in ipairs(messages) do if self.config.highlight_headers then -- Overlay section header with nice display local header_value = self.headers[message.role] @@ -847,7 +852,7 @@ function Chat:render() end end - if i == #self.messages and message.role == constants.ROLE.USER then + if i == #messages and message.role == constants.ROLE.USER then -- Highlight tools in the last user message local assistant_msg = self:get_message(constants.ROLE.ASSISTANT) if assistant_msg and assistant_msg.tool_calls and #assistant_msg.tool_calls > 0 then @@ -883,7 +888,7 @@ function Chat:render() -- Auto fold non-assistant messages if enabled if self.config.auto_fold and self:visible() then - if message.role ~= constants.ROLE.ASSISTANT and message.section and i < #self.messages then + if message.role ~= constants.ROLE.ASSISTANT and message.section and i < #messages then vim.api.nvim_win_call(self.winnr, function() local fold_level = vim.fn.foldlevel(message.section.start_line) if fold_level > 0 and vim.fn.foldclosed(message.section.start_line) == -1 then diff --git a/lua/CopilotChat/utils/orderedmap.lua b/lua/CopilotChat/utils/orderedmap.lua index 778c686d..1907c161 100644 --- a/lua/CopilotChat/utils/orderedmap.lua +++ b/lua/CopilotChat/utils/orderedmap.lua @@ -1,6 +1,7 @@ ---@class OrderedMap ---@field set fun(self:OrderedMap, key:any, value:any) ---@field get fun(self:OrderedMap, key:any):any +---@field remove fun(self:OrderedMap, key:any) ---@field keys fun(self:OrderedMap):table ---@field values fun(self:OrderedMap):table @@ -22,6 +23,18 @@ local function orderedmap() return self._data[key] end, + remove = function(self, key) + if self._data[key] then + self._data[key] = nil + for i, k in ipairs(self._keys) do + if k == key then + table.remove(self._keys, i) + break + end + end + end + end, + keys = function(self) return self._keys end, diff --git a/tests/orderedmap_spec.lua b/tests/orderedmap_spec.lua index 9000915c..b5fa5a37 100644 --- a/tests/orderedmap_spec.lua +++ b/tests/orderedmap_spec.lua @@ -25,4 +25,13 @@ describe('CopilotChat.utils.orderedmap', function() assert.are.same({ 'a' }, map:keys()) assert.are.same({ 2 }, map:values()) end) + + it('removes values and updates order', function() + local map = orderedmap() + map:set('a', 1) + map:set('b', 2) + map:remove('a') + assert.are.same({ 'b' }, map:keys()) + assert.are.same({ 2 }, map:values()) + end) end) From 418562ef83fba5c155a06f27c144fcb28c5ff815 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 12:17:26 +0200 Subject: [PATCH 128/250] refactor(ui): remove redundant spinner checks in chat (#1404) Simplifies Chat:start and Chat:finish by removing unnecessary checks for self.spinner. Assumes spinner is always present, streamlining the code and reducing conditional branches. Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 95d7fc1e..f018d6b7 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -464,19 +464,12 @@ function Chat:start() utils.return_to_normal_mode() end - if self.spinner then - self.spinner:start() - end - + self.spinner:start() vim.bo[self.bufnr].modifiable = false end --- Finish writing to the chat window. function Chat:finish() - if not self.spinner then - return - end - self.spinner:finish() vim.bo[self.bufnr].modifiable = true if self.config.auto_insert_mode and self:focused() then From 9fdf8951efff6ab4f46e06945e5d6425bdbf4f80 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 20:45:26 +0200 Subject: [PATCH 129/250] feat(diff): add experimental unified diff support, refactor handling (#1392) - Introduce experimental unified diff parsing and application utilities - Refactor mappings to support both block and unified diff formats - Add configuration option to select diff format - Update prompts and instructions for diff formats and tool usage - Improve chat UI parsing for diff blocks - Add tests for diff utilities and edge cases Signed-off-by: Tomas Slusny --- lua/CopilotChat/config.lua | 2 + lua/CopilotChat/config/mappings.lua | 340 ++++++------------ lua/CopilotChat/config/prompts.lua | 59 +-- lua/CopilotChat/init.lua | 16 +- .../instructions/edit_file_block.lua | 41 +++ .../instructions/edit_file_unified.lua | 68 ++++ lua/CopilotChat/instructions/tool_use.lua | 12 + lua/CopilotChat/ui/chat.lua | 42 ++- lua/CopilotChat/utils/diff.lua | 229 ++++++++++++ tests/diff_spec.lua | 111 ++++++ 10 files changed, 620 insertions(+), 300 deletions(-) create mode 100644 lua/CopilotChat/instructions/edit_file_block.lua create mode 100644 lua/CopilotChat/instructions/edit_file_unified.lua create mode 100644 lua/CopilotChat/instructions/tool_use.lua create mode 100644 lua/CopilotChat/utils/diff.lua create mode 100644 tests/diff_spec.lua diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index b525e995..5261c70e 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -19,6 +19,7 @@ ---@field tools string|table|nil ---@field resources string|table|nil ---@field sticky string|table|nil +---@field diff 'block'|'unified'? ---@field language string? ---@field temperature number? ---@field headless boolean? @@ -62,6 +63,7 @@ return { tools = nil, -- Default tool or array of tools (or groups) to share with LLM (can be specified manually in prompt via @). resources = 'selection', -- Default resources to share with LLM (can be specified manually in prompt via #). sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat (can be specified manually in prompt via >). + diff = 'block', -- Default diff format to use, 'block' or 'unified'. language = 'English', -- Default language to use for answers temperature = 0.1, -- Result temperature diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 23867f61..97a384f8 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -4,103 +4,25 @@ local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') +local diff = require('CopilotChat.utils.diff') local files = require('CopilotChat.utils.files') ----@class CopilotChat.config.mappings.Diff ----@field change string ----@field reference string ----@field filename string ----@field filetype string ----@field start_line number ----@field end_line number ----@field bufnr number? - ---- Get diff data from a block ----@param bufnr number ----@param block CopilotChat.ui.chat.Block? ----@return CopilotChat.config.mappings.Diff? -local function get_diff(bufnr, block) - -- If no block found, return nil - if not block then - return nil - end - - local header = block.header - local selection = select.get(bufnr) - local filename = nil - local filetype = nil - local start_line = nil - local end_line = nil - local reference = nil - local bufnr = nil - - if selection then - -- If we have a selection, use it as default source of truth - filename = selection.filename - filetype = selection.filetype - start_line = selection.start_line - end_line = selection.end_line - reference = selection.content - bufnr = selection.bufnr - end - - -- If we have header info, use it as source of truth - if header.start_line and header.end_line then - filename = files.uri_to_filename(header.filename) - filetype = header.filetype or files.filetype(filename) - start_line = header.start_line - end_line = header.end_line - - -- Try to find matching buffer and window - bufnr = nil - for _, win in ipairs(vim.api.nvim_list_wins()) do - local win_buf = vim.api.nvim_win_get_buf(win) - if files.filename_same(vim.api.nvim_buf_get_name(win_buf), header.filename) then - bufnr = win_buf - break - end - end - - -- If we found a valid buffer, get the reference content - if bufnr and utils.buf_valid(bufnr) then - local lines = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false) - reference = table.concat(lines, '\n') - filetype = vim.bo[bufnr].filetype - end - end - - -- If we are missing info, there is no diff to be made - if not start_line or not end_line or not filename then - return nil - end - - return { - change = block.content, - reference = reference or '', - filetype = filetype or '', - filename = filename, - start_line = start_line, - end_line = end_line, - bufnr = bufnr, - } -end - --- Prepare a buffer for applying a diff ----@param diff CopilotChat.config.mappings.Diff? ----@param source CopilotChat.source? ----@return CopilotChat.config.mappings.Diff? -local function prepare_diff_buffer(diff, source) - if not diff then - return diff +---@param filename string? +---@param source CopilotChat.source +---@return integer +local function prepare_diff_buffer(filename, source) + if not filename then + filename = vim.api.nvim_buf_get_name(source.bufnr) end - local diff_bufnr = diff.bufnr + local diff_bufnr = nil -- If buffer is not found, try to load it if not diff_bufnr then -- Try to find matching buffer first for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if files.filename_same(vim.api.nvim_buf_get_name(buf), diff.filename) then + if files.filename_same(vim.api.nvim_buf_get_name(buf), filename) then diff_bufnr = buf break end @@ -108,11 +30,9 @@ local function prepare_diff_buffer(diff, source) -- If still not found, create a new buffer if not diff_bufnr then - diff_bufnr = vim.fn.bufadd(diff.filename) + diff_bufnr = vim.fn.bufadd(filename) vim.fn.bufload(diff_bufnr) end - - diff.bufnr = diff_bufnr end -- If source exists, update it to point to the diff buffer @@ -121,7 +41,7 @@ local function prepare_diff_buffer(diff, source) vim.api.nvim_win_set_buf(source.winnr, diff_bufnr) end - return diff + return diff_bufnr end ---@class CopilotChat.config.mapping @@ -132,9 +52,6 @@ end ---@class CopilotChat.config.mapping.yank_diff : CopilotChat.config.mapping ---@field register string? ----@class CopilotChat.config.mapping.show_diff : CopilotChat.config.mapping ----@field full_diff boolean? - ---@class CopilotChat.config.mappings ---@field complete CopilotChat.config.mapping|false|nil ---@field close CopilotChat.config.mapping|false|nil @@ -145,7 +62,7 @@ end ---@field jump_to_diff CopilotChat.config.mapping|false|nil ---@field quickfix_diffs CopilotChat.config.mapping|false|nil ---@field yank_diff CopilotChat.config.mapping.yank_diff|false|nil ----@field show_diff CopilotChat.config.mapping.show_diff|false|nil +---@field show_diff CopilotChat.config.mapping|false|nil ---@field show_info CopilotChat.config.mapping|false|nil ---@field show_help CopilotChat.config.mapping|false|nil return { @@ -248,87 +165,41 @@ return { normal = '', insert = '', callback = function(source) - local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) - diff = prepare_diff_buffer(diff, source) - if not diff then + local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + if not block then return end - local lines = utils.split_lines(diff.change) - vim.api.nvim_buf_set_lines(diff.bufnr, diff.start_line - 1, diff.end_line, false, lines) - select.set(source.bufnr, source.winnr, diff.start_line, diff.start_line + #lines - 1) - select.highlight(source.bufnr) + local path = block.header.filename + local bufnr = prepare_diff_buffer(path, source) + local new_lines, applied = diff.apply_diff(block, bufnr) + if not applied then + new_lines = utils.split_lines(block.content) + end + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) + local first, last = diff.get_diff_region(block, bufnr) + if first and last then + select.set(bufnr, source.winnr, first, last) + select.highlight(bufnr) + end end, }, jump_to_diff = { normal = 'gj', callback = function(source) - local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) - diff = prepare_diff_buffer(diff, source) - if not diff then + local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + if not block then return end - select.set(source.bufnr, source.winnr, diff.start_line, diff.end_line) - select.highlight(source.bufnr) - end, - }, - - quickfix_answers = { - normal = 'gqa', - callback = function() - local items = {} - local messages = copilot.chat:get_messages() - for i, message in ipairs(messages) do - if message.section and message.role == constants.ROLE.ASSISTANT then - local prev_message = messages[i - 1] - local text = '' - if prev_message then - text = prev_message.content - end - - table.insert(items, { - bufnr = copilot.chat.bufnr, - lnum = message.section.start_line, - end_lnum = message.section.end_line, - text = text, - }) - end - end - - vim.fn.setqflist(items) - vim.cmd('copen') - end, - }, - - quickfix_diffs = { - normal = 'gqd', - callback = function(source) - local items = {} - local messages = copilot.chat:get_messages() - for _, message in ipairs(messages) do - if message.section then - for _, block in ipairs(message.section.blocks) do - local diff = get_diff(source.bufnr, block) - if diff then - local text = string.format('%s (%s)', diff.filename, diff.filetype) - if diff.start_line and diff.end_line then - text = text .. string.format(' [lines %d-%d]', diff.start_line, diff.end_line) - end - - table.insert(items, { - bufnr = copilot.chat.bufnr, - lnum = block.start_line, - end_lnum = block.end_line, - text = text, - }) - end - end - end - - vim.fn.setqflist(items) - vim.cmd('copen') + local path = block.header.filename + local bufnr = prepare_diff_buffer(path, source) + local first, last = diff.get_diff_region(block, bufnr) + if first and last and bufnr then + select.set(bufnr, source.winnr, first, last) + select.highlight(bufnr) end end, }, @@ -348,99 +219,96 @@ return { show_diff = { normal = 'gd', - full_diff = false, -- Show full diff instead of unified diff when showing diff window callback = function(source) - local diff = get_diff(source.bufnr, copilot.chat:get_block(constants.ROLE.ASSISTANT, true)) - diff = prepare_diff_buffer(diff, source) - if not diff then + local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + if not block then return end + local path = block.header.filename + local bufnr = prepare_diff_buffer(path, source) + local new_lines, applied = diff.apply_diff(block, bufnr) + if not applied then + new_lines = utils.split_lines(block.content) + end + local original_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local opts = { - filetype = diff.filetype, - syntax = 'diff', + filetype = vim.bo[bufnr].filetype, + text = applied and table.concat(new_lines, '\n') or table.concat(original_lines, '\n'), } - if copilot.config.mappings.show_diff.full_diff then - local original = utils.buf_valid(diff.bufnr) and vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) or {} - - if #original > 0 then - -- Find all diffs from the same file in this section - local message = copilot.chat:get_message(constants.ROLE.ASSISTANT, true) - local section = message and message.section - local same_file_diffs = {} - if section then - for _, block in ipairs(section.blocks) do - local block_diff = get_diff(source.bufnr, block) - if block_diff and block_diff.bufnr == diff.bufnr then - table.insert(same_file_diffs, block_diff) - end - end - end + opts.on_show = function() + vim.api.nvim_win_call(source.winnr, function() + vim.cmd('diffthis') + end) - -- Ensure we at least apply the current diff - if #same_file_diffs == 0 then - table.insert(same_file_diffs, diff) - end - - -- Sort diffs by start_line in descending order (apply from bottom to top) - table.sort(same_file_diffs, function(a, b) - return a.start_line > b.start_line - end) + vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.cmd('diffthis') + end) + end - local result = vim.deepcopy(original) + opts.on_hide = function() + vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.cmd('diffoff') + end) + end - -- Apply diffs from bottom to top so line numbers remain valid - for _, d in ipairs(same_file_diffs) do - local change_lines = utils.split_lines(d.change) + copilot.chat:overlay(opts) + end, + }, - -- Remove original lines (from end to start to avoid index shifting) - for i = d.end_line, d.start_line, -1 do - if result[i] then - table.remove(result, i) - end + quickfix_diffs = { + normal = 'gqd', + callback = function() + local items = {} + local messages = copilot.chat:get_messages() + for _, message in ipairs(messages) do + if message.section then + for _, block in ipairs(message.section.blocks) do + local text = string.format('%s (%s)', block.header.filename, block.header.filetype) + if block.header.start_line and block.header.end_line then + text = text .. string.format(' [lines %d-%d]', block.header.start_line, block.header.end_line) end - -- Insert replacement lines at start_line - for i = #change_lines, 1, -1 do - table.insert(result, d.start_line, change_lines[i]) - end + table.insert(items, { + bufnr = copilot.chat.bufnr, + lnum = block.start_line, + end_lnum = block.end_line, + text = text, + }) end - - opts.text = table.concat(result, '\n') - else - opts.text = diff.change end - opts.on_show = function() - vim.api.nvim_win_call(vim.fn.bufwinid(diff.bufnr), function() - vim.cmd('diffthis') - end) + vim.fn.setqflist(items) + vim.cmd('copen') + end + end, + }, - vim.api.nvim_win_call(copilot.chat.winnr, function() - vim.cmd('diffthis') - end) - end + quickfix_answers = { + normal = 'gqa', + callback = function() + local items = {} + for i, message in ipairs(copilot.chat.messages) do + if message.section and message.role == constants.ROLE.ASSISTANT then + local prev_message = copilot.chat.messages[i - 1] + local text = '' + if prev_message then + text = prev_message.content + end - opts.on_hide = function() - vim.api.nvim_win_call(copilot.chat.winnr, function() - vim.cmd('diffoff') - end) + table.insert(items, { + bufnr = copilot.chat.bufnr, + lnum = message.section.start_line, + end_lnum = message.section.end_line, + text = text, + }) end - else - opts.text = tostring(vim.diff(diff.reference, diff.change, { - result_type = 'unified', - ignore_blank_lines = true, - ignore_whitespace = true, - ignore_whitespace_change = true, - ignore_whitespace_change_at_eol = true, - ignore_cr_at_eol = true, - algorithm = 'myers', - ctxlen = #diff.reference, - })) end - copilot.chat:overlay(opts) + vim.fn.setqflist(items) + vim.cmd('copen') end, }, diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 6b8562d2..40d9be1e 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -23,14 +23,15 @@ The user works in editor called Neovim which has these core concepts: - Treesitter: Provides syntax highlighting, code folding, and structural text editing based on syntax tree parsing - Visual selection: Text selected in visual mode that can be shared as context The user is working on a {OS_NAME} machine. Please respond with system specific commands if applicable. -The user is currently in workspace directory {DIR} (typically the project root). Current file paths will be relative to this directory. +The user is currently in workspace directory {DIR} (project root). File paths are relative to this directory. Context is provided to you in several ways: - Resources: Contextual data shared via "# " headers and referenced via "##" links - Code blocks with file path labels and line numbers (e.g., ```lua path=/file.lua start_line=1 end_line=10```) + Note: Line numbers prefixed to each line are for reference only and should never be included when outputting code - Visual selections: Text selected in visual mode that can be shared as context -- Diffs: Changes shown in unified diff format with line prefixes (+, -, etc.) +- Diffs: Changes shown in unified diff format (+, -, etc.) - Conversation history When resources (like buffers, files, or diffs) change, their content in the chat history is replaced with the latest version rather than appended as new data. @@ -40,57 +41,8 @@ If you can infer the project type (languages, frameworks, libraries) from contex For implementing features, break down the request into concepts and provide a clear solution. Think creatively to provide complete solutions based on the information available. Never fabricate or hallucinate file contents you haven't actually seen in the provided context. +When outputting code, never include line number prefixes - they are only for reference when analyzing the provided context. - -If tools are available for a requested action (such as file edit, read, search, diagnostics, etc.), you MUST use the tool to perform the action. Only provide manual code or instructions if no tool exists for that purpose. -- Always prefer tool usage over manual edits or suggestions. -- Follow JSON schema precisely when using tools, including all required properties and outputting valid JSON. -- Use appropriate tools for tasks rather than asking for manual actions or generating code for actions you can perform directly. -- Execute actions directly when you indicate you'll do so, without asking for permission. -- Only use tools that exist and use proper invocation procedures - no multi_tool_use.parallel unless specified. -- Before using tools to retrieve information, check if context is already available as described in the context instructions above. -- If you don't have explicit tool definitions in your system prompt, clearly state this limitation when asked. NEVER pretend to have tool capabilities you don't possess. - - -Use these instructions when editing files via code blocks. Your goal is to produce clear, minimal, and precise file edits. - -Steps for presenting code changes: -1. For each change, use the following markdown code block format with triple backticks: - ``` path= start_line= end_line= - - ``` - -2. Examples: - ```lua path={DIR}/lua/CopilotChat/init.lua start_line=40 end_line=50 - local function example() - print("This is an example function.") - end - ``` - - ```python path={DIR}/scripts/example.py start_line=10 end_line=15 - def example_function(): - print("This is an example function.") - ``` - - ```json path={DIR}/config/settings.json start_line=5 end_line=8 - { - "setting": "value", - "enabled": true - } - ``` - -3. Requirements for code content: - - Always use the absolute file path in the code block header. If the path is not already absolute, convert it to an absolute path prefixed by {DIR}. - - Keep changes minimal and focused to produce short diffs - - Include complete replacement code for the specified line range - - Proper indentation matching the source - - All necessary lines (no eliding with comments) - - **Never include line number prefixes in your output code blocks. Only output valid code, exactly as it should appear in the file. Line numbers are only allowed in the code block header.** - - Address any diagnostics issues when fixing code - -4. If multiple changes are needed, present them as separate code blocks. - - ]], }, @@ -205,10 +157,9 @@ If no issues found, confirm the code is well-written and explain why. }, Commit = { - prompt = 'Write commit message for the change with commitizen convention. Keep the title under 50 characters and wrap message at 72 characters. Format as a gitcommit code block. If user has COMMIT_EDITMSG opened, generate replacement block for whole buffer.', + prompt = 'Write commit message for the change with commitizen convention. Keep the title under 50 characters and wrap message at 72 characters. Format as a gitcommit code block.', resources = { 'gitdiff:staged', - 'buffer', }, }, } diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 69d6ac77..5dc129af 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -563,7 +563,21 @@ function M.resolve_prompt(prompt, config) config.system_prompt = M.config.prompts[config.system_prompt].system_prompt end - config.system_prompt = config.system_prompt .. '\n' .. M.config.prompts.COPILOT_BASE.system_prompt + config.system_prompt = vim.trim(config.system_prompt) .. '\n' .. M.config.prompts.COPILOT_BASE.system_prompt + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.tool_use')) + + if config.diff == 'unified' then + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.edit_file_unified')) + else + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.edit_file_block')) + end + config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) config.system_prompt = config.system_prompt:gsub('{DIR}', state.source.cwd()) diff --git a/lua/CopilotChat/instructions/edit_file_block.lua b/lua/CopilotChat/instructions/edit_file_block.lua new file mode 100644 index 00000000..8abc8719 --- /dev/null +++ b/lua/CopilotChat/instructions/edit_file_block.lua @@ -0,0 +1,41 @@ +return [[ + +Use these instructions when editing files via code blocks. Your goal is to produce clear, minimal, and precise file edits. + +Steps for presenting code changes: +1. For each change, use the following markdown code block format with triple backticks: + ``` path= start_line= end_line= + + ``` + +2. Examples: + ```lua path={DIR}/lua/CopilotChat/init.lua start_line=40 end_line=50 + local function example() + print("This is an example function.") + end + ``` + + ```python path={DIR}/scripts/example.py start_line=10 end_line=15 + def example_function(): + print("This is an example function.") + ``` + + ```json path={DIR}/config/settings.json start_line=5 end_line=8 + { + "setting": "value", + "enabled": true + } + ``` + +3. Requirements for code content: + - Always use the absolute file path in the code block header. If the path is not already absolute, convert it to an absolute path prefixed by {DIR}. + - Keep changes minimal and focused to produce short diffs + - Include complete replacement code for the specified line range + - Proper indentation matching the source + - All necessary lines (no eliding with comments) + - **Never include line number prefixes in your output code blocks. Only output valid code, exactly as it should appear in the file. Line numbers are only allowed in the code block header.** + - Address any diagnostics issues when fixing code + +4. If multiple changes are needed, present them as separate code blocks. + +]] diff --git a/lua/CopilotChat/instructions/edit_file_unified.lua b/lua/CopilotChat/instructions/edit_file_unified.lua new file mode 100644 index 00000000..b5d20861 --- /dev/null +++ b/lua/CopilotChat/instructions/edit_file_unified.lua @@ -0,0 +1,68 @@ +return [[ + +Return edits similar to unified diffs that `diff -U0` would produce. + +- Always include the first 2 lines with the file paths (no timestamps). +- Start each hunk of changes with a `@@ ... @@` line. +- Do not include line numbers in the hunk header. +- The user's patch tool needs CORRECT patches that apply cleanly against the current contents of the file. +- Indentation matters in the diffs! + +Context lines: +- For each hunk that contains changes, you MUST always include 2-3 context lines before the change. +- ALWAYS prefix every context line with a single space character. +- Context lines MUST ONLY appear BEFORE changes, NEVER after changes. +- MISSING CONTEXT LINES WILL CAUSE PATCH FAILURES - they are mandatory, not optional. +- MISSING SPACE PREFIXES WILL CAUSE PATCH FAILURES - they are mandatory, not optional. + +Change lines: +- Mark all lines to be removed or changed with `-`. +- Mark all new or modified lines with `+`. +- Only output hunks that specify changes with `+` or `-` lines. + +Other instructions: +- Start a new hunk for each section of the file that needs changes. +- When editing a function, method, loop, etc., replace the entire code block: delete the entire existing version with `-` lines, then add the new, updated version with `+` lines. +- To move code within a file, use 2 hunks: one to delete it from its current location, one to insert it in the new location. +- To make a new file, show a diff from `--- /dev/null` to `+++ path/to/new/file.ext`. + +Example: + +```diff +--- mathweb/flask/app.py ++++ mathweb/flask/app.py +@@ ... @@ +-class MathWeb: ++import sympy ++ ++class MathWeb: +@@ ... @@ +-def is_prime(x): +- if x < 2: +- return False +- for i in range(2, int(math.sqrt(x)) + 1): +- if x % i == 0: +- return False +- return True +@@ ... @@ +-@app.route('/prime/') +-def nth_prime(n): +- count = 0 +- num = 1 +- while count < n: +- num += 1 +- if is_prime(num): +- count += 1 +- return str(num) ++@app.route('/prime/') ++def nth_prime(n): ++ count = 0 ++ num = 1 ++ while count < n: ++ num += 1 ++ if sympy.isprime(num): ++ count += 1 ++ return str(num) +``` + +]] diff --git a/lua/CopilotChat/instructions/tool_use.lua b/lua/CopilotChat/instructions/tool_use.lua new file mode 100644 index 00000000..989bf209 --- /dev/null +++ b/lua/CopilotChat/instructions/tool_use.lua @@ -0,0 +1,12 @@ +return [[ + +If tools are available for a requested action (such as file edit, read, search, diagnostics, etc.), you MUST use the tool to perform the action. Only provide manual code or instructions if no tool exists for that purpose. +- Always prefer tool usage over manual edits or suggestions. +- Follow JSON schema precisely when using tools, including all required properties and outputting valid JSON. +- Use appropriate tools for tasks rather than asking for manual actions or generating code for actions you can perform directly. +- Execute actions directly when you indicate you'll do so, without asking for permission. +- Only use tools that exist and use proper invocation procedures - no multi_tool_use.parallel unless specified. +- Before using tools to retrieve information, check if context is already available as described in the context instructions above. +- If you don't have explicit tool definitions in your system prompt, clearly state this limitation when asked. NEVER pretend to have tool capabilities you don't possess. + +]] diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index f018d6b7..51604d6a 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -16,11 +16,6 @@ function CopilotChatFoldExpr(lnum, separator) return '=' end -local HEADER_PATTERNS = { - '^(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', - '^(%w+)$', -} - ---@param headers table? ---@return string?, string? local function match_section_header(headers, separator, line) @@ -43,7 +38,12 @@ local function match_block_header(header) return end - for _, pattern in ipairs(HEADER_PATTERNS) do + local patterns = { + '^(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', + '^(%w+)$', + } + + for _, pattern in ipairs(patterns) do local type, path, start_line, end_line = header:match(pattern) if path then return type, path, tonumber(start_line) or 1, tonumber(end_line) or tonumber(start_line) or 1 @@ -53,6 +53,23 @@ local function match_block_header(header) end end +---@param header? CopilotChat.ui.chat.Header +---@param content? string +---@return string? +local function match_block_content(header, content) + if not header or header.filetype ~= 'diff' or not content then + return + end + + local lines = vim.split(content, '\n') + for _, line in ipairs(lines) do + local diff_filename = line:match('^%+%+%+%s+(.*)') + if diff_filename then + return vim.trim(diff_filename) + end + end +end + --- Get the last line and column of the chat window. ---@param bufnr number ---@return number, number @@ -69,10 +86,10 @@ local function last(bufnr) end ---@class CopilotChat.ui.chat.Header ----@field filename string ----@field start_line number ----@field end_line number ---@field filetype string +---@field filename string +---@field start_line number? +---@field end_line number? ---@class CopilotChat.ui.chat.Block ---@field header CopilotChat.ui.chat.Header @@ -694,6 +711,7 @@ function Chat:parse() if name == 'block_header' then local header_text = vim.treesitter.get_node_text(node, self.bufnr) local filetype, filename, start_line, end_line = match_block_header(header_text) + if filetype then current_block = { header = { @@ -710,6 +728,12 @@ function Chat:parse() elseif name == 'block_content' then local content = vim.treesitter.get_node_text(node, self.bufnr) current_block.end_line = end_row + + local filename = match_block_content(current_block.header, content) + if filename then + current_block.header.filename = filename + end + table.insert(current_block.content, content) end end diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua new file mode 100644 index 00000000..8ca1a58e --- /dev/null +++ b/lua/CopilotChat/utils/diff.lua @@ -0,0 +1,229 @@ +local M = {} + +--- Parse unified diff, return file_path and hunks +---@param diff_text string The unified diff text +---@return string?, table[] +function M.parse_unified_diff(diff_text) + local hunks = {} + local current_hunk = nil + local file_path = nil + + for _, line in ipairs(vim.split(diff_text, '\n')) do + local diff_filename = line:match('^%+%+%+%s+(.*)') + if diff_filename then + file_path = diff_filename + elseif line:match('^@@') then + if current_hunk then + table.insert(hunks, current_hunk) + end + current_hunk = { minus = {}, plus = {}, context = {} } + elseif current_hunk then + local prefix = line:sub(1, 1) + local rest = line:sub(2) + if prefix == '-' then + table.insert(current_hunk.minus, rest) + elseif prefix == '+' then + table.insert(current_hunk.plus, rest) + elseif #current_hunk.plus == 0 and #current_hunk.minus == 0 then + if prefix == ' ' then + table.insert(current_hunk.context, rest) + elseif line ~= '' then + table.insert(current_hunk.context, line) + end + end + end + end + if current_hunk then + table.insert(hunks, current_hunk) + end + return file_path, hunks +end + +--- Apply unified diff to a table of lines and return new lines +---@param diff_text string +---@param original_lines table +---@return table, boolean +function M.apply_unified_diff(diff_text, original_lines) + local _, hunks = M.parse_unified_diff(diff_text) + local lines = vim.deepcopy(original_lines) + local applied_any = false + + for _, hunk in ipairs(hunks) do + -- Build the full hunk pattern: context + minus lines + local hunk_pattern = {} + for _, ctx in ipairs(hunk.context) do + table.insert(hunk_pattern, ctx) + end + for _, minus in ipairs(hunk.minus) do + table.insert(hunk_pattern, minus) + end + + -- Find all possible matches for the hunk pattern + local match_indices = {} + for i = 1, #lines - #hunk_pattern + 1 do + local match = true + for j = 1, #hunk_pattern do + if vim.trim(lines[i + j - 1]) ~= vim.trim(hunk_pattern[j]) then + match = false + break + end + end + if match then + table.insert(match_indices, i) + end + end + + if #match_indices == 1 then + local idx = match_indices[1] + -- Replace the matched region with context + plus lines + local new_region = {} + for _, ctx in ipairs(hunk.context) do + table.insert(new_region, ctx) + end + for _, plus in ipairs(hunk.plus) do + table.insert(new_region, plus) + end + + for j = 1, #hunk_pattern do + table.remove(lines, idx) + end + for j = #new_region, 1, -1 do + table.insert(lines, idx, new_region[j]) + end + applied_any = true + end + + -- If no match or multiple matches, just skip to next hunk + end + + return lines, applied_any +end + +--- Apply diff indices from vim.diff to original and new lines +---@param hunks table Indices from vim.diff (result_type = 'indices') +---@param original_lines table Lines before patch +---@param new_lines table Lines after patch +---@return table Patched lines +function M.apply_diff_indices(hunks, original_lines, new_lines) + local result = {} + local orig_idx = 1 + + for _, hunk in ipairs(hunks) do + local start_a, count_a, start_b, count_b = unpack(hunk) + -- Add unchanged lines before hunk + for i = orig_idx, start_a - 1 do + table.insert(result, original_lines[i]) + end + -- Add changed lines from new_lines + for i = start_b, start_b + count_b - 1 do + table.insert(result, new_lines[i]) + end + orig_idx = start_a + count_a + end + -- Add remaining lines + for i = orig_idx, #original_lines do + table.insert(result, original_lines[i]) + end + return result +end + +--- Get changed regions for jump/highlight +---@param diff_text string The unified diff text +---@return number?, number? +function M.get_unified_diff_region(diff_text, original_lines) + local _, hunks = M.parse_unified_diff(diff_text) + local first, last + + for _, hunk in ipairs(hunks) do + for i = 1, #original_lines - #hunk.minus + 1 do + local match = true + for j = 1, #hunk.minus do + if vim.trim(original_lines[i + j - 1]) ~= vim.trim(hunk.minus[j]) then + match = false + break + end + end + if match then + local region_start = i + local region_end = i + #hunk.plus - 1 + if not first or region_start < first then + first = region_start + end + if not last or region_end > last then + last = region_end + end + break + end + end + end + + if first and last then + return first, last + end + + return nil, nil +end + +--- Apply a diff (unified or indices) to buffer lines +---@param block CopilotChat.ui.chat.Block Block containing diff info +---@param bufnr integer Buffer number +---@return table new_lines, boolean applied +function M.apply_diff(block, bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + if block.header.filetype == 'diff' then + return M.apply_unified_diff(block.content, lines) + elseif block.header.start_line and block.header.end_line then + local start_idx = block.header.start_line + local end_idx = block.header.end_line + local original_lines = vim.list_slice(lines, start_idx, end_idx) + local patched_lines = vim.split(block.content, '\n') + local hunks = vim.diff( + table.concat(original_lines, '\n'), + table.concat(patched_lines, '\n'), + { result_type = 'indices', algorithm = 'myers', ctxlen = 3 } + ) + local region_new_lines = M.apply_diff_indices(hunks, original_lines, patched_lines) + local new_lines = {} + -- Add lines before region + for i = 1, start_idx - 1 do + table.insert(new_lines, lines[i]) + end + -- Add patched region + for _, line in ipairs(region_new_lines) do + table.insert(new_lines, line) + end + -- Add lines after region + for i = end_idx + 1, #lines do + table.insert(new_lines, lines[i]) + end + return new_lines, true + end + return lines, false +end + +--- Get changed region for diff (unified or indices) +---@param block CopilotChat.ui.chat.Block Block containing diff info +---@param bufnr integer Buffer number +---@return number? first, number? last +function M.get_diff_region(block, bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + if block.header.filetype == 'diff' then + return M.get_unified_diff_region(block.content, lines) + elseif block.header.start_line and block.header.end_line then + local original_lines = vim.api.nvim_buf_get_lines(bufnr, block.header.start_line - 1, block.header.end_line, false) + local patched_lines = vim.split(block.content, '\n') + local hunks = vim.diff( + table.concat(original_lines, '\n'), + table.concat(patched_lines, '\n'), + { result_type = 'indices', algorithm = 'myers', ctxlen = 3 } + ) + if hunks and #hunks > 0 then + local first = hunks[1][1] + local last = hunks[#hunks][1] + hunks[#hunks][2] - 1 + return first, last + end + end + return nil, nil +end + +return M diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua new file mode 100644 index 00000000..62866c40 --- /dev/null +++ b/tests/diff_spec.lua @@ -0,0 +1,111 @@ +local diff = require('CopilotChat.utils.diff') + +describe('CopilotChat.utils.diff', function() + it('parses unified diff', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context line +-old line ++new line +]] + local file_path, hunks = diff.parse_unified_diff(diff_text) + assert.equals('b/foo.txt', file_path) + assert.equals('context line', hunks[1].context[1]) + assert.equals('old line', hunks[1].minus[1]) + assert.equals('new line', hunks[1].plus[1]) + end) + + it('applies unified diff', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-old ++new +]] + local original = { 'context', 'old', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, original) + assert.is_true(applied) + assert.are.same({ 'context', 'new', 'other' }, result) + end) + + it('gets unified diff region', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-old ++new +]] + local original = { 'context', 'old', 'other' } + local first, last = diff.get_unified_diff_region(diff_text, original) + assert.equals(2, first) + assert.equals(2, last) + end) + + it('applies unified diff with no context', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ +-old ++new +]] + local original = { 'old', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, original) + assert.is_true(applied) + assert.are.same({ 'new', 'other' }, result) + end) + + it('applies unified diff with multiline edits', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context1 + context2 +-old1 +-old2 ++new1 ++new2 +]] + local original = { + 'context1', + 'context2', + 'old1', + 'old2', + 'context3', + 'other', + } + local result, applied = diff.apply_unified_diff(diff_text, original) + assert.is_true(applied) + assert.are.same({ + 'context1', + 'context2', + 'new1', + 'new2', + 'context3', + 'other', + }, result) + end) + + it('does not apply ambiguous edit', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-old ++new +]] + local original = { 'context', 'old', 'context', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, original) + -- Should not apply because there are two possible matches + assert.is_false(applied) + assert.are.same({ 'context', 'old', 'context', 'old' }, result) + end) +end) From 5c3a558f2d740df740735fbb3ea0be822004136d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 12 Sep 2025 20:58:33 +0200 Subject: [PATCH 130/250] fix(ui): handle missing filename in chat block header (#1406) Previously, chat blocks without a filename would display 'block' as the filename. This change updates the logic to use nil for missing filenames and ensures the UI displays 'block' only when filename is not present. This improves clarity and consistency in chat block rendering. --- lua/CopilotChat/ui/chat.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 51604d6a..3cf5efc7 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -48,7 +48,7 @@ local function match_block_header(header) if path then return type, path, tonumber(start_line) or 1, tonumber(end_line) or tonumber(start_line) or 1 elseif type then - return type, 'block' + return type, nil end end end @@ -801,7 +801,7 @@ function Chat:render() local header = block.header local filetype = header.filetype local filename = header.filename - local text = string.format('[%s] %s', filetype, filename) + local text = string.format('[%s] %s', filetype, filename or 'block') if header.start_line and header.end_line then text = text .. string.format(' lines %d-%d', header.start_line, header.end_line) end From 35ad8ff61f47c5546c036b9b7310ce0dd87e8d20 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 07:37:23 +0200 Subject: [PATCH 131/250] feat(diff): use diff-match-patch for better diff handling (#1407) Refactored diff utility to use diff-match-patch for unified diff application, improving reliability and handling of ambiguous hunks. Updated buffer patching and region detection to use new approach. Adjusted tests to match new diff API and behaviors. Added vendor diff_match_patch implementation. Signed-off-by: Tomas Slusny --- README.md | 8 + lua/CopilotChat/config/mappings.lua | 14 +- lua/CopilotChat/config/prompts.lua | 4 +- .../instructions/edit_file_unified.lua | 80 +- lua/CopilotChat/utils/diff.lua | 311 ++- lua/CopilotChat/vendor/diff_match_patch.lua | 2085 +++++++++++++++++ tests/diff_spec.lua | 201 +- 7 files changed, 2419 insertions(+), 284 deletions(-) create mode 100644 lua/CopilotChat/vendor/diff_match_patch.lua diff --git a/README.md b/README.md index 85bee0e5..9f1cda76 100644 --- a/README.md +++ b/README.md @@ -514,6 +514,14 @@ make test See [CONTRIBUTING.md](/CONTRIBUTING.md) for detailed guidelines. +# Acknowledgments + +## diff-match-patch + +CopilotChat.nvim includes [diff-match-patch (Lua port)](https://github.com/google/diff-match-patch) for diffing and patching functionality. +Copyright 2018 The diff-match-patch Authors. +Licensed under the Apache License 2.0. + # Contributors Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 97a384f8..05122044 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -172,11 +172,7 @@ return { local path = block.header.filename local bufnr = prepare_diff_buffer(path, source) - local new_lines, applied = diff.apply_diff(block, bufnr) - if not applied then - new_lines = utils.split_lines(block.content) - end - + local new_lines = diff.apply_diff(block, bufnr) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) local first, last = diff.get_diff_region(block, bufnr) if first and last then @@ -227,15 +223,11 @@ return { local path = block.header.filename local bufnr = prepare_diff_buffer(path, source) - local new_lines, applied = diff.apply_diff(block, bufnr) - if not applied then - new_lines = utils.split_lines(block.content) - end - local original_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local new_lines = diff.apply_diff(block, bufnr) local opts = { filetype = vim.bo[bufnr].filetype, - text = applied and table.concat(new_lines, '\n') or table.concat(original_lines, '\n'), + text = table.concat(new_lines, '\n'), } opts.on_show = function() diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 40d9be1e..53baa21f 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -29,7 +29,7 @@ The user is currently in workspace directory {DIR} (project root). File paths ar Context is provided to you in several ways: - Resources: Contextual data shared via "# " headers and referenced via "##" links - Code blocks with file path labels and line numbers (e.g., ```lua path=/file.lua start_line=1 end_line=10```) - Note: Line numbers prefixed to each line are for reference only and should never be included when outputting code + Note: Each line in code block can be prefixed with : for your reference only. NEVER include these line numbers in your responses. - Visual selections: Text selected in visual mode that can be shared as context - Diffs: Changes shown in unified diff format (+, -, etc.) - Conversation history @@ -41,7 +41,7 @@ If you can infer the project type (languages, frameworks, libraries) from contex For implementing features, break down the request into concepts and provide a clear solution. Think creatively to provide complete solutions based on the information available. Never fabricate or hallucinate file contents you haven't actually seen in the provided context. -When outputting code, never include line number prefixes - they are only for reference when analyzing the provided context. +When outputting code or diffs, NEVER include line number prefixes - they are only for reference when analyzing the provided context. ]], }, diff --git a/lua/CopilotChat/instructions/edit_file_unified.lua b/lua/CopilotChat/instructions/edit_file_unified.lua index b5d20861..9eb8f56f 100644 --- a/lua/CopilotChat/instructions/edit_file_unified.lua +++ b/lua/CopilotChat/instructions/edit_file_unified.lua @@ -2,67 +2,33 @@ return [[ Return edits similar to unified diffs that `diff -U0` would produce. -- Always include the first 2 lines with the file paths (no timestamps). -- Start each hunk of changes with a `@@ ... @@` line. -- Do not include line numbers in the hunk header. -- The user's patch tool needs CORRECT patches that apply cleanly against the current contents of the file. -- Indentation matters in the diffs! +Make sure you include the first 2 lines with the file paths. +Don't include timestamps with the file paths. +Do not use any file path prefixes, just use --- path/to/file and +++ path/to/file. -Context lines: -- For each hunk that contains changes, you MUST always include 2-3 context lines before the change. -- ALWAYS prefix every context line with a single space character. -- Context lines MUST ONLY appear BEFORE changes, NEVER after changes. -- MISSING CONTEXT LINES WILL CAUSE PATCH FAILURES - they are mandatory, not optional. -- MISSING SPACE PREFIXES WILL CAUSE PATCH FAILURES - they are mandatory, not optional. +Start each hunk of changes with a `@@` line. -Change lines: -- Mark all lines to be removed or changed with `-`. -- Mark all new or modified lines with `+`. -- Only output hunks that specify changes with `+` or `-` lines. +The user's patch tool needs CORRECT patches that apply cleanly against the current contents of the file! +Code can start with line number prefixes for reference (e.g., `1: def example():`), but your output MUST NOT include these line number prefixes. +Think carefully and make sure you include and mark all lines that need to be removed or changed as `-` lines. +Make sure you mark all new or modified lines with `+`. +Don't leave out any lines or the diff patch won't apply correctly. -Other instructions: -- Start a new hunk for each section of the file that needs changes. -- When editing a function, method, loop, etc., replace the entire code block: delete the entire existing version with `-` lines, then add the new, updated version with `+` lines. -- To move code within a file, use 2 hunks: one to delete it from its current location, one to insert it in the new location. -- To make a new file, show a diff from `--- /dev/null` to `+++ path/to/new/file.ext`. +Indentation matters in the diffs! -Example: +Start a new hunk for each section of the file that needs changes. -```diff ---- mathweb/flask/app.py -+++ mathweb/flask/app.py -@@ ... @@ --class MathWeb: -+import sympy -+ -+class MathWeb: -@@ ... @@ --def is_prime(x): -- if x < 2: -- return False -- for i in range(2, int(math.sqrt(x)) + 1): -- if x % i == 0: -- return False -- return True -@@ ... @@ --@app.route('/prime/') --def nth_prime(n): -- count = 0 -- num = 1 -- while count < n: -- num += 1 -- if is_prime(num): -- count += 1 -- return str(num) -+@app.route('/prime/') -+def nth_prime(n): -+ count = 0 -+ num = 1 -+ while count < n: -+ num += 1 -+ if sympy.isprime(num): -+ count += 1 -+ return str(num) -``` +Only output hunks that specify changes with `+` or `-` lines. + +Output hunks in whatever order makes the most sense. +Hunks don't need to be in any particular order. + +When editing a function, method, loop, etc use a hunk to replace the *entire* code block. +Delete the entire existing version with `-` lines and then add a new, updated version with `+` lines. +This will help you generate correct code and correct diffs. + +To move code within a file, use 2 hunks: 1 to delete it from its current location, 1 to insert it in the new location. + +To make a new file, show a diff from `--- /dev/null` to `+++ path/to/new/file.ext`. ]] diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 8ca1a58e..86449c97 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -1,204 +1,182 @@ local M = {} ---- Parse unified diff, return file_path and hunks ----@param diff_text string The unified diff text ----@return string?, table[] -function M.parse_unified_diff(diff_text) +--- Parse unified diff hunks from diff text +---@param diff_text string +---@return table hunks +local function parse_hunks(diff_text) local hunks = {} local current_hunk = nil - local file_path = nil - for _, line in ipairs(vim.split(diff_text, '\n')) do - local diff_filename = line:match('^%+%+%+%s+(.*)') - if diff_filename then - file_path = diff_filename - elseif line:match('^@@') then + if line:match('^@@') then if current_hunk then table.insert(hunks, current_hunk) end - current_hunk = { minus = {}, plus = {}, context = {} } + local start_old, len_old, start_new, len_new = line:match('@@%s%-(%d+),?(%d*)%s%+(%d+),?(%d*)%s@@') + current_hunk = { + start_old = tonumber(start_old), + len_old = tonumber(len_old) or 1, + start_new = tonumber(start_new), + len_new = tonumber(len_new) or 1, + old_snippet = {}, + new_snippet = {}, + } elseif current_hunk then - local prefix = line:sub(1, 1) - local rest = line:sub(2) + local prefix, rest = line:sub(1, 1), tostring(line:sub(2)) if prefix == '-' then - table.insert(current_hunk.minus, rest) + table.insert(current_hunk.old_snippet, rest) elseif prefix == '+' then - table.insert(current_hunk.plus, rest) - elseif #current_hunk.plus == 0 and #current_hunk.minus == 0 then - if prefix == ' ' then - table.insert(current_hunk.context, rest) - elseif line ~= '' then - table.insert(current_hunk.context, line) - end + table.insert(current_hunk.new_snippet, rest) + elseif prefix == ' ' then + table.insert(current_hunk.old_snippet, rest) + table.insert(current_hunk.new_snippet, rest) end end end if current_hunk then table.insert(hunks, current_hunk) end - return file_path, hunks + return hunks end ---- Apply unified diff to a table of lines and return new lines ----@param diff_text string ----@param original_lines table ----@return table, boolean -function M.apply_unified_diff(diff_text, original_lines) - local _, hunks = M.parse_unified_diff(diff_text) - local lines = vim.deepcopy(original_lines) - local applied_any = false - - for _, hunk in ipairs(hunks) do - -- Build the full hunk pattern: context + minus lines - local hunk_pattern = {} - for _, ctx in ipairs(hunk.context) do - table.insert(hunk_pattern, ctx) - end - for _, minus in ipairs(hunk.minus) do - table.insert(hunk_pattern, minus) - end +--- Apply a single hunk to content, with fallback/context logic +---@param hunk table +---@param content string +---@return string patched_content, boolean applied_cleanly +local function apply_hunk(hunk, content) + local dmp = require('CopilotChat.vendor.diff_match_patch') + local patch = dmp.patch_make(table.concat(hunk.old_snippet, '\n'), table.concat(hunk.new_snippet, '\n')) + + -- First try: direct application + local patched, results = dmp.patch_apply(patch, content) + if not vim.tbl_contains(results, false) then + return patched, true + end - -- Find all possible matches for the hunk pattern - local match_indices = {} - for i = 1, #lines - #hunk_pattern + 1 do - local match = true - for j = 1, #hunk_pattern do - if vim.trim(lines[i + j - 1]) ~= vim.trim(hunk_pattern[j]) then - match = false - break + -- Fallback: try smaller context window + local lines = vim.split(content, '\n') + local insert_idx = hunk.start_old or 1 + if not hunk.start_old then + -- No starting point, try to find best match + local match_idx, best_score = nil, -1 + local context_lines = vim.tbl_filter(function(line) + return line and line ~= '' + end, hunk.old_snippet) + local context_len = #context_lines + if context_len > 0 then + for i = 1, #lines - context_len + 1 do + local score = 0 + for j = 1, context_len do + if vim.trim(lines[i + j - 1] or '') == vim.trim(context_lines[j] or '') then + score = score + 1 + end + end + if score > best_score then + best_score = score + match_idx = i end - end - if match then - table.insert(match_indices, i) end end - - if #match_indices == 1 then - local idx = match_indices[1] - -- Replace the matched region with context + plus lines - local new_region = {} - for _, ctx in ipairs(hunk.context) do - table.insert(new_region, ctx) - end - for _, plus in ipairs(hunk.plus) do - table.insert(new_region, plus) - end - - for j = 1, #hunk_pattern do - table.remove(lines, idx) - end - for j = #new_region, 1, -1 do - table.insert(lines, idx, new_region[j]) - end - applied_any = true + if best_score > 0 and match_idx then + insert_idx = match_idx end + end - -- If no match or multiple matches, just skip to next hunk + -- Define context window around insert point + local context_size = 10 + local start_idx = insert_idx + local end_idx = insert_idx + #hunk.old_snippet + local context_start = math.max(1, start_idx - context_size) + local context_end = math.min(#lines, end_idx + context_size) + local context_window = table.concat(vim.list_slice(lines, context_start, context_end), '\n') + + local patched_window, window_results = dmp.patch_apply(patch, context_window) + if not vim.tbl_contains(window_results, false) then + -- Patch succeeded in window, splice back + local new_lines = vim.list_slice(lines, 1, context_start - 1) + vim.list_extend(new_lines, vim.split(patched_window, '\n')) + vim.list_extend(new_lines, lines, context_end + 1, #lines) + return table.concat(new_lines, '\n'), true end - return lines, applied_any + -- Fallback: direct replacement + local new_lines = vim.list_slice(lines, 1, start_idx - 1) + vim.list_extend(new_lines, hunk.new_snippet) + vim.list_extend(new_lines, lines, end_idx + 1, #lines) + return table.concat(new_lines, '\n'), false end ---- Apply diff indices from vim.diff to original and new lines ----@param hunks table Indices from vim.diff (result_type = 'indices') ----@param original_lines table Lines before patch ----@param new_lines table Lines after patch ----@return table Patched lines -function M.apply_diff_indices(hunks, original_lines, new_lines) - local result = {} - local orig_idx = 1 - +--- Apply unified diff to a table of lines and return new lines +---@param diff_text string +---@param original_content string +---@return table, boolean, integer, integer +function M.apply_unified_diff(diff_text, original_content) + local hunks = parse_hunks(diff_text) + local new_content = original_content + local applied = false for _, hunk in ipairs(hunks) do - local start_a, count_a, start_b, count_b = unpack(hunk) - -- Add unchanged lines before hunk - for i = orig_idx, start_a - 1 do - table.insert(result, original_lines[i]) - end - -- Add changed lines from new_lines - for i = start_b, start_b + count_b - 1 do - table.insert(result, new_lines[i]) - end - orig_idx = start_a + count_a - end - -- Add remaining lines - for i = orig_idx, #original_lines do - table.insert(result, original_lines[i]) + local patched, ok = apply_hunk(hunk, new_content) + new_content = patched + applied = applied or ok end - return result -end - ---- Get changed regions for jump/highlight ----@param diff_text string The unified diff text ----@return number?, number? -function M.get_unified_diff_region(diff_text, original_lines) - local _, hunks = M.parse_unified_diff(diff_text) + local original_lines = vim.split(original_content, '\n') + local new_lines = vim.split(new_content, '\n') local first, last - - for _, hunk in ipairs(hunks) do - for i = 1, #original_lines - #hunk.minus + 1 do - local match = true - for j = 1, #hunk.minus do - if vim.trim(original_lines[i + j - 1]) ~= vim.trim(hunk.minus[j]) then - match = false - break - end - end - if match then - local region_start = i - local region_end = i + #hunk.plus - 1 - if not first or region_start < first then - first = region_start - end - if not last or region_end > last then - last = region_end - end - break + local max_len = math.max(#original_lines, #new_lines) + for i = 1, max_len do + if original_lines[i] ~= new_lines[i] then + if not first then + first = i end + last = i end end - - if first and last then - return first, last - end - - return nil, nil + return new_lines, applied, first, last end ---- Apply a diff (unified or indices) to buffer lines +--- Get diff from block content and buffer lines ---@param block CopilotChat.ui.chat.Block Block containing diff info ---@param bufnr integer Buffer number ----@return table new_lines, boolean applied -function M.apply_diff(block, bufnr) +---@return string diff, string content +function M.get_diff(block, bufnr) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local content = table.concat(lines, '\n') if block.header.filetype == 'diff' then - return M.apply_unified_diff(block.content, lines) - elseif block.header.start_line and block.header.end_line then - local start_idx = block.header.start_line - local end_idx = block.header.end_line - local original_lines = vim.list_slice(lines, start_idx, end_idx) - local patched_lines = vim.split(block.content, '\n') - local hunks = vim.diff( + return block.content, content + end + + local patched_lines = vim.split(block.content, '\n') + local start_idx = block.header.start_line + local end_idx = block.header.end_line + local original_lines = lines + if start_idx and end_idx then + local new_lines = vim.list_slice(original_lines, 1, start_idx - 1) + vim.list_extend(new_lines, patched_lines) + vim.list_extend(new_lines, original_lines, end_idx + 1, #original_lines) + patched_lines = new_lines + end + + return tostring( + vim.diff( table.concat(original_lines, '\n'), table.concat(patched_lines, '\n'), - { result_type = 'indices', algorithm = 'myers', ctxlen = 3 } + { algorithm = 'myers', ctxlen = 20, interhunkctxlen = 50, ignore_whitespace_change = true } ) - local region_new_lines = M.apply_diff_indices(hunks, original_lines, patched_lines) - local new_lines = {} - -- Add lines before region - for i = 1, start_idx - 1 do - table.insert(new_lines, lines[i]) - end - -- Add patched region - for _, line in ipairs(region_new_lines) do - table.insert(new_lines, line) - end - -- Add lines after region - for i = end_idx + 1, #lines do - table.insert(new_lines, lines[i]) - end - return new_lines, true + ), + content +end + +--- Apply a diff (unified or indices) to buffer lines +---@param block CopilotChat.ui.chat.Block Block containing diff info +---@param bufnr integer Buffer number +---@return table new_lines +function M.apply_diff(block, bufnr) + local diff, content = M.get_diff(block, bufnr) + local new_lines, applied, _, _ = M.apply_unified_diff(diff, content) + if not applied then + vim.notify('Diff for ' .. block.header.filename .. ' failed to apply cleanly for:\n' .. diff, vim.log.levels.WARN) end - return lines, false + + return new_lines end --- Get changed region for diff (unified or indices) @@ -206,24 +184,9 @@ end ---@param bufnr integer Buffer number ---@return number? first, number? last function M.get_diff_region(block, bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - if block.header.filetype == 'diff' then - return M.get_unified_diff_region(block.content, lines) - elseif block.header.start_line and block.header.end_line then - local original_lines = vim.api.nvim_buf_get_lines(bufnr, block.header.start_line - 1, block.header.end_line, false) - local patched_lines = vim.split(block.content, '\n') - local hunks = vim.diff( - table.concat(original_lines, '\n'), - table.concat(patched_lines, '\n'), - { result_type = 'indices', algorithm = 'myers', ctxlen = 3 } - ) - if hunks and #hunks > 0 then - local first = hunks[1][1] - local last = hunks[#hunks][1] + hunks[#hunks][2] - 1 - return first, last - end - end - return nil, nil + local diff, content = M.get_diff(block, bufnr) + local _, _, first, last = M.apply_unified_diff(diff, content) + return first, last end return M diff --git a/lua/CopilotChat/vendor/diff_match_patch.lua b/lua/CopilotChat/vendor/diff_match_patch.lua new file mode 100644 index 00000000..b2c397d0 --- /dev/null +++ b/lua/CopilotChat/vendor/diff_match_patch.lua @@ -0,0 +1,2085 @@ +--[[ +* Diff Match and Patch +* Copyright 2018 The diff-match-patch Authors. +* https://github.com/google/diff-match-patch +* +* Based on the JavaScript implementation by Neil Fraser. +* Ported to Lua by Duncan Cross. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +--]] + +local bit = require('bit') +local band, bor, lshift = bit.band, bit.bor, bit.lshift +local type, setmetatable, ipairs, select = type, setmetatable, ipairs, select +local unpack, tonumber, error = unpack, tonumber, error +local strsub, strbyte, strchar, gmatch, gsub = string.sub, string.byte, string.char, string.gmatch, string.gsub +local strmatch, strfind, strformat = string.match, string.find, string.format +local tinsert, tremove, tconcat = table.insert, table.remove, table.concat +local max, min, floor, ceil, abs = math.max, math.min, math.floor, math.ceil, math.abs +local clock = os.clock + +-- Utility functions. + +local percentEncode_pattern = "[^A-Za-z0-9%-=;',./~!@#$%&*%(%)_%+ %?]" +local function percentEncode_replace(v) + return strformat('%%%02X', strbyte(v)) +end + +local function indexOf(a, b, start) + if #b == 0 then + return nil + end + return strfind(a, b, start, true) +end + +local htmlEncode_pattern = '[&<>\n]' +local htmlEncode_replace = { + ['&'] = '&', + ['<'] = '<', + ['>'] = '>', + ['\n'] = '¶
', +} + +-- Public API Functions +-- (Exported at the end of the script) + +local diff_main, diff_cleanupSemantic, diff_cleanupEfficiency, diff_levenshtein, diff_prettyHtml + +local match_main + +local patch_make, patch_toText, patch_fromText, patch_apply + +--[[ +* The data structure representing a diff is an array of tuples: +* {{DIFF_DELETE, 'Hello'}, {DIFF_INSERT, 'Goodbye'}, {DIFF_EQUAL, ' world.'}} +* which means: delete 'Hello', add 'Goodbye' and keep ' world.' +--]] +local DIFF_DELETE = -1 +local DIFF_INSERT = 1 +local DIFF_EQUAL = 0 + +-- Number of seconds to map a diff before giving up (0 for infinity). +local Diff_Timeout = 1.0 +-- Cost of an empty edit operation in terms of edit characters. +local Diff_EditCost = 4 +-- At what point is no match declared (0.0 = perfection, 1.0 = very loose). +local Match_Threshold = 0.5 +-- How far to search for a match (0 = exact location, 1000+ = broad match). +-- A match this many characters away from the expected location will add +-- 1.0 to the score (0.0 is a perfect match). +local Match_Distance = 1000 +-- When deleting a large block of text (over ~64 characters), how close do +-- the contents have to be to match the expected contents. (0.0 = perfection, +-- 1.0 = very loose). Note that Match_Threshold controls how closely the +-- end points of a delete need to match. +local Patch_DeleteThreshold = 0.5 +-- Chunk size for context length. +local Patch_Margin = 4 +-- The number of bits in an int. +local Match_MaxBits = 32 + +function settings(new) + if new then + Diff_Timeout = new.Diff_Timeout or Diff_Timeout + Diff_EditCost = new.Diff_EditCost or Diff_EditCost + Match_Threshold = new.Match_Threshold or Match_Threshold + Match_Distance = new.Match_Distance or Match_Distance + Patch_DeleteThreshold = new.Patch_DeleteThreshold or Patch_DeleteThreshold + Patch_Margin = new.Patch_Margin or Patch_Margin + Match_MaxBits = new.Match_MaxBits or Match_MaxBits + else + return { + Diff_Timeout = Diff_Timeout, + Diff_EditCost = Diff_EditCost, + Match_Threshold = Match_Threshold, + Match_Distance = Match_Distance, + Patch_DeleteThreshold = Patch_DeleteThreshold, + Patch_Margin = Patch_Margin, + Match_MaxBits = Match_MaxBits, + } + end +end + +-- --------------------------------------------------------------------------- +-- DIFF API +-- --------------------------------------------------------------------------- + +-- The private diff functions +local _diff_compute, _diff_bisect, _diff_halfMatchI, _diff_halfMatch, _diff_cleanupSemanticScore, _diff_cleanupSemanticLossless, _diff_cleanupMerge, _diff_commonPrefix, _diff_commonSuffix, _diff_commonOverlap, _diff_xIndex, _diff_text1, _diff_text2, _diff_toDelta, _diff_fromDelta + +--[[ +* Find the differences between two texts. Simplifies the problem by stripping +* any common prefix or suffix off the texts before diffing. +* @param {string} text1 Old string to be diffed. +* @param {string} text2 New string to be diffed. +* @param {boolean} opt_checklines Has no effect in Lua. +* @param {number} opt_deadline Optional time when the diff should be complete +* by. Used internally for recursive calls. Users should set DiffTimeout +* instead. +* @return {Array.>} Array of diff tuples. +--]] +function diff_main(text1, text2, opt_checklines, opt_deadline) + -- Set a deadline by which time the diff must be complete. + if opt_deadline == nil then + if Diff_Timeout <= 0 then + opt_deadline = 2 ^ 31 + else + opt_deadline = clock() + Diff_Timeout + end + end + local deadline = opt_deadline + + -- Check for null inputs. + if text1 == nil or text1 == nil then + error('Null inputs. (diff_main)') + end + + -- Check for equality (speedup). + if text1 == text2 then + if #text1 > 0 then + return { { DIFF_EQUAL, text1 } } + end + return {} + end + + -- LUANOTE: Due to the lack of Unicode support, Lua is incapable of + -- implementing the line-mode speedup. + local checklines = false + + -- Trim off common prefix (speedup). + local commonlength = _diff_commonPrefix(text1, text2) + local commonprefix + if commonlength > 0 then + commonprefix = strsub(text1, 1, commonlength) + text1 = strsub(text1, commonlength + 1) + text2 = strsub(text2, commonlength + 1) + end + + -- Trim off common suffix (speedup). + commonlength = _diff_commonSuffix(text1, text2) + local commonsuffix + if commonlength > 0 then + commonsuffix = strsub(text1, -commonlength) + text1 = strsub(text1, 1, -commonlength - 1) + text2 = strsub(text2, 1, -commonlength - 1) + end + + -- Compute the diff on the middle block. + local diffs = _diff_compute(text1, text2, checklines, deadline) + + -- Restore the prefix and suffix. + if commonprefix then + tinsert(diffs, 1, { DIFF_EQUAL, commonprefix }) + end + if commonsuffix then + diffs[#diffs + 1] = { DIFF_EQUAL, commonsuffix } + end + + _diff_cleanupMerge(diffs) + return diffs +end + +--[[ +* Reduce the number of edits by eliminating semantically trivial equalities. +* @param {Array.>} diffs Array of diff tuples. +--]] +function diff_cleanupSemantic(diffs) + local changes = false + local equalities = {} -- Stack of indices where equalities are found. + local equalitiesLength = 0 -- Keeping our own length var is faster. + local lastEquality = nil + -- Always equal to diffs[equalities[equalitiesLength]][2] + local pointer = 1 -- Index of current position. + -- Number of characters that changed prior to the equality. + local length_insertions1 = 0 + local length_deletions1 = 0 + -- Number of characters that changed after the equality. + local length_insertions2 = 0 + local length_deletions2 = 0 + + while diffs[pointer] do + if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. + equalitiesLength = equalitiesLength + 1 + equalities[equalitiesLength] = pointer + length_insertions1 = length_insertions2 + length_deletions1 = length_deletions2 + length_insertions2 = 0 + length_deletions2 = 0 + lastEquality = diffs[pointer][2] + else -- An insertion or deletion. + if diffs[pointer][1] == DIFF_INSERT then + length_insertions2 = length_insertions2 + #diffs[pointer][2] + else + length_deletions2 = length_deletions2 + #diffs[pointer][2] + end + -- Eliminate an equality that is smaller or equal to the edits on both + -- sides of it. + if + lastEquality + and (#lastEquality <= max(length_insertions1, length_deletions1)) + and (#lastEquality <= max(length_insertions2, length_deletions2)) + then + -- Duplicate record. + tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) + -- Change second copy to insert. + diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT + -- Throw away the equality we just deleted. + equalitiesLength = equalitiesLength - 1 + -- Throw away the previous equality (it needs to be reevaluated). + equalitiesLength = equalitiesLength - 1 + pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 + length_insertions1, length_deletions1 = 0, 0 -- Reset the counters. + length_insertions2, length_deletions2 = 0, 0 + lastEquality = nil + changes = true + end + end + pointer = pointer + 1 + end + + -- Normalize the diff. + if changes then + _diff_cleanupMerge(diffs) + end + _diff_cleanupSemanticLossless(diffs) + + -- Find any overlaps between deletions and insertions. + -- e.g: abcxxxxxxdef + -- -> abcxxxdef + -- e.g: xxxabcdefxxx + -- -> defxxxabc + -- Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = 2 + while diffs[pointer] do + if diffs[pointer - 1][1] == DIFF_DELETE and diffs[pointer][1] == DIFF_INSERT then + local deletion = diffs[pointer - 1][2] + local insertion = diffs[pointer][2] + local overlap_length1 = _diff_commonOverlap(deletion, insertion) + local overlap_length2 = _diff_commonOverlap(insertion, deletion) + if overlap_length1 >= overlap_length2 then + if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then + -- Overlap found. Insert an equality and trim the surrounding edits. + tinsert(diffs, pointer, { DIFF_EQUAL, strsub(insertion, 1, overlap_length1) }) + diffs[pointer - 1][2] = strsub(deletion, 1, #deletion - overlap_length1) + diffs[pointer + 1][2] = strsub(insertion, overlap_length1 + 1) + pointer = pointer + 1 + end + else + if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then + -- Reverse overlap found. + -- Insert an equality and swap and trim the surrounding edits. + tinsert(diffs, pointer, { DIFF_EQUAL, strsub(deletion, 1, overlap_length2) }) + diffs[pointer - 1] = { DIFF_INSERT, strsub(insertion, 1, #insertion - overlap_length2) } + diffs[pointer + 1] = { DIFF_DELETE, strsub(deletion, overlap_length2 + 1) } + pointer = pointer + 1 + end + end + pointer = pointer + 1 + end + pointer = pointer + 1 + end +end + +--[[ +* Reduce the number of edits by eliminating operationally trivial equalities. +* @param {Array.>} diffs Array of diff tuples. +--]] +function diff_cleanupEfficiency(diffs) + local changes = false + -- Stack of indices where equalities are found. + local equalities = {} + -- Keeping our own length var is faster. + local equalitiesLength = 0 + -- Always equal to diffs[equalities[equalitiesLength]][2] + local lastEquality = nil + -- Index of current position. + local pointer = 1 + + -- The following four are really booleans but are stored as numbers because + -- they are used at one point like this: + -- + -- (pre_ins + pre_del + post_ins + post_del) == 3 + -- + -- ...i.e. checking that 3 of them are true and 1 of them is false. + + -- Is there an insertion operation before the last equality. + local pre_ins = 0 + -- Is there a deletion operation before the last equality. + local pre_del = 0 + -- Is there an insertion operation after the last equality. + local post_ins = 0 + -- Is there a deletion operation after the last equality. + local post_del = 0 + + while diffs[pointer] do + if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. + local diffText = diffs[pointer][2] + if (#diffText < Diff_EditCost) and (post_ins == 1 or post_del == 1) then + -- Candidate found. + equalitiesLength = equalitiesLength + 1 + equalities[equalitiesLength] = pointer + pre_ins, pre_del = post_ins, post_del + lastEquality = diffText + else + -- Not a candidate, and can never become one. + equalitiesLength = 0 + lastEquality = nil + end + post_ins, post_del = 0, 0 + else -- An insertion or deletion. + if diffs[pointer][1] == DIFF_DELETE then + post_del = 1 + else + post_ins = 1 + end + --[[ + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + --]] + if + lastEquality + and ( + (pre_ins + pre_del + post_ins + post_del == 4) + or ((#lastEquality < Diff_EditCost / 2) and (pre_ins + pre_del + post_ins + post_del == 3)) + ) + then + -- Duplicate record. + tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) + -- Change second copy to insert. + diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT + -- Throw away the equality we just deleted. + equalitiesLength = equalitiesLength - 1 + lastEquality = nil + if (pre_ins == 1) and (pre_del == 1) then + -- No changes made which could affect previous entry, keep going. + post_ins, post_del = 1, 1 + equalitiesLength = 0 + else + -- Throw away the previous equality. + equalitiesLength = equalitiesLength - 1 + pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 + post_ins, post_del = 0, 0 + end + changes = true + end + end + pointer = pointer + 1 + end + + if changes then + _diff_cleanupMerge(diffs) + end +end + +--[[ +* Compute the Levenshtein distance; the number of inserted, deleted or +* substituted characters. +* @param {Array.>} diffs Array of diff tuples. +* @return {number} Number of changes. +--]] +function diff_levenshtein(diffs) + local levenshtein = 0 + local insertions, deletions = 0, 0 + for x, diff in ipairs(diffs) do + local op, data = diff[1], diff[2] + if op == DIFF_INSERT then + insertions = insertions + #data + elseif op == DIFF_DELETE then + deletions = deletions + #data + elseif op == DIFF_EQUAL then + -- A deletion and an insertion is one substitution. + levenshtein = levenshtein + max(insertions, deletions) + insertions = 0 + deletions = 0 + end + end + levenshtein = levenshtein + max(insertions, deletions) + return levenshtein +end + +--[[ +* Convert a diff array into a pretty HTML report. +* @param {Array.>} diffs Array of diff tuples. +* @return {string} HTML representation. +--]] +function diff_prettyHtml(diffs) + local html = {} + for x, diff in ipairs(diffs) do + local op = diff[1] -- Operation (insert, delete, equal) + local data = diff[2] -- Text of change. + local text = gsub(data, htmlEncode_pattern, htmlEncode_replace) + if op == DIFF_INSERT then + html[x] = '' .. text .. '' + elseif op == DIFF_DELETE then + html[x] = '' .. text .. '' + elseif op == DIFF_EQUAL then + html[x] = '' .. text .. '' + end + end + return tconcat(html) +end + +-- --------------------------------------------------------------------------- +-- UNOFFICIAL/PRIVATE DIFF FUNCTIONS +-- --------------------------------------------------------------------------- + +--[[ +* Find the differences between two texts. Assumes that the texts do not +* have any common prefix or suffix. +* @param {string} text1 Old string to be diffed. +* @param {string} text2 New string to be diffed. +* @param {boolean} checklines Has no effect in Lua. +* @param {number} deadline Time when the diff should be complete by. +* @return {Array.>} Array of diff tuples. +* @private +--]] +function _diff_compute(text1, text2, checklines, deadline) + if #text1 == 0 then + -- Just add some text (speedup). + return { { DIFF_INSERT, text2 } } + end + + if #text2 == 0 then + -- Just delete some text (speedup). + return { { DIFF_DELETE, text1 } } + end + + local diffs + + local longtext = (#text1 > #text2) and text1 or text2 + local shorttext = (#text1 > #text2) and text2 or text1 + local i = indexOf(longtext, shorttext) + + if i ~= nil then + -- Shorter text is inside the longer text (speedup). + diffs = { + { DIFF_INSERT, strsub(longtext, 1, i - 1) }, + { DIFF_EQUAL, shorttext }, + { DIFF_INSERT, strsub(longtext, i + #shorttext) }, + } + -- Swap insertions for deletions if diff is reversed. + if #text1 > #text2 then + diffs[1][1], diffs[3][1] = DIFF_DELETE, DIFF_DELETE + end + return diffs + end + + if #shorttext == 1 then + -- Single character string. + -- After the previous speedup, the character can't be an equality. + return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } + end + + -- Check to see if the problem can be split in two. + do + local text1_a, text1_b, text2_a, text2_b, mid_common = _diff_halfMatch(text1, text2) + + if text1_a then + -- A half-match was found, sort out the return data. + -- Send both pairs off for separate processing. + local diffs_a = diff_main(text1_a, text2_a, checklines, deadline) + local diffs_b = diff_main(text1_b, text2_b, checklines, deadline) + -- Merge the results. + local diffs_a_len = #diffs_a + diffs = diffs_a + diffs[diffs_a_len + 1] = { DIFF_EQUAL, mid_common } + for i, b_diff in ipairs(diffs_b) do + diffs[diffs_a_len + 1 + i] = b_diff + end + return diffs + end + end + + return _diff_bisect(text1, text2, deadline) +end + +--[[ +* Find the 'middle snake' of a diff, split the problem in two +* and return the recursively constructed diff. +* See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. +* @param {string} text1 Old string to be diffed. +* @param {string} text2 New string to be diffed. +* @param {number} deadline Time at which to bail if not yet complete. +* @return {Array.>} Array of diff tuples. +* @private +--]] +function _diff_bisect(text1, text2, deadline) + -- Cache the text lengths to prevent multiple calls. + local text1_length = #text1 + local text2_length = #text2 + local _sub, _element + local max_d = ceil((text1_length + text2_length) / 2) + local v_offset = max_d + local v_length = 2 * max_d + local v1 = {} + local v2 = {} + -- Setting all elements to -1 is faster in Lua than mixing integers and nil. + for x = 0, v_length - 1 do + v1[x] = -1 + v2[x] = -1 + end + v1[v_offset + 1] = 0 + v2[v_offset + 1] = 0 + local delta = text1_length - text2_length + -- If the total number of characters is odd, then + -- the front path will collide with the reverse path. + local front = (delta % 2 ~= 0) + -- Offsets for start and end of k loop. + -- Prevents mapping of space beyond the grid. + local k1start = 0 + local k1end = 0 + local k2start = 0 + local k2end = 0 + for d = 0, max_d - 1 do + -- Bail out if deadline is reached. + if clock() > deadline then + break + end + + -- Walk the front path one step. + for k1 = -d + k1start, d - k1end, 2 do + local k1_offset = v_offset + k1 + local x1 + if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then + x1 = v1[k1_offset + 1] + else + x1 = v1[k1_offset - 1] + 1 + end + local y1 = x1 - k1 + while (x1 <= text1_length) and (y1 <= text2_length) and (strsub(text1, x1, x1) == strsub(text2, y1, y1)) do + x1 = x1 + 1 + y1 = y1 + 1 + end + v1[k1_offset] = x1 + if x1 > text1_length + 1 then + -- Ran off the right of the graph. + k1end = k1end + 2 + elseif y1 > text2_length + 1 then + -- Ran off the bottom of the graph. + k1start = k1start + 2 + elseif front then + local k2_offset = v_offset + delta - k1 + if k2_offset >= 0 and k2_offset < v_length and v2[k2_offset] ~= -1 then + -- Mirror x2 onto top-left coordinate system. + local x2 = text1_length - v2[k2_offset] + 1 + if x1 > x2 then + -- Overlap detected. + return _diff_bisectSplit(text1, text2, x1, y1, deadline) + end + end + end + end + + -- Walk the reverse path one step. + for k2 = -d + k2start, d - k2end, 2 do + local k2_offset = v_offset + k2 + local x2 + if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then + x2 = v2[k2_offset + 1] + else + x2 = v2[k2_offset - 1] + 1 + end + local y2 = x2 - k2 + while (x2 <= text1_length) and (y2 <= text2_length) and (strsub(text1, -x2, -x2) == strsub(text2, -y2, -y2)) do + x2 = x2 + 1 + y2 = y2 + 1 + end + v2[k2_offset] = x2 + if x2 > text1_length + 1 then + -- Ran off the left of the graph. + k2end = k2end + 2 + elseif y2 > text2_length + 1 then + -- Ran off the top of the graph. + k2start = k2start + 2 + elseif not front then + local k1_offset = v_offset + delta - k2 + if k1_offset >= 0 and k1_offset < v_length and v1[k1_offset] ~= -1 then + local x1 = v1[k1_offset] + local y1 = v_offset + x1 - k1_offset + -- Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2 + 1 + if x1 > x2 then + -- Overlap detected. + return _diff_bisectSplit(text1, text2, x1, y1, deadline) + end + end + end + end + end + -- Diff took too long and hit the deadline or + -- number of diffs equals number of characters, no commonality at all. + return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } +end + +--[[ + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} x Index of split point in text1. + * @param {number} y Index of split point in text2. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {Array.>} Array of diff tuples. + * @private +--]] +function _diff_bisectSplit(text1, text2, x, y, deadline) + local text1a = strsub(text1, 1, x - 1) + local text2a = strsub(text2, 1, y - 1) + local text1b = strsub(text1, x) + local text2b = strsub(text2, y) + + -- Compute both diffs serially. + local diffs = diff_main(text1a, text2a, false, deadline) + local diffsb = diff_main(text1b, text2b, false, deadline) + + local diffs_len = #diffs + for i, v in ipairs(diffsb) do + diffs[diffs_len + i] = v + end + return diffs +end + +--[[ +* Determine the common prefix of two strings. +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {number} The number of characters common to the start of each +* string. +--]] +function _diff_commonPrefix(text1, text2) + -- Quick check for common null cases. + if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, 1) ~= strbyte(text2, 1)) then + return 0 + end + -- Binary search. + -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ + local pointermin = 1 + local pointermax = min(#text1, #text2) + local pointermid = pointermax + local pointerstart = 1 + while pointermin < pointermid do + if strsub(text1, pointerstart, pointermid) == strsub(text2, pointerstart, pointermid) then + pointermin = pointermid + pointerstart = pointermin + else + pointermax = pointermid + end + pointermid = floor(pointermin + (pointermax - pointermin) / 2) + end + return pointermid +end + +--[[ +* Determine the common suffix of two strings. +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {number} The number of characters common to the end of each string. +--]] +function _diff_commonSuffix(text1, text2) + -- Quick check for common null cases. + if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, -1) ~= strbyte(text2, -1)) then + return 0 + end + -- Binary search. + -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ + local pointermin = 1 + local pointermax = min(#text1, #text2) + local pointermid = pointermax + local pointerend = 1 + while pointermin < pointermid do + if strsub(text1, -pointermid, -pointerend) == strsub(text2, -pointermid, -pointerend) then + pointermin = pointermid + pointerend = pointermin + else + pointermax = pointermid + end + pointermid = floor(pointermin + (pointermax - pointermin) / 2) + end + return pointermid +end + +--[[ +* Determine if the suffix of one string is the prefix of another. +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {number} The number of characters common to the end of the first +* string and the start of the second string. +* @private +--]] +function _diff_commonOverlap(text1, text2) + -- Cache the text lengths to prevent multiple calls. + local text1_length = #text1 + local text2_length = #text2 + -- Eliminate the null case. + if text1_length == 0 or text2_length == 0 then + return 0 + end + -- Truncate the longer string. + if text1_length > text2_length then + text1 = strsub(text1, text1_length - text2_length + 1) + elseif text1_length < text2_length then + text2 = strsub(text2, 1, text1_length) + end + local text_length = min(text1_length, text2_length) + -- Quick check for the worst case. + if text1 == text2 then + return text_length + end + + -- Start by looking for a single character match + -- and increase length until no match is found. + -- Performance analysis: https://neil.fraser.name/news/2010/11/04/ + local best = 0 + local length = 1 + while true do + local pattern = strsub(text1, text_length - length + 1) + local found = strfind(text2, pattern, 1, true) + if found == nil then + return best + end + length = length + found - 1 + if found == 1 or strsub(text1, text_length - length + 1) == strsub(text2, 1, length) then + best = length + length = length + 1 + end + end +end + +--[[ +* Does a substring of shorttext exist within longtext such that the substring +* is at least half the length of longtext? +* This speedup can produce non-minimal diffs. +* Closure, but does not reference any external variables. +* @param {string} longtext Longer string. +* @param {string} shorttext Shorter string. +* @param {number} i Start index of quarter length substring within longtext. +* @return {?Array.} Five element Array, containing the prefix of +* longtext, the suffix of longtext, the prefix of shorttext, the suffix +* of shorttext and the common middle. Or nil if there was no match. +* @private +--]] +function _diff_halfMatchI(longtext, shorttext, i) + -- Start with a 1/4 length substring at position i as a seed. + local seed = strsub(longtext, i, i + floor(#longtext / 4)) + local j = 0 -- LUANOTE: do not change to 1, was originally -1 + local best_common = '' + local best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b + while true do + j = indexOf(shorttext, seed, j + 1) + if j == nil then + break + end + local prefixLength = _diff_commonPrefix(strsub(longtext, i), strsub(shorttext, j)) + local suffixLength = _diff_commonSuffix(strsub(longtext, 1, i - 1), strsub(shorttext, 1, j - 1)) + if #best_common < suffixLength + prefixLength then + best_common = strsub(shorttext, j - suffixLength, j - 1) .. strsub(shorttext, j, j + prefixLength - 1) + best_longtext_a = strsub(longtext, 1, i - suffixLength - 1) + best_longtext_b = strsub(longtext, i + prefixLength) + best_shorttext_a = strsub(shorttext, 1, j - suffixLength - 1) + best_shorttext_b = strsub(shorttext, j + prefixLength) + end + end + if #best_common * 2 >= #longtext then + return { best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common } + else + return nil + end +end + +--[[ +* Do the two texts share a substring which is at least half the length of the +* longer text? +* @param {string} text1 First string. +* @param {string} text2 Second string. +* @return {?Array.} Five element Array, containing the prefix of +* text1, the suffix of text1, the prefix of text2, the suffix of +* text2 and the common middle. Or nil if there was no match. +* @private +--]] +function _diff_halfMatch(text1, text2) + if Diff_Timeout <= 0 then + -- Don't risk returning a non-optimal diff if we have unlimited time. + return nil + end + local longtext = (#text1 > #text2) and text1 or text2 + local shorttext = (#text1 > #text2) and text2 or text1 + if (#longtext < 4) or (#shorttext * 2 < #longtext) then + return nil -- Pointless. + end + + -- First check if the second quarter is the seed for a half-match. + local hm1 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 4)) + -- Check again based on the third quarter. + local hm2 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 2)) + local hm + if not hm1 and not hm2 then + return nil + elseif not hm2 then + hm = hm1 + elseif not hm1 then + hm = hm2 + else + -- Both matched. Select the longest. + hm = (#hm1[5] > #hm2[5]) and hm1 or hm2 + end + + -- A half-match was found, sort out the return data. + local text1_a, text1_b, text2_a, text2_b + if #text1 > #text2 then + text1_a, text1_b = hm[1], hm[2] + text2_a, text2_b = hm[3], hm[4] + else + text2_a, text2_b = hm[1], hm[2] + text1_a, text1_b = hm[3], hm[4] + end + local mid_common = hm[5] + return text1_a, text1_b, text2_a, text2_b, mid_common +end + +--[[ +* Given two strings, compute a score representing whether the internal +* boundary falls on logical boundaries. +* Scores range from 6 (best) to 0 (worst). +* @param {string} one First string. +* @param {string} two Second string. +* @return {number} The score. +* @private +--]] +function _diff_cleanupSemanticScore(one, two) + if (#one == 0) or (#two == 0) then + -- Edges are the best. + return 6 + end + + -- Each port of this function behaves slightly differently due to + -- subtle differences in each language's definition of things like + -- 'whitespace'. Since this function's purpose is largely cosmetic, + -- the choice has been made to use each language's native features + -- rather than force total conformity. + local char1 = strsub(one, -1) + local char2 = strsub(two, 1, 1) + local nonAlphaNumeric1 = strmatch(char1, '%W') + local nonAlphaNumeric2 = strmatch(char2, '%W') + local whitespace1 = nonAlphaNumeric1 and strmatch(char1, '%s') + local whitespace2 = nonAlphaNumeric2 and strmatch(char2, '%s') + local lineBreak1 = whitespace1 and strmatch(char1, '%c') + local lineBreak2 = whitespace2 and strmatch(char2, '%c') + local blankLine1 = lineBreak1 and strmatch(one, '\n\r?\n$') + local blankLine2 = lineBreak2 and strmatch(two, '^\r?\n\r?\n') + + if blankLine1 or blankLine2 then + -- Five points for blank lines. + return 5 + elseif lineBreak1 or lineBreak2 then + -- Four points for line breaks. + return 4 + elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then + -- Three points for end of sentences. + return 3 + elseif whitespace1 or whitespace2 then + -- Two points for whitespace. + return 2 + elseif nonAlphaNumeric1 or nonAlphaNumeric2 then + -- One point for non-alphanumeric. + return 1 + end + return 0 +end + +--[[ +* Look for single edits surrounded on both sides by equalities +* which can be shifted sideways to align the edit to a word boundary. +* e.g: The cat came. -> The cat came. +* @param {Array.>} diffs Array of diff tuples. +--]] +function _diff_cleanupSemanticLossless(diffs) + local pointer = 2 + -- Intentionally ignore the first and last element (don't need checking). + while diffs[pointer + 1] do + local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] + if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then + -- This is a single edit surrounded by equalities. + local diff = diffs[pointer] + + local equality1 = prevDiff[2] + local edit = diff[2] + local equality2 = nextDiff[2] + + -- First, shift the edit as far left as possible. + local commonOffset = _diff_commonSuffix(equality1, edit) + if commonOffset > 0 then + local commonString = strsub(edit, -commonOffset) + equality1 = strsub(equality1, 1, -commonOffset - 1) + edit = commonString .. strsub(edit, 1, -commonOffset - 1) + equality2 = commonString .. equality2 + end + + -- Second, step character by character right, looking for the best fit. + local bestEquality1 = equality1 + local bestEdit = edit + local bestEquality2 = equality2 + local bestScore = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) + + while strbyte(edit, 1) == strbyte(equality2, 1) do + equality1 = equality1 .. strsub(edit, 1, 1) + edit = strsub(edit, 2) .. strsub(equality2, 1, 1) + equality2 = strsub(equality2, 2) + local score = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) + -- The >= encourages trailing rather than leading whitespace on edits. + if score >= bestScore then + bestScore = score + bestEquality1 = equality1 + bestEdit = edit + bestEquality2 = equality2 + end + end + if prevDiff[2] ~= bestEquality1 then + -- We have an improvement, save it back to the diff. + if #bestEquality1 > 0 then + diffs[pointer - 1][2] = bestEquality1 + else + tremove(diffs, pointer - 1) + pointer = pointer - 1 + end + diffs[pointer][2] = bestEdit + if #bestEquality2 > 0 then + diffs[pointer + 1][2] = bestEquality2 + else + tremove(diffs, pointer + 1, 1) + pointer = pointer - 1 + end + end + end + pointer = pointer + 1 + end +end + +--[[ +* Reorder and merge like edit sections. Merge equalities. +* Any edit section can move as long as it doesn't cross an equality. +* @param {Array.>} diffs Array of diff tuples. +--]] +function _diff_cleanupMerge(diffs) + diffs[#diffs + 1] = { DIFF_EQUAL, '' } -- Add a dummy entry at the end. + local pointer = 1 + local count_delete, count_insert = 0, 0 + local text_delete, text_insert = '', '' + local commonlength + while diffs[pointer] do + local diff_type = diffs[pointer][1] + if diff_type == DIFF_INSERT then + count_insert = count_insert + 1 + text_insert = text_insert .. diffs[pointer][2] + pointer = pointer + 1 + elseif diff_type == DIFF_DELETE then + count_delete = count_delete + 1 + text_delete = text_delete .. diffs[pointer][2] + pointer = pointer + 1 + elseif diff_type == DIFF_EQUAL then + -- Upon reaching an equality, check for prior redundancies. + if count_delete + count_insert > 1 then + if (count_delete > 0) and (count_insert > 0) then + -- Factor out any common prefixies. + commonlength = _diff_commonPrefix(text_insert, text_delete) + if commonlength > 0 then + local back_pointer = pointer - count_delete - count_insert + if (back_pointer > 1) and (diffs[back_pointer - 1][1] == DIFF_EQUAL) then + diffs[back_pointer - 1][2] = diffs[back_pointer - 1][2] .. strsub(text_insert, 1, commonlength) + else + tinsert(diffs, 1, { DIFF_EQUAL, strsub(text_insert, 1, commonlength) }) + pointer = pointer + 1 + end + text_insert = strsub(text_insert, commonlength + 1) + text_delete = strsub(text_delete, commonlength + 1) + end + -- Factor out any common suffixies. + commonlength = _diff_commonSuffix(text_insert, text_delete) + if commonlength ~= 0 then + diffs[pointer][2] = strsub(text_insert, -commonlength) .. diffs[pointer][2] + text_insert = strsub(text_insert, 1, -commonlength - 1) + text_delete = strsub(text_delete, 1, -commonlength - 1) + end + end + -- Delete the offending records and add the merged ones. + pointer = pointer - count_delete - count_insert + for i = 1, count_delete + count_insert do + tremove(diffs, pointer) + end + if #text_delete > 0 then + tinsert(diffs, pointer, { DIFF_DELETE, text_delete }) + pointer = pointer + 1 + end + if #text_insert > 0 then + tinsert(diffs, pointer, { DIFF_INSERT, text_insert }) + pointer = pointer + 1 + end + pointer = pointer + 1 + elseif (pointer > 1) and (diffs[pointer - 1][1] == DIFF_EQUAL) then + -- Merge this equality with the previous one. + diffs[pointer - 1][2] = diffs[pointer - 1][2] .. diffs[pointer][2] + tremove(diffs, pointer) + else + pointer = pointer + 1 + end + count_insert, count_delete = 0, 0 + text_delete, text_insert = '', '' + end + end + if diffs[#diffs][2] == '' then + diffs[#diffs] = nil -- Remove the dummy entry at the end. + end + + -- Second pass: look for single edits surrounded on both sides by equalities + -- which can be shifted sideways to eliminate an equality. + -- e.g: ABAC -> ABAC + local changes = false + pointer = 2 + -- Intentionally ignore the first and last element (don't need checking). + while pointer < #diffs do + local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] + if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then + -- This is a single edit surrounded by equalities. + local diff = diffs[pointer] + local currentText = diff[2] + local prevText = prevDiff[2] + local nextText = nextDiff[2] + if #prevText == 0 then + tremove(diffs, pointer - 1) + changes = true + elseif strsub(currentText, -#prevText) == prevText then + -- Shift the edit over the previous equality. + diff[2] = prevText .. strsub(currentText, 1, -#prevText - 1) + nextDiff[2] = prevText .. nextDiff[2] + tremove(diffs, pointer - 1) + changes = true + elseif strsub(currentText, 1, #nextText) == nextText then + -- Shift the edit over the next equality. + prevDiff[2] = prevText .. nextText + diff[2] = strsub(currentText, #nextText + 1) .. nextText + tremove(diffs, pointer + 1) + changes = true + end + end + pointer = pointer + 1 + end + -- If shifts were made, the diff needs reordering and another shift sweep. + if changes then + -- LUANOTE: no return value, but necessary to use 'return' to get + -- tail calls. + return _diff_cleanupMerge(diffs) + end +end + +--[[ +* loc is a location in text1, compute and return the equivalent location in +* text2. +* e.g. 'The cat' vs 'The big cat', 1->1, 5->8 +* @param {Array.>} diffs Array of diff tuples. +* @param {number} loc Location within text1. +* @return {number} Location within text2. +--]] +function _diff_xIndex(diffs, loc) + local chars1 = 1 + local chars2 = 1 + local last_chars1 = 1 + local last_chars2 = 1 + local x + for _x, diff in ipairs(diffs) do + x = _x + if diff[1] ~= DIFF_INSERT then -- Equality or deletion. + chars1 = chars1 + #diff[2] + end + if diff[1] ~= DIFF_DELETE then -- Equality or insertion. + chars2 = chars2 + #diff[2] + end + if chars1 > loc then -- Overshot the location. + break + end + last_chars1 = chars1 + last_chars2 = chars2 + end + -- Was the location deleted? + if diffs[x + 1] and (diffs[x][1] == DIFF_DELETE) then + return last_chars2 + end + -- Add the remaining character length. + return last_chars2 + (loc - last_chars1) +end + +--[[ +* Compute and return the source text (all equalities and deletions). +* @param {Array.>} diffs Array of diff tuples. +* @return {string} Source text. +--]] +function _diff_text1(diffs) + local text = {} + for x, diff in ipairs(diffs) do + if diff[1] ~= DIFF_INSERT then + text[#text + 1] = diff[2] + end + end + return tconcat(text) +end + +--[[ +* Compute and return the destination text (all equalities and insertions). +* @param {Array.>} diffs Array of diff tuples. +* @return {string} Destination text. +--]] +function _diff_text2(diffs) + local text = {} + for x, diff in ipairs(diffs) do + if diff[1] ~= DIFF_DELETE then + text[#text + 1] = diff[2] + end + end + return tconcat(text) +end + +--[[ +* Crush the diff into an encoded string which describes the operations +* required to transform text1 into text2. +* E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. +* Operations are tab-separated. Inserted text is escaped using %xx notation. +* @param {Array.>} diffs Array of diff tuples. +* @return {string} Delta text. +--]] +function _diff_toDelta(diffs) + local text = {} + for x, diff in ipairs(diffs) do + local op, data = diff[1], diff[2] + if op == DIFF_INSERT then + text[x] = '+' .. gsub(data, percentEncode_pattern, percentEncode_replace) + elseif op == DIFF_DELETE then + text[x] = '-' .. #data + elseif op == DIFF_EQUAL then + text[x] = '=' .. #data + end + end + return tconcat(text, '\t') +end + +--[[ +* Given the original text1, and an encoded string which describes the +* operations required to transform text1 into text2, compute the full diff. +* @param {string} text1 Source string for the diff. +* @param {string} delta Delta text. +* @return {Array.>} Array of diff tuples. +* @throws {Errorend If invalid input. +--]] +function _diff_fromDelta(text1, delta) + local diffs = {} + local diffsLength = 0 -- Keeping our own length var is faster + local pointer = 1 -- Cursor in text1 + for token in gmatch(delta, '[^\t]+') do + -- Each token begins with a one character parameter which specifies the + -- operation of this token (delete, insert, equality). + local tokenchar, param = strsub(token, 1, 1), strsub(token, 2) + if tokenchar == '+' then + local invalidDecode = false + local decoded = gsub(param, '%%(.?.?)', function(c) + local n = tonumber(c, 16) + if (#c ~= 2) or (n == nil) then + invalidDecode = true + return '' + end + return strchar(n) + end) + if invalidDecode then + -- Malformed URI sequence. + error('Illegal escape in _diff_fromDelta: ' .. param) + end + diffsLength = diffsLength + 1 + diffs[diffsLength] = { DIFF_INSERT, decoded } + elseif (tokenchar == '-') or (tokenchar == '=') then + local n = tonumber(param) + if (n == nil) or (n < 0) then + error('Invalid number in _diff_fromDelta: ' .. param) + end + local text = strsub(text1, pointer, pointer + n - 1) + pointer = pointer + n + if tokenchar == '=' then + diffsLength = diffsLength + 1 + diffs[diffsLength] = { DIFF_EQUAL, text } + else + diffsLength = diffsLength + 1 + diffs[diffsLength] = { DIFF_DELETE, text } + end + else + error('Invalid diff operation in _diff_fromDelta: ' .. token) + end + end + if pointer ~= #text1 + 1 then + error('Delta length (' .. (pointer - 1) .. ') does not equal source text length (' .. #text1 .. ').') + end + return diffs +end + +-- --------------------------------------------------------------------------- +-- MATCH API +-- --------------------------------------------------------------------------- + +local _match_bitap, _match_alphabet + +--[[ +* Locate the best instance of 'pattern' in 'text' near 'loc'. +* @param {string} text The text to search. +* @param {string} pattern The pattern to search for. +* @param {number} loc The location to search around. +* @return {number} Best match index or -1. +--]] +function match_main(text, pattern, loc) + -- Check for null inputs. + if text == nil or pattern == nil or loc == nil then + error('Null inputs. (match_main)') + end + + if text == pattern then + -- Shortcut (potentially not guaranteed by the algorithm) + return 1 + elseif #text == 0 then + -- Nothing to match. + return -1 + end + loc = max(1, min(loc, #text)) + if strsub(text, loc, loc + #pattern - 1) == pattern then + -- Perfect match at the perfect spot! (Includes case of null pattern) + return loc + else + -- Do a fuzzy compare. + return _match_bitap(text, pattern, loc) + end +end + +-- --------------------------------------------------------------------------- +-- UNOFFICIAL/PRIVATE MATCH FUNCTIONS +-- --------------------------------------------------------------------------- + +--[[ +* Initialise the alphabet for the Bitap algorithm. +* @param {string} pattern The text to encode. +* @return {Object} Hash of character locations. +* @private +--]] +function _match_alphabet(pattern) + local s = {} + local i = 0 + for c in gmatch(pattern, '.') do + s[c] = bor(s[c] or 0, lshift(1, #pattern - i - 1)) + i = i + 1 + end + return s +end + +--[[ +* Locate the best instance of 'pattern' in 'text' near 'loc' using the +* Bitap algorithm. +* @param {string} text The text to search. +* @param {string} pattern The pattern to search for. +* @param {number} loc The location to search around. +* @return {number} Best match index or -1. +* @private +--]] +function _match_bitap(text, pattern, loc) + if #pattern > Match_MaxBits then + error('Pattern too long.') + end + + -- Initialise the alphabet. + local s = _match_alphabet(pattern) + + --[[ + * Compute and return the score for a match with e errors and x location. + * Accesses loc and pattern through being a closure. + * @param {number} e Number of errors in match. + * @param {number} x Location of match. + * @return {number} Overall score for match (0.0 = good, 1.0 = bad). + * @private + --]] + local function _match_bitapScore(e, x) + local accuracy = e / #pattern + local proximity = abs(loc - x) + if Match_Distance == 0 then + -- Dodge divide by zero error. + return (proximity == 0) and 1 or accuracy + end + return accuracy + (proximity / Match_Distance) + end + + -- Highest score beyond which we give up. + local score_threshold = Match_Threshold + -- Is there a nearby exact match? (speedup) + local best_loc = indexOf(text, pattern, loc) + if best_loc then + score_threshold = min(_match_bitapScore(0, best_loc), score_threshold) + -- LUANOTE: Ideally we'd also check from the other direction, but Lua + -- doesn't have an efficent lastIndexOf function. + end + + -- Initialise the bit arrays. + local matchmask = lshift(1, #pattern - 1) + best_loc = -1 + + local bin_min, bin_mid + local bin_max = #pattern + #text + local last_rd + for d = 0, #pattern - 1, 1 do + -- Scan for the best match; each iteration allows for one more error. + -- Run a binary search to determine how far from 'loc' we can stray at this + -- error level. + bin_min = 0 + bin_mid = bin_max + while bin_min < bin_mid do + if _match_bitapScore(d, loc + bin_mid) <= score_threshold then + bin_min = bin_mid + else + bin_max = bin_mid + end + bin_mid = floor(bin_min + (bin_max - bin_min) / 2) + end + -- Use the result from this iteration as the maximum for the next. + bin_max = bin_mid + local start = max(1, loc - bin_mid + 1) + local finish = min(loc + bin_mid, #text) + #pattern + + local rd = {} + for j = start, finish do + rd[j] = 0 + end + rd[finish + 1] = lshift(1, d) - 1 + for j = finish, start, -1 do + local charMatch = s[strsub(text, j - 1, j - 1)] or 0 + if d == 0 then -- First pass: exact match. + rd[j] = band(bor((rd[j + 1] * 2), 1), charMatch) + else + -- Subsequent passes: fuzzy match. + -- Functions instead of operators make this hella messy. + rd[j] = bor( + band(bor(lshift(rd[j + 1], 1), 1), charMatch), + bor(bor(lshift(bor(last_rd[j + 1], last_rd[j]), 1), 1), last_rd[j + 1]) + ) + end + if band(rd[j], matchmask) ~= 0 then + local score = _match_bitapScore(d, j - 1) + -- This match will almost certainly be better than any existing match. + -- But check anyway. + if score <= score_threshold then + -- Told you so. + score_threshold = score + best_loc = j - 1 + if best_loc > loc then + -- When passing loc, don't exceed our current distance from loc. + start = max(1, loc * 2 - best_loc) + else + -- Already passed loc, downhill from here on in. + break + end + end + end + end + -- No hope for a (better) match at greater error levels. + if _match_bitapScore(d + 1, loc) > score_threshold then + break + end + last_rd = rd + end + return best_loc +end + +-- ----------------------------------------------------------------------------- +-- PATCH API +-- ----------------------------------------------------------------------------- + +local _patch_addContext, _patch_deepCopy, _patch_addPadding, _patch_splitMax, _patch_appendText, _new_patch_obj + +--[[ +* Compute a list of patches to turn text1 into text2. +* Use diffs if provided, otherwise compute it ourselves. +* There are four ways to call this function, depending on what data is +* available to the caller: +* Method 1: +* a = text1, b = text2 +* Method 2: +* a = diffs +* Method 3 (optimal): +* a = text1, b = diffs +* Method 4 (deprecated, use method 3): +* a = text1, b = text2, c = diffs +* +* @param {string|Array.>} a text1 (methods 1,3,4) or +* Array of diff tuples for text1 to text2 (method 2). +* @param {string|Array.>} opt_b text2 (methods 1,4) or +* Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). +* @param {string|Array.>} opt_c Array of diff tuples for +* text1 to text2 (method 4) or undefined (methods 1,2,3). +* @return {Array.<_new_patch_obj>} Array of patch objects. +--]] +function patch_make(a, opt_b, opt_c) + local text1, diffs + local type_a, type_b, type_c = type(a), type(opt_b), type(opt_c) + if (type_a == 'string') and (type_b == 'string') and (type_c == 'nil') then + -- Method 1: text1, text2 + -- Compute diffs from text1 and text2. + text1 = a + diffs = diff_main(text1, opt_b, true) + if #diffs > 2 then + diff_cleanupSemantic(diffs) + diff_cleanupEfficiency(diffs) + end + elseif (type_a == 'table') and (type_b == 'nil') and (type_c == 'nil') then + -- Method 2: diffs + -- Compute text1 from diffs. + diffs = a + text1 = _diff_text1(diffs) + elseif (type_a == 'string') and (type_b == 'table') and (type_c == 'nil') then + -- Method 3: text1, diffs + text1 = a + diffs = opt_b + elseif (type_a == 'string') and (type_b == 'string') and (type_c == 'table') then + -- Method 4: text1, text2, diffs + -- text2 is not used. + text1 = a + diffs = opt_c + else + error('Unknown call format to patch_make.') + end + + if diffs[1] == nil then + return {} -- Get rid of the null case. + end + + local patches = {} + local patch = _new_patch_obj() + local patchDiffLength = 0 -- Keeping our own length var is faster. + local char_count1 = 0 -- Number of characters into the text1 string. + local char_count2 = 0 -- Number of characters into the text2 string. + -- Start with text1 (prepatch_text) and apply the diffs until we arrive at + -- text2 (postpatch_text). We recreate the patches one by one to determine + -- context info. + local prepatch_text, postpatch_text = text1, text1 + for x, diff in ipairs(diffs) do + local diff_type, diff_text = diff[1], diff[2] + + if (patchDiffLength == 0) and (diff_type ~= DIFF_EQUAL) then + -- A new patch starts here. + patch.start1 = char_count1 + 1 + patch.start2 = char_count2 + 1 + end + + if diff_type == DIFF_INSERT then + patchDiffLength = patchDiffLength + 1 + patch.diffs[patchDiffLength] = diff + patch.length2 = patch.length2 + #diff_text + postpatch_text = strsub(postpatch_text, 1, char_count2) .. diff_text .. strsub(postpatch_text, char_count2 + 1) + elseif diff_type == DIFF_DELETE then + patch.length1 = patch.length1 + #diff_text + patchDiffLength = patchDiffLength + 1 + patch.diffs[patchDiffLength] = diff + postpatch_text = strsub(postpatch_text, 1, char_count2) .. strsub(postpatch_text, char_count2 + #diff_text + 1) + elseif diff_type == DIFF_EQUAL then + if (#diff_text <= Patch_Margin * 2) and (patchDiffLength ~= 0) and (#diffs ~= x) then + -- Small equality inside a patch. + patchDiffLength = patchDiffLength + 1 + patch.diffs[patchDiffLength] = diff + patch.length1 = patch.length1 + #diff_text + patch.length2 = patch.length2 + #diff_text + elseif #diff_text >= Patch_Margin * 2 then + -- Time for a new patch. + if patchDiffLength ~= 0 then + _patch_addContext(patch, prepatch_text) + patches[#patches + 1] = patch + patch = _new_patch_obj() + patchDiffLength = 0 + -- Unlike Unidiff, our patch lists have a rolling context. + -- https://github.com/google/diff-match-patch/wiki/Unidiff + -- Update prepatch text & pos to reflect the application of the + -- just completed patch. + prepatch_text = postpatch_text + char_count1 = char_count2 + end + end + end + + -- Update the current character count. + if diff_type ~= DIFF_INSERT then + char_count1 = char_count1 + #diff_text + end + if diff_type ~= DIFF_DELETE then + char_count2 = char_count2 + #diff_text + end + end + + -- Pick up the leftover patch if not empty. + if patchDiffLength > 0 then + _patch_addContext(patch, prepatch_text) + patches[#patches + 1] = patch + end + + return patches +end + +--[[ +* Merge a set of patches onto the text. Return a patched text, as well +* as a list of true/false values indicating which patches were applied. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @param {string} text Old text. +* @return {Array.>} Two return values, the +* new text and an array of boolean values. +--]] +function patch_apply(patches, text) + if patches[1] == nil then + return text, {} + end + + -- Deep copy the patches so that no changes are made to originals. + patches = _patch_deepCopy(patches) + + local nullPadding = _patch_addPadding(patches) + text = nullPadding .. text .. nullPadding + + _patch_splitMax(patches) + -- delta keeps track of the offset between the expected and actual location + -- of the previous patch. If there are patches expected at positions 10 and + -- 20, but the first patch was found at 12, delta is 2 and the second patch + -- has an effective expected position of 22. + local delta = 0 + local results = {} + for x, patch in ipairs(patches) do + local expected_loc = patch.start2 + delta + local text1 = _diff_text1(patch.diffs) + local start_loc + local end_loc = -1 + if #text1 > Match_MaxBits then + -- _patch_splitMax will only provide an oversized pattern in + -- the case of a monster delete. + start_loc = match_main(text, strsub(text1, 1, Match_MaxBits), expected_loc) + if start_loc ~= -1 then + end_loc = match_main(text, strsub(text1, -Match_MaxBits), expected_loc + #text1 - Match_MaxBits) + if end_loc == -1 or start_loc >= end_loc then + -- Can't find valid trailing context. Drop this patch. + start_loc = -1 + end + end + else + start_loc = match_main(text, text1, expected_loc) + end + if start_loc == -1 then + -- No match found. :( + results[x] = false + -- Subtract the delta for this failed patch from subsequent patches. + delta = delta - patch.length2 - patch.length1 + else + -- Found a match. :) + results[x] = true + delta = start_loc - expected_loc + local text2 + if end_loc == -1 then + text2 = strsub(text, start_loc, start_loc + #text1 - 1) + else + text2 = strsub(text, start_loc, end_loc + Match_MaxBits - 1) + end + if text1 == text2 then + -- Perfect match, just shove the replacement text in. + text = strsub(text, 1, start_loc - 1) .. _diff_text2(patch.diffs) .. strsub(text, start_loc + #text1) + else + -- Imperfect match. Run a diff to get a framework of equivalent + -- indices. + local diffs = diff_main(text1, text2, false) + if (#text1 > Match_MaxBits) and (diff_levenshtein(diffs) / #text1 > Patch_DeleteThreshold) then + -- The end points match, but the content is unacceptably bad. + results[x] = false + else + _diff_cleanupSemanticLossless(diffs) + local index1 = 1 + local index2 + for y, mod in ipairs(patch.diffs) do + if mod[1] ~= DIFF_EQUAL then + index2 = _diff_xIndex(diffs, index1) + end + if mod[1] == DIFF_INSERT then + text = strsub(text, 1, start_loc + index2 - 2) .. mod[2] .. strsub(text, start_loc + index2 - 1) + elseif mod[1] == DIFF_DELETE then + text = strsub(text, 1, start_loc + index2 - 2) + .. strsub(text, start_loc + _diff_xIndex(diffs, index1 + #mod[2] - 1)) + end + if mod[1] ~= DIFF_DELETE then + index1 = index1 + #mod[2] + end + end + end + end + end + end + -- Strip the padding off. + text = strsub(text, #nullPadding + 1, -#nullPadding - 1) + return text, results +end + +--[[ +* Take a list of patches and return a textual representation. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @return {string} Text representation of patches. +--]] +function patch_toText(patches) + local text = {} + for x, patch in ipairs(patches) do + _patch_appendText(patch, text) + end + return tconcat(text) +end + +--[[ +* Parse a textual representation of patches and return a list of patch objects. +* @param {string} textline Text representation of patches. +* @return {Array.<_new_patch_obj>} Array of patch objects. +* @throws {Error} If invalid input. +--]] +function patch_fromText(textline) + local patches = {} + if #textline == 0 then + return patches + end + local text = {} + for line in gmatch(textline, '([^\n]*)') do + text[#text + 1] = line + end + local textPointer = 1 + while textPointer <= #text do + local start1, length1, start2, length2 = strmatch(text[textPointer], '^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@$') + if start1 == nil then + error('Invalid patch string: "' .. text[textPointer] .. '"') + end + local patch = _new_patch_obj() + patches[#patches + 1] = patch + + start1 = tonumber(start1) + length1 = tonumber(length1) or 1 + if length1 == 0 then + start1 = start1 + 1 + end + patch.start1 = start1 + patch.length1 = length1 + + start2 = tonumber(start2) + length2 = tonumber(length2) or 1 + if length2 == 0 then + start2 = start2 + 1 + end + patch.start2 = start2 + patch.length2 = length2 + + textPointer = textPointer + 1 + + while true do + local line = text[textPointer] + if line == nil then + break + end + local sign + sign, line = strsub(line, 1, 1), strsub(line, 2) + + local invalidDecode = false + local decoded = gsub(line, '%%(.?.?)', function(c) + local n = tonumber(c, 16) + if (#c ~= 2) or (n == nil) then + invalidDecode = true + return '' + end + return strchar(n) + end) + if invalidDecode then + -- Malformed URI sequence. + error('Illegal escape in patch_fromText: ' .. line) + end + + line = decoded + + if sign == '-' then + -- Deletion. + patch.diffs[#patch.diffs + 1] = { DIFF_DELETE, line } + elseif sign == '+' then + -- Insertion. + patch.diffs[#patch.diffs + 1] = { DIFF_INSERT, line } + elseif sign == ' ' then + -- Minor equality. + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, line } + elseif sign == '@' then + -- Start of next patch. + break + elseif sign == '' then + -- Blank line? Whatever. + else + -- WTF? + error('Invalid patch mode "' .. sign .. '" in: ' .. line) + end + textPointer = textPointer + 1 + end + end + return patches +end + +-- --------------------------------------------------------------------------- +-- UNOFFICIAL/PRIVATE PATCH FUNCTIONS +-- --------------------------------------------------------------------------- + +local patch_meta = { + __tostring = function(patch) + local buf = {} + _patch_appendText(patch, buf) + return tconcat(buf) + end, +} + +--[[ +* Class representing one patch operation. +* @constructor +--]] +function _new_patch_obj() + return setmetatable({ + --[[ @type {Array.>} ]] + diffs = {}, + --[[ @type {?number} ]] + start1 = 1, -- nil; + --[[ @type {?number} ]] + start2 = 1, -- nil; + --[[ @type {number} ]] + length1 = 0, + --[[ @type {number} ]] + length2 = 0, + }, patch_meta) +end + +--[[ +* Increase the context until it is unique, +* but don't let the pattern expand beyond Match_MaxBits. +* @param {_new_patch_obj} patch The patch to grow. +* @param {string} text Source text. +* @private +--]] +function _patch_addContext(patch, text) + if #text == 0 then + return + end + local pattern = strsub(text, patch.start2, patch.start2 + patch.length1 - 1) + local padding = 0 + + -- LUANOTE: Lua's lack of a lastIndexOf function results in slightly + -- different logic here than in other language ports. + -- Look for the first two matches of pattern in text. If two are found, + -- increase the pattern length. + local firstMatch = indexOf(text, pattern) + local secondMatch = nil + if firstMatch ~= nil then + secondMatch = indexOf(text, pattern, firstMatch + 1) + end + while (#pattern == 0 or secondMatch ~= nil) and (#pattern < Match_MaxBits - Patch_Margin - Patch_Margin) do + padding = padding + Patch_Margin + pattern = strsub(text, max(1, patch.start2 - padding), patch.start2 + patch.length1 - 1 + padding) + firstMatch = indexOf(text, pattern) + if firstMatch ~= nil then + secondMatch = indexOf(text, pattern, firstMatch + 1) + else + secondMatch = nil + end + end + -- Add one chunk for good luck. + padding = padding + Patch_Margin + + -- Add the prefix. + local prefix = strsub(text, max(1, patch.start2 - padding), patch.start2 - 1) + if #prefix > 0 then + tinsert(patch.diffs, 1, { DIFF_EQUAL, prefix }) + end + -- Add the suffix. + local suffix = strsub(text, patch.start2 + patch.length1, patch.start2 + patch.length1 - 1 + padding) + if #suffix > 0 then + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, suffix } + end + + -- Roll back the start points. + patch.start1 = patch.start1 - #prefix + patch.start2 = patch.start2 - #prefix + -- Extend the lengths. + patch.length1 = patch.length1 + #prefix + #suffix + patch.length2 = patch.length2 + #prefix + #suffix +end + +--[[ +* Given an array of patches, return another array that is identical. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @return {Array.<_new_patch_obj>} Array of patch objects. +--]] +function _patch_deepCopy(patches) + local patchesCopy = {} + for x, patch in ipairs(patches) do + local patchCopy = _new_patch_obj() + local diffsCopy = {} + for i, diff in ipairs(patch.diffs) do + diffsCopy[i] = { diff[1], diff[2] } + end + patchCopy.diffs = diffsCopy + patchCopy.start1 = patch.start1 + patchCopy.start2 = patch.start2 + patchCopy.length1 = patch.length1 + patchCopy.length2 = patch.length2 + patchesCopy[x] = patchCopy + end + return patchesCopy +end + +--[[ +* Add some padding on text start and end so that edges can match something. +* Intended to be called only from within patch_apply. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +* @return {string} The padding string added to each side. +--]] +function _patch_addPadding(patches) + local paddingLength = Patch_Margin + local nullPadding = '' + for x = 1, paddingLength do + nullPadding = nullPadding .. strchar(x) + end + + -- Bump all the patches forward. + for x, patch in ipairs(patches) do + patch.start1 = patch.start1 + paddingLength + patch.start2 = patch.start2 + paddingLength + end + + -- Add some padding on start of first diff. + local patch = patches[1] + local diffs = patch.diffs + local firstDiff = diffs[1] + if (firstDiff == nil) or (firstDiff[1] ~= DIFF_EQUAL) then + -- Add nullPadding equality. + tinsert(diffs, 1, { DIFF_EQUAL, nullPadding }) + patch.start1 = patch.start1 - paddingLength -- Should be 0. + patch.start2 = patch.start2 - paddingLength -- Should be 0. + patch.length1 = patch.length1 + paddingLength + patch.length2 = patch.length2 + paddingLength + elseif paddingLength > #firstDiff[2] then + -- Grow first equality. + local extraLength = paddingLength - #firstDiff[2] + firstDiff[2] = strsub(nullPadding, #firstDiff[2] + 1) .. firstDiff[2] + patch.start1 = patch.start1 - extraLength + patch.start2 = patch.start2 - extraLength + patch.length1 = patch.length1 + extraLength + patch.length2 = patch.length2 + extraLength + end + + -- Add some padding on end of last diff. + patch = patches[#patches] + diffs = patch.diffs + local lastDiff = diffs[#diffs] + if (lastDiff == nil) or (lastDiff[1] ~= DIFF_EQUAL) then + -- Add nullPadding equality. + diffs[#diffs + 1] = { DIFF_EQUAL, nullPadding } + patch.length1 = patch.length1 + paddingLength + patch.length2 = patch.length2 + paddingLength + elseif paddingLength > #lastDiff[2] then + -- Grow last equality. + local extraLength = paddingLength - #lastDiff[2] + lastDiff[2] = lastDiff[2] .. strsub(nullPadding, 1, extraLength) + patch.length1 = patch.length1 + extraLength + patch.length2 = patch.length2 + extraLength + end + + return nullPadding +end + +--[[ +* Look through the patches and break up any which are longer than the maximum +* limit of the match algorithm. +* Intended to be called only from within patch_apply. +* @param {Array.<_new_patch_obj>} patches Array of patch objects. +--]] +function _patch_splitMax(patches) + local patch_size = Match_MaxBits + local x = 1 + while true do + local patch = patches[x] + if patch == nil then + return + end + if patch.length1 > patch_size then + local bigpatch = patch + -- Remove the big old patch. + tremove(patches, x) + x = x - 1 + local start1 = bigpatch.start1 + local start2 = bigpatch.start2 + local precontext = '' + while bigpatch.diffs[1] do + -- Create one of several smaller patches. + local patch = _new_patch_obj() + local empty = true + patch.start1 = start1 - #precontext + patch.start2 = start2 - #precontext + if precontext ~= '' then + patch.length1, patch.length2 = #precontext, #precontext + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, precontext } + end + while bigpatch.diffs[1] and (patch.length1 < patch_size - Patch_Margin) do + local diff_type = bigpatch.diffs[1][1] + local diff_text = bigpatch.diffs[1][2] + if diff_type == DIFF_INSERT then + -- Insertions are harmless. + patch.length2 = patch.length2 + #diff_text + start2 = start2 + #diff_text + patch.diffs[#patch.diffs + 1] = bigpatch.diffs[1] + tremove(bigpatch.diffs, 1) + empty = false + elseif + (diff_type == DIFF_DELETE) + and (#patch.diffs == 1) + and (patch.diffs[1][1] == DIFF_EQUAL) + and (#diff_text > 2 * patch_size) + then + -- This is a large deletion. Let it pass in one chunk. + patch.length1 = patch.length1 + #diff_text + start1 = start1 + #diff_text + empty = false + patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } + tremove(bigpatch.diffs, 1) + else + -- Deletion or equality. + -- Only take as much as we can stomach. + diff_text = strsub(diff_text, 1, patch_size - patch.length1 - Patch_Margin) + patch.length1 = patch.length1 + #diff_text + start1 = start1 + #diff_text + if diff_type == DIFF_EQUAL then + patch.length2 = patch.length2 + #diff_text + start2 = start2 + #diff_text + else + empty = false + end + patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } + if diff_text == bigpatch.diffs[1][2] then + tremove(bigpatch.diffs, 1) + else + bigpatch.diffs[1][2] = strsub(bigpatch.diffs[1][2], #diff_text + 1) + end + end + end + -- Compute the head context for the next patch. + precontext = _diff_text2(patch.diffs) + precontext = strsub(precontext, -Patch_Margin) + -- Append the end context for this patch. + local postcontext = strsub(_diff_text1(bigpatch.diffs), 1, Patch_Margin) + if postcontext ~= '' then + patch.length1 = patch.length1 + #postcontext + patch.length2 = patch.length2 + #postcontext + if patch.diffs[1] and (patch.diffs[#patch.diffs][1] == DIFF_EQUAL) then + patch.diffs[#patch.diffs][2] = patch.diffs[#patch.diffs][2] .. postcontext + else + patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, postcontext } + end + end + if not empty then + x = x + 1 + tinsert(patches, x, patch) + end + end + end + x = x + 1 + end +end + +--[[ +* Emulate GNU diff's format. +* Header: @@ -382,8 +481,9 @@ +* @return {string} The GNU diff string. +--]] +function _patch_appendText(patch, text) + local coords1, coords2 + local length1, length2 = patch.length1, patch.length2 + local start1, start2 = patch.start1, patch.start2 + local diffs = patch.diffs + + if length1 == 1 then + coords1 = start1 + else + coords1 = ((length1 == 0) and (start1 - 1) or start1) .. ',' .. length1 + end + + if length2 == 1 then + coords2 = start2 + else + coords2 = ((length2 == 0) and (start2 - 1) or start2) .. ',' .. length2 + end + text[#text + 1] = '@@ -' .. coords1 .. ' +' .. coords2 .. ' @@\n' + + local op + -- Escape the body of the patch with %xx notation. + for x, diff in ipairs(patch.diffs) do + local diff_type = diff[1] + if diff_type == DIFF_INSERT then + op = '+' + elseif diff_type == DIFF_DELETE then + op = '-' + elseif diff_type == DIFF_EQUAL then + op = ' ' + end + text[#text + 1] = op .. gsub(diffs[x][2], percentEncode_pattern, percentEncode_replace) .. '\n' + end + + return text +end + +-- Expose the API +local _M = {} + +_M.DIFF_DELETE = DIFF_DELETE +_M.DIFF_INSERT = DIFF_INSERT +_M.DIFF_EQUAL = DIFF_EQUAL + +_M.diff_main = diff_main +_M.diff_cleanupSemantic = diff_cleanupSemantic +_M.diff_cleanupEfficiency = diff_cleanupEfficiency +_M.diff_levenshtein = diff_levenshtein +_M.diff_prettyHtml = diff_prettyHtml + +_M.match_main = match_main + +_M.patch_make = patch_make +_M.patch_toText = patch_toText +_M.patch_fromText = patch_fromText +_M.patch_apply = patch_apply + +-- Expose some non-API functions as well, for testing purposes etc. +_M.diff_commonPrefix = _diff_commonPrefix +_M.diff_commonSuffix = _diff_commonSuffix +_M.diff_commonOverlap = _diff_commonOverlap +_M.diff_halfMatch = _diff_halfMatch +_M.diff_bisect = _diff_bisect +_M.diff_cleanupMerge = _diff_cleanupMerge +_M.diff_cleanupSemanticLossless = _diff_cleanupSemanticLossless +_M.diff_text1 = _diff_text1 +_M.diff_text2 = _diff_text2 +_M.diff_toDelta = _diff_toDelta +_M.diff_fromDelta = _diff_fromDelta +_M.diff_xIndex = _diff_xIndex +_M.match_alphabet = _match_alphabet +_M.match_bitap = _match_bitap +_M.new_patch_obj = _new_patch_obj +_M.patch_addContext = _patch_addContext +_M.patch_splitMax = _patch_splitMax +_M.patch_addPadding = _patch_addPadding +_M.settings = settings + +return _M diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 62866c40..cdea4c1b 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -1,22 +1,6 @@ local diff = require('CopilotChat.utils.diff') describe('CopilotChat.utils.diff', function() - it('parses unified diff', function() - local diff_text = [[ ---- a/foo.txt -+++ b/foo.txt -@@ ... @@ - context line --old line -+new line -]] - local file_path, hunks = diff.parse_unified_diff(diff_text) - assert.equals('b/foo.txt', file_path) - assert.equals('context line', hunks[1].context[1]) - assert.equals('old line', hunks[1].minus[1]) - assert.equals('new line', hunks[1].plus[1]) - end) - it('applies unified diff', function() local diff_text = [[ --- a/foo.txt @@ -27,26 +11,12 @@ describe('CopilotChat.utils.diff', function() +new ]] local original = { 'context', 'old', 'other' } - local result, applied = diff.apply_unified_diff(diff_text, original) + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) assert.is_true(applied) assert.are.same({ 'context', 'new', 'other' }, result) end) - it('gets unified diff region', function() - local diff_text = [[ ---- a/foo.txt -+++ b/foo.txt -@@ ... @@ - context --old -+new -]] - local original = { 'context', 'old', 'other' } - local first, last = diff.get_unified_diff_region(diff_text, original) - assert.equals(2, first) - assert.equals(2, last) - end) - it('applies unified diff with no context', function() local diff_text = [[ --- a/foo.txt @@ -56,7 +26,8 @@ describe('CopilotChat.utils.diff', function() +new ]] local original = { 'old', 'other' } - local result, applied = diff.apply_unified_diff(diff_text, original) + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) assert.is_true(applied) assert.are.same({ 'new', 'other' }, result) end) @@ -81,7 +52,8 @@ describe('CopilotChat.utils.diff', function() 'context3', 'other', } - local result, applied = diff.apply_unified_diff(diff_text, original) + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) assert.is_true(applied) assert.are.same({ 'context1', @@ -93,7 +65,7 @@ describe('CopilotChat.utils.diff', function() }, result) end) - it('does not apply ambiguous edit', function() + it('gets unified diff region', function() local diff_text = [[ --- a/foo.txt +++ b/foo.txt @@ -102,10 +74,159 @@ describe('CopilotChat.utils.diff', function() -old +new ]] - local original = { 'context', 'old', 'context', 'old' } - local result, applied = diff.apply_unified_diff(diff_text, original) - -- Should not apply because there are two possible matches - assert.is_false(applied) - assert.are.same({ 'context', 'old', 'context', 'old' }, result) + local original = { 'context', 'old', 'other' } + local original_content = table.concat(original, '\n') + local _, _, first, last = diff.apply_unified_diff(diff_text, original_content) + assert.equals(2, first) + assert.equals(2, last) + end) + + it('applies unified diff with only additions', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context ++added1 ++added2 +]] + local original = { 'context', 'other' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'context', 'added1', 'added2', 'other' }, result) + end) + + it('applies unified diff with only deletions', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-old1 +-old2 +]] + local original = { 'context', 'old1', 'old2', 'other' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'context', 'other' }, result) + end) + + it('applies unified diff with changes at start and end', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ +-oldstart ++newstart + context +-oldend ++newend +]] + local original = { 'oldstart', 'context', 'oldend' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'newstart', 'context', 'newend' }, result) + end) + + it('applies unified diff with multiple hunks', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context1 +-old1 ++new1 +@@ ... @@ + context2 +-old2 ++new2 +]] + local original = { 'context1', 'old1', 'context2', 'old2', 'other' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'context1', 'new1', 'context2', 'new2', 'other' }, result) + end) + + it('applies unified diff with no changes', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context + unchanged +]] + local original = { 'context', 'unchanged' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same(original, result) + end) + + it('applies unified diff with all lines deleted', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ +-old1 +-old2 +-old3 +]] + local original = { 'old1', 'old2', 'old3' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ '' }, result) + end) + + it('applies unified diff with all lines added to empty file', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ ++new1 ++new2 ++new3 +]] + local original = {} + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'new1', 'new2', 'new3' }, result) + end) + + it('applies unified diff with changes at end of file', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ + context +-oldend ++newend +]] + local original = { 'context', 'oldend' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'context', 'newend' }, result) + end) + + it('applies unified diff with changes at start of file', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ ... @@ +-oldstart ++newstart + context +]] + local original = { 'oldstart', 'context' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'newstart', 'context' }, result) end) end) From 06af2b3a6fd89892a80ebd62d4c1d1a64031d8ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 13 Sep 2025 05:37:42 +0000 Subject: [PATCH 132/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 456e34a1..d1a712ba 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 12 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 13 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -31,9 +31,11 @@ Table of Contents *CopilotChat-table-of-contents* 6. Development |CopilotChat-development| - Setup |CopilotChat-setup| - Contributing |CopilotChat-contributing| -7. Contributors |CopilotChat-contributors| -8. Stargazers |CopilotChat-stargazers| -9. Links |CopilotChat-links| +7. Acknowledgments |CopilotChat-acknowledgments| + - diff-match-patch |CopilotChat-diff-match-patch| +8. Contributors |CopilotChat-contributors| +9. Stargazers |CopilotChat-stargazers| +10. Links |CopilotChat-links| CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. @@ -597,7 +599,19 @@ See CONTRIBUTING.md for detailed guidelines. ============================================================================== -7. Contributors *CopilotChat-contributors* +7. Acknowledgments *CopilotChat-acknowledgments* + + +DIFF-MATCH-PATCH *CopilotChat-diff-match-patch* + +CopilotChat.nvim includes diff-match-patch (Lua port) + for diffing and patching +functionality. Copyright 2018 The diff-match-patch Authors. Licensed under the +Apache License 2.0. + + +============================================================================== +8. Contributors *CopilotChat-contributors* Thanks goes to these wonderful people (emoji key ): @@ -608,12 +622,12 @@ Contributions of any kind are welcome! ============================================================================== -8. Stargazers *CopilotChat-stargazers* +9. Stargazers *CopilotChat-stargazers* ============================================================================== -9. Links *CopilotChat-links* +10. Links *CopilotChat-links* 1. *Stargazers over time*: https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg?variant=adaptive From 19a4d2990fe1f8c0697636059500d521793cede0 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 07:41:11 +0200 Subject: [PATCH 133/250] refactor(diff): use plenary.log for debug logging (#1408) Replace vim.notify with plenary.log.debug in diff apply function to avoid user-facing warnings and improve logging granularity. This change helps keep the UI clean while still providing debug information for failed diff applications. --- lua/CopilotChat/utils/diff.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 86449c97..7e862c54 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -1,3 +1,5 @@ +local log = require('plenary.log') + local M = {} --- Parse unified diff hunks from diff text @@ -173,7 +175,7 @@ function M.apply_diff(block, bufnr) local diff, content = M.get_diff(block, bufnr) local new_lines, applied, _, _ = M.apply_unified_diff(diff, content) if not applied then - vim.notify('Diff for ' .. block.header.filename .. ' failed to apply cleanly for:\n' .. diff, vim.log.levels.WARN) + log.debug('Diff for ' .. block.header.filename .. ' failed to apply cleanly for:\n' .. diff) end return new_lines From a88874ef3663aea6bc09eb09c1df4a46ae8577f5 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 14:12:58 +0200 Subject: [PATCH 134/250] feat(diff): apply all code blocks for a file at once when showing diff (#1409) Refactored diff preview logic to process all code blocks for a file in one pass, improving consistency and correctness. Updated diff utility functions to accept lines instead of buffer numbers, and adjusted context lengths for more accurate region detection. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 28 ++++++++++++++++++++++++---- lua/CopilotChat/utils/diff.lua | 23 +++++++++++------------ tests/diff_spec.lua | 2 +- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 05122044..74429e33 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -172,9 +172,10 @@ return { local path = block.header.filename local bufnr = prepare_diff_buffer(path, source) - local new_lines = diff.apply_diff(block, bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local new_lines = diff.apply_diff(block, lines) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, new_lines) - local first, last = diff.get_diff_region(block, bufnr) + local first, last = diff.get_diff_region(block, lines) if first and last then select.set(bufnr, source.winnr, first, last) select.highlight(bufnr) @@ -192,7 +193,8 @@ return { local path = block.header.filename local bufnr = prepare_diff_buffer(path, source) - local first, last = diff.get_diff_region(block, bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local first, last = diff.get_diff_region(block, lines) if first and last and bufnr then select.set(bufnr, source.winnr, first, last) select.highlight(bufnr) @@ -223,7 +225,25 @@ return { local path = block.header.filename local bufnr = prepare_diff_buffer(path, source) - local new_lines = diff.apply_diff(block, bufnr) + + -- Collect all blocks for the same filename + local message = copilot.chat:get_message(constants.ROLE.ASSISTANT, true) + local blocks = {} + if message and message.section and message.section.blocks then + for _, b in ipairs(message.section.blocks) do + if b.header.filename == path then + table.insert(blocks, b) + end + end + else + blocks = { block } + end + + -- Apply all diffs for the filename + local new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i = #blocks, 1, -1 do + new_lines = diff.apply_diff(blocks[i], new_lines) + end local opts = { filetype = vim.bo[bufnr].filetype, diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 7e862c54..6ff56bac 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -120,8 +120,8 @@ function M.apply_unified_diff(diff_text, original_content) new_content = patched applied = applied or ok end - local original_lines = vim.split(original_content, '\n') - local new_lines = vim.split(new_content, '\n') + local original_lines = vim.split(original_content, '\n', { trimempty = true }) + local new_lines = vim.split(new_content, '\n', { trimempty = true }) local first, last local max_len = math.max(#original_lines, #new_lines) for i = 1, max_len do @@ -137,10 +137,9 @@ end --- Get diff from block content and buffer lines ---@param block CopilotChat.ui.chat.Block Block containing diff info ----@param bufnr integer Buffer number +---@param lines table table of lines ---@return string diff, string content -function M.get_diff(block, bufnr) - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) +function M.get_diff(block, lines) local content = table.concat(lines, '\n') if block.header.filetype == 'diff' then return block.content, content @@ -161,7 +160,7 @@ function M.get_diff(block, bufnr) vim.diff( table.concat(original_lines, '\n'), table.concat(patched_lines, '\n'), - { algorithm = 'myers', ctxlen = 20, interhunkctxlen = 50, ignore_whitespace_change = true } + { algorithm = 'myers', ctxlen = 10, interhunkctxlen = 10, ignore_whitespace_change = true } ) ), content @@ -169,10 +168,10 @@ end --- Apply a diff (unified or indices) to buffer lines ---@param block CopilotChat.ui.chat.Block Block containing diff info ----@param bufnr integer Buffer number +---@param lines table table of lines ---@return table new_lines -function M.apply_diff(block, bufnr) - local diff, content = M.get_diff(block, bufnr) +function M.apply_diff(block, lines) + local diff, content = M.get_diff(block, lines) local new_lines, applied, _, _ = M.apply_unified_diff(diff, content) if not applied then log.debug('Diff for ' .. block.header.filename .. ' failed to apply cleanly for:\n' .. diff) @@ -183,10 +182,10 @@ end --- Get changed region for diff (unified or indices) ---@param block CopilotChat.ui.chat.Block Block containing diff info ----@param bufnr integer Buffer number +---@param lines table table of lines ---@return number? first, number? last -function M.get_diff_region(block, bufnr) - local diff, content = M.get_diff(block, bufnr) +function M.get_diff_region(block, lines) + local diff, content = M.get_diff(block, lines) local _, _, first, last = M.apply_unified_diff(diff, content) return first, last end diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index cdea4c1b..58e2f4c9 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -179,7 +179,7 @@ describe('CopilotChat.utils.diff', function() local original_content = table.concat(original, '\n') local result, applied = diff.apply_unified_diff(diff_text, original_content) assert.is_true(applied) - assert.are.same({ '' }, result) + assert.are.same({}, result) end) it('applies unified diff with all lines added to empty file', function() From 00d0fb310ad364e76e306a6626a40b85fc5bbd98 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 14:41:37 +0200 Subject: [PATCH 135/250] fix(chat): automatically start treesitter if not started (#1410) As treesitter is now a requirement, it needs to be started automatically. nvim-treesitter does not auto start it anymore on latest branch so we need to do it ourselves. Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 3cf5efc7..3389ea5f 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -117,6 +117,7 @@ end ---@field private separator string ---@field private spinner CopilotChat.ui.spinner.Spinner ---@field private chat_overlay CopilotChat.ui.overlay.Overlay +---@field private last_changedtick number? 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) @@ -616,6 +617,12 @@ function Chat:create() local bufnr = Overlay.create(self) vim.bo[bufnr].syntax = 'markdown' vim.bo[bufnr].textwidth = 0 + vim.bo[bufnr].undolevels = 10 + self.spinner.bufnr = bufnr + + vim.schedule(function() + pcall(vim.treesitter.start, bufnr) + end) vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { buffer = bufnr, @@ -627,7 +634,6 @@ function Chat:create() end, }) - self.spinner.bufnr = bufnr return bufnr end @@ -647,10 +653,10 @@ function Chat:parse() -- Skip parsing if buffer hasn't changed local changedtick = vim.api.nvim_buf_get_changedtick(self.bufnr) - if self._last_changedtick == changedtick then + if self.last_changedtick == changedtick then return false end - self._last_changedtick = changedtick + self.last_changedtick = changedtick local parser = vim.treesitter.get_parser(self.bufnr, 'markdown') if not parser then From 559e75423774b3a291a58d33a1144c94444e52ac Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 15:05:16 +0200 Subject: [PATCH 136/250] fix(ui): improve help rendering and treesitter usage (#1411) - Avoid starting treesitter if markdown parser already exists - Fix help message line positioning in chat - Render help virtual lines above for better visibility --- lua/CopilotChat/ui/chat.lua | 6 ++++-- lua/CopilotChat/ui/overlay.lua | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 3389ea5f..99bc7fbf 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -621,7 +621,9 @@ function Chat:create() self.spinner.bufnr = bufnr vim.schedule(function() - pcall(vim.treesitter.start, bufnr) + if not vim.treesitter.get_parser(bufnr, 'markdown', {}) then + pcall(vim.treesitter.start, bufnr) + end end) vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { @@ -906,7 +908,7 @@ function Chat:render() end msg = msg .. self.token_count .. '/' .. self.token_max_count .. ' tokens used' end - self:show_help(msg, message.section.start_line - 1) + self:show_help(msg, message.section.start_line) end -- Auto fold non-assistant messages if enabled diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 7a56c33b..547394b8 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -143,6 +143,7 @@ function Overlay:show_help(msg, pos) id = 1, hl_mode = 'combine', priority = 100, + virt_lines_above = true, virt_lines = vim.tbl_map(function(t) return { { t, 'CopilotChatHelp' } } end, vim.split(msg, '\n')), From 0514e7d1944f0caddca3fcb111a8d20c52a6dd18 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 16:35:23 +0200 Subject: [PATCH 137/250] refactor(instructions): simplify edit file block format (#1412) Streamlined instructions for presenting file edits in code blocks. Reduced redundancy, clarified formatting, and emphasized minimal, precise changes. Multiple changes are now presented as separate code blocks for clarity. --- .../instructions/edit_file_block.lua | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/lua/CopilotChat/instructions/edit_file_block.lua b/lua/CopilotChat/instructions/edit_file_block.lua index 8abc8719..f5f9bf9e 100644 --- a/lua/CopilotChat/instructions/edit_file_block.lua +++ b/lua/CopilotChat/instructions/edit_file_block.lua @@ -1,41 +1,26 @@ return [[ -Use these instructions when editing files via code blocks. Your goal is to produce clear, minimal, and precise file edits. +Use these instructions when editing files via code blocks. Present changes as clear, minimal, and precise file edits. -Steps for presenting code changes: -1. For each change, use the following markdown code block format with triple backticks: - ``` path= start_line= end_line= - - ``` +For each change, use this markdown code block format: +``` path= start_line= end_line= + +``` -2. Examples: - ```lua path={DIR}/lua/CopilotChat/init.lua start_line=40 end_line=50 - local function example() - print("This is an example function.") - end - ``` +Example: +```lua path={DIR}/lua/CopilotChat/init.lua start_line=40 end_line=50 +local function example() + print("This is an example function.") +end +``` - ```python path={DIR}/scripts/example.py start_line=10 end_line=15 - def example_function(): - print("This is an example function.") - ``` +Code content requirements: +Always use absolute file paths in headers. Convert relative paths to absolute by prefixing with {DIR}. +Keep changes minimal and focused. Include complete replacement code for the specified line range. +Use proper indentation matching the source file. Include all necessary lines without eliding code. +NEVER include line number prefixes in output code blocks - output only valid code as it should appear in the file. +Address any diagnostics issues when fixing code. - ```json path={DIR}/config/settings.json start_line=5 end_line=8 - { - "setting": "value", - "enabled": true - } - ``` - -3. Requirements for code content: - - Always use the absolute file path in the code block header. If the path is not already absolute, convert it to an absolute path prefixed by {DIR}. - - Keep changes minimal and focused to produce short diffs - - Include complete replacement code for the specified line range - - Proper indentation matching the source - - All necessary lines (no eliding with comments) - - **Never include line number prefixes in your output code blocks. Only output valid code, exactly as it should appear in the file. Line numbers are only allowed in the code block header.** - - Address any diagnostics issues when fixing code - -4. If multiple changes are needed, present them as separate code blocks. +Present multiple changes as separate code blocks. ]] From c15f65e5dc5151230c97f9fd4d386e513fc47c63 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 19:01:08 +0200 Subject: [PATCH 138/250] perf(core): do not require calling setup(), add lazy initialization (#1413) Refactored plugin initialization to use lazy loading via metatable, setting an `initialized` flag and moving setup logic to `init()`. Replaced `add_providers` with `set_providers` for clarity. Improved health check to verify initialization. Cleaned up chat state handling and logging logic for better reliability and maintainability. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 2 +- lua/CopilotChat/health.lua | 8 +- lua/CopilotChat/init.lua | 143 ++++++++++++++++++++--------------- lua/CopilotChat/tiktoken.lua | 1 - 4 files changed, 87 insertions(+), 67 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 5cd45ffc..cf240a55 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -211,7 +211,7 @@ end --- Set a provider resolver on the client ---@param resolver function: A function that returns a table of providers -function Client:add_providers(resolver) +function Client:set_providers(resolver) self.provider_resolver = resolver end diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index 0c3bcfe9..3a67e706 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -57,11 +57,11 @@ function M.check() error('nvim: unsupported, please upgrade to 0.10.0 or later. See "https://neovim.io/".') end - local setup_called = require('CopilotChat').config ~= nil - if setup_called then - ok('setup: called') + local initialized = require('CopilotChat').initialized + if initialized then + ok('initialized: true') else - error('setup: not called, required for plugin to work. See `:h CopilotChat-installation`.') + error('initialized: false, something went wrong. See `:h CopilotChat-installation`.') end local testfile = os.tmpname() diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 5dc129af..f473750a 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -23,6 +23,14 @@ local M = setmetatable({}, { if key == 'config' then return require('CopilotChat.config') end + + -- Lazy initialize + local initialized = rawget(t, 'initialized') + if not initialized then + rawset(t, 'initialized', true) + rawget(t, 'init')() + end + return rawget(t, key) end, }) @@ -33,8 +41,8 @@ local M = setmetatable({}, { --- @field cwd fun():string --- @class CopilotChat.state ---- @field source CopilotChat.source? ---- @field sticky string[]? +--- @field source CopilotChat.source +--- @field sticky string[] local state = { source = { bufnr = nil, @@ -44,7 +52,7 @@ local state = { end, }, - sticky = nil, + sticky = {}, } --- Insert sticky values from config into prompt @@ -1023,28 +1031,32 @@ function M.log_level(level) M.config.log_level = level M.config.debug = level == 'debug' - log.new({ - plugin = constants.PLUGIN_NAME, - level = level, - outfile = M.config.log_path, - fmt_msg = function(is_console, mode_name, src_path, src_line, msg) - local nameupper = mode_name:upper() - if is_console then - return string.format('[%s] %s', nameupper, msg) - else - local lineinfo = src_path .. ':' .. src_line - return string.format('[%-6s%s] %s: %s\n', nameupper, os.date(), lineinfo, msg) - end - end, - }, true) + if level ~= log.level then + log.new({ + plugin = constants.PLUGIN_NAME, + level = level, + outfile = M.config.log_path, + fmt_msg = function(is_console, mode_name, src_path, src_line, msg) + local nameupper = mode_name:upper() + if is_console then + return string.format('[%s] %s', nameupper, msg) + else + local lineinfo = src_path .. ':' .. src_line + return string.format('[%-6s%s] %s: %s\n', nameupper, os.date(), lineinfo, msg) + end + end, + }, true) + log.level = level + end end ---- Set up the plugin ----@param config CopilotChat.config.Config? -function M.setup(config) - -- Little bit of update magic - for k, v in pairs(vim.tbl_deep_extend('force', M.config, config or {})) do - M.config[k] = v +--- Initialize the plugin if not already initialized. +function M.init() + -- Set log level + if M.config.debug then + M.log_level('debug') + else + M.log_level(M.config.log_level) end -- Save proxy and insecure settings @@ -1054,15 +1066,53 @@ function M.setup(config) }) -- Load the providers - client:stop() - client:add_providers(function() + client:set_providers(function() return M.config.providers end) - if M.config.debug then - M.log_level('debug') - else - M.log_level(M.config.log_level) + -- Initialize chat + if not M.chat then + 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) + + vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { + buffer = bufnr, + callback = function(ev) + if ev.event == 'BufEnter' then + update_source() + end + + vim.schedule(function() + select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) + end) + 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) + end +end + +--- Set up the plugin +---@param config CopilotChat.config.Config? +function M.setup(config) + for k, v in pairs(vim.tbl_deep_extend('force', M.config, config or {})) do + M.config[k] = v end if not M.config.separator or M.config.separator == '' then @@ -1073,42 +1123,13 @@ function M.setup(config) end if M.chat then + client:stop() M.chat:close(state.source.bufnr) M.chat:delete() + M.chat = nil 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) - - vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { - buffer = bufnr, - callback = function(ev) - if ev.event == 'BufEnter' then - update_source() - end - - vim.schedule(function() - select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) - end) - 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) + M.init() for name, prompt in pairs(list_prompts()) do if prompt.prompt then diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 09ccaf37..2dd2914d 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,4 +1,3 @@ -local log = require('plenary.log') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') From e9e426d6f9199138513f6fc2bc9f1058690e97f5 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 13 Sep 2025 20:49:58 +0200 Subject: [PATCH 139/250] refactor(chat): streamline message parsing logic (#1414) Move message parsing to get_messages, removing redundant parse calls from get_block, get_message, add_message, and remove_message. This simplifies the code and ensures messages are always parsed when retrieved, reducing unnecessary parsing and improving performance. Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 99bc7fbf..85b69d90 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -187,7 +187,6 @@ 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) - self:parse() local messages = self:get_messages() if cursor then @@ -228,6 +227,7 @@ end --- Get list of all chat messages ---@return table function Chat:get_messages() + self:parse() return self.messages:values() end @@ -236,7 +236,6 @@ end ---@param cursor boolean? If true, returns the message closest to the cursor position ---@return CopilotChat.ui.chat.Message? function Chat:get_message(role, cursor) - self:parse() local messages = self:get_messages() if cursor then @@ -499,10 +498,7 @@ end ---@param message CopilotChat.ui.chat.Message ---@param replace boolean? If true, replaces the last message if it has same role function Chat:add_message(message, replace) - self:parse() - - local messages = self:get_messages() - local current_message = messages[#messages] + local current_message = self:get_message() local is_new = not current_message or current_message.role ~= message.role or (message.id and current_message.id ~= message.id) @@ -550,8 +546,6 @@ end ---@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) - self:parse() - local message = self:get_message(role, cursor) if not message then return From d5ea51d3f55dc1941c13cf0c44440de0a7f8019f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 15 Sep 2025 17:31:20 +0200 Subject: [PATCH 140/250] fix(client): correct history handling for headless ask (#1416) Refactors ask request construction to properly handle history in headless mode. Removes prompt parameter from internal request generation and ensures the prompt is included as a user message only when appropriate. Also updates token counting and history trimming logic to avoid removing the current prompt in headless scenarios. Resolves: #1415 --- lua/CopilotChat/client.lua | 28 ++++++++-------------------- lua/CopilotChat/init.lua | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index cf240a55..93e1c91d 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -142,11 +142,10 @@ local function generate_resource_messages(resources) end --- Generate ask request ---- @param prompt string --- @param system_prompt string --- @param history table --- @param generated_messages table -local function generate_ask_request(prompt, system_prompt, history, generated_messages) +local function generate_ask_request(system_prompt, history, generated_messages) local messages = {} system_prompt = vim.trim(system_prompt) @@ -162,15 +161,6 @@ local function generate_ask_request(prompt, system_prompt, history, generated_me -- Include generated messages and history vim.list_extend(messages, generated_messages) vim.list_extend(messages, history) - - -- Include user prompt if we have no history - if not utils.empty(prompt) and utils.empty(history) then - table.insert(messages, { - content = prompt, - role = constants.ROLE.USER, - }) - end - return messages end @@ -303,10 +293,9 @@ function Client:info() end --- Ask a question to Copilot ----@param prompt string: The prompt to send to Copilot ---@param opts CopilotChat.client.AskOptions: Options for the request ---@return CopilotChat.client.AskResponse? -function Client:ask(prompt, opts) +function Client:ask(opts) opts = opts or {} local job_id = utils.uuid() @@ -350,20 +339,20 @@ function Client:ask(prompt, opts) notify.publish(notify.STATUS, 'Generating request') end - local history = not opts.headless and vim.deepcopy(opts.history) or {} + local history = vim.deepcopy(opts.history) local tool_calls = orderedmap() local generated_messages = {} local resource_messages = generate_resource_messages(opts.resources) if max_tokens then -- Count required tokens that we cannot reduce - local prompt_tokens = tiktoken:count(prompt) local system_tokens = tiktoken:count(opts.system_prompt) + local prompt_tokens = #history > 0 and tiktoken:count(history[#history].content) or 0 local resource_tokens = #resource_messages > 0 and tiktoken:count(resource_messages[1].content) or 0 local required_tokens = prompt_tokens + system_tokens + resource_tokens - log.debug('Prompt tokens:', prompt_tokens) log.debug('System tokens:', system_tokens) + log.debug('Prompt tokens:', prompt_tokens) log.debug('Resource tokens:', resource_tokens) -- Calculate how many tokens we can use for history @@ -373,8 +362,8 @@ function Client:ask(prompt, opts) history_tokens = history_tokens + tiktoken:count(msg.content) end - -- Remove history messages until we are under the limit - while history_tokens > history_limit and #history > 0 do + -- Remove history messages except prompt until we are under the limit + while history_tokens > history_limit and #history > 1 do local entry = table.remove(history, 1) history_tokens = history_tokens - tiktoken:count(entry.content) end @@ -522,8 +511,7 @@ function Client:ask(prompt, opts) end local headers = self:authenticate(provider_name) - local request = - provider.prepare_input(generate_ask_request(prompt, opts.system_prompt, history, generated_messages), options) + local request = provider.prepare_input(generate_ask_request(opts.system_prompt, history, generated_messages), options) local is_stream = request.stream local args = { diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index f473750a..d50251f1 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -888,9 +888,20 @@ function M.ask(prompt, config) return end - local ask_response = client.ask(client, prompt, { + -- Build history, when in headless mode its just current prompt + local history + if not config.headless then + history = M.chat:get_messages() + else + history = { + content = prompt, + role = constants.ROLE.USER, + } + end + + local ask_response = client:ask({ headless = config.headless, - history = M.chat:get_messages(), + history = history, resources = resolved_resources, tools = selected_tools, system_prompt = system_prompt, From 87615648ff4dc852d1cf7ec099f0a7c37b1b2c87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Sep 2025 15:31:40 +0000 Subject: [PATCH 141/250] 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 d1a712ba..cadb7816 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 13 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 15 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From ed86374a1733601dfe79858e05033d62468f60e7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 02:40:39 +0200 Subject: [PATCH 142/250] [pre-commit.ci] pre-commit autoupdate (#1418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/JohnnyMorganz/StyLua: v2.1.0 → v2.2.0](https://github.com/JohnnyMorganz/StyLua/compare/v2.1.0...v2.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a90c2791..242a4e01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,6 @@ repos: hooks: - id: prettier - repo: https://github.com/JohnnyMorganz/StyLua - rev: v2.1.0 + rev: v2.2.0 hooks: - id: stylua-github From 1bbe03470c52feed9eaccb5af4a64c1f2972e4a8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Sep 2025 00:40:56 +0000 Subject: [PATCH 143/250] 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 cadb7816..bf35ba50 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 15 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 16 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 2279dbe42702397c969aeaa5aebae475a16bcaa9 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 16 Sep 2025 02:51:00 +0200 Subject: [PATCH 144/250] fix(provider): safely call curl.post for model policy (#1419) Wrap curl.post in pcall to prevent errors when enabling model policy. This avoids potential crashes if the request fails. --- lua/CopilotChat/config/providers.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 3a4c7d24..5d0126e6 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -333,7 +333,7 @@ M.copilot = { for _, model in ipairs(models) do if not model.policy then - curl.post('https://api.githubcopilot.com/models/' .. model.id .. '/policy', { + pcall(curl.post, 'https://api.githubcopilot.com/models/' .. model.id .. '/policy', { headers = headers, json_request = true, body = { state = 'enabled' }, From 0c68c653061677320dc263f966db89bebb7621d7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 16 Sep 2025 02:53:44 +0200 Subject: [PATCH 145/250] refactor(config): simplify chat headers formatting (#1420) Remove redundant markdown-style prefixes from chat headers in config. This makes the headers cleaner and easier to customize. --- lua/CopilotChat/config.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 5261c70e..e43b1838 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -112,9 +112,9 @@ return { history_path = vim.fn.stdpath('data') .. '/copilotchat_history', -- Default path to stored history headers = { - user = '## User ', -- Header to use for user questions - assistant = '## Copilot ', -- Header to use for AI answers - tool = '## Tool ', -- Header to use for tool calls + user = 'User', -- Header to use for user questions + assistant = 'Copilot', -- Header to use for AI answers + tool = 'Tool', -- Header to use for tool calls }, separator = '───', -- Separator to use in chat From b784122eddd127bf26dc4d3a375f5b1ed6848182 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 16 Sep 2025 02:57:51 +0200 Subject: [PATCH 146/250] chore(main): release 4.7.0 (#1398) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ version.txt | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ea1e6e..3cbea449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## [4.7.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.6.0...v4.7.0) (2025-09-16) + + +### Features + +* **chat:** switch to treesitter based chat parsing ([#1394](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1394)) ([ba364fe](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/ba364fe04b36121a594435c3f54261c7a8e450a6)) +* **diff:** add experimental unified diff support, refactor handling ([#1392](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1392)) ([9fdf895](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/9fdf8951efff6ab4f46e06945e5d6425bdbf4f80)) +* **diff:** apply all code blocks for a file at once when showing diff ([#1409](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1409)) ([a88874e](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/a88874ef3663aea6bc09eb09c1df4a46ae8577f5)) +* **diff:** use diff-match-patch for better diff handling ([#1407](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1407)) ([35ad8ff](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/35ad8ff61f47c5546c036b9b7310ce0dd87e8d20)) +* **health:** require markdown parser and copilotchat query ([#1401](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1401)) ([f49df19](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/f49df19d5a8925d295ac6472c30b36584bd10d93)) + + +### Bug Fixes + +* **chat:** automatically start treesitter if not started ([#1410](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1410)) ([00d0fb3](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/00d0fb310ad364e76e306a6626a40b85fc5bbd98)) +* **client:** correct history handling for headless ask ([#1416](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1416)) ([d5ea51d](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/d5ea51d3f55dc1941c13cf0c44440de0a7f8019f)), closes [#1415](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1415) +* **provider:** safely call curl.post for model policy ([#1419](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1419)) ([2279dbe](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/2279dbe42702397c969aeaa5aebae475a16bcaa9)) +* **ui:** handle missing filename in chat block header ([#1406](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1406)) ([5c3a558](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/5c3a558f2d740df740735fbb3ea0be822004136d)) +* **ui:** improve help rendering and treesitter usage ([#1411](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1411)) ([559e754](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/559e75423774b3a291a58d33a1144c94444e52ac)) +* **ui:** preserve extra fields in chat messages ([#1399](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1399)) ([f2f523f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/f2f523fe3fdb855da1b3dcabf4f2981cdc3b2c2d)) + + +### Performance Improvements + +* **chat:** optimize message storage and access ([#1403](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1403)) ([1041ad0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/1041ad0034e65e4a63859172d31e7045c8975d87)) +* **chat:** simplify last line/column calculation ([#1402](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1402)) ([4a45e69](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/4a45e69de8ad2b72ef62ede5a554c68c9632e718)) +* **core:** do not require calling setup(), add lazy initialization ([#1413](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1413)) ([c15f65e](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/c15f65e5dc5151230c97f9fd4d386e513fc47c63)) + ## [4.6.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.5.1...v4.6.0) (2025-08-31) diff --git a/version.txt b/version.txt index 6016e8ad..f6cdf409 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.6.0 +4.7.0 From 1b176f209a9ff85905fa5952898b1f9d4ad676cc Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 16 Sep 2025 09:18:49 +0200 Subject: [PATCH 147/250] refactor(init): merge init logic into setup for simplicity (#1422) Simplifies plugin initialization by merging the init logic into the setup function. Removes the separate init function and ensures all configuration, provider setup, and chat initialization are handled in setup. This reduces redundancy and makes the initialization flow clearer. Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 104 ++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 56 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index d50251f1..d8a3e378 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -28,7 +28,7 @@ local M = setmetatable({}, { local initialized = rawget(t, 'initialized') if not initialized then rawset(t, 'initialized', true) - rawget(t, 'init')() + rawget(t, 'setup')() end return rawget(t, key) @@ -1061,8 +1061,20 @@ function M.log_level(level) end end ---- Initialize the plugin if not already initialized. -function M.init() +--- Set up the plugin +---@param config CopilotChat.config.Config? +function M.setup(config) + for k, v in pairs(vim.tbl_deep_extend('force', M.config, config or {})) do + M.config[k] = v + end + + if not M.config.separator or M.config.separator == '' then + log.warn( + 'Empty separator is not allowed, using default separator instead. Set `separator` in config to change this.' + ) + M.config.separator = '---' + end + -- Set log level if M.config.debug then M.log_level('debug') @@ -1077,70 +1089,50 @@ function M.init() }) -- Load the providers + client:stop() client:set_providers(function() return M.config.providers end) -- Initialize chat - if not M.chat then - 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) - - vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { - buffer = bufnr, - callback = function(ev) - if ev.event == 'BufEnter' then - update_source() - end - - vim.schedule(function() - select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) - end) - end, - }) + if M.chat then + M.chat:close(state.source.bufnr) + M.chat:delete() + 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 + M.chat = require('CopilotChat.ui.chat')(M.config, function(bufnr) + for name, _ in pairs(M.config.mappings) do + map_key(name, bufnr) + end - finish(true) - end) - end -end + require('CopilotChat.completion').enable(bufnr, M.config.chat_autocomplete) ---- Set up the plugin ----@param config CopilotChat.config.Config? -function M.setup(config) - for k, v in pairs(vim.tbl_deep_extend('force', M.config, config or {})) do - M.config[k] = v - end + vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { + buffer = bufnr, + callback = function(ev) + if ev.event == 'BufEnter' then + update_source() + end - if not M.config.separator or M.config.separator == '' then - log.warn( - 'Empty separator is not allowed, using default separator instead. Set `separator` in config to change this.' - ) - M.config.separator = '---' - end + vim.schedule(function() + select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) + end) + end, + }) - if M.chat then - client:stop() - M.chat:close(state.source.bufnr) - M.chat:delete() - M.chat = nil - 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 - M.init() + finish(true) + end) for name, prompt in pairs(list_prompts()) do if prompt.prompt then From 9a63e83b9fade8e7fa50deb414d58b703352b13a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 16 Sep 2025 10:17:12 +0200 Subject: [PATCH 148/250] fix(ui): increase separator virt_text priority (#1424) Set the separator virtual text priority to 2000 to ensure it overrides other plugins' virtual text overlays in the chat UI (render-markdown.nvim uses 1000) --- lua/CopilotChat/ui/chat.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 85b69d90..267b8aed 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -794,7 +794,7 @@ function Chat:render() { string.rep(self.separator, vim.go.columns - #header_value - 1), 'CopilotChatSeparator' }, }, virt_text_pos = 'overlay', - priority = 300, + priority = 2000, -- High priority to override other plugins if enabled strict = false, }) From 92dceb4ece955deea39fd1d7a57c26e66d5ce38d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 16 Sep 2025 16:53:10 +0200 Subject: [PATCH 149/250] fix(chat): ensure user prompt is wrapped in a list (#1427) Previously, the user prompt was not wrapped in a table when initializing the chat history, which could cause issues when the code expects a list of messages. This change ensures the prompt is always provided as a list with the correct structure. Closes #1426 --- lua/CopilotChat/init.lua | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index d8a3e378..98c1c1e6 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -894,8 +894,10 @@ function M.ask(prompt, config) history = M.chat:get_messages() else history = { - content = prompt, - role = constants.ROLE.USER, + { + content = prompt, + role = constants.ROLE.USER, + }, } end From 62426536f86dd617e9d72c7086f68ed8d5111e45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 09:18:51 +0200 Subject: [PATCH 150/250] chore(main): release 4.7.1 (#1425) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ version.txt | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbea449..894a8113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [4.7.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.7.0...v4.7.1) (2025-09-16) + + +### Bug Fixes + +* **chat:** ensure user prompt is wrapped in a list ([#1427](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1427)) ([92dceb4](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/92dceb4ece955deea39fd1d7a57c26e66d5ce38d)), closes [#1426](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1426) +* **ui:** increase separator virt_text priority ([#1424](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1424)) ([9a63e83](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/9a63e83b9fade8e7fa50deb414d58b703352b13a)) + ## [4.7.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.6.0...v4.7.0) (2025-09-16) diff --git a/version.txt b/version.txt index f6cdf409..7c66fca5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.7.0 +4.7.1 From e91c806f6fa23bd1ac455fb68e81b99e197f072f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Sep 2025 07:19:12 +0000 Subject: [PATCH 151/250] 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 bf35ba50..5f96c503 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 16 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 17 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From d3ddd226fc08a146412cc28c6c380372a086847d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 17 Sep 2025 10:30:14 +0200 Subject: [PATCH 152/250] docs(readme): update feature list for clarity and accuracy (#1431) Refined feature descriptions in README: - Clarified tool calling and privacy features - Updated interactive chat and token efficiency wording - Added scriptable Lua API highlight Improves transparency and better communicates capabilities. Signed-off-by: Tomas Slusny --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9f1cda76..8da6be23 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. - 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your [GitHub Copilot settings](https://github.com/settings/copilot/features) and the models provided by GitHub's API. -- 🔧 **Tool Calling** - LLM can use workspace functions (file reading, git operations, search) with your explicit approval -- 🔒 **Explicit Control** - Only shares what you specifically request - no background data collection -- 📝 **Interactive Chat** - Rich UI with completion, diffs, and quickfix integration +- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with your explicit approval +- 🔒 **Privacy First** - Only shares what you explicitly request - no background data collection +- 📝 **Interactive Chat** - Interactive UI with completion, diffs, and quickfix integration - 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context -- ⚡ **Efficient** - Smart token usage with tiktoken counting and history management +- ⚡ **Token Efficient** - Resource replacement prevents duplicate context, history management via tiktoken counting +- 🔗 **Scriptable** - Comprehensive Lua API for automation and headless mode operation - 🔌 **Extensible** - [Custom functions](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/functions) and [providers](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/providers), plus integrations like [mcphub.nvim](https://github.com/ravitemer/mcphub.nvim) # Installation From 386b51b8e5d1d3a664da625ebb5be6ba8cc1a0e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Sep 2025 08:30:34 +0000 Subject: [PATCH 153/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 5f96c503..b86efef6 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -41,11 +41,12 @@ CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. - 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your GitHub Copilot settings and the models provided by GitHub’s API. -- 🔧 **Tool Calling** - LLM can use workspace functions (file reading, git operations, search) with your explicit approval -- 🔒 **Explicit Control** - Only shares what you specifically request - no background data collection -- 📝 **Interactive Chat** - Rich UI with completion, diffs, and quickfix integration +- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with your explicit approval +- 🔒 **Privacy First** - Only shares what you explicitly request - no background data collection +- 📝 **Interactive Chat** - Interactive UI with completion, diffs, and quickfix integration - 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context -- ⚡ **Efficient** - Smart token usage with tiktoken counting and history management +- ⚡ **Token Efficient** - Resource replacement prevents duplicate context, history management via tiktoken counting +- 🔗 **Scriptable** - Comprehensive Lua API for automation and headless mode operation - 🔌 **Extensible** - Custom functions and providers , plus integrations like mcphub.nvim From 74611b56e813f50e905122387b92fb832ac9616c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 17 Sep 2025 18:51:28 +0200 Subject: [PATCH 154/250] fix(chat): do not create multiple chat isntances (#1432) this breaks notification listeners Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 58 ++++++++++++++++++------------------- lua/CopilotChat/ui/chat.lua | 2 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 98c1c1e6..268405bd 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1100,41 +1100,41 @@ function M.setup(config) if M.chat then M.chat:close(state.source.bufnr) M.chat:delete() - 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) + else + M.chat = require('CopilotChat.ui.chat')(M.config, function(bufnr) + for name, _ in pairs(M.config.mappings) do + map_key(name, bufnr) + end - vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { - buffer = bufnr, - callback = function(ev) - if ev.event == 'BufEnter' then - update_source() - end + require('CopilotChat.completion').enable(bufnr, M.config.chat_autocomplete) - vim.schedule(function() - select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) - end) - end, - }) - - if M.config.insert_at_end then - vim.api.nvim_create_autocmd({ 'InsertEnter' }, { + vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { buffer = bufnr, - callback = function() - vim.cmd('normal! 0') - vim.cmd('normal! G$') - vim.v.char = 'x' + callback = function(ev) + if ev.event == 'BufEnter' then + update_source() + end + + vim.schedule(function() + select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) + end) end, }) - end - finish(true) - 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) + 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 267b8aed..8cd08c11 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -164,7 +164,7 @@ local Chat = class(function(self, config, on_buf_create) if not msg or msg == '' then self.chat_overlay:restore(self.winnr, self.bufnr) else - self:overlay({ text = msg }) + self.chat_overlay:show(msg, self.winnr) end end) end, Overlay) From 181a523f5bf6b8f277e6a994907c23f9e84d2445 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Sep 2025 20:14:43 +0200 Subject: [PATCH 155/250] chore(main): release 4.7.2 (#1433) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 894a8113..a7d125ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [4.7.2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.7.1...v4.7.2) (2025-09-17) + + +### Bug Fixes + +* **chat:** do not create multiple chat isntances ([#1432](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1432)) ([74611b5](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/74611b56e813f50e905122387b92fb832ac9616c)) + ## [4.7.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.7.0...v4.7.1) (2025-09-16) diff --git a/version.txt b/version.txt index 7c66fca5..af9764a5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.7.1 +4.7.2 From 16aa92419d48957319a3f6b06c9d74ebdcead80c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 21 Sep 2025 06:18:40 +0200 Subject: [PATCH 156/250] fix(mappings): make sure function resolution is not ran in fast context (#1436) functions call get_messages and those use vim.api functions. So move it at start of show_info block Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 4 ++-- lua/CopilotChat/init.lua | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 74429e33..bcd9590e 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -337,10 +337,10 @@ return { local system_prompt = config.system_prompt async.run(function() - local infos = client:info() + local resolved_resources = copilot.resolve_functions(prompt, config) local selected_tools = copilot.resolve_tools(prompt, config) local selected_model = copilot.resolve_model(prompt, config) - local resolved_resources = copilot.resolve_functions(prompt, config) + local infos = client:info() selected_tools = vim.tbl_map(function(tool) return tool.name diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 268405bd..35eec19f 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -515,7 +515,7 @@ end ---@param config CopilotChat.config.Shared? ---@return CopilotChat.config.prompts.Prompt, string function M.resolve_prompt(prompt, config) - if not prompt then + if prompt == nil then local message = M.chat:get_message(constants.ROLE.USER) if message then prompt = message.content From acd1e6d38d216289cd0748808da25dfc66015612 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 21 Sep 2025 04:19:02 +0000 Subject: [PATCH 157/250] 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 b86efef6..7b80dd0c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 17 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 21 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From df8efe9d2368c876d607b513bb384eaa8daf1d12 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 28 Sep 2025 11:37:00 +0200 Subject: [PATCH 158/250] fix(os): use vim.uv.os_uname for OS detection (#1449) Replace usage of jit.os with vim.uv.os_uname().sysname to not rely on neovim builds with LuaJIT --- lua/CopilotChat/init.lua | 2 +- lua/CopilotChat/tiktoken.lua | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 35eec19f..34f564ae 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -586,7 +586,7 @@ function M.resolve_prompt(prompt, config) .. vim.trim(require('CopilotChat.instructions.edit_file_block')) end - config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) + config.system_prompt = config.system_prompt:gsub('{OS_NAME}', vim.uv.os_uname().sysname) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) config.system_prompt = config.system_prompt:gsub('{DIR}', state.source.cwd()) end diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 2dd2914d..abe1ce1d 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -6,13 +6,14 @@ local class = require('CopilotChat.utils.class') --- Get the library extension based on the operating system --- @return string local function get_lib_extension() - if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then + local os_name = vim.uv.os_uname().sysname:lower() + if os_name:find('darwin') then return '.dylib' - end - if jit.os:lower() == 'windows' then + elseif os_name:find('windows') then return '.dll' + else + return '.so' end - return '.so' end --- Load tiktoken data from cache or download it From d574851bebfe94b46ee37814d0f12fcfb7109de2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 28 Sep 2025 09:37:16 +0000 Subject: [PATCH 159/250] 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 7b80dd0c..a93e7ad5 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 21 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 28 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 2f3e07398ba81ccc0cf3d1fca9805aaca126264e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 28 Sep 2025 11:51:21 +0200 Subject: [PATCH 160/250] chore(main): release 4.7.3 (#1437) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ version.txt | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d125ec..6c5ede94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [4.7.3](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.7.2...v4.7.3) (2025-09-28) + + +### Bug Fixes + +* **mappings:** make sure function resolution is not ran in fast context ([#1436](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1436)) ([16aa924](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/16aa92419d48957319a3f6b06c9d74ebdcead80c)) +* **os:** use vim.uv.os_uname for OS detection ([#1449](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1449)) ([df8efe9](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/df8efe9d2368c876d607b513bb384eaa8daf1d12)) + ## [4.7.2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.7.1...v4.7.2) (2025-09-17) diff --git a/version.txt b/version.txt index af9764a5..87b18a56 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.7.2 +4.7.3 From 60ad5121bbfe3785ff11b6a137a17106a0ded564 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 22:04:30 +0200 Subject: [PATCH 161/250] [pre-commit.ci] pre-commit autoupdate (#1450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/JohnnyMorganz/StyLua: v2.2.0 → v2.3.0](https://github.com/JohnnyMorganz/StyLua/compare/v2.2.0...v2.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 242a4e01..7602f97a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,6 @@ repos: hooks: - id: prettier - repo: https://github.com/JohnnyMorganz/StyLua - rev: v2.2.0 + rev: v2.3.0 hooks: - id: stylua-github From 481db772c752fa029329194a2bc2c2073356a05a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 29 Sep 2025 20:04:54 +0000 Subject: [PATCH 162/250] 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 a93e7ad5..f7eabd6e 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 28 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 29 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 7a8e238e36ea9e1df9d6309434a37bcdc15a9fae Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 2 Oct 2025 00:05:06 +0200 Subject: [PATCH 163/250] fix(url): ensure main thread scheduling before fetching (#1453) Call utils.schedule_main() before fetching URL data to ensure that subsequent operations run on the main thread. This prevents potential issues with asynchronous execution and improves reliability when accessing resources. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/functions.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 2f085705..ca0ba2f2 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -545,6 +545,7 @@ return { input.url = 'https://' .. input.url end + utils.schedule_main() local data, mimetype = resources.get_url(input.url) if not data then error('URL not found: ' .. input.url) From 77cfadb3884581d018d8299f83b925097ac35c3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Oct 2025 22:05:23 +0000 Subject: [PATCH 164/250] 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 f7eabd6e..70b1a375 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 September 29 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 01 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 1e06be97df058fa0bb4af54659c39918a4999c86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 00:07:28 +0200 Subject: [PATCH 165/250] chore(main): release 4.7.4 (#1454) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 7 +++++++ version.txt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5ede94..529ec198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [4.7.4](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.7.3...v4.7.4) (2025-10-01) + + +### Bug Fixes + +* **url:** ensure main thread scheduling before fetching ([#1453](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1453)) ([7a8e238](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/7a8e238e36ea9e1df9d6309434a37bcdc15a9fae)) + ## [4.7.3](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v4.7.2...v4.7.3) (2025-09-28) diff --git a/version.txt b/version.txt index 87b18a56..b48b2de9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.7.3 +4.7.4 From cf4f7a58a0e65be6ccbdbc83142e189f96cd9fb5 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 6 Oct 2025 03:52:11 +0200 Subject: [PATCH 166/250] refactor(functions)!: unify buffer/selection/diagnostics resources (#1456) * refactor(functions)!: unify buffer/selection/diagnostics resources - Replace separate `buffer`, `buffers`, `selection`, `diagnostics` and 'quickfix' resources with a unified `buffer` and `selection` resource that automatically includes diagnostics in the output. - Replace `register` resource with simplified `clipboard` resource - Add new `bash` and `edit` tools for LLM-only usage. - Update README to reflect new resource/tool types and usage. - Remove unused/duplicated mappings and code for old resources. - Improve enum handling and selection UI for resource schemas. BREAKING CHANGE: Removes `buffers`, `diagnostics`, `register`, and `quickfix` resources. Use `buffer`, `selection`, or `clipboard` instead. Signed-off-by: Tomas Slusny * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Signed-off-by: Tomas Slusny Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 36 +- lua/CopilotChat/config/functions.lua | 573 ++++++++++++--------------- lua/CopilotChat/config/mappings.lua | 29 +- lua/CopilotChat/functions.lua | 36 +- 4 files changed, 303 insertions(+), 371 deletions(-) diff --git a/README.md b/README.md index 8da6be23..bcc84655 100644 --- a/README.md +++ b/README.md @@ -110,11 +110,11 @@ EOF # Sticky prompt that persists -> #buffer:current +> #buffer:active > You are a helpful coding assistant ``` -When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdiff` etc. You'll see the proposed function call and can approve/reject it before execution. +When you use `@copilot`, the LLM can call functions like `bash`, `edit`, `file`, `glob`, `grep`, `gitdiff` etc. You'll see the proposed function call and can approve/reject it before execution. # Usage @@ -143,7 +143,6 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif | `` | `` | Reset and clear the chat window | | `` | `` | Submit the current prompt | | - | `grr` | Toggle sticky prompt for line under cursor | -| - | `grx` | Clear all sticky prompts in prompt | | `` | `` | Accept nearest diff | | - | `gj` | Jump to section of nearest diff | | - | `gqa` | Add all answers from chat to quickfix list | @@ -168,20 +167,23 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif All predefined functions belong to the `copilot` group. -| Function | Description | Example Usage | -| ------------- | ------------------------------------------------ | ---------------------- | -| `buffer` | Retrieves content from a specific buffer | `#buffer` | -| `buffers` | Fetches content from multiple buffers | `#buffers:visible` | -| `diagnostics` | Collects code diagnostics (errors, warnings) | `#diagnostics:current` | -| `file` | Reads content from a specified file path | `#file:path/to/file` | -| `gitdiff` | Retrieves git diff information | `#gitdiff:staged` | -| `gitstatus` | Retrieves git status information | `#gitstatus` | -| `glob` | Lists filenames matching a pattern in workspace | `#glob:**/*.lua` | -| `grep` | Searches for a pattern across files in workspace | `#grep:TODO` | -| `quickfix` | Includes content of files in quickfix list | `#quickfix` | -| `register` | Provides access to specified Vim register | `#register:+` | -| `selection` | Includes the current visual selection | `#selection` | -| `url` | Fetches content from a specified URL | `#url:https://...` | +| Function | Type | Description | Example Usage | +| ----------- | -------- | ------------------------------------------------------ | -------------------- | +| `bash` | Tool | Executes a bash command and returns output | `@copilot` only | +| `buffer` | Resource | Retrieves content from buffer(s) with diagnostics | `#buffer:active` | +| `clipboard` | Resource | Provides access to system clipboard content | `#clipboard` | +| `edit` | Tool | Applies a unified diff to a file | `@copilot` only | +| `file` | Resource | Reads content from a specified file path | `#file:path/to/file` | +| `gitdiff` | Resource | Retrieves git diff information | `#gitdiff:staged` | +| `glob` | Resource | Lists filenames matching a pattern in workspace | `#glob:**/*.lua` | +| `grep` | Resource | Searches for a pattern across files in workspace | `#grep:TODO` | +| `selection` | Resource | Includes the current visual selection with diagnostics | `#selection` | +| `url` | Resource | Fetches content from a specified URL | `#url:https://...` | + +**Type Legend:** + +- **Resource**: Can be used manually via `#function` syntax +- **Tool**: Can only be called by LLM via `@copilot` (for safety/complexity reasons) ## Predefined Prompts diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index ca0ba2f2..da97beb1 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -2,6 +2,44 @@ local resources = require('CopilotChat.resources') local utils = require('CopilotChat.utils') local files = require('CopilotChat.utils.files') +--- Get diagnostics for a buffer and format them as text +---@param bufnr number +---@param start_line number? +---@param end_line number? +---@return string +local function get_diagnostics_text(bufnr, start_line, end_line) + local diagnostics = vim.diagnostic.get(bufnr, { + severity = { min = vim.diagnostic.severity.HINT }, + }) + + if #diagnostics == 0 then + return '' + end + + local diag_lines = { '\n--- Diagnostics ---' } + for _, diag in ipairs(diagnostics) do + local diag_lnum = diag.lnum + 1 + -- If range is specified, filter diagnostics within range + if not start_line or (diag_lnum >= start_line and diag_lnum <= end_line) then + local severity = vim.diagnostic.severity[diag.severity] or 'UNKNOWN' + local line_text = vim.api.nvim_buf_get_lines(bufnr, diag.lnum, diag.lnum + 1, false)[1] or '' + table.insert( + diag_lines, + string.format( + '%s line=%d-%d: %s\n > %s', + severity, + diag.lnum + 1, + diag.end_lnum and (diag.end_lnum + 1) or (diag.lnum + 1), + diag.message, + line_text + ) + ) + end + end + + return #diag_lines > 1 and table.concat(diag_lines, '\n') or '' +end + ---@class CopilotChat.config.functions.Function ---@field description string? ---@field schema table? @@ -50,64 +88,38 @@ return { end, }, - glob = { + url = { group = 'copilot', - uri = 'files://glob/{pattern}', - description = 'Lists filenames matching a pattern in your workspace. Useful for discovering relevant files or understanding the project structure.', + uri = 'https://{url}', + description = 'Fetches content from a specified URL. Useful for referencing documentation, examples, or other online resources.', schema = { type = 'object', - required = { 'pattern' }, + required = { 'url' }, properties = { - pattern = { + url = { type = 'string', - description = 'Glob pattern to match files.', - default = '**/*', + description = 'URL to include in chat context.', }, }, }, - resolve = function(input, source) - local out = files.glob(source.cwd(), { - pattern = input.pattern, - }) - - return { - { - uri = 'files://glob/' .. input.pattern, - mimetype = 'text/plain', - data = table.concat(out, '\n'), - }, - } - end, - }, - - grep = { - group = 'copilot', - uri = 'files://grep/{pattern}', - description = 'Searches for a pattern across files in your workspace. Helpful for finding specific code elements or patterns.', - - schema = { - type = 'object', - required = { 'pattern' }, - properties = { - pattern = { - type = 'string', - description = 'Pattern to search for.', - }, - }, - }, + resolve = function(input) + if not input.url:match('^https?://') then + input.url = 'https://' .. input.url + end - resolve = function(input, source) - local out = files.grep(source.cwd(), { - pattern = input.pattern, - }) + utils.schedule_main() + local data, mimetype = resources.get_url(input.url) + if not data then + error('URL not found: ' .. input.url) + end return { { - uri = 'files://grep/' .. input.pattern, - mimetype = 'text/plain', - data = table.concat(out, '\n'), + uri = input.url, + mimetype = mimetype, + data = data, }, } end, @@ -115,124 +127,128 @@ return { buffer = { group = 'copilot', - uri = 'buffer://{name}', - description = 'Retrieves content from a specific buffer. Useful for discussing or analyzing code from a particular file that is currently loaded.', + uri = 'neovim://buffer/{scope}', + description = 'Retrieves content from buffer(s) with diagnostics. Scope can be a buffer number, filename, or one of: active, visible, listed, quickfix.', schema = { type = 'object', - required = { 'name' }, + required = { 'scope' }, properties = { - name = { + scope = { type = 'string', - description = 'Buffer filename to include in chat context.', + description = 'Buffer scope: active (current), visible (shown in windows), listed (all listed buffers), quickfix (buffers in quickfix list), or a specific buffer number/filename.', enum = function() - return vim - .iter(vim.api.nvim_list_bufs()) - :filter(function(buf) - return buf and utils.buf_valid(buf) and vim.fn.buflisted(buf) == 1 - end) - :map(function(buf) - return vim.api.nvim_buf_get_name(buf) - end) - :totable() + local opts = { + { display = 'active (current buffer)', value = 'active' }, + { display = 'visible (all visible buffers)', value = 'visible' }, + { display = 'listed (all listed buffers)', value = 'listed' }, + { display = 'quickfix (buffers in quickfix)', value = 'quickfix' }, + } + + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if utils.buf_valid(buf) and vim.fn.buflisted(buf) == 1 then + local name = vim.api.nvim_buf_get_name(buf) + if name and name ~= '' then + local display_name = vim.fn.fnamemodify(name, ':~:.') + table.insert(opts, { display = display_name, value = name }) + end + end + end + return opts end, + default = 'active', }, }, }, resolve = function(input, source) utils.schedule_main() - local name = input.name or vim.api.nvim_buf_get_name(source.bufnr) - local found_buf = nil - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_get_name(buf) == name then - found_buf = buf - break + local scope = input.scope or 'active' + local buffers = {} + + -- Determine which buffers to include based on scope + if scope == 'active' then + if source and source.bufnr and utils.buf_valid(source.bufnr) then + buffers = { source.bufnr } + end + elseif scope == 'visible' then + buffers = vim.tbl_filter(function(b) + return utils.buf_valid(b) and vim.fn.buflisted(b) == 1 and #vim.fn.win_findbuf(b) > 0 + end, vim.api.nvim_list_bufs()) + elseif scope == 'listed' then + buffers = vim.tbl_filter(function(b) + return utils.buf_valid(b) and vim.fn.buflisted(b) == 1 + end, vim.api.nvim_list_bufs()) + elseif scope == 'quickfix' then + local items = vim.fn.getqflist() + local file_to_bufnr = {} + for _, item in ipairs(items) do + local filename = item.filename or vim.api.nvim_buf_get_name(item.bufnr) + if filename and item.bufnr and utils.buf_valid(item.bufnr) then + file_to_bufnr[filename] = item.bufnr + end + end + buffers = vim.tbl_values(file_to_bufnr) + elseif tonumber(scope) then + local bufnr = tonumber(scope) + if utils.buf_valid(bufnr) then + buffers = { bufnr } end end - if not found_buf then - error('Buffer not found: ' .. name) - end - local data, mimetype = resources.get_buffer(found_buf) - if not data then - error('Buffer not found: ' .. name) - end - return { - { - uri = 'buffer://' .. name, - name = name, - mimetype = mimetype, - data = data, - }, - } - end, - }, - buffers = { - group = 'copilot', - uri = 'buffers://{scope}', - description = 'Fetches content from multiple buffers. Helps with discussing or analyzing code across multiple files simultaneously.', - - schema = { - type = 'object', - required = { 'scope' }, - properties = { - scope = { - type = 'string', - description = 'Scope of buffers to include in chat context.', - enum = { 'listed', 'visible' }, - default = 'listed', - }, - }, - }, + if #buffers == 0 then + error('No buffers found for input: ' .. scope) + end - resolve = function(input) - utils.schedule_main() - return vim - .iter(vim.api.nvim_list_bufs()) - :filter(function(bufnr) - return utils.buf_valid(bufnr) - and vim.fn.buflisted(bufnr) == 1 - and (input.scope == 'listed' or #vim.fn.win_findbuf(bufnr) > 0) - end) - :map(function(bufnr) - local name = vim.api.nvim_buf_get_name(bufnr) - local data, mimetype = resources.get_buffer(bufnr) - if not data then - return nil + local results = {} + for _, bufnr in ipairs(buffers) do + local name = vim.api.nvim_buf_get_name(bufnr) + local data, mimetype = resources.get_buffer(bufnr) + if data then + local diag_text = get_diagnostics_text(bufnr) + if diag_text ~= '' then + data = data .. diag_text end - return { - uri = 'buffer://' .. name, + + table.insert(results, { + uri = 'buffer://' .. bufnr, name = name, mimetype = mimetype, data = data, - } - end) - :filter(function(file_data) - return file_data ~= nil - end) - :totable() + }) + end + end + + return results end, }, selection = { group = 'copilot', uri = 'neovim://selection', - description = 'Includes the content of the current visual selection. Useful for discussing specific code snippets or text blocks.', + description = 'Includes the content of the current visual selection with diagnostics. Useful for discussing specific code snippets or text blocks.', resolve = function(_, source) utils.schedule_main() - local selection = require('CopilotChat.select').get(source.bufnr) + + local select = require('CopilotChat.select') + local selection = select.get(source.bufnr) if not selection then return {} end + local data = selection.content + local diag_text = get_diagnostics_text(source.bufnr, selection.start_line, selection.end_line) + if diag_text ~= '' then + data = data .. diag_text + end + return { { uri = 'neovim://selection', name = selection.filename, mimetype = files.mimetype_to_filetype(selection.filetype), - data = selection.content, + data = data, annotations = { start_line = selection.start_line, end_line = selection.end_line, @@ -242,210 +258,86 @@ return { end, }, - quickfix = { + clipboard = { group = 'copilot', - uri = 'neovim://quickfix', - description = 'Includes the content of all files referenced in the current quickfix list. Useful for discussing compilation errors, search results, or other collected locations.', + uri = 'neovim://clipboard', + description = 'Provides access to the system clipboard content. Useful for discussing copied text or code snippets.', resolve = function() utils.schedule_main() - - local items = vim.fn.getqflist() - if not items or #items == 0 then + local lines = vim.fn.getreg('+') + if not lines or lines == '' then return {} end - local file_to_bufnr = {} - for _, item in ipairs(items) do - local filename = item.filename or vim.api.nvim_buf_get_name(item.bufnr) - if filename then - if item.bufnr and utils.buf_valid(item.bufnr) then - file_to_bufnr[filename] = item.bufnr - else - file_to_bufnr[filename] = false - end - end - end - - return vim - .iter(vim.tbl_keys(file_to_bufnr)) - :map(function(file) - local bufnr = file_to_bufnr[file] - local data, mimetype, uri - if bufnr and bufnr ~= false then - data, mimetype = resources.get_buffer(bufnr) - uri = 'buffer://' .. file - else - data, mimetype = resources.get_file(file) - uri = 'file://' .. file - end - if not data then - return nil - end - return { - uri = uri, - name = file, - mimetype = mimetype, - data = data, - } - end) - :filter(function(file_data) - return file_data ~= nil - end) - :totable() + return { + { + uri = 'neovim://clipboard', + mimetype = 'text/plain', + data = lines, + }, + } end, }, - diagnostics = { + glob = { group = 'copilot', - uri = 'neovim://diagnostics/{scope}/{severity}', - description = 'Collects code diagnostics (errors, warnings, etc.) from specified buffers. Helpful for troubleshooting and fixing code issues.', + uri = 'files://glob/{pattern}', + description = 'Lists filenames matching a pattern in your workspace. Useful for discovering relevant files or understanding the project structure.', schema = { type = 'object', - required = { 'scope', 'severity' }, + required = { 'pattern' }, properties = { - scope = { - type = 'string', - description = 'Scope of buffers to use for retrieving diagnostics.', - enum = { 'current', 'listed', 'visible', 'selection' }, - default = 'current', - }, - severity = { + pattern = { type = 'string', - description = 'Minimum severity level of diagnostics to include.', - enum = { 'error', 'warn', 'info', 'hint' }, - default = 'warn', + description = 'Glob pattern to match files.', + default = '**/*', }, }, }, resolve = function(input, source) - utils.schedule_main() - local out = {} - local scope = input.scope or 'current' - local buffers = {} - - -- Get buffers based on scope - if scope == 'current' or scope == 'selection' then - if source and source.bufnr and utils.buf_valid(source.bufnr) then - buffers = { source.bufnr } - end - elseif scope == 'listed' then - buffers = vim.tbl_filter(function(b) - return utils.buf_valid(b) and vim.fn.buflisted(b) == 1 - end, vim.api.nvim_list_bufs()) - elseif scope == 'visible' then - buffers = vim.tbl_filter(function(b) - return utils.buf_valid(b) and vim.fn.buflisted(b) == 1 and #vim.fn.win_findbuf(b) > 0 - end, vim.api.nvim_list_bufs()) - else - buffers = vim.tbl_filter(function(b) - return utils.buf_valid(b) and vim.api.nvim_buf_get_name(b) == input.scope - end, vim.api.nvim_list_bufs()) - end - - -- By default, collect from the whole buffer - local selection_start_line = 1 - local selection_end_line = vim.api.nvim_buf_line_count(source.bufnr) - -- Determine selection range if scope is 'selection' - if scope == 'selection' then - local select = require('CopilotChat.select') - local selection = select.get(source.bufnr) - if selection then - selection_start_line = selection.start_line - selection_end_line = selection.end_line - else - return out - end - end - - -- Collect diagnostics for each buffer - for _, bufnr in ipairs(buffers) do - local name = vim.api.nvim_buf_get_name(bufnr) - local diagnostics = vim.diagnostic.get(bufnr, { - severity = { - min = vim.diagnostic.severity[input.severity:upper()], - }, - }) - - if #diagnostics > 0 then - local diag_lines = {} - for _, diag in ipairs(diagnostics) do - -- Diagnostics.lnum are 0-indexed, so add 1 for comparison - local diag_lnum = diag.lnum + 1 - if diag_lnum >= selection_start_line and diag_lnum <= selection_end_line then - local severity = vim.diagnostic.severity[diag.severity] or 'UNKNOWN' - local line_text = vim.api.nvim_buf_get_lines(bufnr, diag.lnum, diag.lnum + 1, false)[1] or '' - - table.insert( - diag_lines, - string.format( - '%s line=%d-%d: %s\n > %s', - severity, - diag.lnum + 1, - diag.end_lnum and (diag.end_lnum + 1) or (diag.lnum + 1), - diag.message, - line_text - ) - ) - end - end - - table.insert(out, { - uri = 'neovim://diagnostics/' .. name, - mimetype = 'text/plain', - data = table.concat(diag_lines, '\n'), - }) - end - end + local out = files.glob(source.cwd(), { + pattern = input.pattern, + }) - return out + return { + { + uri = 'files://glob/' .. input.pattern, + mimetype = 'text/plain', + data = table.concat(out, '\n'), + }, + } end, }, - register = { + grep = { group = 'copilot', - uri = 'neovim://register/{register}', - description = 'Provides access to the content of a specified Vim register. Useful for discussing yanked text, clipboard content, or previously executed commands.', + uri = 'files://grep/{pattern}', + description = 'Searches for a pattern across files in your workspace. Helpful for finding specific code elements or patterns.', schema = { type = 'object', - required = { 'register' }, + required = { 'pattern' }, properties = { - register = { + pattern = { type = 'string', - description = 'Register to include in chat context.', - enum = { - '+', - '*', - '"', - '0', - '-', - '.', - '%', - ':', - '#', - '=', - '/', - }, - default = '+', + description = 'Pattern to search for.', }, }, }, - resolve = function(input) - utils.schedule_main() - local lines = vim.fn.getreg(input.register) - if not lines or lines == '' then - return {} - end + resolve = function(input, source) + local out = files.grep(source.cwd(), { + pattern = input.pattern, + }) return { { - uri = 'neovim://register/' .. input.register, + uri = 'files://grep/' .. input.pattern, mimetype = 'text/plain', - data = lines, + data = table.concat(out, '\n'), }, } end, @@ -499,65 +391,104 @@ return { end, }, - gitstatus = { + bash = { group = 'copilot', - uri = 'git://status', - description = 'Retrieves the status of the current git repository. Useful for discussing changes, commits, and other git-related tasks.', + description = 'Executes a bash command and returns its output. Useful for running shell commands, checking file contents, or gathering system information.', - resolve = function(_, source) - local cmd = { - 'git', - '-C', - source.cwd(), - 'status', - } + schema = { + type = 'object', + required = { 'command' }, + properties = { + command = { + type = 'string', + description = 'Bash command to execute.', + }, + }, + }, - local out = utils.system(cmd) + resolve = function(input, source) + local cmd = { 'bash', '-c', input.command } + local out = utils.system(cmd, { cwd = source.cwd() }) return { { - uri = 'git://status', - mimetype = 'text/plain', data = out.stdout, }, } end, }, - url = { + edit = { group = 'copilot', - uri = 'https://{url}', - description = 'Fetches content from a specified URL. Useful for referencing documentation, examples, or other online resources.', + description = 'Applies a unified diff to a file. The diff should be in unified diff format (similar to diff -U0 output).', schema = { type = 'object', - required = { 'url' }, + required = { 'filename', 'diff' }, properties = { - url = { + filename = { type = 'string', - description = 'URL to include in chat context.', + description = 'Path to file to edit.', + }, + diff = { + type = 'string', + description = 'Unified diff content to apply to the file.', }, }, }, - resolve = function(input) - if not input.url:match('^https?://') then - input.url = 'https://' .. input.url + resolve = function(input, source) + utils.schedule_main() + + local select = require('CopilotChat.select') + local diff = require('CopilotChat.utils.diff') + + -- Find or create the buffer for the file + local filename = input.filename + local diff_bufnr = nil + + -- Try to find matching buffer first + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if files.filename_same(vim.api.nvim_buf_get_name(buf), filename) then + diff_bufnr = buf + break + end end - utils.schedule_main() - local data, mimetype = resources.get_url(input.url) - if not data then - error('URL not found: ' .. input.url) + -- If still not found, try to load or create buffer + if not diff_bufnr then + diff_bufnr = vim.fn.bufadd(filename) + vim.fn.bufload(diff_bufnr) end - return { - { - uri = input.url, - mimetype = mimetype, - data = data, - }, - } + -- Get current buffer content + local lines = vim.api.nvim_buf_get_lines(diff_bufnr, 0, -1, false) + local content = table.concat(lines, '\n') + + -- Apply the unified diff + local new_lines, applied, first, last = diff.apply_unified_diff(input.diff, content) + + if applied then + -- Apply changes to buffer + vim.api.nvim_buf_set_lines(diff_bufnr, 0, -1, false, new_lines) + + -- If source window is valid, switch to the edited buffer and highlight changes + if source and source.winnr and vim.api.nvim_win_is_valid(source.winnr) then + vim.api.nvim_win_set_buf(source.winnr, diff_bufnr) + if first and last then + select.set(diff_bufnr, source.winnr, first, last) + select.highlight(diff_bufnr) + end + end + + return { + { + data = string.format('Successfully applied diff to %s (lines %d-%d)', filename, first or 0, last or 0), + }, + } + else + error('Failed to apply diff to ' .. filename) + end end, }, } diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index bcd9590e..448e9516 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -61,6 +61,7 @@ end ---@field accept_diff CopilotChat.config.mapping|false|nil ---@field jump_to_diff CopilotChat.config.mapping|false|nil ---@field quickfix_diffs CopilotChat.config.mapping|false|nil +---@field quickfix_answers CopilotChat.config.mapping|false|nil ---@field yank_diff CopilotChat.config.mapping.yank_diff|false|nil ---@field show_diff CopilotChat.config.mapping|false|nil ---@field show_info CopilotChat.config.mapping|false|nil @@ -133,34 +134,6 @@ return { end, }, - clear_stickies = { - normal = 'grx', - callback = function() - local message = copilot.chat:get_message(constants.ROLE.USER) - local section = message and message.section - if not section then - return - end - - local lines = utils.split_lines(message.content) - local new_lines = {} - local changed = false - - for _, line in ipairs(lines) do - if not vim.startswith(vim.trim(line), '> ') then - table.insert(new_lines, line) - else - changed = true - end - end - - if changed then - message.content = table.concat(new_lines, '\n') - copilot.chat:add_message(message, true) - end - end, - }, - accept_diff = { normal = '', insert = '', diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index e63b63a3..9d0befdf 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -57,7 +57,14 @@ local function filter_schema(tbl, root) for k, v in pairs(tbl) do if not utils.empty(v) then if type(v) ~= 'function' and k ~= 'examples' then - result[k] = type(v) == 'table' and filter_schema(v) or v + if k == 'enum' and type(v) == 'table' and type(v[1]) == 'table' and v[1].value then + -- If enum contains objects with value/display, extract just the values + result[k] = vim.tbl_map(function(item) + return item.value + end, v) + else + result[k] = type(v) == 'table' and filter_schema(v) or v + end end end end @@ -211,11 +218,30 @@ function M.enter_input(schema, source) if #choices == 0 then choice = nil elseif #choices == 1 then - choice = choices[1] + -- Handle both string and table choices + choice = type(choices[1]) == 'table' and choices[1].value or choices[1] else - choice = utils.select(choices, { - prompt = string.format('Select %s> ', prop_name), - }) + -- Check if choices are objects with display/value + local has_display = type(choices[1]) == 'table' and choices[1].display ~= nil + local selected + + if has_display then + -- Use format_item to display the display field + selected = utils.select(choices, { + prompt = string.format('Select %s> ', prop_name), + format_item = function(item) + return item.display + end, + }) + -- Extract the value from the selected item + choice = selected and selected.value or nil + else + -- Regular string choices + selected = utils.select(choices, { + prompt = string.format('Select %s> ', prop_name), + }) + choice = selected + end end table.insert(out, choice or '') From 3ee1a96f567e3c0f23c6180a9059a2bc076b7528 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 6 Oct 2025 01:52:35 +0000 Subject: [PATCH 167/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 54 +++++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 70b1a375..27042059 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 01 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 06 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -145,13 +145,13 @@ EXAMPLES *CopilotChat-examples* # Sticky prompt that persists - > #buffer:current + > #buffer:active > You are a helpful coding assistant < -When you use `@copilot`, the LLM can call functions like `glob`, `file`, -`gitdiff` etc. You’ll see the proposed function call and can approve/reject -it before execution. +When you use `@copilot`, the LLM can call functions like `bash`, `edit`, +`file`, `glob`, `grep`, `gitdiff` etc. You’ll see the proposed function call +and can approve/reject it before execution. ============================================================================== @@ -183,7 +183,6 @@ CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* Reset and clear the chat window Submit the current prompt - grr Toggle sticky prompt for line under cursor - - grx Clear all sticky prompts in prompt Accept nearest diff - gj Jump to section of nearest diff - gqa Add all answers from chat to quickfix list @@ -206,37 +205,40 @@ PREDEFINED FUNCTIONS *CopilotChat-predefined-functions* All predefined functions belong to the `copilot` group. - ------------------------------------------------------------------------------ - Function Description Example Usage - ------------- ----------------------------------------- ---------------------- - buffer Retrieves content from a specific buffer #buffer + ------------------------------------------------------------------------------------- + Function Type Description Example Usage + ----------- ---------- ----------------------------------------- -------------------- + bash Tool Executes a bash command and returns @copilot only + output - buffers Fetches content from multiple buffers #buffers:visible + buffer Resource Retrieves content from buffer(s) with #buffer:active + diagnostics - diagnostics Collects code diagnostics (errors, #diagnostics:current - warnings) + clipboard Resource Provides access to system clipboard #clipboard + content - file Reads content from a specified file path #file:path/to/file + edit Tool Applies a unified diff to a file @copilot only - gitdiff Retrieves git diff information #gitdiff:staged + file Resource Reads content from a specified file path #file:path/to/file - gitstatus Retrieves git status information #gitstatus + gitdiff Resource Retrieves git diff information #gitdiff:staged - glob Lists filenames matching a pattern in #glob:**/*.lua - workspace + glob Resource Lists filenames matching a pattern in #glob:**/*.lua + workspace - grep Searches for a pattern across files in #grep:TODO - workspace + grep Resource Searches for a pattern across files in #grep:TODO + workspace - quickfix Includes content of files in quickfix #quickfix - list + selection Resource Includes the current visual selection #selection + with diagnostics - register Provides access to specified Vim register #register:+ + url Resource Fetches content from a specified URL #url:https://... + ------------------------------------------------------------------------------------- +**Type Legend:** - selection Includes the current visual selection #selection +- **Resource**: Can be used manually via `#function` syntax +- **Tool**: Can only be called by LLM via `@copilot` (for safety/complexity reasons) - url Fetches content from a specified URL #url:https://... - ------------------------------------------------------------------------------ PREDEFINED PROMPTS *CopilotChat-predefined-prompts* From 92f269971c33a6e2f405da8b14f01cd109b9a3a3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 6 Oct 2025 03:58:07 +0200 Subject: [PATCH 168/250] fix(utils): properly pass cwd as argument to system wrapper (#1458) --- lua/CopilotChat/config/functions.lua | 2 +- lua/CopilotChat/utils.lua | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index da97beb1..8d6d72ec 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -408,7 +408,7 @@ return { resolve = function(input, source) local cmd = { 'bash', '-c', input.command } - local out = utils.system(cmd, { cwd = source.cwd() }) + local out = utils.system(cmd, source.cwd()) return { { diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 8b32a85b..cbdced39 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -125,9 +125,9 @@ end --- Call a system command ---@param cmd table The command ---@async -M.system = async.wrap(function(cmd, callback) - vim.system(cmd, { text = true }, callback) -end, 2) +M.system = async.wrap(function(cmd, cwd, callback) + vim.system(cmd, { cwd = cwd, text = true }, callback) +end, 3) --- Schedule a function only when needed (not on main thread) ---@param callback function The callback From 5801bfeaae4146f3127cb6c0bcbac721a172b85d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 8 Oct 2025 04:08:52 +0200 Subject: [PATCH 169/250] fix(mappings): use resource name if available in preview header (#1459) When displaying resource previews, prefer showing the resource name instead of the URI if the name is available. This improves readability in the preview header, especially for resources with user-friendly names. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 448e9516..635886c2 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -385,7 +385,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.uri, #resource_lines) + local header = string.format('**%s** (%s lines)', resource.name or resource.uri, #resource_lines) if #resource_lines > 10 then header = header .. ' (truncated)' end From f68deee85b8d734db1a9fbf63ce17a8164921267 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 8 Oct 2025 02:09:11 +0000 Subject: [PATCH 170/250] 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 27042059..ea562be2 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 06 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 08 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From db4b51e1bb6a96b94496a6050f300f67258be872 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 18 Oct 2025 03:18:20 +0200 Subject: [PATCH 171/250] fix(mappings): use get_messages for quickfix answers (#1466) Closes #1465 Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 635886c2..f484df24 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -275,9 +275,10 @@ return { normal = 'gqa', callback = function() local items = {} - for i, message in ipairs(copilot.chat.messages) do + local messages = copilot.chat:get_messages() + for i, message in ipairs(messages) do if message.section and message.role == constants.ROLE.ASSISTANT then - local prev_message = copilot.chat.messages[i - 1] + local prev_message = messages[i - 1] local text = '' if prev_message then text = prev_message.content From 98435447243eb80a7c228b1447f14f3294d39739 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 18 Oct 2025 01:18:43 +0000 Subject: [PATCH 172/250] 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 ea562be2..2c939e8d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 08 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 18 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From b06fd119d66559a3ff29bae2fabb70dea01fafc8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 21 Oct 2025 22:20:48 +0200 Subject: [PATCH 173/250] feat(prompt): support custom Copilot instructions file (#1467) Add support for including custom Copilot instructions from .github/copilot-instructions.md in the system prompt. Refactor prompt resolution logic into a new prompt.lua module, and update function and tool resolution to use this module. Also simplify diff hunk application fallback logic. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config.lua | 2 +- lua/CopilotChat/config/mappings.lua | 4 +- lua/CopilotChat/init.lua | 347 ++-------------- .../instructions/custom_instructions.lua | 6 + lua/CopilotChat/prompt.lua | 390 ++++++++++++++++++ lua/CopilotChat/utils/diff.lua | 18 +- 6 files changed, 435 insertions(+), 332 deletions(-) create mode 100644 lua/CopilotChat/instructions/custom_instructions.lua create mode 100644 lua/CopilotChat/prompt.lua diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index e43b1838..682923b3 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -14,7 +14,7 @@ ---@field blend number? ---@class CopilotChat.config.Shared ----@field system_prompt nil|string|fun(source: CopilotChat.source):string +---@field system_prompt nil|string ---@field model string? ---@field tools string|table|nil ---@field resources string|table|nil diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index f484df24..a7cd5158 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -307,10 +307,10 @@ return { end local lines = {} - local config, prompt = copilot.resolve_prompt(message.content) - local system_prompt = config.system_prompt async.run(function() + local config, prompt = copilot.resolve_prompt(message.content) + local system_prompt = config.system_prompt local resolved_resources = copilot.resolve_functions(prompt, config) local selected_tools = copilot.resolve_tools(prompt, config) local selected_model = copilot.resolve_model(prompt, config) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 34f564ae..c738d4a8 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1,18 +1,13 @@ 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 prompts = require('CopilotChat.prompt') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') local orderedmap = require('CopilotChat.utils.orderedmap') -local WORD = '([^%s:]+)' -local WORD_NO_INPUT = '([^%s]+)' -local WORD_WITH_INPUT_QUOTED = WORD .. ':`([^`]+)`' -local WORD_WITH_INPUT_UNQUOTED = WORD .. ':?([^%s`]*)' local BLOCK_OUTPUT_FORMAT = '```%s\n%s\n```' ---@class CopilotChat @@ -234,19 +229,25 @@ end ---@return any local function handle_error(config, cb) return function() - local ok, out = pcall(cb) + local function error_handler(err) + return { + err = utils.make_string(err), + traceback = debug.traceback(), + } + end + + local ok, out = xpcall(cb, error_handler) if ok then return out end + log.error(out.err .. '\n' .. out.traceback) - log.error(out) if config.headless then return end utils.schedule_main() - out = out or 'Unknown error' - out = utils.make_string(out) + out = out.err M.chat:add_message({ role = constants.ROLE.ASSISTANT, @@ -307,291 +308,25 @@ end ---@param config CopilotChat.config.Shared? ---@return table, string function M.resolve_tools(prompt, config) - config, prompt = M.resolve_prompt(prompt, config) - - local tools = {} - for _, tool in ipairs(functions.parse_tools(M.config.functions)) do - tools[tool.name] = tool - end - - local enabled_tools = {} - local tool_matches = utils.to_table(config.tools) - - -- Check for @tool pattern to find enabled tools - prompt = prompt:gsub('@' .. WORD, function(match) - for name, tool in pairs(M.config.functions) do - if name == match or tool.group == match then - table.insert(tool_matches, match) - return '' - end - end - return '@' .. match - end) - for _, match in ipairs(tool_matches) do - for name, tool in pairs(M.config.functions) do - if name == match or tool.group == match then - table.insert(enabled_tools, tools[name]) - end - end - end - - return enabled_tools, prompt + return prompts.resolve_tools(prompt, config) end --- Call and resolve function calls from the prompt. ---@param prompt string? ---@param config CopilotChat.config.Shared? ----@return table, table, string +---@return table, table, table, string ---@async function M.resolve_functions(prompt, config) - config, prompt = M.resolve_prompt(prompt, config) - - local tools = {} - for _, tool in ipairs(functions.parse_tools(M.config.functions)) do - tools[tool.name] = tool - end - - if config.resources then - local resources = utils.to_table(config.resources) - local lines = utils.split_lines(prompt) - for i = #resources, 1, -1 do - local resource = resources[i] - table.insert(lines, 1, '#' .. resource) - end - prompt = table.concat(lines, '\n') - end - - local resolved_resources = {} - local resolved_tools = {} - local tool_calls = {} - for _, message in ipairs(M.chat:get_messages()) do - if message.tool_calls then - for _, tool_call in ipairs(message.tool_calls) do - table.insert(tool_calls, tool_call) - end - end - end - - local resource_matches = {} - - -- Check for #word:`input` pattern - for word, input in prompt:gmatch('#' .. WORD_WITH_INPUT_QUOTED) do - local pattern = string.format('#%s:`%s`', word, input) - table.insert(resource_matches, { - pattern = pattern, - word = word, - input = input, - }) - end - - -- Check for #word:input pattern - for word, input in prompt:gmatch('#' .. WORD_WITH_INPUT_UNQUOTED) do - local pattern = utils.empty(input) and string.format('#%s', word) or string.format('#%s:%s', word, input) - table.insert(resource_matches, { - pattern = pattern, - word = word, - input = input, - }) - end - - -- Check for ##word:input pattern - for word in prompt:gmatch('##' .. WORD_NO_INPUT) do - local pattern = string.format('##%s', word) - table.insert(resource_matches, { - pattern = pattern, - word = word, - }) - end - - -- Resolve each function reference - local function expand_function(name, input) - notify.publish(notify.STATUS, 'Running function: ' .. name) - - local tool_id = nil - if not utils.empty(tool_calls) then - for _, tool_call in ipairs(tool_calls) do - if tool_call.name == name and vim.trim(tool_call.id) == vim.trim(input) then - input = utils.empty(tool_call.arguments) and {} or utils.json_decode(tool_call.arguments) - tool_id = tool_call.id - break - end - end - end - - local tool = M.config.functions[name] - if not tool then - -- Check if input matches uri - for tool_name, tool_spec in pairs(M.config.functions) do - if tool_spec.uri then - local match = functions.match_uri(name, tool_spec.uri) - if match then - name = tool_name - tool = tool_spec - input = match - break - end - end - end - end - if not tool then - return nil - end - if not tool_id and not tool.uri then - return nil - end - - local schema = tools[name] and tools[name].schema or nil - local ok, output - if config.stop_on_function_failure then - output = tool.resolve(functions.parse_input(input, schema), state.source) - ok = true - else - ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source) - end - - local result = '' - if not ok then - result = utils.make_string(output) - else - for _, content in ipairs(output) do - if content then - local content_out = nil - if content.uri then - if - not vim.tbl_contains(resolved_resources, function(resource) - return resource.uri == content.uri - end, { predicate = true }) - then - content_out = '##' .. content.uri - table.insert(resolved_resources, content) - end - - if tool_id then - table.insert(state.sticky, '##' .. content.uri) - end - else - content_out = content.data - end - - if content_out then - if not utils.empty(result) then - result = result .. '\n' - end - result = result .. content_out - end - end - end - end - - if tool_id then - table.insert(resolved_tools, { - id = tool_id, - result = result, - }) - - return '' - end - - return result - end - - -- Resolve and process all tools - for _, match in ipairs(resource_matches) do - if not utils.empty(match.pattern) then - local out = expand_function(match.word, match.input) - if out == nil then - out = match.pattern - end - out = out:gsub('%%', '%%%%') -- Escape percent signs for gsub - prompt = prompt:gsub(vim.pesc(match.pattern), out, 1) - end - end - - return resolved_resources, resolved_tools, prompt + return prompts.resolve_functions(prompt, config) end --- Resolve the final prompt and config from prompt template. ---@param prompt string? ---@param config CopilotChat.config.Shared? ---@return CopilotChat.config.prompts.Prompt, string +---@async function M.resolve_prompt(prompt, config) - if prompt == nil then - local message = M.chat:get_message(constants.ROLE.USER) - if message then - prompt = message.content - end - end - - local prompts_to_use = list_prompts() - local depth = 0 - local MAX_DEPTH = 10 - - local function resolve(inner_config, inner_prompt) - if depth >= MAX_DEPTH then - return inner_config, inner_prompt - end - depth = depth + 1 - - inner_prompt = string.gsub(inner_prompt, '/' .. WORD, function(match) - local p = prompts_to_use[match] - if p then - local resolved_config, resolved_prompt = resolve(p, p.prompt or '') - inner_config = vim.tbl_deep_extend('force', inner_config, resolved_config) - return resolved_prompt - end - - return '/' .. match - end) - - depth = depth - 1 - return inner_config, inner_prompt - end - - local function resolve_system_prompt(system_prompt) - if type(system_prompt) == 'function' then - local ok, result = pcall(system_prompt) - if not ok then - log.warn('Failed to resolve system prompt function: ' .. result) - return nil - end - return result - end - - return system_prompt - end - - config = vim.tbl_deep_extend('force', M.config, config or {}) - config, prompt = resolve(config, prompt or '') - - if config.system_prompt then - config.system_prompt = resolve_system_prompt(config.system_prompt) - - if M.config.prompts[config.system_prompt] then - -- Name references are good for making system prompt auto sticky - config.system_prompt = M.config.prompts[config.system_prompt].system_prompt - end - - config.system_prompt = vim.trim(config.system_prompt) .. '\n' .. M.config.prompts.COPILOT_BASE.system_prompt - config.system_prompt = vim.trim(config.system_prompt) - .. '\n' - .. vim.trim(require('CopilotChat.instructions.tool_use')) - - if config.diff == 'unified' then - config.system_prompt = vim.trim(config.system_prompt) - .. '\n' - .. vim.trim(require('CopilotChat.instructions.edit_file_unified')) - else - config.system_prompt = vim.trim(config.system_prompt) - .. '\n' - .. vim.trim(require('CopilotChat.instructions.edit_file_block')) - end - - config.system_prompt = config.system_prompt:gsub('{OS_NAME}', vim.uv.os_uname().sysname) - config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) - config.system_prompt = config.system_prompt:gsub('{DIR}', state.source.cwd()) - end - - return config, prompt + return prompts.resolve_prompt(prompt, config) end --- Resolve the model from the prompt. @@ -600,22 +335,7 @@ end ---@return string, string ---@async function M.resolve_model(prompt, config) - config, prompt = M.resolve_prompt(prompt, config) - - local models = vim.tbl_map(function(model) - return model.id - end, list_models()) - - local selected_model = config.model or '' - prompt = prompt:gsub('%$' .. WORD, function(match) - if vim.tbl_contains(models, match) then - selected_model = match - return '' - end - return '$' .. match - end) - - return selected_model, prompt + return prompts.resolve_model(prompt, config) end --- Get the current source buffer and window. @@ -813,30 +533,33 @@ function M.ask(prompt, config) -- After opening window we need to schedule to next cycle so everything properly resolves schedule(function() - -- Prepare chat if not config.headless then + -- Prepare chat store_sticky(prompt) M.chat:start() M.chat:append('\n') end - -- Resolve prompt references - config, prompt = M.resolve_prompt(prompt, config) - local system_prompt = config.system_prompt or '' - - -- Remove sticky prefix - prompt = table.concat( - vim.tbl_map(function(l) - return l:gsub('^>%s+', '') - end, vim.split(prompt, '\n')), - '\n' - ) - async.run(handle_error(config, function() + config, prompt = M.resolve_prompt(prompt, config) + local system_prompt = config.system_prompt or '' local selected_tools, prompt = M.resolve_tools(prompt, config) - local resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) + local resolved_resources, resolved_tools, resolved_stickies, prompt = M.resolve_functions(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) + -- Remove sticky prefix + prompt = table.concat( + vim.tbl_map(function(l) + return l:gsub('^>%s+', '') + end, vim.split(prompt, '\n')), + '\n' + ) + + -- Add resolved stickies to state + for _, sticky in ipairs(resolved_stickies) do + table.insert(state.sticky, sticky) + end + prompt = vim.trim(prompt) if not config.headless then @@ -916,7 +639,7 @@ function M.ask(prompt, config) end), }) - -- If there was no error and no response, it means job was cancelled + -- If there was no error and no response, it means job was canceled if ask_response == nil then return end diff --git a/lua/CopilotChat/instructions/custom_instructions.lua b/lua/CopilotChat/instructions/custom_instructions.lua new file mode 100644 index 00000000..57b1ba44 --- /dev/null +++ b/lua/CopilotChat/instructions/custom_instructions.lua @@ -0,0 +1,6 @@ +return [[ + +Custom instructions from user's `{FILENAME}`: +{CONTENT} + +]] diff --git a/lua/CopilotChat/prompt.lua b/lua/CopilotChat/prompt.lua new file mode 100644 index 00000000..b35da0f2 --- /dev/null +++ b/lua/CopilotChat/prompt.lua @@ -0,0 +1,390 @@ +local client = require('CopilotChat.client') +local constants = require('CopilotChat.constants') +local functions = require('CopilotChat.functions') +local notify = require('CopilotChat.notify') +local files = require('CopilotChat.utils.files') +local utils = require('CopilotChat.utils') + +local WORD = '([^%s:]+)' +local WORD_NO_INPUT = '([^%s]+)' +local WORD_WITH_INPUT_QUOTED = WORD .. ':`([^`]+)`' +local WORD_WITH_INPUT_UNQUOTED = WORD .. ':?([^%s`]*)' + +--- List available models. +--- @return CopilotChat.client.Model[] +local function list_models() + local models = client:models() + local result = vim.tbl_keys(models) + + table.sort(result, function(a, b) + a = models[a] + b = models[b] + if a.provider ~= b.provider then + return a.provider < b.provider + end + return a.id < b.id + end) + + return vim.tbl_map(function(id) + return models[id] + end, result) +end + +--- List available prompts. +---@return table +local function list_prompts() + local config = require('CopilotChat.config') + local prompts_to_use = {} + + for name, prompt in pairs(config.prompts) do + local val = prompt + if type(prompt) == 'string' then + val = { + prompt = prompt, + } + end + + prompts_to_use[name] = val + end + + return prompts_to_use +end + +--- Find custom instructions in the current working directory. +---@param cwd string +---@return table +local function find_custom_instructions(cwd) + local out = {} + local copilot_instructions_path = vim.fs.joinpath(cwd, '.github', 'copilot-instructions.md') + local copilot_instructions = files.read_file(copilot_instructions_path) + if copilot_instructions then + table.insert(out, { + filename = copilot_instructions_path, + content = vim.trim(copilot_instructions), + }) + end + return out +end + +local M = {} + +--- Resolve enabled tools from the prompt. +---@param prompt string? +---@param config CopilotChat.config.Shared? +---@return table, string +function M.resolve_tools(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) + + local tools = {} + for _, tool in ipairs(functions.parse_tools(config.functions)) do + tools[tool.name] = tool + end + + local enabled_tools = {} + local tool_matches = utils.to_table(config.tools) + + -- Check for @tool pattern to find enabled tools + prompt = prompt:gsub('@' .. WORD, function(match) + for name, tool in pairs(config.functions) do + if name == match or tool.group == match then + table.insert(tool_matches, match) + return '' + end + end + return '@' .. match + end) + for _, match in ipairs(tool_matches) do + for name, tool in pairs(config.functions) do + if name == match or tool.group == match then + table.insert(enabled_tools, tools[name]) + end + end + end + + return enabled_tools, prompt +end + +--- Call and resolve function calls from the prompt. +---@param prompt string? +---@param config CopilotChat.config.Shared? +---@return table, table, table, string +---@async +function M.resolve_functions(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) + + local chat = require('CopilotChat').chat + local source = require('CopilotChat').get_source() + + local tools = {} + for _, tool in ipairs(functions.parse_tools(config.functions)) do + tools[tool.name] = tool + end + + if config.resources then + local resources = utils.to_table(config.resources) + local lines = utils.split_lines(prompt) + for i = #resources, 1, -1 do + local resource = resources[i] + table.insert(lines, 1, '#' .. resource) + end + prompt = table.concat(lines, '\n') + end + + local resolved_resources = {} + local resolved_tools = {} + local resolved_stickies = {} + local tool_calls = {} + + utils.schedule_main() + for _, message in ipairs(chat:get_messages()) do + if message.tool_calls then + for _, tool_call in ipairs(message.tool_calls) do + table.insert(tool_calls, tool_call) + end + end + end + + local resource_matches = {} + + -- Check for #word:`input` pattern + for word, input in prompt:gmatch('#' .. WORD_WITH_INPUT_QUOTED) do + local pattern = string.format('#%s:`%s`', word, input) + table.insert(resource_matches, { + pattern = pattern, + word = word, + input = input, + }) + end + + -- Check for #word:input pattern + for word, input in prompt:gmatch('#' .. WORD_WITH_INPUT_UNQUOTED) do + local pattern = utils.empty(input) and string.format('#%s', word) or string.format('#%s:%s', word, input) + table.insert(resource_matches, { + pattern = pattern, + word = word, + input = input, + }) + end + + -- Check for ##word:input pattern + for word in prompt:gmatch('##' .. WORD_NO_INPUT) do + local pattern = string.format('##%s', word) + table.insert(resource_matches, { + pattern = pattern, + word = word, + }) + end + + -- Resolve each function reference + local function expand_function(name, input) + notify.publish(notify.STATUS, 'Running function: ' .. name) + + local tool_id = nil + if not utils.empty(tool_calls) then + for _, tool_call in ipairs(tool_calls) do + if tool_call.name == name and vim.trim(tool_call.id) == vim.trim(input) then + input = utils.empty(tool_call.arguments) and {} or utils.json_decode(tool_call.arguments) + tool_id = tool_call.id + break + end + end + end + + local tool = config.functions[name] + if not tool then + -- Check if input matches uri + for tool_name, tool_spec in pairs(config.functions) do + if tool_spec.uri then + local match = functions.match_uri(name, tool_spec.uri) + if match then + name = tool_name + tool = tool_spec + input = match + break + end + end + end + end + if not tool then + return nil + end + if not tool_id and not tool.uri then + return nil + end + + local schema = tools[name] and tools[name].schema or nil + local ok, output + if config.stop_on_function_failure then + output = tool.resolve(functions.parse_input(input, schema), source) + ok = true + else + ok, output = pcall(tool.resolve, functions.parse_input(input, schema), source) + end + + local result = '' + if not ok then + result = utils.make_string(output) + else + for _, content in ipairs(output) do + if content then + local content_out = nil + if content.uri then + if + not vim.tbl_contains(resolved_resources, function(resource) + return resource.uri == content.uri + end, { predicate = true }) + then + content_out = '##' .. content.uri + table.insert(resolved_resources, content) + end + + if tool_id then + table.insert(resolved_stickies, '##' .. content.uri) + end + else + content_out = content.data + end + + if content_out then + if not utils.empty(result) then + result = result .. '\n' + end + result = result .. content_out + end + end + end + end + + if tool_id then + table.insert(resolved_tools, { + id = tool_id, + result = result, + }) + + return '' + end + + return result + end + + -- Resolve and process all tools + for _, match in ipairs(resource_matches) do + if not utils.empty(match.pattern) then + local out = expand_function(match.word, match.input) + if out == nil then + out = match.pattern + end + out = out:gsub('%%', '%%%%') -- Escape percent signs for gsub + prompt = prompt:gsub(vim.pesc(match.pattern), out, 1) + end + end + + return resolved_resources, resolved_tools, resolved_stickies, prompt +end + +--- Resolve the final prompt and config from prompt template. +---@param prompt string? +---@param config CopilotChat.config.Shared? +---@return CopilotChat.config.prompts.Prompt, string +---@async +function M.resolve_prompt(prompt, config) + local chat = require('CopilotChat').chat + local source = require('CopilotChat').get_source() + + if prompt == nil then + utils.schedule_main() + local message = chat:get_message(constants.ROLE.USER) + if message then + prompt = message.content + end + end + + local prompts_to_use = list_prompts() + local depth = 0 + local MAX_DEPTH = 10 + + local function resolve(inner_config, inner_prompt) + if depth >= MAX_DEPTH then + return inner_config, inner_prompt + end + depth = depth + 1 + + inner_prompt = string.gsub(inner_prompt, '/' .. WORD, function(match) + local p = prompts_to_use[match] + if p then + local resolved_config, resolved_prompt = resolve(p, p.prompt or '') + inner_config = vim.tbl_deep_extend('force', inner_config, resolved_config) + return resolved_prompt + end + + return '/' .. match + end) + + depth = depth - 1 + return inner_config, inner_prompt + end + + config = vim.tbl_deep_extend('force', require('CopilotChat.config'), config or {}) + config, prompt = resolve(config, prompt or '') + + if config.system_prompt then + if config.prompts[config.system_prompt] then + -- Name references are good for making system prompt auto sticky + config.system_prompt = config.prompts[config.system_prompt].system_prompt + end + + local custom_instructions = vim.trim(require('CopilotChat.instructions.custom_instructions')) + for _, instruction in ipairs(find_custom_instructions(source.cwd())) do + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. custom_instructions:gsub('{FILENAME}', instruction.filename):gsub('{CONTENT}', instruction.content) + end + + config.system_prompt = vim.trim(config.system_prompt) .. '\n' .. config.prompts.COPILOT_BASE.system_prompt + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.tool_use')) + + if config.diff == 'unified' then + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.edit_file_unified')) + else + config.system_prompt = vim.trim(config.system_prompt) + .. '\n' + .. vim.trim(require('CopilotChat.instructions.edit_file_block')) + end + + config.system_prompt = config.system_prompt:gsub('{OS_NAME}', vim.uv.os_uname().sysname) + config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) + config.system_prompt = config.system_prompt:gsub('{DIR}', source.cwd()) + end + + return config, prompt +end + +--- Resolve the model from the prompt. +---@param prompt string? +---@param config CopilotChat.config.Shared? +---@return string, string +---@async +function M.resolve_model(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) + + local models = vim.tbl_map(function(model) + return model.id + end, list_models()) + + local selected_model = config.model or '' + prompt = prompt:gsub('%$' .. WORD, function(match) + if vim.tbl_contains(models, match) then + selected_model = match + return '' + end + return '$' .. match + end) + + return selected_model, prompt +end + +return M diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 6ff56bac..17eb3091 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -54,7 +54,7 @@ local function apply_hunk(hunk, content) return patched, true end - -- Fallback: try smaller context window + -- Fallback: direct replacement local lines = vim.split(content, '\n') local insert_idx = hunk.start_old or 1 if not hunk.start_old then @@ -83,24 +83,8 @@ local function apply_hunk(hunk, content) end end - -- Define context window around insert point - local context_size = 10 local start_idx = insert_idx local end_idx = insert_idx + #hunk.old_snippet - local context_start = math.max(1, start_idx - context_size) - local context_end = math.min(#lines, end_idx + context_size) - local context_window = table.concat(vim.list_slice(lines, context_start, context_end), '\n') - - local patched_window, window_results = dmp.patch_apply(patch, context_window) - if not vim.tbl_contains(window_results, false) then - -- Patch succeeded in window, splice back - local new_lines = vim.list_slice(lines, 1, context_start - 1) - vim.list_extend(new_lines, vim.split(patched_window, '\n')) - vim.list_extend(new_lines, lines, context_end + 1, #lines) - return table.concat(new_lines, '\n'), true - end - - -- Fallback: direct replacement local new_lines = vim.list_slice(lines, 1, start_idx - 1) vim.list_extend(new_lines, hunk.new_snippet) vim.list_extend(new_lines, lines, end_idx + 1, #lines) From b967f5410fe43ff21a7cedacb5dc40f70ad041c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 21 Oct 2025 20:21:14 +0000 Subject: [PATCH 174/250] 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 2c939e8d..168f7a56 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 18 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 21 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 4d9256418ca276b294f3e00445aa0d6faec4c1ea Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 21 Oct 2025 23:13:30 +0200 Subject: [PATCH 175/250] refactor: move list_prompts to prompts module completely (#1468) - Renamed `prompt.lua` to `prompts.lua` for clarity and consistency. - Moved prompt listing logic from `init.lua` to `prompts.lua`. - Updated all references to use the new `prompts` module. - Removed unused model and prompt listing functions from `init.lua`. --- lua/CopilotChat/init.lua | 62 +++++++-------------- lua/CopilotChat/{prompt.lua => prompts.lua} | 61 +++++++------------- 2 files changed, 38 insertions(+), 85 deletions(-) rename lua/CopilotChat/{prompt.lua => prompts.lua} (94%) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index c738d4a8..0eb6f9de 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -2,7 +2,7 @@ local async = require('plenary.async') local log = require('plenary.log') local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') -local prompts = require('CopilotChat.prompt') +local prompts = require('CopilotChat.prompts') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') @@ -145,45 +145,6 @@ local function store_sticky(prompt) state.sticky = sticky end ---- List available models. ---- @return CopilotChat.client.Model[] -local function list_models() - local models = client:models() - local result = vim.tbl_keys(models) - - table.sort(result, function(a, b) - a = models[a] - b = models[b] - if a.provider ~= b.provider then - return a.provider < b.provider - end - return a.id < b.id - end) - - return vim.tbl_map(function(id) - return models[id] - 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 - - 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) @@ -413,7 +374,22 @@ end --- Select default Copilot GPT model. function M.select_model() async.run(function() - local models = list_models() + local models = client:models() + local result = vim.tbl_keys(models) + + table.sort(result, function(a, b) + a = models[a] + b = models[b] + if a.provider ~= b.provider then + return a.provider < b.provider + end + return a.id < b.id + end) + + models = vim.tbl_map(function(id) + return models[id] + end, result) + local choices = vim.tbl_map(function(model) return { id = model.id, @@ -467,7 +443,7 @@ end --- Select a prompt template to use. ---@param config CopilotChat.config.Shared? function M.select_prompt(config) - local prompts = list_prompts() + local prompts = prompts.list_prompts() local keys = vim.tbl_keys(prompts) table.sort(keys) @@ -859,7 +835,7 @@ function M.setup(config) end) end - for name, prompt in pairs(list_prompts()) do + for name, prompt in pairs(prompts.list_prompts()) do if prompt.prompt then vim.api.nvim_create_user_command('CopilotChat' .. name, function(args) local input = prompt.prompt diff --git a/lua/CopilotChat/prompt.lua b/lua/CopilotChat/prompts.lua similarity index 94% rename from lua/CopilotChat/prompt.lua rename to lua/CopilotChat/prompts.lua index b35da0f2..c67c4ee7 100644 --- a/lua/CopilotChat/prompt.lua +++ b/lua/CopilotChat/prompts.lua @@ -10,29 +10,27 @@ local WORD_NO_INPUT = '([^%s]+)' local WORD_WITH_INPUT_QUOTED = WORD .. ':`([^`]+)`' local WORD_WITH_INPUT_UNQUOTED = WORD .. ':?([^%s`]*)' ---- List available models. ---- @return CopilotChat.client.Model[] -local function list_models() - local models = client:models() - local result = vim.tbl_keys(models) - - table.sort(result, function(a, b) - a = models[a] - b = models[b] - if a.provider ~= b.provider then - return a.provider < b.provider - end - return a.id < b.id - end) - - return vim.tbl_map(function(id) - return models[id] - end, result) +--- Find custom instructions in the current working directory. +---@param cwd string +---@return table +local function find_custom_instructions(cwd) + local out = {} + local copilot_instructions_path = vim.fs.joinpath(cwd, '.github', 'copilot-instructions.md') + local copilot_instructions = files.read_file(copilot_instructions_path) + if copilot_instructions then + table.insert(out, { + filename = copilot_instructions_path, + content = vim.trim(copilot_instructions), + }) + end + return out end +local M = {} + --- List available prompts. ---@return table -local function list_prompts() +function M.list_prompts() local config = require('CopilotChat.config') local prompts_to_use = {} @@ -50,24 +48,6 @@ local function list_prompts() return prompts_to_use end ---- Find custom instructions in the current working directory. ----@param cwd string ----@return table -local function find_custom_instructions(cwd) - local out = {} - local copilot_instructions_path = vim.fs.joinpath(cwd, '.github', 'copilot-instructions.md') - local copilot_instructions = files.read_file(copilot_instructions_path) - if copilot_instructions then - table.insert(out, { - filename = copilot_instructions_path, - content = vim.trim(copilot_instructions), - }) - end - return out -end - -local M = {} - --- Resolve enabled tools from the prompt. ---@param prompt string? ---@param config CopilotChat.config.Shared? @@ -299,7 +279,7 @@ function M.resolve_prompt(prompt, config) end end - local prompts_to_use = list_prompts() + local prompts_to_use = M.list_prompts() local depth = 0 local MAX_DEPTH = 10 @@ -370,10 +350,7 @@ end ---@async function M.resolve_model(prompt, config) config, prompt = M.resolve_prompt(prompt, config) - - local models = vim.tbl_map(function(model) - return model.id - end, list_models()) + local models = vim.tbl_keys(client:models()) local selected_model = config.model or '' prompt = prompt:gsub('%$' .. WORD, function(match) From 1ff0bb3b28d92e916d7c63a9189415ea2aaa24de Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 23 Oct 2025 07:49:12 +0200 Subject: [PATCH 176/250] refactor!: move source/sticky logic to chat window (#1469) This refactors the management of source and sticky prompt logic by moving it from the main module to the chat window implementation. The `CopilotChat.source` and sticky state are now encapsulated within the `CopilotChat.ui.chat.Chat` class, providing a cleaner separation of concerns and reducing global state. All related methods and type annotations have been updated accordingly. The sticky prompt insertion and retrieval logic is now handled via `get_sticky` and `set_sticky` methods on the chat window, and the source window/buffer is managed through `get_source` and `set_source` methods. This change also removes the sticky toggle mapping and updates documentation and function signatures for consistency. BREAKING CHANGE: get_source, and set_source methods moved to require('CopilotChat').chat --- README.md | 9 +- lua/CopilotChat/completion.lua | 2 +- lua/CopilotChat/config.lua | 2 +- lua/CopilotChat/config/functions.lua | 2 +- lua/CopilotChat/config/mappings.lua | 36 +------- lua/CopilotChat/functions.lua | 2 +- lua/CopilotChat/init.lua | 132 +++++++-------------------- lua/CopilotChat/prompts.lua | 4 +- lua/CopilotChat/ui/chat.lua | 115 +++++++++++++---------- 9 files changed, 111 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index bcc84655..0daa5d60 100644 --- a/README.md +++ b/README.md @@ -408,10 +408,6 @@ chat.toggle(config) -- Toggle chat window visibility with optional con chat.reset() -- Reset the chat chat.stop() -- Stop current output --- Source Management -chat.get_source() -- Get the current source buffer and window -chat.set_source(winnr) -- Set the source window - -- Prompt & Model Management chat.select_prompt(config) -- Open prompt selector with optional config chat.select_model() -- Open model selector @@ -441,7 +437,6 @@ window:get_message(role, cursor) -- Get chat message by role, eith 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 window:append(text) -- Append text to chat window @@ -449,6 +444,10 @@ window:clear() -- Clear chat window content window:start() -- Start writing to chat window window:finish() -- Finish writing to chat window +-- Source Management +window.get_source() -- Get the current source buffer and window +window.set_source(winnr) -- Set the source window + -- Navigation window:follow() -- Move cursor to end of chat content window:focus() -- Focus the chat window diff --git a/lua/CopilotChat/completion.lua b/lua/CopilotChat/completion.lua index 97b3e9d4..fdb509de 100644 --- a/lua/CopilotChat/completion.lua +++ b/lua/CopilotChat/completion.lua @@ -131,7 +131,7 @@ end --- Trigger the completion for the chat window. ---@param without_input boolean? function M.complete(without_input) - local source = require('CopilotChat').get_source() + local source = require('CopilotChat').chat:get_source() local info = M.info() local bufnr = vim.api.nvim_get_current_buf() local line = vim.api.nvim_get_current_line() diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 682923b3..b78ddf61 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -23,7 +23,7 @@ ---@field language string? ---@field temperature number? ---@field headless boolean? ----@field callback nil|fun(response: CopilotChat.client.Message, source: CopilotChat.source) +---@field callback nil|fun(response: CopilotChat.client.Message, source: CopilotChat.ui.chat.Source) ---@field remember_as_sticky boolean? ---@field window CopilotChat.config.Window? ---@field show_help boolean? diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 8d6d72ec..92226594 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -45,7 +45,7 @@ end ---@field schema table? ---@field group string? ---@field uri string? ----@field resolve fun(input: table, source: CopilotChat.source):CopilotChat.client.Resource[] +---@field resolve fun(input: table, source: CopilotChat.ui.chat.Source):CopilotChat.client.Resource[] ---@type table return { diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index a7cd5158..c286bfee 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -9,7 +9,7 @@ local files = require('CopilotChat.utils.files') --- Prepare a buffer for applying a diff ---@param filename string? ----@param source CopilotChat.source +---@param source CopilotChat.ui.chat.Source ---@return integer local function prepare_diff_buffer(filename, source) if not filename then @@ -47,7 +47,7 @@ end ---@class CopilotChat.config.mapping ---@field normal string? ---@field insert string? ----@field callback fun(source: CopilotChat.source) +---@field callback fun(source: CopilotChat.ui.chat.Source) ---@class CopilotChat.config.mapping.yank_diff : CopilotChat.config.mapping ---@field register string? @@ -57,7 +57,6 @@ end ---@field close CopilotChat.config.mapping|false|nil ---@field reset CopilotChat.config.mapping|false|nil ---@field submit_prompt CopilotChat.config.mapping|false|nil ----@field toggle_sticky CopilotChat.config.mapping|false|nil ---@field accept_diff CopilotChat.config.mapping|false|nil ---@field jump_to_diff CopilotChat.config.mapping|false|nil ---@field quickfix_diffs CopilotChat.config.mapping|false|nil @@ -103,37 +102,6 @@ return { end, }, - toggle_sticky = { - normal = 'grr', - callback = function() - local message = copilot.chat:get_message(constants.ROLE.USER) - local section = message and message.section - if not section then - return - end - - local cursor = vim.api.nvim_win_get_cursor(copilot.chat.winnr) - if cursor[1] < section.start_line or cursor[1] > section.end_line then - return - end - - local current_line = vim.trim(vim.api.nvim_get_current_line()) - if current_line == '' then - return - end - - local cur_line = cursor[1] - vim.api.nvim_buf_set_lines(copilot.chat.bufnr, cur_line - 1, cur_line, false, {}) - - if vim.startswith(current_line, '> ') then - return - end - - copilot.chat:add_sticky(current_line) - vim.api.nvim_win_set_cursor(copilot.chat.winnr, cursor) - end, - }, - accept_diff = { normal = '', insert = '', diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index 9d0befdf..bed78c8a 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -199,7 +199,7 @@ end --- Get input from the user based on the schema ---@param schema table? ----@param source CopilotChat.source +---@param source CopilotChat.ui.chat.Source ---@return string? function M.enter_input(schema, source) if not schema or not schema.properties then diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0eb6f9de..717e4801 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -30,35 +30,18 @@ local M = setmetatable({}, { end, }) ---- @class CopilotChat.source ---- @field bufnr number? ---- @field winnr number? ---- @field cwd fun():string - ---- @class CopilotChat.state ---- @field source CopilotChat.source ---- @field sticky string[] -local state = { - source = { - bufnr = nil, - winnr = nil, - cwd = function() - return '.' - end, - }, - - sticky = {}, -} - ---- Insert sticky values from config into prompt +--- Process sticky values from prompt and config +--- Extracts stickies from prompt, adds config-based stickies, stores them, returns clean prompt ---@param prompt string ---@param config CopilotChat.config.Shared -local function insert_sticky(prompt, config) +---@return string clean_prompt The prompt without sticky prefixes +local function process_sticky(prompt, config) 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 = orderedmap() + -- Extract existing stickies from combined prompt local sticky_indices = {} local in_code_block = false for _, line in ipairs(vim.split(combined_prompt, '\n')) do @@ -69,6 +52,8 @@ local function insert_sticky(prompt, config) stickies:set(vim.trim(line:sub(3)), true) end end + + -- Find sticky lines in new prompt to remove them for i, line in ipairs(lines) do if vim.startswith(line, '> ') then table.insert(sticky_indices, i) @@ -80,6 +65,7 @@ local function insert_sticky(prompt, config) lines = vim.split(vim.trim(table.concat(lines, '\n')), '\n') + -- Add config-based stickies if config.remember_as_sticky and config.model and config.model ~= M.config.model then stickies:set('$' .. config.model, true) end @@ -111,38 +97,17 @@ local function insert_sticky(prompt, config) end end - -- Insert stickies at start of prompt - local prompt_lines = {} + -- Store stickies + local sticky_array = {} for _, sticky in ipairs(stickies:keys()) do if sticky ~= '' then - table.insert(prompt_lines, '> ' .. sticky) + table.insert(sticky_array, sticky) end end - if #prompt_lines > 0 then - table.insert(prompt_lines, '') - end - for _, line in ipairs(lines) do - table.insert(prompt_lines, line) - end - if #lines == 0 then - table.insert(prompt_lines, '') - end + M.chat:set_sticky(sticky_array) - return table.concat(prompt_lines, '\n') -end - -local function store_sticky(prompt) - local sticky = {} - local in_code_block = false - for _, line in ipairs(vim.split(prompt, '\n')) do - if line:match('^```') then - in_code_block = not in_code_block - end - if vim.startswith(line, '> ') and not in_code_block then - table.insert(sticky, line:sub(3)) - end - end - state.sticky = sticky + -- Return clean prompt + return table.concat(lines, '\n') end --- Finish writing to chat buffer. @@ -155,15 +120,16 @@ local function finish(start_of_chat) table.insert(sticky, sticky_line) end end - state.sticky = sticky + M.chat:set_sticky(sticky) end local prompt_content = '' 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 - for _, sticky in ipairs(state.sticky) do + local current_sticky = M.chat:get_sticky() + if not utils.empty(current_sticky) then + for _, sticky in ipairs(current_sticky) do prompt_content = prompt_content .. '> ' .. sticky .. '\n' end prompt_content = prompt_content .. '\n' @@ -231,7 +197,7 @@ local function map_key(name, bufnr, fn) if not fn then fn = function() - key.callback(state.source) + key.callback(M.chat:get_source()) end end @@ -261,7 +227,7 @@ end --- Updates the source buffer based on previous or current window. local function update_source() local use_prev_window = M.chat:focused() - M.set_source(use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) or vim.api.nvim_get_current_win()) + M.chat:set_source(use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) or vim.api.nvim_get_current_win()) end --- Resolve enabled tools from the prompt. @@ -299,39 +265,6 @@ function M.resolve_model(prompt, config) return prompts.resolve_model(prompt, config) end ---- Get the current source buffer and window. -function M.get_source() - return state.source -end - ---- Sets the source to the given window. ----@param source_winnr number ----@return boolean if the source was set -function M.set_source(source_winnr) - local source_bufnr = vim.api.nvim_win_get_buf(source_winnr) - - -- Check if the window is valid to use as a source - if source_winnr ~= M.chat.winnr and source_bufnr ~= M.chat.bufnr and vim.fn.win_gettype(source_winnr) == '' then - state.source = { - bufnr = source_bufnr, - winnr = source_winnr, - cwd = function() - local ok, dir = pcall(function() - return vim.w[source_winnr].cchat_cwd - end) - if not ok or not dir or dir == '' then - return '.' - end - return dir - end, - } - - return true - end - - return false -end - --- Open the chat window. ---@param config CopilotChat.config.Shared? function M.open(config) @@ -343,11 +276,11 @@ function M.open(config) -- Add sticky values from provided config when opening the chat local message = M.chat:get_message(constants.ROLE.USER) if message then - local prompt = insert_sticky(message.content, config) - if prompt then + local clean_prompt = process_sticky(message.content, config) + if clean_prompt and clean_prompt ~= '' then M.chat:add_message({ role = constants.ROLE.USER, - content = '\n' .. prompt, + content = '\n> ' .. table.concat(M.chat:get_sticky(), '\n> ') .. '\n\n' .. clean_prompt, }, true) end end @@ -358,7 +291,7 @@ end --- Close the chat window. function M.close() - M.chat:close(state.source.bufnr) + M.chat:close() end --- Toggle the chat window. @@ -504,14 +437,13 @@ function M.ask(prompt, config) end -- Resolve prompt after window is opened - prompt = insert_sticky(prompt, config) + prompt = process_sticky(prompt, config) prompt = vim.trim(prompt) -- After opening window we need to schedule to next cycle so everything properly resolves schedule(function() if not config.headless then -- Prepare chat - store_sticky(prompt) M.chat:start() M.chat:append('\n') end @@ -531,10 +463,12 @@ function M.ask(prompt, config) '\n' ) - -- Add resolved stickies to state + -- Add resolved stickies to chat + local current_sticky = M.chat:get_sticky() for _, sticky in ipairs(resolved_stickies) do - table.insert(state.sticky, sticky) + table.insert(current_sticky, sticky) end + M.chat:set_sticky(current_sticky) prompt = vim.trim(prompt) @@ -627,7 +561,7 @@ function M.ask(prompt, config) -- Call the callback function if config.callback then utils.schedule_main() - config.callback(response, state.source) + config.callback(response, M.chat:get_source()) end if not config.headless then @@ -656,7 +590,7 @@ function M.stop(reset) if reset then M.chat:clear() vim.diagnostic.reset(vim.api.nvim_create_namespace('copilot-chat-diagnostics')) - select.set(state.source.bufnr) + select.set(M.chat:get_source().bufnr) end if stopped or reset then @@ -797,7 +731,7 @@ function M.setup(config) -- Initialize chat if M.chat then - M.chat:close(state.source.bufnr) + M.chat:close() M.chat:delete() else M.chat = require('CopilotChat.ui.chat')(M.config, function(bufnr) @@ -815,7 +749,7 @@ function M.setup(config) end vim.schedule(function() - select.highlight(state.source.bufnr, not (M.config.highlight_selection and M.chat:focused())) + select.highlight(M.chat:get_source().bufnr, not (M.config.highlight_selection and M.chat:focused())) end) end, }) diff --git a/lua/CopilotChat/prompts.lua b/lua/CopilotChat/prompts.lua index c67c4ee7..21eb6cd4 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -93,7 +93,7 @@ function M.resolve_functions(prompt, config) config, prompt = M.resolve_prompt(prompt, config) local chat = require('CopilotChat').chat - local source = require('CopilotChat').get_source() + local source = chat:get_source() local tools = {} for _, tool in ipairs(functions.parse_tools(config.functions)) do @@ -269,7 +269,7 @@ end ---@async function M.resolve_prompt(prompt, config) local chat = require('CopilotChat').chat - local source = require('CopilotChat').get_source() + local source = chat:get_source() if prompt == nil then utils.schedule_main() diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 8cd08c11..0d2e9bc5 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -106,6 +106,11 @@ end ---@field id string? ---@field section CopilotChat.ui.chat.Section? +--- @class CopilotChat.ui.chat.Source +--- @field bufnr number? +--- @field winnr number? +--- @field cwd fun():string + ---@class CopilotChat.ui.chat.Chat : CopilotChat.ui.overlay.Overlay ---@field winnr number? ---@field config CopilotChat.config.Shared @@ -118,6 +123,8 @@ end ---@field private spinner CopilotChat.ui.spinner.Spinner ---@field private chat_overlay CopilotChat.ui.overlay.Overlay ---@field private last_changedtick number? +---@field private source CopilotChat.ui.chat.Source +---@field private sticky table 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) @@ -127,6 +134,16 @@ local Chat = class(function(self, config, on_buf_create) self.token_max_count = nil self.messages = orderedmap() + self.source = { + bufnr = nil, + winnr = nil, + cwd = function() + return '.' + end, + } + + self.sticky = {} + self.layout = nil self.headers = {} for k, v in pairs(config.headers or {}) do @@ -269,53 +286,21 @@ function Chat:get_message(role, cursor) end end ---- Add a sticky line to the prompt in the chat window. ----@param sticky string -function Chat:add_sticky(sticky) - if not self:visible() then - return - end - - local prompt = self:get_message(constants.ROLE.USER) - if not prompt or not prompt.section then - return - end - - local lines = vim.split(prompt.content, '\n') - local insert_line = 1 - local first_one = true - local found = false - - for i = insert_line, #lines do - local line = lines[i] - if line and line ~= '' then - if vim.startswith(line, '> ') then - if line:sub(3) == sticky then - found = true - break - end - - first_one = false - else - break - end - elseif i >= 2 then - break - end - - insert_line = insert_line + 1 - end +--- Get the current sticky array. +---@return table +function Chat:get_sticky() + return self.sticky +end - if found then - return - end +--- Set the sticky array. +---@param sticky table +function Chat:set_sticky(sticky) + self.sticky = sticky +end - insert_line = prompt.section.start_line + insert_line - 1 - local to_insert = first_one and { '> ' .. sticky, '' } or { '> ' .. sticky } - local modifiable = vim.bo[self.bufnr].modifiable - vim.bo[self.bufnr].modifiable = true - vim.api.nvim_buf_set_lines(self.bufnr, insert_line - 1, insert_line - 1, false, to_insert) - vim.bo[self.bufnr].modifiable = modifiable +--- Clear the sticky array. +function Chat:clear_sticky() + self.sticky = {} end ---@class CopilotChat.ui.Chat.show_overlay @@ -430,8 +415,7 @@ function Chat:open(config) end --- Close the chat window. ----@param bufnr number? -function Chat:close(bufnr) +function Chat:close() if not self:visible() then return end @@ -441,8 +425,8 @@ function Chat:close(bufnr) end if self.layout == 'replace' then - if bufnr then - self:restore(self.winnr, bufnr) + if self.source.bufnr then + self:restore(self.winnr, self.source.bufnr) end else vim.api.nvim_win_close(self.winnr, true) @@ -919,4 +903,37 @@ function Chat:render() end end +--- Get the current source buffer and window. +function Chat:get_source() + return self.source +end + +--- Sets the source to the given window. +---@param source_winnr number +---@return boolean if the source was set +function Chat:set_source(source_winnr) + local source_bufnr = vim.api.nvim_win_get_buf(source_winnr) + + -- Check if the window is valid to use as a source + if source_winnr ~= self.winnr and source_bufnr ~= self.bufnr and vim.fn.win_gettype(source_winnr) == '' then + self.source = { + bufnr = source_bufnr, + winnr = source_winnr, + cwd = function() + local ok, dir = pcall(function() + return vim.w[source_winnr].cchat_cwd + end) + if not ok or not dir or dir == '' then + return '.' + end + return dir + end, + } + + return true + end + + return false +end + return Chat From 5d46a69645a78f68475f1fcb49b2902bc8066efe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Oct 2025 05:49:37 +0000 Subject: [PATCH 177/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 168f7a56..3c131191 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 21 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 23 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -484,10 +484,6 @@ CORE *CopilotChat-core* chat.reset() -- Reset the chat chat.stop() -- Stop current output - -- Source Management - chat.get_source() -- Get the current source buffer and window - chat.set_source(winnr) -- Set the source window - -- Prompt & Model Management chat.select_prompt(config) -- Open prompt selector with optional config chat.select_model() -- Open model selector @@ -518,7 +514,6 @@ You can also access the chat window UI methods through the `chat.chat` object: 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 window:append(text) -- Append text to chat window @@ -526,6 +521,10 @@ You can also access the chat window UI methods through the `chat.chat` object: window:start() -- Start writing to chat window window:finish() -- Finish writing to chat window + -- Source Management + window.get_source() -- Get the current source buffer and window + window.set_source(winnr) -- Set the source window + -- Navigation window:follow() -- Move cursor to end of chat content window:focus() -- Focus the chat window From cb8fb0f888c5352bc96a2f0320e60bfb4ba478d8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 23 Oct 2025 08:04:36 +0200 Subject: [PATCH 178/250] refactor(chat)!: move prompt resolution helpers to prompts module (#1470) Moves the prompt resolution functions (`resolve_prompt`, `resolve_tools`, `resolve_model`, and `resolve_functions`) from the main CopilotChat module to the `CopilotChat.prompts` module. Updates all references and documentation to use the new location. This improves code organization by separating prompt parsing logic from the main chat interface. BREAKING CHANGE: The prompt resolution functions have been moved to `require('CopilotChat.prompts')` --- README.md | 13 +++-- lua/CopilotChat/config/mappings.lua | 78 ++++++++++++++++++----------- lua/CopilotChat/init.lua | 43 ++-------------- 3 files changed, 62 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 0daa5d60..b2d80dc0 100644 --- a/README.md +++ b/README.md @@ -397,9 +397,6 @@ local chat = require("CopilotChat") -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config chat.response() -- Get the last response text -chat.resolve_prompt() -- Resolve prompt references -chat.resolve_tools() -- Resolve tools that are available for automatic use by LLM -chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management chat.open(config) -- Open chat window with optional config @@ -456,6 +453,16 @@ window:focus() -- Focus the chat window window:overlay(opts) -- Show overlay with specified options ``` +## Prompt parser + +```lua +local parser = require("CopilotChat.prompts") + +parser.resolve_prompt() -- Resolve prompt references +parser.resolve_tools() -- Resolve tools that are available for automatic use by LLM +parser.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) +``` + ## Example Usage ```lua diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index c286bfee..fb4b6388 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -1,10 +1,8 @@ local async = require('plenary.async') -local copilot = require('CopilotChat') local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') -local diff = require('CopilotChat.utils.diff') local files = require('CopilotChat.utils.files') --- Prepare a buffer for applying a diff @@ -77,7 +75,7 @@ return { normal = 'q', insert = '', callback = function() - copilot.close() + require('CopilotChat').close() end, }, @@ -85,7 +83,7 @@ return { normal = '', insert = '', callback = function() - copilot.reset() + require('CopilotChat').reset() end, }, @@ -93,6 +91,7 @@ return { normal = '', insert = '', callback = function() + local copilot = require('CopilotChat') local message = copilot.chat:get_message(constants.ROLE.USER, true) if not message then return @@ -106,7 +105,10 @@ return { normal = '', insert = '', callback = function(source) - local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + local chat = require('CopilotChat').chat + local diff = require('CopilotChat.utils.diff') + + local block = chat:get_block(constants.ROLE.ASSISTANT, true) if not block then return end @@ -127,7 +129,10 @@ return { jump_to_diff = { normal = 'gj', callback = function(source) - local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + local chat = require('CopilotChat').chat + local diff = require('CopilotChat.utils.diff') + + local block = chat:get_block(constants.ROLE.ASSISTANT, true) if not block then return end @@ -147,19 +152,24 @@ return { normal = 'gy', register = '"', -- Default register to use for yanking callback = function() - local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + local config = require('CopilotChat.config') + local chat = require('CopilotChat').chat + local block = chat:get_block(constants.ROLE.ASSISTANT, true) if not block then return end - vim.fn.setreg(copilot.config.mappings.yank_diff.register, block.content) + vim.fn.setreg(config.mappings.yank_diff.register, block.content) end, }, show_diff = { normal = 'gd', callback = function(source) - local block = copilot.chat:get_block(constants.ROLE.ASSISTANT, true) + local chat = require('CopilotChat').chat + local diff = require('CopilotChat.utils.diff') + + local block = chat:get_block(constants.ROLE.ASSISTANT, true) if not block then return end @@ -168,7 +178,7 @@ return { local bufnr = prepare_diff_buffer(path, source) -- Collect all blocks for the same filename - local message = copilot.chat:get_message(constants.ROLE.ASSISTANT, true) + local message = chat:get_message(constants.ROLE.ASSISTANT, true) local blocks = {} if message and message.section and message.section.blocks then for _, b in ipairs(message.section.blocks) do @@ -196,26 +206,27 @@ return { vim.cmd('diffthis') end) - vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.api.nvim_win_call(chat.winnr, function() vim.cmd('diffthis') end) end opts.on_hide = function() - vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.api.nvim_win_call(chat.winnr, function() vim.cmd('diffoff') end) end - copilot.chat:overlay(opts) + chat:overlay(opts) end, }, quickfix_diffs = { normal = 'gqd', callback = function() + local chat = require('CopilotChat').chat local items = {} - local messages = copilot.chat:get_messages() + local messages = chat:get_messages() for _, message in ipairs(messages) do if message.section then for _, block in ipairs(message.section.blocks) do @@ -225,7 +236,7 @@ return { end table.insert(items, { - bufnr = copilot.chat.bufnr, + bufnr = chat.bufnr, lnum = block.start_line, end_lnum = block.end_line, text = text, @@ -242,8 +253,9 @@ return { quickfix_answers = { normal = 'gqa', callback = function() + local chat = require('CopilotChat').chat local items = {} - local messages = copilot.chat:get_messages() + local messages = chat:get_messages() for i, message in ipairs(messages) do if message.section and message.role == constants.ROLE.ASSISTANT then local prev_message = messages[i - 1] @@ -253,7 +265,7 @@ return { end table.insert(items, { - bufnr = copilot.chat.bufnr, + bufnr = chat.bufnr, lnum = message.section.start_line, end_lnum = message.section.end_line, text = text, @@ -269,7 +281,10 @@ return { show_info = { normal = 'gc', callback = function(source) - local message = copilot.chat:get_message(constants.ROLE.USER, true) + local chat = require('CopilotChat').chat + local prompts = require('CopilotChat.prompts') + + local message = chat:get_message(constants.ROLE.USER, true) if not message then return end @@ -277,11 +292,11 @@ return { local lines = {} async.run(function() - local config, prompt = copilot.resolve_prompt(message.content) + local config, prompt = prompts.resolve_prompt(message.content) local system_prompt = config.system_prompt - local resolved_resources = copilot.resolve_functions(prompt, config) - local selected_tools = copilot.resolve_tools(prompt, config) - local selected_model = copilot.resolve_model(prompt, config) + local resolved_resources = prompts.resolve_functions(prompt, config) + local selected_tools = prompts.resolve_tools(prompt, config) + local selected_model = prompts.resolve_model(prompt, config) local infos = client:info() selected_tools = vim.tbl_map(function(tool) @@ -289,8 +304,8 @@ return { end, selected_tools) utils.schedule_main() - table.insert(lines, '**Logs**: `' .. copilot.config.log_path .. '`') - table.insert(lines, '**History**: `' .. copilot.config.history_path .. '`') + table.insert(lines, '**Logs**: `' .. config.log_path .. '`') + table.insert(lines, '**History**: `' .. config.history_path .. '`') table.insert(lines, '') for provider, infolines in pairs(infos) do @@ -368,7 +383,7 @@ return { table.insert(lines, '') end - copilot.chat:overlay({ + chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) end) @@ -378,6 +393,9 @@ return { show_help = { normal = 'gh', callback = function() + local config = require('CopilotChat.config') + local chat = require('CopilotChat').chat + local chat_help = '**`Special tokens`**\n' chat_help = chat_help .. '`@` to share function\n' chat_help = chat_help .. '`#` to add resource\n' @@ -387,22 +405,22 @@ return { chat_help = chat_help .. '`> ` to make a sticky prompt (copied to next prompt)\n' chat_help = chat_help .. '\n**`Mappings`**\n' - local chat_keys = vim.tbl_keys(copilot.config.mappings) + local chat_keys = vim.tbl_keys(config.mappings) table.sort(chat_keys, function(a, b) - a = copilot.config.mappings[a] + a = config.mappings[a] a = a and (a.normal or a.insert) or '' - b = copilot.config.mappings[b] + b = config.mappings[b] b = b and (b.normal or b.insert) or '' return a < b end) for _, name in ipairs(chat_keys) do - local info = utils.key_to_info(name, copilot.config.mappings[name], '`') + local info = utils.key_to_info(name, config.mappings[name], '`') if info ~= '' then chat_help = chat_help .. info .. '\n' end end - copilot.chat:overlay({ + chat:overlay({ text = chat_help, }) end, diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 717e4801..64745e8c 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -230,41 +230,6 @@ local function update_source() M.chat:set_source(use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) or vim.api.nvim_get_current_win()) end ---- Resolve enabled tools from the prompt. ----@param prompt string? ----@param config CopilotChat.config.Shared? ----@return table, string -function M.resolve_tools(prompt, config) - return prompts.resolve_tools(prompt, config) -end - ---- Call and resolve function calls from the prompt. ----@param prompt string? ----@param config CopilotChat.config.Shared? ----@return table, table, table, string ----@async -function M.resolve_functions(prompt, config) - return prompts.resolve_functions(prompt, config) -end - ---- Resolve the final prompt and config from prompt template. ----@param prompt string? ----@param config CopilotChat.config.Shared? ----@return CopilotChat.config.prompts.Prompt, string ----@async -function M.resolve_prompt(prompt, config) - return prompts.resolve_prompt(prompt, config) -end - ---- Resolve the model from the prompt. ----@param prompt string? ----@param config CopilotChat.config.Shared? ----@return string, string ----@async -function M.resolve_model(prompt, config) - return prompts.resolve_model(prompt, config) -end - --- Open the chat window. ---@param config CopilotChat.config.Shared? function M.open(config) @@ -449,11 +414,11 @@ function M.ask(prompt, config) end async.run(handle_error(config, function() - config, prompt = M.resolve_prompt(prompt, config) + config, prompt = prompts.resolve_prompt(prompt, config) local system_prompt = config.system_prompt or '' - local selected_tools, prompt = M.resolve_tools(prompt, config) - local resolved_resources, resolved_tools, resolved_stickies, prompt = M.resolve_functions(prompt, config) - local selected_model, prompt = M.resolve_model(prompt, config) + local selected_tools, prompt = prompts.resolve_tools(prompt, config) + local resolved_resources, resolved_tools, resolved_stickies, prompt = prompts.resolve_functions(prompt, config) + local selected_model, prompt = prompts.resolve_model(prompt, config) -- Remove sticky prefix prompt = table.concat( From bd8b48e8851c8796c5ca7d0e55875a6ed77d9bd7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Oct 2025 06:05:02 +0000 Subject: [PATCH 179/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 3c131191..2da651a9 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -27,6 +27,7 @@ Table of Contents *CopilotChat-table-of-contents* 5. API Reference |CopilotChat-api-reference| - Core |CopilotChat-core| - Chat Window |CopilotChat-chat-window| + - Prompt parser |CopilotChat-prompt-parser| - Example Usage |CopilotChat-example-usage| 6. Development |CopilotChat-development| - Setup |CopilotChat-setup| @@ -473,9 +474,6 @@ CORE *CopilotChat-core* -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config chat.response() -- Get the last response text - chat.resolve_prompt() -- Resolve prompt references - chat.resolve_tools() -- Resolve tools that are available for automatic use by LLM - chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management chat.open(config) -- Open chat window with optional config @@ -534,6 +532,17 @@ You can also access the chat window UI methods through the `chat.chat` object: < +PROMPT PARSER *CopilotChat-prompt-parser* + +>lua + local parser = require("CopilotChat.prompts") + + parser.resolve_prompt() -- Resolve prompt references + parser.resolve_tools() -- Resolve tools that are available for automatic use by LLM + parser.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) +< + + EXAMPLE USAGE *CopilotChat-example-usage* >lua From 94dfc019f86659d3aeee54d5f1999f4c93a35aa6 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 23 Oct 2025 10:40:38 +0200 Subject: [PATCH 180/250] fix(chat): reinsert stickies before prompt processing (#1472) This change ensures that sticky messages are properly reinserted before processing the prompt, preserving their context in the chat flow. The previous logic for removing sticky prefixes was removed, and stickies are now stored correctly after resolution. Closes #1471 Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 64745e8c..212bcb55 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -404,6 +404,7 @@ function M.ask(prompt, config) -- Resolve prompt after window is opened prompt = process_sticky(prompt, config) prompt = vim.trim(prompt) + prompt = table.concat(M.chat:get_sticky(), '\n') .. '\n\n' .. prompt -- After opening window we need to schedule to next cycle so everything properly resolves schedule(function() @@ -420,15 +421,7 @@ function M.ask(prompt, config) local resolved_resources, resolved_tools, resolved_stickies, prompt = prompts.resolve_functions(prompt, config) local selected_model, prompt = prompts.resolve_model(prompt, config) - -- Remove sticky prefix - prompt = table.concat( - vim.tbl_map(function(l) - return l:gsub('^>%s+', '') - end, vim.split(prompt, '\n')), - '\n' - ) - - -- Add resolved stickies to chat + -- Store resolved stickies to chat local current_sticky = M.chat:get_sticky() for _, sticky in ipairs(resolved_stickies) do table.insert(current_sticky, sticky) From 746a6971d15eec41a4e22bfdb88cd9f0f135442d Mon Sep 17 00:00:00 2001 From: Mihamina Rakotomandimby Date: Fri, 24 Oct 2025 19:23:03 +0300 Subject: [PATCH 181/250] feat(provider): Support OpenAI Responses API (#1463) Closes #1442 --- lua/CopilotChat/config/providers.lua | 274 ++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 5d0126e6..5b4e95f9 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -196,6 +196,36 @@ local function get_github_models_token(tag) return github_device_flow(tag, '178c6fc778ccc68e1d6a', 'read:user copilot') end +--- Helper function to extract text content from Responses API output parts +---@param parts table Array of content parts from Responses API +---@return string The concatenated text content +local function extract_text_from_parts(parts) + local content = '' + if not parts or type(parts) ~= 'table' then + return content + end + + for _, part in ipairs(parts) do + if type(part) == 'table' then + -- Handle different content types from Responses API + if part.type == 'output_text' or part.type == 'text' then + content = content .. (part.text or '') + elseif part.output_text then + -- Handle nested output_text + if type(part.output_text) == 'string' then + content = content .. part.output_text + elseif type(part.output_text) == 'table' and part.output_text.text then + content = content .. part.output_text.text + end + end + elseif type(part) == 'string' then + content = content .. part + end + end + + return content +end + ---@class CopilotChat.config.providers.Options ---@field model CopilotChat.client.Model ---@field temperature number? @@ -308,6 +338,10 @@ M.copilot = { return model.capabilities.type == 'chat' and model.model_picker_enabled end) :map(function(model) + local supported_endpoints = model.supported_endpoints or {} + -- Pre-compute whether this model uses the Responses API + local use_responses = vim.tbl_contains(supported_endpoints, '/responses') + return { id = model.id, name = model.name, @@ -318,6 +352,7 @@ M.copilot = { tools = model.capabilities.supports.tool_calls, policy = not model['policy'] or model['policy']['state'] == 'enabled', version = model.version, + use_responses = use_responses, } end) :totable() @@ -347,6 +382,63 @@ M.copilot = { prepare_input = function(inputs, opts) local is_o1 = vim.startswith(opts.model.id, 'o1') + -- Check if this model uses the Responses API + if opts.model.use_responses then + -- Prepare input for Responses API + local instructions = nil + local input_messages = {} + + for _, msg in ipairs(inputs) do + if msg.role == constants.ROLE.SYSTEM then + -- Combine system messages as instructions + if instructions then + instructions = instructions .. '\n\n' .. msg.content + else + instructions = msg.content + end + else + -- Include the message in the input array + table.insert(input_messages, { + role = msg.role, + content = msg.content, + }) + end + end + + -- The Responses API expects the input field to be an array of message objects + local out = { + model = opts.model.id, + -- Always request streaming for Responses API (honor model.streaming or default to true) + stream = opts.model.streaming ~= false, + input = input_messages, + } + + -- Add instructions if we have any system messages + if instructions then + out.instructions = instructions + end + + -- Add tools for Responses API if available + if opts.tools and opts.model.tools then + out.tools = vim.tbl_map(function(tool) + return { + type = 'function', + ['function'] = { + name = tool.name, + description = tool.description, + parameters = tool.schema, + strict = true, + }, + } + end, opts.tools) + end + + -- Note: temperature is not supported by Responses API, so we don't include it + + return out + end + + -- Original Chat Completion API logic inputs = vim.tbl_map(function(input) local output = { role = input.role, @@ -411,7 +503,179 @@ M.copilot = { return out end, - prepare_output = function(output) + prepare_output = function(output, opts) + -- Check if this model uses the Responses API + if opts and opts.model and opts.model.use_responses then + -- Handle Responses API output format + local content = '' + local reasoning = '' + local finish_reason = nil + local total_tokens = 0 + local tool_calls = {} + + -- Check for error in response + if output.error then + -- Surface the error as a finish reason to stop processing + local error_msg = output.error + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + return { + content = '', + reasoning = '', + finish_reason = 'error: ' .. tostring(error_msg), + total_tokens = nil, + tool_calls = {}, + } + end + + if output.type then + -- This is a streaming response from Responses API + if output.type == 'response.created' or output.type == 'response.in_progress' then + -- In-progress events, we don't have content yet + return { + content = '', + reasoning = '', + finish_reason = nil, + total_tokens = nil, + tool_calls = {}, + } + elseif output.type == 'response.completed' then + -- Completed response: do NOT resend content here to avoid duplication. + -- Only signal finish and capture usage/reasoning. + local response = output.response + if response then + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary + end + if response.usage then + total_tokens = response.usage.total_tokens + end + finish_reason = 'stop' + end + return { + content = '', + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = total_tokens, + tool_calls = {}, + } + elseif output.type == 'response.content.delta' or output.type == 'response.output_text.delta' then + -- Streaming content delta + if output.delta then + if type(output.delta) == 'string' then + content = output.delta + elseif type(output.delta) == 'table' then + if output.delta.content then + content = output.delta.content + elseif output.delta.output_text then + content = extract_text_from_parts({ output.delta.output_text }) + elseif output.delta.text then + content = output.delta.text + end + end + end + elseif output.type == 'response.delta' then + -- Handle response.delta with nested output_text + if output.delta and output.delta.output_text then + content = extract_text_from_parts({ output.delta.output_text }) + end + elseif output.type == 'response.content.done' or output.type == 'response.output_text.done' then + -- Terminal content event; keep streaming open until response.completed provides usage info + finish_reason = nil + elseif output.type == 'response.error' then + -- Handle error event + local error_msg = output.error + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + finish_reason = 'error: ' .. tostring(error_msg) + elseif output.type == 'response.tool_call.delta' then + -- Handle tool call delta events + if output.delta and output.delta.tool_calls then + for _, tool_call in ipairs(output.delta.tool_calls) do + local id = tool_call.id or ('tooluse_' .. (tool_call.index or 1)) + local existing_call = nil + for _, tc in ipairs(tool_calls) do + if tc.id == id then + existing_call = tc + break + end + end + if not existing_call then + table.insert(tool_calls, { + id = id, + index = tool_call.index or #tool_calls + 1, + name = tool_call.name or '', + arguments = tool_call.arguments or '', + }) + else + -- Append arguments + existing_call.arguments = existing_call.arguments .. (tool_call.arguments or '') + end + end + end + end + elseif output.response then + -- Non-streaming response or final response + local response = output.response + + -- Check for error in the response object + if response.error then + local error_msg = response.error + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + return { + content = '', + reasoning = '', + finish_reason = 'error: ' .. tostring(error_msg), + total_tokens = nil, + tool_calls = {}, + } + end + + if response.output and #response.output > 0 then + for _, msg in ipairs(response.output) do + if msg.content and #msg.content > 0 then + content = content .. extract_text_from_parts(msg.content) + end + -- Extract tool calls from output messages + if msg.tool_calls then + for i, tool_call in ipairs(msg.tool_calls) do + local id = tool_call.id or ('tooluse_' .. i) + table.insert(tool_calls, { + id = id, + index = tool_call.index or i, + name = tool_call.name or '', + arguments = tool_call.arguments or '', + }) + end + end + end + end + + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary + end + + if response.usage then + total_tokens = response.usage.total_tokens + end + + finish_reason = response.status == 'completed' and 'stop' or nil + end + + return { + content = content, + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = total_tokens, + tool_calls = tool_calls, + } + end + + -- Original Chat Completion API logic local tool_calls = {} local choice @@ -458,7 +722,13 @@ M.copilot = { } end, - get_url = function() + get_url = function(opts) + -- Check if this model uses the Responses API + if opts and opts.model and opts.model.use_responses then + return 'https://api.githubcopilot.com/responses' + end + + -- Default to Chat Completion API return 'https://api.githubcopilot.com/chat/completions' end, } From a7138a0ee04d8af42c262554eccee168bbf1454f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Oct 2025 16:23:26 +0000 Subject: [PATCH 182/250] 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 2da651a9..729d2e4b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 23 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 24 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 2ef8c894d74b85bf9d7207369f721064aeb9fb40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20W=C3=B6lfel?= Date: Sun, 2 Nov 2025 12:31:38 +0100 Subject: [PATCH 183/250] fix(functions): insert explicit selected buffers (#1477) --- lua/CopilotChat/config/functions.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 92226594..f9063047 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -150,7 +150,7 @@ return { local name = vim.api.nvim_buf_get_name(buf) if name and name ~= '' then local display_name = vim.fn.fnamemodify(name, ':~:.') - table.insert(opts, { display = display_name, value = name }) + table.insert(opts, { display = display_name, value = tostring(buf) }) end end end From 4fd6dbae83220de920eb366fd05d72db0194ea76 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 2 Nov 2025 11:31:57 +0000 Subject: [PATCH 184/250] 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 729d2e4b..0c3004d2 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 October 24 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 02 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 3d16702149d61d0c0b84a72756e6d2a65f3f30a8 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 12:32:54 +0100 Subject: [PATCH 185/250] docs: add towoe as a contributor for code (#1478) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index f658f7f4..8bbee71d 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -466,6 +466,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/49014608?v=4", "profile": "https://github.com/ctchen222", "contributions": ["code"] + }, + { + "login": "towoe", + "name": "Tobias Wölfel", + "avatar_url": "https://avatars.githubusercontent.com/u/8666134?v=4", + "profile": "https://github.com/towoe", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index b2d80dc0..0732a5d9 100644 --- a/README.md +++ b/README.md @@ -625,6 +625,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Samiul Islam
Samiul Islam

💻 Rui Costa
Rui Costa

💻 CTCHEN
CTCHEN

💻 + Tobias Wölfel
Tobias Wölfel

💻 From a1a7bf6ac7d4f0792174a0072b80adb5d361e086 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:13:45 +0100 Subject: [PATCH 186/250] [pre-commit.ci] pre-commit autoupdate (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/JohnnyMorganz/StyLua: v2.3.0 → v2.3.1](https://github.com/JohnnyMorganz/StyLua/compare/v2.3.0...v2.3.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7602f97a..ac8e688c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,6 @@ repos: hooks: - id: prettier - repo: https://github.com/JohnnyMorganz/StyLua - rev: v2.3.0 + rev: v2.3.1 hooks: - id: stylua-github From db56110b19c702053e86d54b48d8d570c59d0b6b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Nov 2025 20:14:10 +0000 Subject: [PATCH 187/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 0c3004d2..cf023055 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 02 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 03 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -627,7 +627,7 @@ Apache License 2.0. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 8769d22bde355f7160f3649c3ca66e195b6fcddc Mon Sep 17 00:00:00 2001 From: Alexander Garcia Date: Sun, 9 Nov 2025 16:28:05 -0600 Subject: [PATCH 188/250] fix(chat): insert stickies passed to `open()` (#1476) Ensure that `stickies` that are passed as arguments to `open()` are merged with the static config and persisted in the chat window Fixes #1475 --- lua/CopilotChat/init.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 212bcb55..9125bb4a 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -242,10 +242,18 @@ function M.open(config) local message = M.chat:get_message(constants.ROLE.USER) if message then local clean_prompt = process_sticky(message.content, config) + local stickies = M.chat:get_sticky() + local content = '' + if not vim.tbl_isempty(stickies) then + content = '\n> ' .. table.concat(stickies, '\n> ') .. '\n\n' + end if clean_prompt and clean_prompt ~= '' then + content = content .. clean_prompt + end + if content ~= '' then M.chat:add_message({ role = constants.ROLE.USER, - content = '\n> ' .. table.concat(M.chat:get_sticky(), '\n> ') .. '\n\n' .. clean_prompt, + content = content, }, true) end end From e58b67720aa4eca9d6679f361dc9b40be892582b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Nov 2025 22:28:25 +0000 Subject: [PATCH 189/250] 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 cf023055..539986b7 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 03 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From ce485330c76a5b63ccfb02b7dd18890a748ca558 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 9 Nov 2025 23:29:00 +0100 Subject: [PATCH 190/250] docs: add garcia5 as a contributor for code (#1484) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 8bbee71d..7d438502 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -473,6 +473,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/8666134?v=4", "profile": "https://github.com/towoe", "contributions": ["code"] + }, + { + "login": "garcia5", + "name": "Alexander Garcia", + "avatar_url": "https://avatars.githubusercontent.com/u/21695295?v=4", + "profile": "https://github.com/garcia5", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 0732a5d9..ffdbb60b 100644 --- a/README.md +++ b/README.md @@ -626,6 +626,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Rui Costa
Rui Costa

💻 CTCHEN
CTCHEN

💻 Tobias Wölfel
Tobias Wölfel

💻 + Alexander Garcia
Alexander Garcia

💻 From d34aa9ed142fc9702a8f375ef2df5aaadb4fce97 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 19 Nov 2025 08:44:52 +0100 Subject: [PATCH 191/250] refactor(diff): remove diff-match-patch dependency (#1491) Remove the bundled diff-match-patch Lua port and replace its usage in the diff utility with a simpler, line-based matching and replacement algorithm. This reduces code complexity and removes a large vendored file. The new approach uses context-aware line matching to apply hunks, which should be sufficient for the plugin's needs. Closes #1490 Signed-off-by: Tomas Slusny --- README.md | 8 - lua/CopilotChat/utils/diff.lua | 127 +- lua/CopilotChat/vendor/diff_match_patch.lua | 2085 ------------------- tests/diff_spec.lua | 75 + 4 files changed, 156 insertions(+), 2139 deletions(-) delete mode 100644 lua/CopilotChat/vendor/diff_match_patch.lua diff --git a/README.md b/README.md index ffdbb60b..89607cbc 100644 --- a/README.md +++ b/README.md @@ -523,14 +523,6 @@ make test See [CONTRIBUTING.md](/CONTRIBUTING.md) for detailed guidelines. -# Acknowledgments - -## diff-match-patch - -CopilotChat.nvim includes [diff-match-patch (Lua port)](https://github.com/google/diff-match-patch) for diffing and patching functionality. -Copyright 2018 The diff-match-patch Authors. -Licensed under the Apache License 2.0. - # Contributors Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 17eb3091..51cf1887 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -40,55 +40,81 @@ local function parse_hunks(diff_text) return hunks end ---- Apply a single hunk to content, with fallback/context logic +--- Try to match old_snippet in lines starting at approximate start_line +---@param lines table +---@param old_snippet table +---@param approx_start number +---@param search_range number +---@return number? matched_start +local function find_best_match(lines, old_snippet, approx_start, search_range) + local best_idx, best_score = nil, -1 + local old_len = #old_snippet + + if old_len == 0 then + return approx_start + end + + local min_start = math.max(1, approx_start - search_range) + local max_start = math.min(#lines - old_len + 1, approx_start + search_range) + + for start_idx = min_start, max_start do + local score = 0 + for i = 1, old_len do + if vim.trim(lines[start_idx + i - 1] or '') == vim.trim(old_snippet[i] or '') then + score = score + 1 + end + end + + if score > best_score then + best_score = score + best_idx = start_idx + end + + if score == old_len then + return best_idx + end + end + + if best_score >= math.ceil(old_len * 0.8) then + return best_idx + end + + return nil +end + +--- Apply a single hunk to content ---@param hunk table ---@param content string ---@return string patched_content, boolean applied_cleanly local function apply_hunk(hunk, content) - local dmp = require('CopilotChat.vendor.diff_match_patch') - local patch = dmp.patch_make(table.concat(hunk.old_snippet, '\n'), table.concat(hunk.new_snippet, '\n')) - - -- First try: direct application - local patched, results = dmp.patch_apply(patch, content) - if not vim.tbl_contains(results, false) then - return patched, true - end - - -- Fallback: direct replacement local lines = vim.split(content, '\n') - local insert_idx = hunk.start_old or 1 - if not hunk.start_old then - -- No starting point, try to find best match - local match_idx, best_score = nil, -1 - local context_lines = vim.tbl_filter(function(line) - return line and line ~= '' - end, hunk.old_snippet) - local context_len = #context_lines - if context_len > 0 then - for i = 1, #lines - context_len + 1 do - local score = 0 - for j = 1, context_len do - if vim.trim(lines[i + j - 1] or '') == vim.trim(context_lines[j] or '') then - score = score + 1 - end - end - if score > best_score then - best_score = score - match_idx = i - end - end + local start_idx = hunk.start_old + + -- If we have a start line hint, try to find best match within +/- 2 lines + if start_idx and start_idx > 0 and start_idx <= #lines then + local match_idx = find_best_match(lines, hunk.old_snippet, start_idx, 2) + if match_idx then + start_idx = match_idx end - if best_score > 0 and match_idx then - insert_idx = match_idx + else + -- No valid start line, search for best match in whole content + local match_idx = find_best_match(lines, hunk.old_snippet, 1, #lines) + if match_idx then + start_idx = match_idx + else + start_idx = 1 end end - local start_idx = insert_idx - local end_idx = insert_idx + #hunk.old_snippet + -- Replace old lines with new lines + local end_idx = start_idx + #hunk.old_snippet - 1 local new_lines = vim.list_slice(lines, 1, start_idx - 1) vim.list_extend(new_lines, hunk.new_snippet) vim.list_extend(new_lines, lines, end_idx + 1, #lines) - return table.concat(new_lines, '\n'), false + + -- Check if we matched exactly at the hinted position + local applied_cleanly = find_best_match(lines, hunk.old_snippet, hunk.start_old or start_idx, 0) == start_idx + return table.concat(new_lines, '\n'), applied_cleanly end --- Apply unified diff to a table of lines and return new lines @@ -104,16 +130,25 @@ function M.apply_unified_diff(diff_text, original_content) new_content = patched applied = applied or ok end - local original_lines = vim.split(original_content, '\n', { trimempty = true }) + local new_lines = vim.split(new_content, '\n', { trimempty = true }) + local hunks = vim.diff( + original_content, + new_content, + { algorithm = 'myers', ctxlen = 10, interhunkctxlen = 10, ignore_whitespace_change = true, result_type = 'indices' } + ) + if not hunks or #hunks == 0 then + return new_lines, applied, nil, nil + end local first, last - local max_len = math.max(#original_lines, #new_lines) - for i = 1, max_len do - if original_lines[i] ~= new_lines[i] then - if not first then - first = i - end - last = i + for _, hunk in ipairs(hunks) do + local hunk_start = hunk[1] + local hunk_end = hunk[1] + hunk[2] - 1 + if not first or hunk_start < first then + first = hunk_start + end + if not last or hunk_end > last then + last = hunk_end end end return new_lines, applied, first, last @@ -129,7 +164,7 @@ function M.get_diff(block, lines) return block.content, content end - local patched_lines = vim.split(block.content, '\n') + local patched_lines = vim.split(block.content, '\n', { trimempty = true }) local start_idx = block.header.start_line local end_idx = block.header.end_line local original_lines = lines diff --git a/lua/CopilotChat/vendor/diff_match_patch.lua b/lua/CopilotChat/vendor/diff_match_patch.lua deleted file mode 100644 index b2c397d0..00000000 --- a/lua/CopilotChat/vendor/diff_match_patch.lua +++ /dev/null @@ -1,2085 +0,0 @@ ---[[ -* Diff Match and Patch -* Copyright 2018 The diff-match-patch Authors. -* https://github.com/google/diff-match-patch -* -* Based on the JavaScript implementation by Neil Fraser. -* Ported to Lua by Duncan Cross. -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. ---]] - -local bit = require('bit') -local band, bor, lshift = bit.band, bit.bor, bit.lshift -local type, setmetatable, ipairs, select = type, setmetatable, ipairs, select -local unpack, tonumber, error = unpack, tonumber, error -local strsub, strbyte, strchar, gmatch, gsub = string.sub, string.byte, string.char, string.gmatch, string.gsub -local strmatch, strfind, strformat = string.match, string.find, string.format -local tinsert, tremove, tconcat = table.insert, table.remove, table.concat -local max, min, floor, ceil, abs = math.max, math.min, math.floor, math.ceil, math.abs -local clock = os.clock - --- Utility functions. - -local percentEncode_pattern = "[^A-Za-z0-9%-=;',./~!@#$%&*%(%)_%+ %?]" -local function percentEncode_replace(v) - return strformat('%%%02X', strbyte(v)) -end - -local function indexOf(a, b, start) - if #b == 0 then - return nil - end - return strfind(a, b, start, true) -end - -local htmlEncode_pattern = '[&<>\n]' -local htmlEncode_replace = { - ['&'] = '&', - ['<'] = '<', - ['>'] = '>', - ['\n'] = '¶
', -} - --- Public API Functions --- (Exported at the end of the script) - -local diff_main, diff_cleanupSemantic, diff_cleanupEfficiency, diff_levenshtein, diff_prettyHtml - -local match_main - -local patch_make, patch_toText, patch_fromText, patch_apply - ---[[ -* The data structure representing a diff is an array of tuples: -* {{DIFF_DELETE, 'Hello'}, {DIFF_INSERT, 'Goodbye'}, {DIFF_EQUAL, ' world.'}} -* which means: delete 'Hello', add 'Goodbye' and keep ' world.' ---]] -local DIFF_DELETE = -1 -local DIFF_INSERT = 1 -local DIFF_EQUAL = 0 - --- Number of seconds to map a diff before giving up (0 for infinity). -local Diff_Timeout = 1.0 --- Cost of an empty edit operation in terms of edit characters. -local Diff_EditCost = 4 --- At what point is no match declared (0.0 = perfection, 1.0 = very loose). -local Match_Threshold = 0.5 --- How far to search for a match (0 = exact location, 1000+ = broad match). --- A match this many characters away from the expected location will add --- 1.0 to the score (0.0 is a perfect match). -local Match_Distance = 1000 --- When deleting a large block of text (over ~64 characters), how close do --- the contents have to be to match the expected contents. (0.0 = perfection, --- 1.0 = very loose). Note that Match_Threshold controls how closely the --- end points of a delete need to match. -local Patch_DeleteThreshold = 0.5 --- Chunk size for context length. -local Patch_Margin = 4 --- The number of bits in an int. -local Match_MaxBits = 32 - -function settings(new) - if new then - Diff_Timeout = new.Diff_Timeout or Diff_Timeout - Diff_EditCost = new.Diff_EditCost or Diff_EditCost - Match_Threshold = new.Match_Threshold or Match_Threshold - Match_Distance = new.Match_Distance or Match_Distance - Patch_DeleteThreshold = new.Patch_DeleteThreshold or Patch_DeleteThreshold - Patch_Margin = new.Patch_Margin or Patch_Margin - Match_MaxBits = new.Match_MaxBits or Match_MaxBits - else - return { - Diff_Timeout = Diff_Timeout, - Diff_EditCost = Diff_EditCost, - Match_Threshold = Match_Threshold, - Match_Distance = Match_Distance, - Patch_DeleteThreshold = Patch_DeleteThreshold, - Patch_Margin = Patch_Margin, - Match_MaxBits = Match_MaxBits, - } - end -end - --- --------------------------------------------------------------------------- --- DIFF API --- --------------------------------------------------------------------------- - --- The private diff functions -local _diff_compute, _diff_bisect, _diff_halfMatchI, _diff_halfMatch, _diff_cleanupSemanticScore, _diff_cleanupSemanticLossless, _diff_cleanupMerge, _diff_commonPrefix, _diff_commonSuffix, _diff_commonOverlap, _diff_xIndex, _diff_text1, _diff_text2, _diff_toDelta, _diff_fromDelta - ---[[ -* Find the differences between two texts. Simplifies the problem by stripping -* any common prefix or suffix off the texts before diffing. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {boolean} opt_checklines Has no effect in Lua. -* @param {number} opt_deadline Optional time when the diff should be complete -* by. Used internally for recursive calls. Users should set DiffTimeout -* instead. -* @return {Array.>} Array of diff tuples. ---]] -function diff_main(text1, text2, opt_checklines, opt_deadline) - -- Set a deadline by which time the diff must be complete. - if opt_deadline == nil then - if Diff_Timeout <= 0 then - opt_deadline = 2 ^ 31 - else - opt_deadline = clock() + Diff_Timeout - end - end - local deadline = opt_deadline - - -- Check for null inputs. - if text1 == nil or text1 == nil then - error('Null inputs. (diff_main)') - end - - -- Check for equality (speedup). - if text1 == text2 then - if #text1 > 0 then - return { { DIFF_EQUAL, text1 } } - end - return {} - end - - -- LUANOTE: Due to the lack of Unicode support, Lua is incapable of - -- implementing the line-mode speedup. - local checklines = false - - -- Trim off common prefix (speedup). - local commonlength = _diff_commonPrefix(text1, text2) - local commonprefix - if commonlength > 0 then - commonprefix = strsub(text1, 1, commonlength) - text1 = strsub(text1, commonlength + 1) - text2 = strsub(text2, commonlength + 1) - end - - -- Trim off common suffix (speedup). - commonlength = _diff_commonSuffix(text1, text2) - local commonsuffix - if commonlength > 0 then - commonsuffix = strsub(text1, -commonlength) - text1 = strsub(text1, 1, -commonlength - 1) - text2 = strsub(text2, 1, -commonlength - 1) - end - - -- Compute the diff on the middle block. - local diffs = _diff_compute(text1, text2, checklines, deadline) - - -- Restore the prefix and suffix. - if commonprefix then - tinsert(diffs, 1, { DIFF_EQUAL, commonprefix }) - end - if commonsuffix then - diffs[#diffs + 1] = { DIFF_EQUAL, commonsuffix } - end - - _diff_cleanupMerge(diffs) - return diffs -end - ---[[ -* Reduce the number of edits by eliminating semantically trivial equalities. -* @param {Array.>} diffs Array of diff tuples. ---]] -function diff_cleanupSemantic(diffs) - local changes = false - local equalities = {} -- Stack of indices where equalities are found. - local equalitiesLength = 0 -- Keeping our own length var is faster. - local lastEquality = nil - -- Always equal to diffs[equalities[equalitiesLength]][2] - local pointer = 1 -- Index of current position. - -- Number of characters that changed prior to the equality. - local length_insertions1 = 0 - local length_deletions1 = 0 - -- Number of characters that changed after the equality. - local length_insertions2 = 0 - local length_deletions2 = 0 - - while diffs[pointer] do - if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. - equalitiesLength = equalitiesLength + 1 - equalities[equalitiesLength] = pointer - length_insertions1 = length_insertions2 - length_deletions1 = length_deletions2 - length_insertions2 = 0 - length_deletions2 = 0 - lastEquality = diffs[pointer][2] - else -- An insertion or deletion. - if diffs[pointer][1] == DIFF_INSERT then - length_insertions2 = length_insertions2 + #diffs[pointer][2] - else - length_deletions2 = length_deletions2 + #diffs[pointer][2] - end - -- Eliminate an equality that is smaller or equal to the edits on both - -- sides of it. - if - lastEquality - and (#lastEquality <= max(length_insertions1, length_deletions1)) - and (#lastEquality <= max(length_insertions2, length_deletions2)) - then - -- Duplicate record. - tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) - -- Change second copy to insert. - diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT - -- Throw away the equality we just deleted. - equalitiesLength = equalitiesLength - 1 - -- Throw away the previous equality (it needs to be reevaluated). - equalitiesLength = equalitiesLength - 1 - pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 - length_insertions1, length_deletions1 = 0, 0 -- Reset the counters. - length_insertions2, length_deletions2 = 0, 0 - lastEquality = nil - changes = true - end - end - pointer = pointer + 1 - end - - -- Normalize the diff. - if changes then - _diff_cleanupMerge(diffs) - end - _diff_cleanupSemanticLossless(diffs) - - -- Find any overlaps between deletions and insertions. - -- e.g: abcxxxxxxdef - -- -> abcxxxdef - -- e.g: xxxabcdefxxx - -- -> defxxxabc - -- Only extract an overlap if it is as big as the edit ahead or behind it. - pointer = 2 - while diffs[pointer] do - if diffs[pointer - 1][1] == DIFF_DELETE and diffs[pointer][1] == DIFF_INSERT then - local deletion = diffs[pointer - 1][2] - local insertion = diffs[pointer][2] - local overlap_length1 = _diff_commonOverlap(deletion, insertion) - local overlap_length2 = _diff_commonOverlap(insertion, deletion) - if overlap_length1 >= overlap_length2 then - if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then - -- Overlap found. Insert an equality and trim the surrounding edits. - tinsert(diffs, pointer, { DIFF_EQUAL, strsub(insertion, 1, overlap_length1) }) - diffs[pointer - 1][2] = strsub(deletion, 1, #deletion - overlap_length1) - diffs[pointer + 1][2] = strsub(insertion, overlap_length1 + 1) - pointer = pointer + 1 - end - else - if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then - -- Reverse overlap found. - -- Insert an equality and swap and trim the surrounding edits. - tinsert(diffs, pointer, { DIFF_EQUAL, strsub(deletion, 1, overlap_length2) }) - diffs[pointer - 1] = { DIFF_INSERT, strsub(insertion, 1, #insertion - overlap_length2) } - diffs[pointer + 1] = { DIFF_DELETE, strsub(deletion, overlap_length2 + 1) } - pointer = pointer + 1 - end - end - pointer = pointer + 1 - end - pointer = pointer + 1 - end -end - ---[[ -* Reduce the number of edits by eliminating operationally trivial equalities. -* @param {Array.>} diffs Array of diff tuples. ---]] -function diff_cleanupEfficiency(diffs) - local changes = false - -- Stack of indices where equalities are found. - local equalities = {} - -- Keeping our own length var is faster. - local equalitiesLength = 0 - -- Always equal to diffs[equalities[equalitiesLength]][2] - local lastEquality = nil - -- Index of current position. - local pointer = 1 - - -- The following four are really booleans but are stored as numbers because - -- they are used at one point like this: - -- - -- (pre_ins + pre_del + post_ins + post_del) == 3 - -- - -- ...i.e. checking that 3 of them are true and 1 of them is false. - - -- Is there an insertion operation before the last equality. - local pre_ins = 0 - -- Is there a deletion operation before the last equality. - local pre_del = 0 - -- Is there an insertion operation after the last equality. - local post_ins = 0 - -- Is there a deletion operation after the last equality. - local post_del = 0 - - while diffs[pointer] do - if diffs[pointer][1] == DIFF_EQUAL then -- Equality found. - local diffText = diffs[pointer][2] - if (#diffText < Diff_EditCost) and (post_ins == 1 or post_del == 1) then - -- Candidate found. - equalitiesLength = equalitiesLength + 1 - equalities[equalitiesLength] = pointer - pre_ins, pre_del = post_ins, post_del - lastEquality = diffText - else - -- Not a candidate, and can never become one. - equalitiesLength = 0 - lastEquality = nil - end - post_ins, post_del = 0, 0 - else -- An insertion or deletion. - if diffs[pointer][1] == DIFF_DELETE then - post_del = 1 - else - post_ins = 1 - end - --[[ - * Five types to be split: - * ABXYCD - * AXCD - * ABXC - * AXCD - * ABXC - --]] - if - lastEquality - and ( - (pre_ins + pre_del + post_ins + post_del == 4) - or ((#lastEquality < Diff_EditCost / 2) and (pre_ins + pre_del + post_ins + post_del == 3)) - ) - then - -- Duplicate record. - tinsert(diffs, equalities[equalitiesLength], { DIFF_DELETE, lastEquality }) - -- Change second copy to insert. - diffs[equalities[equalitiesLength] + 1][1] = DIFF_INSERT - -- Throw away the equality we just deleted. - equalitiesLength = equalitiesLength - 1 - lastEquality = nil - if (pre_ins == 1) and (pre_del == 1) then - -- No changes made which could affect previous entry, keep going. - post_ins, post_del = 1, 1 - equalitiesLength = 0 - else - -- Throw away the previous equality. - equalitiesLength = equalitiesLength - 1 - pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0 - post_ins, post_del = 0, 0 - end - changes = true - end - end - pointer = pointer + 1 - end - - if changes then - _diff_cleanupMerge(diffs) - end -end - ---[[ -* Compute the Levenshtein distance; the number of inserted, deleted or -* substituted characters. -* @param {Array.>} diffs Array of diff tuples. -* @return {number} Number of changes. ---]] -function diff_levenshtein(diffs) - local levenshtein = 0 - local insertions, deletions = 0, 0 - for x, diff in ipairs(diffs) do - local op, data = diff[1], diff[2] - if op == DIFF_INSERT then - insertions = insertions + #data - elseif op == DIFF_DELETE then - deletions = deletions + #data - elseif op == DIFF_EQUAL then - -- A deletion and an insertion is one substitution. - levenshtein = levenshtein + max(insertions, deletions) - insertions = 0 - deletions = 0 - end - end - levenshtein = levenshtein + max(insertions, deletions) - return levenshtein -end - ---[[ -* Convert a diff array into a pretty HTML report. -* @param {Array.>} diffs Array of diff tuples. -* @return {string} HTML representation. ---]] -function diff_prettyHtml(diffs) - local html = {} - for x, diff in ipairs(diffs) do - local op = diff[1] -- Operation (insert, delete, equal) - local data = diff[2] -- Text of change. - local text = gsub(data, htmlEncode_pattern, htmlEncode_replace) - if op == DIFF_INSERT then - html[x] = '' .. text .. '' - elseif op == DIFF_DELETE then - html[x] = '' .. text .. '' - elseif op == DIFF_EQUAL then - html[x] = '' .. text .. '' - end - end - return tconcat(html) -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE DIFF FUNCTIONS --- --------------------------------------------------------------------------- - ---[[ -* Find the differences between two texts. Assumes that the texts do not -* have any common prefix or suffix. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {boolean} checklines Has no effect in Lua. -* @param {number} deadline Time when the diff should be complete by. -* @return {Array.>} Array of diff tuples. -* @private ---]] -function _diff_compute(text1, text2, checklines, deadline) - if #text1 == 0 then - -- Just add some text (speedup). - return { { DIFF_INSERT, text2 } } - end - - if #text2 == 0 then - -- Just delete some text (speedup). - return { { DIFF_DELETE, text1 } } - end - - local diffs - - local longtext = (#text1 > #text2) and text1 or text2 - local shorttext = (#text1 > #text2) and text2 or text1 - local i = indexOf(longtext, shorttext) - - if i ~= nil then - -- Shorter text is inside the longer text (speedup). - diffs = { - { DIFF_INSERT, strsub(longtext, 1, i - 1) }, - { DIFF_EQUAL, shorttext }, - { DIFF_INSERT, strsub(longtext, i + #shorttext) }, - } - -- Swap insertions for deletions if diff is reversed. - if #text1 > #text2 then - diffs[1][1], diffs[3][1] = DIFF_DELETE, DIFF_DELETE - end - return diffs - end - - if #shorttext == 1 then - -- Single character string. - -- After the previous speedup, the character can't be an equality. - return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } - end - - -- Check to see if the problem can be split in two. - do - local text1_a, text1_b, text2_a, text2_b, mid_common = _diff_halfMatch(text1, text2) - - if text1_a then - -- A half-match was found, sort out the return data. - -- Send both pairs off for separate processing. - local diffs_a = diff_main(text1_a, text2_a, checklines, deadline) - local diffs_b = diff_main(text1_b, text2_b, checklines, deadline) - -- Merge the results. - local diffs_a_len = #diffs_a - diffs = diffs_a - diffs[diffs_a_len + 1] = { DIFF_EQUAL, mid_common } - for i, b_diff in ipairs(diffs_b) do - diffs[diffs_a_len + 1 + i] = b_diff - end - return diffs - end - end - - return _diff_bisect(text1, text2, deadline) -end - ---[[ -* Find the 'middle snake' of a diff, split the problem in two -* and return the recursively constructed diff. -* See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. -* @param {string} text1 Old string to be diffed. -* @param {string} text2 New string to be diffed. -* @param {number} deadline Time at which to bail if not yet complete. -* @return {Array.>} Array of diff tuples. -* @private ---]] -function _diff_bisect(text1, text2, deadline) - -- Cache the text lengths to prevent multiple calls. - local text1_length = #text1 - local text2_length = #text2 - local _sub, _element - local max_d = ceil((text1_length + text2_length) / 2) - local v_offset = max_d - local v_length = 2 * max_d - local v1 = {} - local v2 = {} - -- Setting all elements to -1 is faster in Lua than mixing integers and nil. - for x = 0, v_length - 1 do - v1[x] = -1 - v2[x] = -1 - end - v1[v_offset + 1] = 0 - v2[v_offset + 1] = 0 - local delta = text1_length - text2_length - -- If the total number of characters is odd, then - -- the front path will collide with the reverse path. - local front = (delta % 2 ~= 0) - -- Offsets for start and end of k loop. - -- Prevents mapping of space beyond the grid. - local k1start = 0 - local k1end = 0 - local k2start = 0 - local k2end = 0 - for d = 0, max_d - 1 do - -- Bail out if deadline is reached. - if clock() > deadline then - break - end - - -- Walk the front path one step. - for k1 = -d + k1start, d - k1end, 2 do - local k1_offset = v_offset + k1 - local x1 - if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then - x1 = v1[k1_offset + 1] - else - x1 = v1[k1_offset - 1] + 1 - end - local y1 = x1 - k1 - while (x1 <= text1_length) and (y1 <= text2_length) and (strsub(text1, x1, x1) == strsub(text2, y1, y1)) do - x1 = x1 + 1 - y1 = y1 + 1 - end - v1[k1_offset] = x1 - if x1 > text1_length + 1 then - -- Ran off the right of the graph. - k1end = k1end + 2 - elseif y1 > text2_length + 1 then - -- Ran off the bottom of the graph. - k1start = k1start + 2 - elseif front then - local k2_offset = v_offset + delta - k1 - if k2_offset >= 0 and k2_offset < v_length and v2[k2_offset] ~= -1 then - -- Mirror x2 onto top-left coordinate system. - local x2 = text1_length - v2[k2_offset] + 1 - if x1 > x2 then - -- Overlap detected. - return _diff_bisectSplit(text1, text2, x1, y1, deadline) - end - end - end - end - - -- Walk the reverse path one step. - for k2 = -d + k2start, d - k2end, 2 do - local k2_offset = v_offset + k2 - local x2 - if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then - x2 = v2[k2_offset + 1] - else - x2 = v2[k2_offset - 1] + 1 - end - local y2 = x2 - k2 - while (x2 <= text1_length) and (y2 <= text2_length) and (strsub(text1, -x2, -x2) == strsub(text2, -y2, -y2)) do - x2 = x2 + 1 - y2 = y2 + 1 - end - v2[k2_offset] = x2 - if x2 > text1_length + 1 then - -- Ran off the left of the graph. - k2end = k2end + 2 - elseif y2 > text2_length + 1 then - -- Ran off the top of the graph. - k2start = k2start + 2 - elseif not front then - local k1_offset = v_offset + delta - k2 - if k1_offset >= 0 and k1_offset < v_length and v1[k1_offset] ~= -1 then - local x1 = v1[k1_offset] - local y1 = v_offset + x1 - k1_offset - -- Mirror x2 onto top-left coordinate system. - x2 = text1_length - x2 + 1 - if x1 > x2 then - -- Overlap detected. - return _diff_bisectSplit(text1, text2, x1, y1, deadline) - end - end - end - end - end - -- Diff took too long and hit the deadline or - -- number of diffs equals number of characters, no commonality at all. - return { { DIFF_DELETE, text1 }, { DIFF_INSERT, text2 } } -end - ---[[ - * Given the location of the 'middle snake', split the diff in two parts - * and recurse. - * @param {string} text1 Old string to be diffed. - * @param {string} text2 New string to be diffed. - * @param {number} x Index of split point in text1. - * @param {number} y Index of split point in text2. - * @param {number} deadline Time at which to bail if not yet complete. - * @return {Array.>} Array of diff tuples. - * @private ---]] -function _diff_bisectSplit(text1, text2, x, y, deadline) - local text1a = strsub(text1, 1, x - 1) - local text2a = strsub(text2, 1, y - 1) - local text1b = strsub(text1, x) - local text2b = strsub(text2, y) - - -- Compute both diffs serially. - local diffs = diff_main(text1a, text2a, false, deadline) - local diffsb = diff_main(text1b, text2b, false, deadline) - - local diffs_len = #diffs - for i, v in ipairs(diffsb) do - diffs[diffs_len + i] = v - end - return diffs -end - ---[[ -* Determine the common prefix of two strings. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the start of each -* string. ---]] -function _diff_commonPrefix(text1, text2) - -- Quick check for common null cases. - if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, 1) ~= strbyte(text2, 1)) then - return 0 - end - -- Binary search. - -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ - local pointermin = 1 - local pointermax = min(#text1, #text2) - local pointermid = pointermax - local pointerstart = 1 - while pointermin < pointermid do - if strsub(text1, pointerstart, pointermid) == strsub(text2, pointerstart, pointermid) then - pointermin = pointermid - pointerstart = pointermin - else - pointermax = pointermid - end - pointermid = floor(pointermin + (pointermax - pointermin) / 2) - end - return pointermid -end - ---[[ -* Determine the common suffix of two strings. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the end of each string. ---]] -function _diff_commonSuffix(text1, text2) - -- Quick check for common null cases. - if (#text1 == 0) or (#text2 == 0) or (strbyte(text1, -1) ~= strbyte(text2, -1)) then - return 0 - end - -- Binary search. - -- Performance analysis: https://neil.fraser.name/news/2007/10/09/ - local pointermin = 1 - local pointermax = min(#text1, #text2) - local pointermid = pointermax - local pointerend = 1 - while pointermin < pointermid do - if strsub(text1, -pointermid, -pointerend) == strsub(text2, -pointermid, -pointerend) then - pointermin = pointermid - pointerend = pointermin - else - pointermax = pointermid - end - pointermid = floor(pointermin + (pointermax - pointermin) / 2) - end - return pointermid -end - ---[[ -* Determine if the suffix of one string is the prefix of another. -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {number} The number of characters common to the end of the first -* string and the start of the second string. -* @private ---]] -function _diff_commonOverlap(text1, text2) - -- Cache the text lengths to prevent multiple calls. - local text1_length = #text1 - local text2_length = #text2 - -- Eliminate the null case. - if text1_length == 0 or text2_length == 0 then - return 0 - end - -- Truncate the longer string. - if text1_length > text2_length then - text1 = strsub(text1, text1_length - text2_length + 1) - elseif text1_length < text2_length then - text2 = strsub(text2, 1, text1_length) - end - local text_length = min(text1_length, text2_length) - -- Quick check for the worst case. - if text1 == text2 then - return text_length - end - - -- Start by looking for a single character match - -- and increase length until no match is found. - -- Performance analysis: https://neil.fraser.name/news/2010/11/04/ - local best = 0 - local length = 1 - while true do - local pattern = strsub(text1, text_length - length + 1) - local found = strfind(text2, pattern, 1, true) - if found == nil then - return best - end - length = length + found - 1 - if found == 1 or strsub(text1, text_length - length + 1) == strsub(text2, 1, length) then - best = length - length = length + 1 - end - end -end - ---[[ -* Does a substring of shorttext exist within longtext such that the substring -* is at least half the length of longtext? -* This speedup can produce non-minimal diffs. -* Closure, but does not reference any external variables. -* @param {string} longtext Longer string. -* @param {string} shorttext Shorter string. -* @param {number} i Start index of quarter length substring within longtext. -* @return {?Array.} Five element Array, containing the prefix of -* longtext, the suffix of longtext, the prefix of shorttext, the suffix -* of shorttext and the common middle. Or nil if there was no match. -* @private ---]] -function _diff_halfMatchI(longtext, shorttext, i) - -- Start with a 1/4 length substring at position i as a seed. - local seed = strsub(longtext, i, i + floor(#longtext / 4)) - local j = 0 -- LUANOTE: do not change to 1, was originally -1 - local best_common = '' - local best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b - while true do - j = indexOf(shorttext, seed, j + 1) - if j == nil then - break - end - local prefixLength = _diff_commonPrefix(strsub(longtext, i), strsub(shorttext, j)) - local suffixLength = _diff_commonSuffix(strsub(longtext, 1, i - 1), strsub(shorttext, 1, j - 1)) - if #best_common < suffixLength + prefixLength then - best_common = strsub(shorttext, j - suffixLength, j - 1) .. strsub(shorttext, j, j + prefixLength - 1) - best_longtext_a = strsub(longtext, 1, i - suffixLength - 1) - best_longtext_b = strsub(longtext, i + prefixLength) - best_shorttext_a = strsub(shorttext, 1, j - suffixLength - 1) - best_shorttext_b = strsub(shorttext, j + prefixLength) - end - end - if #best_common * 2 >= #longtext then - return { best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common } - else - return nil - end -end - ---[[ -* Do the two texts share a substring which is at least half the length of the -* longer text? -* @param {string} text1 First string. -* @param {string} text2 Second string. -* @return {?Array.} Five element Array, containing the prefix of -* text1, the suffix of text1, the prefix of text2, the suffix of -* text2 and the common middle. Or nil if there was no match. -* @private ---]] -function _diff_halfMatch(text1, text2) - if Diff_Timeout <= 0 then - -- Don't risk returning a non-optimal diff if we have unlimited time. - return nil - end - local longtext = (#text1 > #text2) and text1 or text2 - local shorttext = (#text1 > #text2) and text2 or text1 - if (#longtext < 4) or (#shorttext * 2 < #longtext) then - return nil -- Pointless. - end - - -- First check if the second quarter is the seed for a half-match. - local hm1 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 4)) - -- Check again based on the third quarter. - local hm2 = _diff_halfMatchI(longtext, shorttext, ceil(#longtext / 2)) - local hm - if not hm1 and not hm2 then - return nil - elseif not hm2 then - hm = hm1 - elseif not hm1 then - hm = hm2 - else - -- Both matched. Select the longest. - hm = (#hm1[5] > #hm2[5]) and hm1 or hm2 - end - - -- A half-match was found, sort out the return data. - local text1_a, text1_b, text2_a, text2_b - if #text1 > #text2 then - text1_a, text1_b = hm[1], hm[2] - text2_a, text2_b = hm[3], hm[4] - else - text2_a, text2_b = hm[1], hm[2] - text1_a, text1_b = hm[3], hm[4] - end - local mid_common = hm[5] - return text1_a, text1_b, text2_a, text2_b, mid_common -end - ---[[ -* Given two strings, compute a score representing whether the internal -* boundary falls on logical boundaries. -* Scores range from 6 (best) to 0 (worst). -* @param {string} one First string. -* @param {string} two Second string. -* @return {number} The score. -* @private ---]] -function _diff_cleanupSemanticScore(one, two) - if (#one == 0) or (#two == 0) then - -- Edges are the best. - return 6 - end - - -- Each port of this function behaves slightly differently due to - -- subtle differences in each language's definition of things like - -- 'whitespace'. Since this function's purpose is largely cosmetic, - -- the choice has been made to use each language's native features - -- rather than force total conformity. - local char1 = strsub(one, -1) - local char2 = strsub(two, 1, 1) - local nonAlphaNumeric1 = strmatch(char1, '%W') - local nonAlphaNumeric2 = strmatch(char2, '%W') - local whitespace1 = nonAlphaNumeric1 and strmatch(char1, '%s') - local whitespace2 = nonAlphaNumeric2 and strmatch(char2, '%s') - local lineBreak1 = whitespace1 and strmatch(char1, '%c') - local lineBreak2 = whitespace2 and strmatch(char2, '%c') - local blankLine1 = lineBreak1 and strmatch(one, '\n\r?\n$') - local blankLine2 = lineBreak2 and strmatch(two, '^\r?\n\r?\n') - - if blankLine1 or blankLine2 then - -- Five points for blank lines. - return 5 - elseif lineBreak1 or lineBreak2 then - -- Four points for line breaks. - return 4 - elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then - -- Three points for end of sentences. - return 3 - elseif whitespace1 or whitespace2 then - -- Two points for whitespace. - return 2 - elseif nonAlphaNumeric1 or nonAlphaNumeric2 then - -- One point for non-alphanumeric. - return 1 - end - return 0 -end - ---[[ -* Look for single edits surrounded on both sides by equalities -* which can be shifted sideways to align the edit to a word boundary. -* e.g: The cat came. -> The cat came. -* @param {Array.>} diffs Array of diff tuples. ---]] -function _diff_cleanupSemanticLossless(diffs) - local pointer = 2 - -- Intentionally ignore the first and last element (don't need checking). - while diffs[pointer + 1] do - local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] - if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then - -- This is a single edit surrounded by equalities. - local diff = diffs[pointer] - - local equality1 = prevDiff[2] - local edit = diff[2] - local equality2 = nextDiff[2] - - -- First, shift the edit as far left as possible. - local commonOffset = _diff_commonSuffix(equality1, edit) - if commonOffset > 0 then - local commonString = strsub(edit, -commonOffset) - equality1 = strsub(equality1, 1, -commonOffset - 1) - edit = commonString .. strsub(edit, 1, -commonOffset - 1) - equality2 = commonString .. equality2 - end - - -- Second, step character by character right, looking for the best fit. - local bestEquality1 = equality1 - local bestEdit = edit - local bestEquality2 = equality2 - local bestScore = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) - - while strbyte(edit, 1) == strbyte(equality2, 1) do - equality1 = equality1 .. strsub(edit, 1, 1) - edit = strsub(edit, 2) .. strsub(equality2, 1, 1) - equality2 = strsub(equality2, 2) - local score = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2) - -- The >= encourages trailing rather than leading whitespace on edits. - if score >= bestScore then - bestScore = score - bestEquality1 = equality1 - bestEdit = edit - bestEquality2 = equality2 - end - end - if prevDiff[2] ~= bestEquality1 then - -- We have an improvement, save it back to the diff. - if #bestEquality1 > 0 then - diffs[pointer - 1][2] = bestEquality1 - else - tremove(diffs, pointer - 1) - pointer = pointer - 1 - end - diffs[pointer][2] = bestEdit - if #bestEquality2 > 0 then - diffs[pointer + 1][2] = bestEquality2 - else - tremove(diffs, pointer + 1, 1) - pointer = pointer - 1 - end - end - end - pointer = pointer + 1 - end -end - ---[[ -* Reorder and merge like edit sections. Merge equalities. -* Any edit section can move as long as it doesn't cross an equality. -* @param {Array.>} diffs Array of diff tuples. ---]] -function _diff_cleanupMerge(diffs) - diffs[#diffs + 1] = { DIFF_EQUAL, '' } -- Add a dummy entry at the end. - local pointer = 1 - local count_delete, count_insert = 0, 0 - local text_delete, text_insert = '', '' - local commonlength - while diffs[pointer] do - local diff_type = diffs[pointer][1] - if diff_type == DIFF_INSERT then - count_insert = count_insert + 1 - text_insert = text_insert .. diffs[pointer][2] - pointer = pointer + 1 - elseif diff_type == DIFF_DELETE then - count_delete = count_delete + 1 - text_delete = text_delete .. diffs[pointer][2] - pointer = pointer + 1 - elseif diff_type == DIFF_EQUAL then - -- Upon reaching an equality, check for prior redundancies. - if count_delete + count_insert > 1 then - if (count_delete > 0) and (count_insert > 0) then - -- Factor out any common prefixies. - commonlength = _diff_commonPrefix(text_insert, text_delete) - if commonlength > 0 then - local back_pointer = pointer - count_delete - count_insert - if (back_pointer > 1) and (diffs[back_pointer - 1][1] == DIFF_EQUAL) then - diffs[back_pointer - 1][2] = diffs[back_pointer - 1][2] .. strsub(text_insert, 1, commonlength) - else - tinsert(diffs, 1, { DIFF_EQUAL, strsub(text_insert, 1, commonlength) }) - pointer = pointer + 1 - end - text_insert = strsub(text_insert, commonlength + 1) - text_delete = strsub(text_delete, commonlength + 1) - end - -- Factor out any common suffixies. - commonlength = _diff_commonSuffix(text_insert, text_delete) - if commonlength ~= 0 then - diffs[pointer][2] = strsub(text_insert, -commonlength) .. diffs[pointer][2] - text_insert = strsub(text_insert, 1, -commonlength - 1) - text_delete = strsub(text_delete, 1, -commonlength - 1) - end - end - -- Delete the offending records and add the merged ones. - pointer = pointer - count_delete - count_insert - for i = 1, count_delete + count_insert do - tremove(diffs, pointer) - end - if #text_delete > 0 then - tinsert(diffs, pointer, { DIFF_DELETE, text_delete }) - pointer = pointer + 1 - end - if #text_insert > 0 then - tinsert(diffs, pointer, { DIFF_INSERT, text_insert }) - pointer = pointer + 1 - end - pointer = pointer + 1 - elseif (pointer > 1) and (diffs[pointer - 1][1] == DIFF_EQUAL) then - -- Merge this equality with the previous one. - diffs[pointer - 1][2] = diffs[pointer - 1][2] .. diffs[pointer][2] - tremove(diffs, pointer) - else - pointer = pointer + 1 - end - count_insert, count_delete = 0, 0 - text_delete, text_insert = '', '' - end - end - if diffs[#diffs][2] == '' then - diffs[#diffs] = nil -- Remove the dummy entry at the end. - end - - -- Second pass: look for single edits surrounded on both sides by equalities - -- which can be shifted sideways to eliminate an equality. - -- e.g: ABAC -> ABAC - local changes = false - pointer = 2 - -- Intentionally ignore the first and last element (don't need checking). - while pointer < #diffs do - local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1] - if (prevDiff[1] == DIFF_EQUAL) and (nextDiff[1] == DIFF_EQUAL) then - -- This is a single edit surrounded by equalities. - local diff = diffs[pointer] - local currentText = diff[2] - local prevText = prevDiff[2] - local nextText = nextDiff[2] - if #prevText == 0 then - tremove(diffs, pointer - 1) - changes = true - elseif strsub(currentText, -#prevText) == prevText then - -- Shift the edit over the previous equality. - diff[2] = prevText .. strsub(currentText, 1, -#prevText - 1) - nextDiff[2] = prevText .. nextDiff[2] - tremove(diffs, pointer - 1) - changes = true - elseif strsub(currentText, 1, #nextText) == nextText then - -- Shift the edit over the next equality. - prevDiff[2] = prevText .. nextText - diff[2] = strsub(currentText, #nextText + 1) .. nextText - tremove(diffs, pointer + 1) - changes = true - end - end - pointer = pointer + 1 - end - -- If shifts were made, the diff needs reordering and another shift sweep. - if changes then - -- LUANOTE: no return value, but necessary to use 'return' to get - -- tail calls. - return _diff_cleanupMerge(diffs) - end -end - ---[[ -* loc is a location in text1, compute and return the equivalent location in -* text2. -* e.g. 'The cat' vs 'The big cat', 1->1, 5->8 -* @param {Array.>} diffs Array of diff tuples. -* @param {number} loc Location within text1. -* @return {number} Location within text2. ---]] -function _diff_xIndex(diffs, loc) - local chars1 = 1 - local chars2 = 1 - local last_chars1 = 1 - local last_chars2 = 1 - local x - for _x, diff in ipairs(diffs) do - x = _x - if diff[1] ~= DIFF_INSERT then -- Equality or deletion. - chars1 = chars1 + #diff[2] - end - if diff[1] ~= DIFF_DELETE then -- Equality or insertion. - chars2 = chars2 + #diff[2] - end - if chars1 > loc then -- Overshot the location. - break - end - last_chars1 = chars1 - last_chars2 = chars2 - end - -- Was the location deleted? - if diffs[x + 1] and (diffs[x][1] == DIFF_DELETE) then - return last_chars2 - end - -- Add the remaining character length. - return last_chars2 + (loc - last_chars1) -end - ---[[ -* Compute and return the source text (all equalities and deletions). -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Source text. ---]] -function _diff_text1(diffs) - local text = {} - for x, diff in ipairs(diffs) do - if diff[1] ~= DIFF_INSERT then - text[#text + 1] = diff[2] - end - end - return tconcat(text) -end - ---[[ -* Compute and return the destination text (all equalities and insertions). -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Destination text. ---]] -function _diff_text2(diffs) - local text = {} - for x, diff in ipairs(diffs) do - if diff[1] ~= DIFF_DELETE then - text[#text + 1] = diff[2] - end - end - return tconcat(text) -end - ---[[ -* Crush the diff into an encoded string which describes the operations -* required to transform text1 into text2. -* E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. -* Operations are tab-separated. Inserted text is escaped using %xx notation. -* @param {Array.>} diffs Array of diff tuples. -* @return {string} Delta text. ---]] -function _diff_toDelta(diffs) - local text = {} - for x, diff in ipairs(diffs) do - local op, data = diff[1], diff[2] - if op == DIFF_INSERT then - text[x] = '+' .. gsub(data, percentEncode_pattern, percentEncode_replace) - elseif op == DIFF_DELETE then - text[x] = '-' .. #data - elseif op == DIFF_EQUAL then - text[x] = '=' .. #data - end - end - return tconcat(text, '\t') -end - ---[[ -* Given the original text1, and an encoded string which describes the -* operations required to transform text1 into text2, compute the full diff. -* @param {string} text1 Source string for the diff. -* @param {string} delta Delta text. -* @return {Array.>} Array of diff tuples. -* @throws {Errorend If invalid input. ---]] -function _diff_fromDelta(text1, delta) - local diffs = {} - local diffsLength = 0 -- Keeping our own length var is faster - local pointer = 1 -- Cursor in text1 - for token in gmatch(delta, '[^\t]+') do - -- Each token begins with a one character parameter which specifies the - -- operation of this token (delete, insert, equality). - local tokenchar, param = strsub(token, 1, 1), strsub(token, 2) - if tokenchar == '+' then - local invalidDecode = false - local decoded = gsub(param, '%%(.?.?)', function(c) - local n = tonumber(c, 16) - if (#c ~= 2) or (n == nil) then - invalidDecode = true - return '' - end - return strchar(n) - end) - if invalidDecode then - -- Malformed URI sequence. - error('Illegal escape in _diff_fromDelta: ' .. param) - end - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_INSERT, decoded } - elseif (tokenchar == '-') or (tokenchar == '=') then - local n = tonumber(param) - if (n == nil) or (n < 0) then - error('Invalid number in _diff_fromDelta: ' .. param) - end - local text = strsub(text1, pointer, pointer + n - 1) - pointer = pointer + n - if tokenchar == '=' then - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_EQUAL, text } - else - diffsLength = diffsLength + 1 - diffs[diffsLength] = { DIFF_DELETE, text } - end - else - error('Invalid diff operation in _diff_fromDelta: ' .. token) - end - end - if pointer ~= #text1 + 1 then - error('Delta length (' .. (pointer - 1) .. ') does not equal source text length (' .. #text1 .. ').') - end - return diffs -end - --- --------------------------------------------------------------------------- --- MATCH API --- --------------------------------------------------------------------------- - -local _match_bitap, _match_alphabet - ---[[ -* Locate the best instance of 'pattern' in 'text' near 'loc'. -* @param {string} text The text to search. -* @param {string} pattern The pattern to search for. -* @param {number} loc The location to search around. -* @return {number} Best match index or -1. ---]] -function match_main(text, pattern, loc) - -- Check for null inputs. - if text == nil or pattern == nil or loc == nil then - error('Null inputs. (match_main)') - end - - if text == pattern then - -- Shortcut (potentially not guaranteed by the algorithm) - return 1 - elseif #text == 0 then - -- Nothing to match. - return -1 - end - loc = max(1, min(loc, #text)) - if strsub(text, loc, loc + #pattern - 1) == pattern then - -- Perfect match at the perfect spot! (Includes case of null pattern) - return loc - else - -- Do a fuzzy compare. - return _match_bitap(text, pattern, loc) - end -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE MATCH FUNCTIONS --- --------------------------------------------------------------------------- - ---[[ -* Initialise the alphabet for the Bitap algorithm. -* @param {string} pattern The text to encode. -* @return {Object} Hash of character locations. -* @private ---]] -function _match_alphabet(pattern) - local s = {} - local i = 0 - for c in gmatch(pattern, '.') do - s[c] = bor(s[c] or 0, lshift(1, #pattern - i - 1)) - i = i + 1 - end - return s -end - ---[[ -* Locate the best instance of 'pattern' in 'text' near 'loc' using the -* Bitap algorithm. -* @param {string} text The text to search. -* @param {string} pattern The pattern to search for. -* @param {number} loc The location to search around. -* @return {number} Best match index or -1. -* @private ---]] -function _match_bitap(text, pattern, loc) - if #pattern > Match_MaxBits then - error('Pattern too long.') - end - - -- Initialise the alphabet. - local s = _match_alphabet(pattern) - - --[[ - * Compute and return the score for a match with e errors and x location. - * Accesses loc and pattern through being a closure. - * @param {number} e Number of errors in match. - * @param {number} x Location of match. - * @return {number} Overall score for match (0.0 = good, 1.0 = bad). - * @private - --]] - local function _match_bitapScore(e, x) - local accuracy = e / #pattern - local proximity = abs(loc - x) - if Match_Distance == 0 then - -- Dodge divide by zero error. - return (proximity == 0) and 1 or accuracy - end - return accuracy + (proximity / Match_Distance) - end - - -- Highest score beyond which we give up. - local score_threshold = Match_Threshold - -- Is there a nearby exact match? (speedup) - local best_loc = indexOf(text, pattern, loc) - if best_loc then - score_threshold = min(_match_bitapScore(0, best_loc), score_threshold) - -- LUANOTE: Ideally we'd also check from the other direction, but Lua - -- doesn't have an efficent lastIndexOf function. - end - - -- Initialise the bit arrays. - local matchmask = lshift(1, #pattern - 1) - best_loc = -1 - - local bin_min, bin_mid - local bin_max = #pattern + #text - local last_rd - for d = 0, #pattern - 1, 1 do - -- Scan for the best match; each iteration allows for one more error. - -- Run a binary search to determine how far from 'loc' we can stray at this - -- error level. - bin_min = 0 - bin_mid = bin_max - while bin_min < bin_mid do - if _match_bitapScore(d, loc + bin_mid) <= score_threshold then - bin_min = bin_mid - else - bin_max = bin_mid - end - bin_mid = floor(bin_min + (bin_max - bin_min) / 2) - end - -- Use the result from this iteration as the maximum for the next. - bin_max = bin_mid - local start = max(1, loc - bin_mid + 1) - local finish = min(loc + bin_mid, #text) + #pattern - - local rd = {} - for j = start, finish do - rd[j] = 0 - end - rd[finish + 1] = lshift(1, d) - 1 - for j = finish, start, -1 do - local charMatch = s[strsub(text, j - 1, j - 1)] or 0 - if d == 0 then -- First pass: exact match. - rd[j] = band(bor((rd[j + 1] * 2), 1), charMatch) - else - -- Subsequent passes: fuzzy match. - -- Functions instead of operators make this hella messy. - rd[j] = bor( - band(bor(lshift(rd[j + 1], 1), 1), charMatch), - bor(bor(lshift(bor(last_rd[j + 1], last_rd[j]), 1), 1), last_rd[j + 1]) - ) - end - if band(rd[j], matchmask) ~= 0 then - local score = _match_bitapScore(d, j - 1) - -- This match will almost certainly be better than any existing match. - -- But check anyway. - if score <= score_threshold then - -- Told you so. - score_threshold = score - best_loc = j - 1 - if best_loc > loc then - -- When passing loc, don't exceed our current distance from loc. - start = max(1, loc * 2 - best_loc) - else - -- Already passed loc, downhill from here on in. - break - end - end - end - end - -- No hope for a (better) match at greater error levels. - if _match_bitapScore(d + 1, loc) > score_threshold then - break - end - last_rd = rd - end - return best_loc -end - --- ----------------------------------------------------------------------------- --- PATCH API --- ----------------------------------------------------------------------------- - -local _patch_addContext, _patch_deepCopy, _patch_addPadding, _patch_splitMax, _patch_appendText, _new_patch_obj - ---[[ -* Compute a list of patches to turn text1 into text2. -* Use diffs if provided, otherwise compute it ourselves. -* There are four ways to call this function, depending on what data is -* available to the caller: -* Method 1: -* a = text1, b = text2 -* Method 2: -* a = diffs -* Method 3 (optimal): -* a = text1, b = diffs -* Method 4 (deprecated, use method 3): -* a = text1, b = text2, c = diffs -* -* @param {string|Array.>} a text1 (methods 1,3,4) or -* Array of diff tuples for text1 to text2 (method 2). -* @param {string|Array.>} opt_b text2 (methods 1,4) or -* Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). -* @param {string|Array.>} opt_c Array of diff tuples for -* text1 to text2 (method 4) or undefined (methods 1,2,3). -* @return {Array.<_new_patch_obj>} Array of patch objects. ---]] -function patch_make(a, opt_b, opt_c) - local text1, diffs - local type_a, type_b, type_c = type(a), type(opt_b), type(opt_c) - if (type_a == 'string') and (type_b == 'string') and (type_c == 'nil') then - -- Method 1: text1, text2 - -- Compute diffs from text1 and text2. - text1 = a - diffs = diff_main(text1, opt_b, true) - if #diffs > 2 then - diff_cleanupSemantic(diffs) - diff_cleanupEfficiency(diffs) - end - elseif (type_a == 'table') and (type_b == 'nil') and (type_c == 'nil') then - -- Method 2: diffs - -- Compute text1 from diffs. - diffs = a - text1 = _diff_text1(diffs) - elseif (type_a == 'string') and (type_b == 'table') and (type_c == 'nil') then - -- Method 3: text1, diffs - text1 = a - diffs = opt_b - elseif (type_a == 'string') and (type_b == 'string') and (type_c == 'table') then - -- Method 4: text1, text2, diffs - -- text2 is not used. - text1 = a - diffs = opt_c - else - error('Unknown call format to patch_make.') - end - - if diffs[1] == nil then - return {} -- Get rid of the null case. - end - - local patches = {} - local patch = _new_patch_obj() - local patchDiffLength = 0 -- Keeping our own length var is faster. - local char_count1 = 0 -- Number of characters into the text1 string. - local char_count2 = 0 -- Number of characters into the text2 string. - -- Start with text1 (prepatch_text) and apply the diffs until we arrive at - -- text2 (postpatch_text). We recreate the patches one by one to determine - -- context info. - local prepatch_text, postpatch_text = text1, text1 - for x, diff in ipairs(diffs) do - local diff_type, diff_text = diff[1], diff[2] - - if (patchDiffLength == 0) and (diff_type ~= DIFF_EQUAL) then - -- A new patch starts here. - patch.start1 = char_count1 + 1 - patch.start2 = char_count2 + 1 - end - - if diff_type == DIFF_INSERT then - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - patch.length2 = patch.length2 + #diff_text - postpatch_text = strsub(postpatch_text, 1, char_count2) .. diff_text .. strsub(postpatch_text, char_count2 + 1) - elseif diff_type == DIFF_DELETE then - patch.length1 = patch.length1 + #diff_text - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - postpatch_text = strsub(postpatch_text, 1, char_count2) .. strsub(postpatch_text, char_count2 + #diff_text + 1) - elseif diff_type == DIFF_EQUAL then - if (#diff_text <= Patch_Margin * 2) and (patchDiffLength ~= 0) and (#diffs ~= x) then - -- Small equality inside a patch. - patchDiffLength = patchDiffLength + 1 - patch.diffs[patchDiffLength] = diff - patch.length1 = patch.length1 + #diff_text - patch.length2 = patch.length2 + #diff_text - elseif #diff_text >= Patch_Margin * 2 then - -- Time for a new patch. - if patchDiffLength ~= 0 then - _patch_addContext(patch, prepatch_text) - patches[#patches + 1] = patch - patch = _new_patch_obj() - patchDiffLength = 0 - -- Unlike Unidiff, our patch lists have a rolling context. - -- https://github.com/google/diff-match-patch/wiki/Unidiff - -- Update prepatch text & pos to reflect the application of the - -- just completed patch. - prepatch_text = postpatch_text - char_count1 = char_count2 - end - end - end - - -- Update the current character count. - if diff_type ~= DIFF_INSERT then - char_count1 = char_count1 + #diff_text - end - if diff_type ~= DIFF_DELETE then - char_count2 = char_count2 + #diff_text - end - end - - -- Pick up the leftover patch if not empty. - if patchDiffLength > 0 then - _patch_addContext(patch, prepatch_text) - patches[#patches + 1] = patch - end - - return patches -end - ---[[ -* Merge a set of patches onto the text. Return a patched text, as well -* as a list of true/false values indicating which patches were applied. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @param {string} text Old text. -* @return {Array.>} Two return values, the -* new text and an array of boolean values. ---]] -function patch_apply(patches, text) - if patches[1] == nil then - return text, {} - end - - -- Deep copy the patches so that no changes are made to originals. - patches = _patch_deepCopy(patches) - - local nullPadding = _patch_addPadding(patches) - text = nullPadding .. text .. nullPadding - - _patch_splitMax(patches) - -- delta keeps track of the offset between the expected and actual location - -- of the previous patch. If there are patches expected at positions 10 and - -- 20, but the first patch was found at 12, delta is 2 and the second patch - -- has an effective expected position of 22. - local delta = 0 - local results = {} - for x, patch in ipairs(patches) do - local expected_loc = patch.start2 + delta - local text1 = _diff_text1(patch.diffs) - local start_loc - local end_loc = -1 - if #text1 > Match_MaxBits then - -- _patch_splitMax will only provide an oversized pattern in - -- the case of a monster delete. - start_loc = match_main(text, strsub(text1, 1, Match_MaxBits), expected_loc) - if start_loc ~= -1 then - end_loc = match_main(text, strsub(text1, -Match_MaxBits), expected_loc + #text1 - Match_MaxBits) - if end_loc == -1 or start_loc >= end_loc then - -- Can't find valid trailing context. Drop this patch. - start_loc = -1 - end - end - else - start_loc = match_main(text, text1, expected_loc) - end - if start_loc == -1 then - -- No match found. :( - results[x] = false - -- Subtract the delta for this failed patch from subsequent patches. - delta = delta - patch.length2 - patch.length1 - else - -- Found a match. :) - results[x] = true - delta = start_loc - expected_loc - local text2 - if end_loc == -1 then - text2 = strsub(text, start_loc, start_loc + #text1 - 1) - else - text2 = strsub(text, start_loc, end_loc + Match_MaxBits - 1) - end - if text1 == text2 then - -- Perfect match, just shove the replacement text in. - text = strsub(text, 1, start_loc - 1) .. _diff_text2(patch.diffs) .. strsub(text, start_loc + #text1) - else - -- Imperfect match. Run a diff to get a framework of equivalent - -- indices. - local diffs = diff_main(text1, text2, false) - if (#text1 > Match_MaxBits) and (diff_levenshtein(diffs) / #text1 > Patch_DeleteThreshold) then - -- The end points match, but the content is unacceptably bad. - results[x] = false - else - _diff_cleanupSemanticLossless(diffs) - local index1 = 1 - local index2 - for y, mod in ipairs(patch.diffs) do - if mod[1] ~= DIFF_EQUAL then - index2 = _diff_xIndex(diffs, index1) - end - if mod[1] == DIFF_INSERT then - text = strsub(text, 1, start_loc + index2 - 2) .. mod[2] .. strsub(text, start_loc + index2 - 1) - elseif mod[1] == DIFF_DELETE then - text = strsub(text, 1, start_loc + index2 - 2) - .. strsub(text, start_loc + _diff_xIndex(diffs, index1 + #mod[2] - 1)) - end - if mod[1] ~= DIFF_DELETE then - index1 = index1 + #mod[2] - end - end - end - end - end - end - -- Strip the padding off. - text = strsub(text, #nullPadding + 1, -#nullPadding - 1) - return text, results -end - ---[[ -* Take a list of patches and return a textual representation. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {string} Text representation of patches. ---]] -function patch_toText(patches) - local text = {} - for x, patch in ipairs(patches) do - _patch_appendText(patch, text) - end - return tconcat(text) -end - ---[[ -* Parse a textual representation of patches and return a list of patch objects. -* @param {string} textline Text representation of patches. -* @return {Array.<_new_patch_obj>} Array of patch objects. -* @throws {Error} If invalid input. ---]] -function patch_fromText(textline) - local patches = {} - if #textline == 0 then - return patches - end - local text = {} - for line in gmatch(textline, '([^\n]*)') do - text[#text + 1] = line - end - local textPointer = 1 - while textPointer <= #text do - local start1, length1, start2, length2 = strmatch(text[textPointer], '^@@ %-(%d+),?(%d*) %+(%d+),?(%d*) @@$') - if start1 == nil then - error('Invalid patch string: "' .. text[textPointer] .. '"') - end - local patch = _new_patch_obj() - patches[#patches + 1] = patch - - start1 = tonumber(start1) - length1 = tonumber(length1) or 1 - if length1 == 0 then - start1 = start1 + 1 - end - patch.start1 = start1 - patch.length1 = length1 - - start2 = tonumber(start2) - length2 = tonumber(length2) or 1 - if length2 == 0 then - start2 = start2 + 1 - end - patch.start2 = start2 - patch.length2 = length2 - - textPointer = textPointer + 1 - - while true do - local line = text[textPointer] - if line == nil then - break - end - local sign - sign, line = strsub(line, 1, 1), strsub(line, 2) - - local invalidDecode = false - local decoded = gsub(line, '%%(.?.?)', function(c) - local n = tonumber(c, 16) - if (#c ~= 2) or (n == nil) then - invalidDecode = true - return '' - end - return strchar(n) - end) - if invalidDecode then - -- Malformed URI sequence. - error('Illegal escape in patch_fromText: ' .. line) - end - - line = decoded - - if sign == '-' then - -- Deletion. - patch.diffs[#patch.diffs + 1] = { DIFF_DELETE, line } - elseif sign == '+' then - -- Insertion. - patch.diffs[#patch.diffs + 1] = { DIFF_INSERT, line } - elseif sign == ' ' then - -- Minor equality. - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, line } - elseif sign == '@' then - -- Start of next patch. - break - elseif sign == '' then - -- Blank line? Whatever. - else - -- WTF? - error('Invalid patch mode "' .. sign .. '" in: ' .. line) - end - textPointer = textPointer + 1 - end - end - return patches -end - --- --------------------------------------------------------------------------- --- UNOFFICIAL/PRIVATE PATCH FUNCTIONS --- --------------------------------------------------------------------------- - -local patch_meta = { - __tostring = function(patch) - local buf = {} - _patch_appendText(patch, buf) - return tconcat(buf) - end, -} - ---[[ -* Class representing one patch operation. -* @constructor ---]] -function _new_patch_obj() - return setmetatable({ - --[[ @type {Array.>} ]] - diffs = {}, - --[[ @type {?number} ]] - start1 = 1, -- nil; - --[[ @type {?number} ]] - start2 = 1, -- nil; - --[[ @type {number} ]] - length1 = 0, - --[[ @type {number} ]] - length2 = 0, - }, patch_meta) -end - ---[[ -* Increase the context until it is unique, -* but don't let the pattern expand beyond Match_MaxBits. -* @param {_new_patch_obj} patch The patch to grow. -* @param {string} text Source text. -* @private ---]] -function _patch_addContext(patch, text) - if #text == 0 then - return - end - local pattern = strsub(text, patch.start2, patch.start2 + patch.length1 - 1) - local padding = 0 - - -- LUANOTE: Lua's lack of a lastIndexOf function results in slightly - -- different logic here than in other language ports. - -- Look for the first two matches of pattern in text. If two are found, - -- increase the pattern length. - local firstMatch = indexOf(text, pattern) - local secondMatch = nil - if firstMatch ~= nil then - secondMatch = indexOf(text, pattern, firstMatch + 1) - end - while (#pattern == 0 or secondMatch ~= nil) and (#pattern < Match_MaxBits - Patch_Margin - Patch_Margin) do - padding = padding + Patch_Margin - pattern = strsub(text, max(1, patch.start2 - padding), patch.start2 + patch.length1 - 1 + padding) - firstMatch = indexOf(text, pattern) - if firstMatch ~= nil then - secondMatch = indexOf(text, pattern, firstMatch + 1) - else - secondMatch = nil - end - end - -- Add one chunk for good luck. - padding = padding + Patch_Margin - - -- Add the prefix. - local prefix = strsub(text, max(1, patch.start2 - padding), patch.start2 - 1) - if #prefix > 0 then - tinsert(patch.diffs, 1, { DIFF_EQUAL, prefix }) - end - -- Add the suffix. - local suffix = strsub(text, patch.start2 + patch.length1, patch.start2 + patch.length1 - 1 + padding) - if #suffix > 0 then - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, suffix } - end - - -- Roll back the start points. - patch.start1 = patch.start1 - #prefix - patch.start2 = patch.start2 - #prefix - -- Extend the lengths. - patch.length1 = patch.length1 + #prefix + #suffix - patch.length2 = patch.length2 + #prefix + #suffix -end - ---[[ -* Given an array of patches, return another array that is identical. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {Array.<_new_patch_obj>} Array of patch objects. ---]] -function _patch_deepCopy(patches) - local patchesCopy = {} - for x, patch in ipairs(patches) do - local patchCopy = _new_patch_obj() - local diffsCopy = {} - for i, diff in ipairs(patch.diffs) do - diffsCopy[i] = { diff[1], diff[2] } - end - patchCopy.diffs = diffsCopy - patchCopy.start1 = patch.start1 - patchCopy.start2 = patch.start2 - patchCopy.length1 = patch.length1 - patchCopy.length2 = patch.length2 - patchesCopy[x] = patchCopy - end - return patchesCopy -end - ---[[ -* Add some padding on text start and end so that edges can match something. -* Intended to be called only from within patch_apply. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. -* @return {string} The padding string added to each side. ---]] -function _patch_addPadding(patches) - local paddingLength = Patch_Margin - local nullPadding = '' - for x = 1, paddingLength do - nullPadding = nullPadding .. strchar(x) - end - - -- Bump all the patches forward. - for x, patch in ipairs(patches) do - patch.start1 = patch.start1 + paddingLength - patch.start2 = patch.start2 + paddingLength - end - - -- Add some padding on start of first diff. - local patch = patches[1] - local diffs = patch.diffs - local firstDiff = diffs[1] - if (firstDiff == nil) or (firstDiff[1] ~= DIFF_EQUAL) then - -- Add nullPadding equality. - tinsert(diffs, 1, { DIFF_EQUAL, nullPadding }) - patch.start1 = patch.start1 - paddingLength -- Should be 0. - patch.start2 = patch.start2 - paddingLength -- Should be 0. - patch.length1 = patch.length1 + paddingLength - patch.length2 = patch.length2 + paddingLength - elseif paddingLength > #firstDiff[2] then - -- Grow first equality. - local extraLength = paddingLength - #firstDiff[2] - firstDiff[2] = strsub(nullPadding, #firstDiff[2] + 1) .. firstDiff[2] - patch.start1 = patch.start1 - extraLength - patch.start2 = patch.start2 - extraLength - patch.length1 = patch.length1 + extraLength - patch.length2 = patch.length2 + extraLength - end - - -- Add some padding on end of last diff. - patch = patches[#patches] - diffs = patch.diffs - local lastDiff = diffs[#diffs] - if (lastDiff == nil) or (lastDiff[1] ~= DIFF_EQUAL) then - -- Add nullPadding equality. - diffs[#diffs + 1] = { DIFF_EQUAL, nullPadding } - patch.length1 = patch.length1 + paddingLength - patch.length2 = patch.length2 + paddingLength - elseif paddingLength > #lastDiff[2] then - -- Grow last equality. - local extraLength = paddingLength - #lastDiff[2] - lastDiff[2] = lastDiff[2] .. strsub(nullPadding, 1, extraLength) - patch.length1 = patch.length1 + extraLength - patch.length2 = patch.length2 + extraLength - end - - return nullPadding -end - ---[[ -* Look through the patches and break up any which are longer than the maximum -* limit of the match algorithm. -* Intended to be called only from within patch_apply. -* @param {Array.<_new_patch_obj>} patches Array of patch objects. ---]] -function _patch_splitMax(patches) - local patch_size = Match_MaxBits - local x = 1 - while true do - local patch = patches[x] - if patch == nil then - return - end - if patch.length1 > patch_size then - local bigpatch = patch - -- Remove the big old patch. - tremove(patches, x) - x = x - 1 - local start1 = bigpatch.start1 - local start2 = bigpatch.start2 - local precontext = '' - while bigpatch.diffs[1] do - -- Create one of several smaller patches. - local patch = _new_patch_obj() - local empty = true - patch.start1 = start1 - #precontext - patch.start2 = start2 - #precontext - if precontext ~= '' then - patch.length1, patch.length2 = #precontext, #precontext - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, precontext } - end - while bigpatch.diffs[1] and (patch.length1 < patch_size - Patch_Margin) do - local diff_type = bigpatch.diffs[1][1] - local diff_text = bigpatch.diffs[1][2] - if diff_type == DIFF_INSERT then - -- Insertions are harmless. - patch.length2 = patch.length2 + #diff_text - start2 = start2 + #diff_text - patch.diffs[#patch.diffs + 1] = bigpatch.diffs[1] - tremove(bigpatch.diffs, 1) - empty = false - elseif - (diff_type == DIFF_DELETE) - and (#patch.diffs == 1) - and (patch.diffs[1][1] == DIFF_EQUAL) - and (#diff_text > 2 * patch_size) - then - -- This is a large deletion. Let it pass in one chunk. - patch.length1 = patch.length1 + #diff_text - start1 = start1 + #diff_text - empty = false - patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } - tremove(bigpatch.diffs, 1) - else - -- Deletion or equality. - -- Only take as much as we can stomach. - diff_text = strsub(diff_text, 1, patch_size - patch.length1 - Patch_Margin) - patch.length1 = patch.length1 + #diff_text - start1 = start1 + #diff_text - if diff_type == DIFF_EQUAL then - patch.length2 = patch.length2 + #diff_text - start2 = start2 + #diff_text - else - empty = false - end - patch.diffs[#patch.diffs + 1] = { diff_type, diff_text } - if diff_text == bigpatch.diffs[1][2] then - tremove(bigpatch.diffs, 1) - else - bigpatch.diffs[1][2] = strsub(bigpatch.diffs[1][2], #diff_text + 1) - end - end - end - -- Compute the head context for the next patch. - precontext = _diff_text2(patch.diffs) - precontext = strsub(precontext, -Patch_Margin) - -- Append the end context for this patch. - local postcontext = strsub(_diff_text1(bigpatch.diffs), 1, Patch_Margin) - if postcontext ~= '' then - patch.length1 = patch.length1 + #postcontext - patch.length2 = patch.length2 + #postcontext - if patch.diffs[1] and (patch.diffs[#patch.diffs][1] == DIFF_EQUAL) then - patch.diffs[#patch.diffs][2] = patch.diffs[#patch.diffs][2] .. postcontext - else - patch.diffs[#patch.diffs + 1] = { DIFF_EQUAL, postcontext } - end - end - if not empty then - x = x + 1 - tinsert(patches, x, patch) - end - end - end - x = x + 1 - end -end - ---[[ -* Emulate GNU diff's format. -* Header: @@ -382,8 +481,9 @@ -* @return {string} The GNU diff string. ---]] -function _patch_appendText(patch, text) - local coords1, coords2 - local length1, length2 = patch.length1, patch.length2 - local start1, start2 = patch.start1, patch.start2 - local diffs = patch.diffs - - if length1 == 1 then - coords1 = start1 - else - coords1 = ((length1 == 0) and (start1 - 1) or start1) .. ',' .. length1 - end - - if length2 == 1 then - coords2 = start2 - else - coords2 = ((length2 == 0) and (start2 - 1) or start2) .. ',' .. length2 - end - text[#text + 1] = '@@ -' .. coords1 .. ' +' .. coords2 .. ' @@\n' - - local op - -- Escape the body of the patch with %xx notation. - for x, diff in ipairs(patch.diffs) do - local diff_type = diff[1] - if diff_type == DIFF_INSERT then - op = '+' - elseif diff_type == DIFF_DELETE then - op = '-' - elseif diff_type == DIFF_EQUAL then - op = ' ' - end - text[#text + 1] = op .. gsub(diffs[x][2], percentEncode_pattern, percentEncode_replace) .. '\n' - end - - return text -end - --- Expose the API -local _M = {} - -_M.DIFF_DELETE = DIFF_DELETE -_M.DIFF_INSERT = DIFF_INSERT -_M.DIFF_EQUAL = DIFF_EQUAL - -_M.diff_main = diff_main -_M.diff_cleanupSemantic = diff_cleanupSemantic -_M.diff_cleanupEfficiency = diff_cleanupEfficiency -_M.diff_levenshtein = diff_levenshtein -_M.diff_prettyHtml = diff_prettyHtml - -_M.match_main = match_main - -_M.patch_make = patch_make -_M.patch_toText = patch_toText -_M.patch_fromText = patch_fromText -_M.patch_apply = patch_apply - --- Expose some non-API functions as well, for testing purposes etc. -_M.diff_commonPrefix = _diff_commonPrefix -_M.diff_commonSuffix = _diff_commonSuffix -_M.diff_commonOverlap = _diff_commonOverlap -_M.diff_halfMatch = _diff_halfMatch -_M.diff_bisect = _diff_bisect -_M.diff_cleanupMerge = _diff_cleanupMerge -_M.diff_cleanupSemanticLossless = _diff_cleanupSemanticLossless -_M.diff_text1 = _diff_text1 -_M.diff_text2 = _diff_text2 -_M.diff_toDelta = _diff_toDelta -_M.diff_fromDelta = _diff_fromDelta -_M.diff_xIndex = _diff_xIndex -_M.match_alphabet = _match_alphabet -_M.match_bitap = _match_bitap -_M.new_patch_obj = _new_patch_obj -_M.patch_addContext = _patch_addContext -_M.patch_splitMax = _patch_splitMax -_M.patch_addPadding = _patch_addPadding -_M.settings = settings - -return _M diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 58e2f4c9..41ed262c 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -229,4 +229,79 @@ describe('CopilotChat.utils.diff', function() assert.is_true(applied) assert.are.same({ 'newstart', 'context' }, result) end) + + it('may confuse similar variable names', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,2 +1,2 @@ +-local x = 1 ++local x = 10 +]] + local original = { + 'local x = 1', + 'local y = 2', + 'local x = 3', + 'local z = 4', + } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ + 'local x = 10', + 'local y = 2', + 'local x = 3', + 'local z = 4', + }, result) + end) + + it('may match wrong substring with partial matches', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,2 +1,2 @@ +-old_value ++new_value +]] + local original = { + 'value', + 'old_value', + 'very_old_value', + } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_false(applied) -- not applied cleanly, but adjusted + assert.are.same({ + 'value', + 'new_value', + 'very_old_value', + }, result) + end) + + it('may apply to wrong instance of identical boilerplate code', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + return { +- status = "old" ++ status = "new" +]] + local original = { + 'return {', + ' status = "old"', + '}', + 'return {', + ' status = "old"', + '}', + } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ + 'return {', + ' status = "new"', + '}', + 'return {', + ' status = "old"', + '}', + }, result) + end) end) From fc1655a51455df3435b3766bab9cc85c741ce220 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Nov 2025 07:45:11 +0000 Subject: [PATCH 192/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 539986b7..d071b89f 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 19 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -32,11 +32,9 @@ Table of Contents *CopilotChat-table-of-contents* 6. Development |CopilotChat-development| - Setup |CopilotChat-setup| - Contributing |CopilotChat-contributing| -7. Acknowledgments |CopilotChat-acknowledgments| - - diff-match-patch |CopilotChat-diff-match-patch| -8. Contributors |CopilotChat-contributors| -9. Stargazers |CopilotChat-stargazers| -10. Links |CopilotChat-links| +7. Contributors |CopilotChat-contributors| +8. Stargazers |CopilotChat-stargazers| +9. Links |CopilotChat-links| CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. @@ -610,35 +608,23 @@ See CONTRIBUTING.md for detailed guidelines. ============================================================================== -7. Acknowledgments *CopilotChat-acknowledgments* - - -DIFF-MATCH-PATCH *CopilotChat-diff-match-patch* - -CopilotChat.nvim includes diff-match-patch (Lua port) - for diffing and patching -functionality. Copyright 2018 The diff-match-patch Authors. Licensed under the -Apache License 2.0. - - -============================================================================== -8. Contributors *CopilotChat-contributors* +7. Contributors *CopilotChat-contributors* Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻This project follows the all-contributors specification. Contributions of any kind are welcome! ============================================================================== -9. Stargazers *CopilotChat-stargazers* +8. Stargazers *CopilotChat-stargazers* ============================================================================== -10. Links *CopilotChat-links* +9. Links *CopilotChat-links* 1. *Stargazers over time*: https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg?variant=adaptive From 9f9d587118d647724c854ac19a672a6b412d8c7d Mon Sep 17 00:00:00 2001 From: Max Kharandziuk Date: Wed, 19 Nov 2025 15:37:15 +0100 Subject: [PATCH 193/250] test: add comprehensive unified diff test coverage (#1489) --- lua/CopilotChat/utils/diff.lua | 22 ++- tests/diff_spec.lua | 308 +++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 51cf1887..a67518c9 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -16,9 +16,9 @@ local function parse_hunks(diff_text) local start_old, len_old, start_new, len_new = line:match('@@%s%-(%d+),?(%d*)%s%+(%d+),?(%d*)%s@@') current_hunk = { start_old = tonumber(start_old), - len_old = tonumber(len_old) or 1, + len_old = len_old == '' and 1 or tonumber(len_old), start_new = tonumber(start_new), - len_new = tonumber(len_new) or 1, + len_new = len_new == '' and 1 or tonumber(len_new), old_snippet = {}, new_snippet = {}, } @@ -90,6 +90,24 @@ local function apply_hunk(hunk, content) local lines = vim.split(content, '\n') local start_idx = hunk.start_old + -- Handle insertions (len_old == 0) + if hunk.len_old == 0 then + -- For insertions, start_old indicates where to insert + -- start_old = 0 means insert at beginning + -- start_old = n means insert after line n + if start_idx == 0 then + start_idx = 1 + else + start_idx = start_idx + 1 + end + local new_lines = vim.list_slice(lines, 1, start_idx - 1) + vim.list_extend(new_lines, hunk.new_snippet) + vim.list_extend(new_lines, lines, start_idx, #lines) + -- Insertions are always applied cleanly if we reach this point + return table.concat(new_lines, '\n'), true + end + + -- Handle replacements and deletions (len_old > 0) -- If we have a start line hint, try to find best match within +/- 2 lines if start_idx and start_idx > 0 and start_idx <= #lines then local match_idx = find_best_match(lines, hunk.old_snippet, start_idx, 2) diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 41ed262c..21807810 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -304,4 +304,312 @@ describe('CopilotChat.utils.diff', function() '}', }, result) end) + + it('allows adding at very start with zero original lines', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -0,0 +1,2 @@ ++first ++second +]] + local original = { 'x', 'y' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'first', 'second', 'x', 'y' }, result) + end) + + it('handles insertion at end without context', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -3,0 +4,2 @@ ++new1 ++new2 +]] + local original = { 'a', 'b', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'c', 'new1', 'new2' }, result) + end) + + it('supports multiple adjacent hunks modifying contiguous lines', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,1 @@ +-a ++x +@@ -2,1 +2,1 @@ +-b ++y +]] + local original = { 'a', 'b', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'x', 'y', 'c' }, result) + end) + + it('handles diff with trailing newline missing in original', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,1 @@ +-old ++new +]] + local original_content = 'old' -- no trailing newline + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'new' }, result) + end) + + it('handles diff ending without newline on addition lines', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,2 @@ + old ++new]] + local original = { 'old' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'old', 'new' }, result) + end) + + it('handles hunks with zero-context lines around changes', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -2,0 +3,1 @@ ++added +]] + local original = { 'a', 'b', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'added', 'c' }, result) + end) + + it('handles insertion of identical-to-context line', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,2 @@ + context ++context +]] + local original = { 'context', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ 'context', 'context', 'other' }, result) + end) + + it('rejects hunk with wrong header lengths', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + context +-old ++new +]] + local original = { 'context' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching may still apply despite wrong header lengths + assert.is_not_nil(result) + end) + + it('handles CRLF original with unix diff', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,1 +1,1 @@ +-old ++new +]] + local original_content = 'old\r\n' + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.is_not_nil(result) + assert.is_true(#result >= 1) + end) + + it('handles large insertion with no context', function() + local lines = {} + for i = 1, 10 do + table.insert(lines, '+line' .. i) + end + local diff_text = '--- a/foo.txt\n+++ b/foo.txt\n@@ -4,0 +5,10 @@\n' .. table.concat(lines, '\n') .. '\n' + local original = { 'a', 'b', 'c', 'd', 'e' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + local expected = { 'a', 'b', 'c', 'd' } + for i = 1, 10 do + table.insert(expected, 'line' .. i) + end + table.insert(expected, 'e') + assert.are.same(expected, result) + end) + + it('rejects mismatched deletion ranges', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +0,0 @@ +-old1 +-old2 +-old3 +]] + local original = { 'single' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching may apply the deletion despite mismatch + assert.is_not_nil(result) + end) + + it('handles mixed operations in one hunk', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,5 +1,4 @@ + context1 +-old + unchanged +-old2 ++new2 + context2 +]] + local original = { 'context1', 'old', 'unchanged', 'old2', 'context2' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ 'context1', 'unchanged', 'new2', 'context2' }, result) + end) + + it('handles leading tabs/spaces inside context lines', function() + local diff_text = [[ +--- a/x ++++ b/x +@@ -1,2 +1,2 @@ + indented +-old ++new +]] + local original = { '\tindented', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ '\tindented', 'new' }, result) + end) + + it('respects diff markers even if content begins with + or -', function() + local diff_text = [[ +--- a/x ++++ b/x +@@ -1,2 +1,2 @@ +-+literalplus +--literalminus +++literalplus +++literalminus +]] + local original = { '+literalplus', '-literalminus' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ '+literalplus', '+literalminus' }, result) + end) + + it('applies diff despite slight context mismatch with fuzzy matching', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + slightly different context +-old ++new +]] + local original = { 'context', 'old', 'other' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching will replace context lines that don't match + assert.are.same({ 'slightly different context', 'new', 'other' }, result) + end) + + it('applies even when context is completely wrong due to fuzzy matching', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + totally wrong line + another wrong line +-old ++new +]] + local original = { 'context1', 'context2', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching will replace all old_snippet lines (including wrong context) with new_snippet + assert.are.same({ 'totally wrong line', 'another wrong line', 'new' }, result) + end) + + it('applies with partial context match', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -2,3 +2,3 @@ + matching +-old ++new +]] + local original = { 'first', 'matching', 'old', 'last' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + assert.is_true(applied) + assert.are.same({ 'first', 'matching', 'new', 'last' }, result) + end) + + it('handles context with extra lines not in original', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,5 +1,5 @@ + context1 + context2 + context3 +-old ++new +]] + local original = { 'context1', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Should fail or apply with fuzzy matching + assert.is_not_nil(result) + end) + + it('fails when deletion target does not exist', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,2 +1,1 @@ + context +-nonexistent +]] + local original = { 'context', 'actual' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching might still apply or fail + assert.is_not_nil(result) + end) + + it('applies when context lines are in different order', function() + local diff_text = [[ +--- a/foo.txt ++++ b/foo.txt +@@ -1,3 +1,3 @@ + line2 + line1 +-old ++new +]] + local original = { 'line1', 'line2', 'old' } + local result, applied = diff.apply_unified_diff(diff_text, table.concat(original, '\n')) + -- Fuzzy matching should handle reordered context + assert.is_not_nil(result) + end) end) From be3291c5dca648d15c1e3a1433dc7118b22b188a Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 15:38:37 +0100 Subject: [PATCH 194/250] docs: add kharandziuk as a contributor for code (#1492) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 7d438502..c4ad3996 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -480,6 +480,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/21695295?v=4", "profile": "https://github.com/garcia5", "contributions": ["code"] + }, + { + "login": "kharandziuk", + "name": "Max Kharandziuk", + "avatar_url": "https://avatars.githubusercontent.com/u/3404755?v=4", + "profile": "https://github.com/kharandziuk", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 89607cbc..6e923106 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d CTCHEN
CTCHEN

💻 Tobias Wölfel
Tobias Wölfel

💻 Alexander Garcia
Alexander Garcia

💻 + Max Kharandziuk
Max Kharandziuk

💻 From 91400214254f9e6d601db7495ec2f41e67052dad Mon Sep 17 00:00:00 2001 From: Max Kharandziuk Date: Wed, 19 Nov 2025 23:37:40 +0100 Subject: [PATCH 195/250] fix(diff): implement offset tracking for sequential hunk application (#1493) - Adjust hunk start positions by cumulative offset to match patch utility behavior - Add comprehensive tests for offset logic (additions, deletions, mixed, context) - Ensure unified diff application is robust for multi-hunk scenarios --- lua/CopilotChat/utils/diff.lua | 13 +- tests/diff_spec.lua | 428 +++++++++++++++++++++++++++++++++ 2 files changed, 440 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index a67518c9..c0cf2f85 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -143,10 +143,21 @@ function M.apply_unified_diff(diff_text, original_content) local hunks = parse_hunks(diff_text) local new_content = original_content local applied = false + local offset = 0 -- Track cumulative line offset from previous hunks + for _, hunk in ipairs(hunks) do - local patched, ok = apply_hunk(hunk, new_content) + -- Adjust hunk start position based on accumulated offset + local adjusted_hunk = vim.deepcopy(hunk) + if adjusted_hunk.start_old then + adjusted_hunk.start_old = hunk.start_old + offset + end + + local patched, ok = apply_hunk(adjusted_hunk, new_content) new_content = patched applied = applied or ok + + -- Update offset: (new lines added) - (old lines removed) + offset = offset + (#hunk.new_snippet - #hunk.old_snippet) end local new_lines = vim.split(new_content, '\n', { trimempty = true }) diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua index 21807810..bfaa19a4 100644 --- a/tests/diff_spec.lua +++ b/tests/diff_spec.lua @@ -612,4 +612,432 @@ describe('CopilotChat.utils.diff', function() -- Fuzzy matching should handle reordered context assert.is_not_nil(result) end) + + it('adds max_retry_time and cumulative retry logic', function() + local diff_text = [[ +--- original.py ++++ modified.py +@@ -24,6 +24,7 @@ + import time + + retry_statuses = {HTTPStatus.TOO_MANY_REQUESTS, 502, 503, 504} ++ max_retry_time = 120 # Maximum cumulative retry time in seconds + retry_exceptions = ( + httpx.ReadTimeout, + httpx.ConnectTimeout, +@@ -34,6 +35,7 @@ + def deco(fn): + def wrapped(*args, **kwargs): + last_exc = None ++ total_retry_time = 0 # Track cumulative retry time + for attempt in range(retries): + try: + resp = fn(*args, **kwargs) +@@ -43,6 +45,9 @@ + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) ++ if total_retry_time + delay > max_retry_time: ++ raise TimeoutError("Exceeded maximum retry time of 120 seconds") ++ total_retry_time += delay + time.sleep(delay) + continue + +@@ -59,6 +64,9 @@ + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) ++ if total_retry_time + delay > max_retry_time: ++ raise TimeoutError("Exceeded maximum retry time of 120 seconds") ++ total_retry_time += delay + time.sleep(delay) + continue +]] + local original = [[ +import base64 +import json +import logging +import os +import random +from datetime import datetime, time +from http import HTTPStatus + +import geojson +import httpx +from cachetools import TTLCache, cached +from geopy.distance import geodesic +from shapely.geometry import MultiPolygon, Polygon, shape + +logger = logging.getLogger(__name__) + +httpx_client = httpx.Client( + timeout=10.0, + limits=httpx.Limits(max_keepalive_connections=20, max_connections=100), +) + + +def retry_request(retries=10, backoff=1, max_backoff=40.0): + import time + + retry_statuses = {HTTPStatus.TOO_MANY_REQUESTS, 502, 503, 504} + retry_exceptions = ( + httpx.ReadTimeout, + httpx.ConnectTimeout, + httpx.NetworkError, # includes transient connection errors + httpx.RemoteProtocolError, + ) + + def deco(fn): + def wrapped(*args, **kwargs): + last_exc = None + for attempt in range(retries): + try: + resp = fn(*args, **kwargs) + except retry_exceptions as exc: + last_exc = exc + # backoff and retry + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + time.sleep(delay) + continue + + # Retry on selected HTTP status + if resp.status_code in retry_statuses: + # honor Retry-After if present + ra = resp.headers.get("Retry-After") + if ra: + try: + delay = min(max_backoff, float(ra)) + except ValueError: + delay = min(max_backoff, backoff * (2**attempt)) + else: + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + time.sleep(delay) + continue + + return resp + + if last_exc: + raise last_exc + return resp + + return wrapped + + return deco +]] + local expected = [[ +import base64 +import json +import logging +import os +import random +from datetime import datetime, time +from http import HTTPStatus + +import geojson +import httpx +from cachetools import TTLCache, cached +from geopy.distance import geodesic +from shapely.geometry import MultiPolygon, Polygon, shape + +logger = logging.getLogger(__name__) + +httpx_client = httpx.Client( + timeout=10.0, + limits=httpx.Limits(max_keepalive_connections=20, max_connections=100), +) + + +def retry_request(retries=10, backoff=1, max_backoff=40.0): + import time + + retry_statuses = {HTTPStatus.TOO_MANY_REQUESTS, 502, 503, 504} + max_retry_time = 120 # Maximum cumulative retry time in seconds + retry_exceptions = ( + httpx.ReadTimeout, + httpx.ConnectTimeout, + httpx.NetworkError, # includes transient connection errors + httpx.RemoteProtocolError, + ) + + def deco(fn): + def wrapped(*args, **kwargs): + last_exc = None + total_retry_time = 0 # Track cumulative retry time + for attempt in range(retries): + try: + resp = fn(*args, **kwargs) + except retry_exceptions as exc: + last_exc = exc + # backoff and retry + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + if total_retry_time + delay > max_retry_time: + raise TimeoutError("Exceeded maximum retry time of 120 seconds") + total_retry_time += delay + time.sleep(delay) + continue + + # Retry on selected HTTP status + if resp.status_code in retry_statuses: + # honor Retry-After if present + ra = resp.headers.get("Retry-After") + if ra: + try: + delay = min(max_backoff, float(ra)) + except ValueError: + delay = min(max_backoff, backoff * (2**attempt)) + else: + delay = min(max_backoff, backoff * (2**attempt)) * ( + 1 + random.random() * 0.25 + ) + if total_retry_time + delay > max_retry_time: + raise TimeoutError("Exceeded maximum retry time of 120 seconds") + total_retry_time += delay + time.sleep(delay) + continue + + return resp + + if last_exc: + raise last_exc + return resp + + return wrapped + + return deco +]] + local result, applied = diff.apply_unified_diff(diff_text, original) + local expected_lines = vim.split(expected, '\n', { trimempty = true }) + assert.are.same(expected_lines, result) + end) + + -- Tests for offset tracking in sequential hunk application + it('correctly applies offset when first hunk adds lines', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,4 @@ + line1 ++added1 ++added2 + line2 +@@ -3,1 +5,1 @@ + line3 +]] + local original = { 'line1', 'line2', 'line3' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'line1', 'added1', 'added2', 'line2', 'line3' }, result) + end) + + it('correctly applies offset when first hunk removes lines', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,1 @@ + line1 +-line2 +-line3 +@@ -4,1 +2,1 @@ + line4 +]] + local original = { 'line1', 'line2', 'line3', 'line4' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'line1', 'line4' }, result) + end) + + it('correctly tracks offset through multiple hunks with mixed add/remove', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,2 @@ + a ++b +@@ -2,1 +3,1 @@ +-c ++C +@@ -3,1 +4,3 @@ + d ++e ++f +]] + local original = { 'a', 'c', 'd' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'C', 'd', 'e', 'f' }, result) + end) + + it('handles offset when hunks are far apart', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -2,1 +2,2 @@ + line2 ++inserted +@@ -10,1 +11,1 @@ +-line10 ++LINE10 +]] + local original = { + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + 'line6', + 'line7', + 'line8', + 'line9', + 'line10', + } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + local expected = { + 'line1', + 'line2', + 'inserted', + 'line3', + 'line4', + 'line5', + 'line6', + 'line7', + 'line8', + 'line9', + 'LINE10', + } + assert.are.same(expected, result) + end) + + it('applies three consecutive hunks with positive offset accumulation', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,2 @@ + a ++b +@@ -2,1 +3,2 @@ + c ++d +@@ -3,1 +5,2 @@ + e ++f +]] + local original = { 'a', 'c', 'e' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'c', 'd', 'e', 'f' }, result) + end) + + it('applies three consecutive hunks with negative offset accumulation', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,1 @@ +-x + a +@@ -3,2 +2,1 @@ +-y + b +@@ -5,2 +3,1 @@ +-z + c +]] + local original = { 'x', 'a', 'y', 'b', 'z', 'c' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'a', 'b', 'c' }, result) + end) + + it('handles zero-offset hunks (replacements without size change)', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,1 +1,1 @@ +-old1 ++new1 +@@ -2,1 +2,1 @@ +-old2 ++new2 +@@ -3,1 +3,1 @@ +-old3 ++new3 +]] + local original = { 'old1', 'old2', 'old3' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'new1', 'new2', 'new3' }, result) + end) + + it('applies offset correctly when first hunk is pure insertion (len_old=0)', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -0,0 +1,2 @@ ++inserted1 ++inserted2 +@@ -1,1 +3,1 @@ + original +]] + local original = { 'original' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'inserted1', 'inserted2', 'original' }, result) + end) + + it('handles complex offset scenario with interleaved additions and deletions', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,2 +1,1 @@ +-delete1 + keep1 +@@ -3,1 +2,3 @@ + keep2 ++add1 ++add2 +@@ -4,2 +5,1 @@ +-delete2 + keep3 +]] + local original = { 'delete1', 'keep1', 'keep2', 'delete2', 'keep3' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'keep1', 'keep2', 'add1', 'add2', 'keep3' }, result) + end) + + it('offset tracking works with hunks that have context lines', function() + local diff_text = [[ +--- a/test.txt ++++ b/test.txt +@@ -1,3 +1,4 @@ + ctx1 + line1 ++inserted + ctx2 +@@ -5,2 +6,2 @@ + ctx3 +-line2 ++LINE2 +]] + local original = { 'ctx1', 'line1', 'ctx2', 'ctx3', 'line2' } + local original_content = table.concat(original, '\n') + local result, applied = diff.apply_unified_diff(diff_text, original_content) + assert.is_true(applied) + assert.are.same({ 'ctx1', 'line1', 'inserted', 'ctx2', 'ctx3', 'LINE2' }, result) + end) end) From b3d675ee50f25a39d2fb96bd40e43e9ae274ec86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Nov 2025 22:38:03 +0000 Subject: [PATCH 196/250] 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 d071b89f..005bb8ef 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -613,7 +613,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻This project follows the all-contributors specification. Contributions of any kind are welcome! From b6ff587b7e203dbe282ba370929fbbc4d52f13c3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 20 Nov 2025 01:43:41 +0100 Subject: [PATCH 197/250] fix(ui): preserve block content formatting when parsing chat messages (#1495) Remove trimming of block content when parsing chat messages to ensure that formatting, such as leading and trailing whitespace, is preserved. This prevents unintended loss of formatting in code or text blocks within chat sections. --- lua/CopilotChat/ui/chat.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 0d2e9bc5..45ac51a6 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -734,7 +734,7 @@ function Chat:parse() message.content = vim.trim(table.concat(message.content, '\n')) if message.section then for _, block in ipairs(message.section.blocks) do - block.content = vim.trim(table.concat(block.content, '\n')) + block.content = table.concat(block.content, '\n') end end From df5376c132382dd47e3e552612940cbf25b3580c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Nov 2025 00:44:01 +0000 Subject: [PATCH 198/250] 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 005bb8ef..134ff24c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 19 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 20 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 02c5cf3a6e030ec81795f16ab5e4f3a8861736db Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 22 Dec 2025 11:19:34 +0100 Subject: [PATCH 199/250] fix(providers): Correctly handle tool calls and responses API output (#1501) Closes #1499 Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/providers.lua | 647 ++++++++++++--------------- 1 file changed, 287 insertions(+), 360 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 5b4e95f9..aac7b36c 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -196,34 +196,295 @@ local function get_github_models_token(tag) return github_device_flow(tag, '178c6fc778ccc68e1d6a', 'read:user copilot') end ---- Helper function to extract text content from Responses API output parts ----@param parts table Array of content parts from Responses API +--- Prepare input for Responses API +---@param inputs table +---@param opts CopilotChat.config.providers.Options +---@return table +local function prepare_responses_input(inputs, opts) + local instructions = nil + local input_messages = {} + + for _, msg in ipairs(inputs) do + if msg.role == constants.ROLE.SYSTEM then + instructions = instructions and (instructions .. '\n\n' .. msg.content) or msg.content + elseif msg.role == constants.ROLE.TOOL then + table.insert(input_messages, { + type = 'function_call_output', + call_id = msg.tool_call_id, + output = msg.content, + }) + else + table.insert(input_messages, { + role = msg.role, + content = msg.content, + }) + + if msg.tool_calls then + for _, tool_call in ipairs(msg.tool_calls) do + table.insert(input_messages, { + type = 'function_call', + call_id = tool_call.id, + name = tool_call.name, + arguments = tool_call.arguments or '', + }) + end + end + end + end + + local out = { + model = opts.model.id, + stream = opts.model.streaming ~= false, + input = input_messages, + } + + if instructions then + out.instructions = instructions + end + + if opts.tools and opts.model.tools then + out.tools = vim.tbl_map(function(tool) + return { + type = 'function', + name = tool.name, + description = tool.description, + parameters = tool.schema, + } + end, opts.tools) + end + + return out +end + +--- Prepare input for Chat Completions API +---@param inputs table +---@param opts CopilotChat.config.providers.Options +---@return table +local function prepare_chat_input(inputs, opts) + local is_o1 = vim.startswith(opts.model.id, 'o1') + + inputs = vim.tbl_map(function(input) + local output = { + role = (is_o1 and input.role == constants.ROLE.SYSTEM) and constants.ROLE.USER or input.role, + content = input.content, + } + + if input.tool_call_id then + output.tool_call_id = input.tool_call_id + end + + if input.tool_calls then + output.tool_calls = vim.tbl_map(function(tool_call) + return { + id = tool_call.id, + type = 'function', + ['function'] = { + name = tool_call.name, + arguments = tool_call.arguments or nil, + }, + } + end, input.tool_calls) + end + + return output + end, inputs) + + local out = { + messages = inputs, + model = opts.model.id, + stream = opts.model.streaming or false, + } + + if opts.tools and opts.model.tools then + out.tools = vim.tbl_map(function(tool) + return { + type = 'function', + ['function'] = { + name = tool.name, + description = tool.description, + parameters = tool.schema, + }, + } + end, opts.tools) + end + + if not is_o1 then + out.n = 1 + out.top_p = 1 + out.temperature = opts.temperature + end + + if opts.model.max_output_tokens then + out.max_tokens = opts.model.max_output_tokens + end + + return out +end +---@param parts table Array of content parts ---@return string The concatenated text content local function extract_text_from_parts(parts) - local content = '' if not parts or type(parts) ~= 'table' then - return content + return '' end + local content = '' for _, part in ipairs(parts) do - if type(part) == 'table' then - -- Handle different content types from Responses API - if part.type == 'output_text' or part.type == 'text' then + if type(part) == 'string' then + content = content .. part + elseif type(part) == 'table' then + -- Responses API: parts have type field + if part.type == 'text' or part.type == 'output_text' or part.type == 'input_text' then content = content .. (part.text or '') - elseif part.output_text then - -- Handle nested output_text - if type(part.output_text) == 'string' then - content = content .. part.output_text - elseif type(part.output_text) == 'table' and part.output_text.text then - content = content .. part.output_text.text + -- Fallback for simpler structures + elseif part.text then + content = content .. part.text + end + end + end + return content +end + +--- Parse Responses API output (both streaming and non-streaming) +---@param output table Raw API response +---@return CopilotChat.config.providers.Output +local function prepare_responses_output(output) + local content = '' + local reasoning = '' + local finish_reason = nil + local total_tokens = nil + local tool_calls = {} + + -- Handle errors + local error_msg = output.error or (output.response and output.response.error) + if error_msg then + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + return { + content = '', + reasoning = '', + finish_reason = 'error: ' .. tostring(error_msg), + total_tokens = nil, + tool_calls = {}, + } + end + + -- Handle streaming events + if output.type then + if output.type == 'response.output_text.delta' then + -- Streaming text delta + if output.delta and type(output.delta) == 'string' then + content = output.delta + elseif output.delta and output.delta.text then + content = output.delta.text + end + elseif output.type == 'response.output_item.done' then + -- Complete output item (including tool calls) + local item = output.item + if item and item.type == 'function_call' then + table.insert(tool_calls, { + id = item.call_id or ('tooluse_' .. (#tool_calls + 1)), + index = #tool_calls + 1, + name = item.name or '', + arguments = item.arguments or '', + }) + end + elseif output.type == 'response.completed' or output.type == 'response.done' then + local response = output.response + if response then + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary + end + if response.usage then + total_tokens = response.usage.total_tokens + end + finish_reason = 'stop' + end + elseif output.type == 'response.failed' then + finish_reason = 'error: ' .. (output.error and output.error.message or 'unknown error') + end + -- Handle non-streaming response + elseif output.response then + local response = output.response + if response.output and #response.output > 0 then + for _, msg in ipairs(response.output) do + if msg.content then + content = content .. extract_text_from_parts(msg.content) + end + if msg.tool_calls then + for i, tool_call in ipairs(msg.tool_calls) do + table.insert(tool_calls, { + id = tool_call.call_id or ('tooluse_' .. i), + index = i, + name = tool_call.name or '', + arguments = tool_call.arguments or '', + }) + end end end - elseif type(part) == 'string' then - content = content .. part end + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary + end + if response.usage then + total_tokens = response.usage.total_tokens + end + finish_reason = response.status == 'completed' and 'stop' or nil end - return content + return { + content = content, + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = total_tokens, + tool_calls = tool_calls, + } +end + +--- Parse Chat Completions API output (both streaming and non-streaming) +---@param output table Raw API response +---@return CopilotChat.config.providers.Output +local function prepare_chat_output(output) + local tool_calls = {} + + local choice + if output.choices and #output.choices > 0 then + for _, c in ipairs(output.choices) do + local message = c.message or c.delta + if message and message.tool_calls then + for i, tool_call in ipairs(message.tool_calls) do + local fn = tool_call['function'] + if fn then + local index = tool_call.index or i + local id = utils.empty(tool_call.id) and ('tooluse_' .. index) or tool_call.id + table.insert(tool_calls, { + id = id, + index = index, + name = fn.name, + arguments = fn.arguments or '', + }) + end + end + end + end + choice = output.choices[1] + else + choice = output + end + + 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 or output.usage and output.usage.total_tokens + local finish_reason = choice.finish_reason or choice.done_reason or output.finish_reason or output.done_reason + + return { + content = content, + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = usage, + tool_calls = tool_calls, + } end ---@class CopilotChat.config.providers.Options @@ -380,355 +641,23 @@ M.copilot = { end, prepare_input = function(inputs, opts) - local is_o1 = vim.startswith(opts.model.id, 'o1') - - -- Check if this model uses the Responses API if opts.model.use_responses then - -- Prepare input for Responses API - local instructions = nil - local input_messages = {} - - for _, msg in ipairs(inputs) do - if msg.role == constants.ROLE.SYSTEM then - -- Combine system messages as instructions - if instructions then - instructions = instructions .. '\n\n' .. msg.content - else - instructions = msg.content - end - else - -- Include the message in the input array - table.insert(input_messages, { - role = msg.role, - content = msg.content, - }) - end - end - - -- The Responses API expects the input field to be an array of message objects - local out = { - model = opts.model.id, - -- Always request streaming for Responses API (honor model.streaming or default to true) - stream = opts.model.streaming ~= false, - input = input_messages, - } - - -- Add instructions if we have any system messages - if instructions then - out.instructions = instructions - end - - -- Add tools for Responses API if available - if opts.tools and opts.model.tools then - out.tools = vim.tbl_map(function(tool) - return { - type = 'function', - ['function'] = { - name = tool.name, - description = tool.description, - parameters = tool.schema, - strict = true, - }, - } - end, opts.tools) - end - - -- Note: temperature is not supported by Responses API, so we don't include it - - return out - end - - -- Original Chat Completion API logic - inputs = vim.tbl_map(function(input) - local output = { - role = input.role, - content = input.content, - } - - if is_o1 then - if input.role == constants.ROLE.SYSTEM then - output.role = constants.ROLE.USER - end - end - - if input.tool_call_id then - output.tool_call_id = input.tool_call_id - end - - if input.tool_calls then - output.tool_calls = vim.tbl_map(function(tool_call) - return { - id = tool_call.id, - type = 'function', - ['function'] = { - name = tool_call.name, - arguments = tool_call.arguments or nil, - }, - } - end, input.tool_calls) - end - - return output - end, inputs) - - local out = { - messages = inputs, - model = opts.model.id, - stream = opts.model.streaming or false, - } - - if opts.tools and opts.model.tools then - out.tools = vim.tbl_map(function(tool) - return { - type = 'function', - ['function'] = { - name = tool.name, - description = tool.description, - parameters = tool.schema, - }, - } - end, opts.tools) - end - - if not is_o1 then - out.n = 1 - out.top_p = 1 - out.temperature = opts.temperature + return prepare_responses_input(inputs, opts) end - - if opts.model.max_output_tokens then - out.max_tokens = opts.model.max_output_tokens - end - - return out + return prepare_chat_input(inputs, opts) end, prepare_output = function(output, opts) - -- Check if this model uses the Responses API if opts and opts.model and opts.model.use_responses then - -- Handle Responses API output format - local content = '' - local reasoning = '' - local finish_reason = nil - local total_tokens = 0 - local tool_calls = {} - - -- Check for error in response - if output.error then - -- Surface the error as a finish reason to stop processing - local error_msg = output.error - if type(error_msg) == 'table' then - error_msg = error_msg.message or vim.inspect(error_msg) - end - return { - content = '', - reasoning = '', - finish_reason = 'error: ' .. tostring(error_msg), - total_tokens = nil, - tool_calls = {}, - } - end - - if output.type then - -- This is a streaming response from Responses API - if output.type == 'response.created' or output.type == 'response.in_progress' then - -- In-progress events, we don't have content yet - return { - content = '', - reasoning = '', - finish_reason = nil, - total_tokens = nil, - tool_calls = {}, - } - elseif output.type == 'response.completed' then - -- Completed response: do NOT resend content here to avoid duplication. - -- Only signal finish and capture usage/reasoning. - local response = output.response - if response then - if response.reasoning and response.reasoning.summary then - reasoning = response.reasoning.summary - end - if response.usage then - total_tokens = response.usage.total_tokens - end - finish_reason = 'stop' - end - return { - content = '', - reasoning = reasoning, - finish_reason = finish_reason, - total_tokens = total_tokens, - tool_calls = {}, - } - elseif output.type == 'response.content.delta' or output.type == 'response.output_text.delta' then - -- Streaming content delta - if output.delta then - if type(output.delta) == 'string' then - content = output.delta - elseif type(output.delta) == 'table' then - if output.delta.content then - content = output.delta.content - elseif output.delta.output_text then - content = extract_text_from_parts({ output.delta.output_text }) - elseif output.delta.text then - content = output.delta.text - end - end - end - elseif output.type == 'response.delta' then - -- Handle response.delta with nested output_text - if output.delta and output.delta.output_text then - content = extract_text_from_parts({ output.delta.output_text }) - end - elseif output.type == 'response.content.done' or output.type == 'response.output_text.done' then - -- Terminal content event; keep streaming open until response.completed provides usage info - finish_reason = nil - elseif output.type == 'response.error' then - -- Handle error event - local error_msg = output.error - if type(error_msg) == 'table' then - error_msg = error_msg.message or vim.inspect(error_msg) - end - finish_reason = 'error: ' .. tostring(error_msg) - elseif output.type == 'response.tool_call.delta' then - -- Handle tool call delta events - if output.delta and output.delta.tool_calls then - for _, tool_call in ipairs(output.delta.tool_calls) do - local id = tool_call.id or ('tooluse_' .. (tool_call.index or 1)) - local existing_call = nil - for _, tc in ipairs(tool_calls) do - if tc.id == id then - existing_call = tc - break - end - end - if not existing_call then - table.insert(tool_calls, { - id = id, - index = tool_call.index or #tool_calls + 1, - name = tool_call.name or '', - arguments = tool_call.arguments or '', - }) - else - -- Append arguments - existing_call.arguments = existing_call.arguments .. (tool_call.arguments or '') - end - end - end - end - elseif output.response then - -- Non-streaming response or final response - local response = output.response - - -- Check for error in the response object - if response.error then - local error_msg = response.error - if type(error_msg) == 'table' then - error_msg = error_msg.message or vim.inspect(error_msg) - end - return { - content = '', - reasoning = '', - finish_reason = 'error: ' .. tostring(error_msg), - total_tokens = nil, - tool_calls = {}, - } - end - - if response.output and #response.output > 0 then - for _, msg in ipairs(response.output) do - if msg.content and #msg.content > 0 then - content = content .. extract_text_from_parts(msg.content) - end - -- Extract tool calls from output messages - if msg.tool_calls then - for i, tool_call in ipairs(msg.tool_calls) do - local id = tool_call.id or ('tooluse_' .. i) - table.insert(tool_calls, { - id = id, - index = tool_call.index or i, - name = tool_call.name or '', - arguments = tool_call.arguments or '', - }) - end - end - end - end - - if response.reasoning and response.reasoning.summary then - reasoning = response.reasoning.summary - end - - if response.usage then - total_tokens = response.usage.total_tokens - end - - finish_reason = response.status == 'completed' and 'stop' or nil - end - - return { - content = content, - reasoning = reasoning, - finish_reason = finish_reason, - total_tokens = total_tokens, - tool_calls = tool_calls, - } + return prepare_responses_output(output) end - - -- Original Chat Completion API logic - local tool_calls = {} - - local choice - if output.choices and #output.choices > 0 then - for _, choice in ipairs(output.choices) do - local message = choice.message or choice.delta - if message and message.tool_calls then - for i, tool_call in ipairs(message.tool_calls) do - local fn = tool_call['function'] - if fn then - local index = tool_call.index or i - local id = utils.empty(tool_call.id) and ('tooluse_' .. index) or tool_call.id - table.insert(tool_calls, { - id = id, - index = index, - name = fn.name, - arguments = fn.arguments or '', - }) - end - end - end - end - - choice = output.choices[1] - else - choice = output - end - - 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 - end - local finish_reason = choice.finish_reason or choice.done_reason or output.finish_reason or output.done_reason - - return { - content = content, - reasoning = reasoning, - finish_reason = finish_reason, - total_tokens = usage, - tool_calls = tool_calls, - } + return prepare_chat_output(output) end, get_url = function(opts) - -- Check if this model uses the Responses API if opts and opts.model and opts.model.use_responses then return 'https://api.githubcopilot.com/responses' end - - -- Default to Chat Completion API return 'https://api.githubcopilot.com/chat/completions' end, } @@ -755,17 +684,15 @@ M.github_models = { return vim .iter(response.body) :map(function(model) - local max_output_tokens = model.limits.max_output_tokens - local max_input_tokens = model.limits.max_input_tokens return { id = model.id, name = model.name, - tokenizer = 'o200k_base', - max_input_tokens = max_input_tokens, - 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'), + tokenizer = 'o200k_base', -- GitHub Models doesn't expose tokenizer info + max_input_tokens = model.limits and model.limits.max_input_tokens, + max_output_tokens = model.limits and model.limits.max_output_tokens, + streaming = model.capabilities and vim.tbl_contains(model.capabilities, 'streaming') or false, + tools = model.capabilities and vim.tbl_contains(model.capabilities, 'tool-calling') or false, + reasoning = model.capabilities and vim.tbl_contains(model.capabilities, 'reasoning') or false, version = model.version, } end) From 850c969a500857b895f263acc675bb93cdf9589c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Dec 2025 10:19:50 +0000 Subject: [PATCH 200/250] 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 134ff24c..5f1cd3a7 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 November 20 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 December 22 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From e243fc74f3e81c0a744f34d91b958a9a7cbde5ea Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 22 Dec 2025 11:20:21 +0100 Subject: [PATCH 201/250] docs: remove chat.response() from README (#1502) It was removed. Closes #1497 Signed-off-by: Tomas Slusny --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 6e923106..209034a1 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,6 @@ local chat = require("CopilotChat") -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config -chat.response() -- Get the last response text -- Window Management chat.open(config) -- Open chat window with optional config From ed94e56ee8292f5df351e17709ff4b178ca84200 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Dec 2025 10:20:38 +0000 Subject: [PATCH 202/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 5f1cd3a7..1830fb8a 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -471,7 +471,6 @@ CORE *CopilotChat-core* -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config - chat.response() -- Get the last response text -- Window Management chat.open(config) -- Open chat window with optional config From d4c9ebef6e3a0df268cdf3f70e958fb7b7bb000b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 31 Dec 2025 12:03:27 +0100 Subject: [PATCH 203/250] feat: add .emmyrc.json for LuaJIT runtime and workspace config (#1505) Add .emmyrc.json to configure EmmyLua with LuaJIT runtime and set require patterns for Lua files. Also includes $VIMRUNTIME in the workspace library for better Neovim integration. --- .emmyrc.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .emmyrc.json diff --git a/.emmyrc.json b/.emmyrc.json new file mode 100644 index 00000000..5f9691d9 --- /dev/null +++ b/.emmyrc.json @@ -0,0 +1,9 @@ +{ + "runtime": { + "version": "LuaJIT", + "requirePattern": ["lua/?.lua", "lua/?/init.lua"] + }, + "workspace": { + "library": ["$VIMRUNTIME"] + } +} From 9db5d3eaafe9fc3c91ce9ecfc416de2798982487 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 31 Dec 2025 11:03:46 +0000 Subject: [PATCH 204/250] 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 1830fb8a..f47ea878 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 December 22 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 December 31 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 4fac5689ef07511d99be85bc0d10d12937ebc375 Mon Sep 17 00:00:00 2001 From: Antonio Cheong Date: Fri, 9 Jan 2026 16:06:55 +0000 Subject: [PATCH 205/250] Remove myself from funding & add Tomas instead I have not been involved in a very long time and it feels wrong to have a link to my profile in the sidebar. This plugin is now pretty much maintained by @deathbeam so it probably fits better. --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index ce9dcccd..4b3b3fbc 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,3 @@ # These are supported funding model platforms -github: [acheong08, jellydn] +github: [deathbeam, jellydn] From 21bdecb25aa72119d11d7fc08c7e0ce323f1b540 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 9 Jan 2026 16:13:44 +0000 Subject: [PATCH 206/250] 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 f47ea878..132166c0 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 December 31 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 January 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 90ebb5072a380c705d8c33427c8685754ef83807 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 18 Jan 2026 16:53:12 +0100 Subject: [PATCH 207/250] feat(config): support multiple custom instruction files (#1510) Add `instruction_files` option to configuration, allowing users to specify multiple custom instruction files to be loaded from the current working directory. The prompt resolution logic now iterates over all configured instruction files and includes their contents if present. This makes it easier to manage and extend custom Copilot instructions across different projects. --- lua/CopilotChat/config.lua | 7 +++++++ lua/CopilotChat/prompts.lua | 24 +++++++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index b78ddf61..83d82dc2 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -43,6 +43,7 @@ ---@field log_level 'trace'|'debug'|'info'|'warn'|'error'|'fatal'? ---@field proxy string? ---@field allow_insecure boolean? +---@field instruction_files table? ---@field selection 'visual'|'unnamed'|nil ---@field chat_autocomplete boolean? ---@field log_path string? @@ -105,6 +106,12 @@ return { proxy = nil, -- [protocol://]host[:port] Use this proxy allow_insecure = false, -- Allow insecure server connections + -- Instruction files to look for in current working directory + instruction_files = { + '.github/copilot-instructions.md', + 'AGENTS.MD', + }, + selection = 'visual', -- Selection source chat_autocomplete = true, -- Enable chat autocompletion (when disabled, requires manual `mappings.complete` trigger) diff --git a/lua/CopilotChat/prompts.lua b/lua/CopilotChat/prompts.lua index 21eb6cd4..e8e9ed28 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -12,16 +12,22 @@ local WORD_WITH_INPUT_UNQUOTED = WORD .. ':?([^%s`]*)' --- Find custom instructions in the current working directory. ---@param cwd string +---@param config CopilotChat.config.Config ---@return table -local function find_custom_instructions(cwd) +local function find_custom_instructions(cwd, config) local out = {} - local copilot_instructions_path = vim.fs.joinpath(cwd, '.github', 'copilot-instructions.md') - local copilot_instructions = files.read_file(copilot_instructions_path) - if copilot_instructions then - table.insert(out, { - filename = copilot_instructions_path, - content = vim.trim(copilot_instructions), - }) + local files_to_check = {} + for _, relpath in ipairs(config.instruction_files or {}) do + table.insert(files_to_check, vim.fs.joinpath(cwd, relpath)) + end + for _, path in ipairs(files_to_check) do + local content = files.read_file(path) + if content then + table.insert(out, { + filename = path, + content = vim.trim(content), + }) + end end return out end @@ -314,7 +320,7 @@ function M.resolve_prompt(prompt, config) end local custom_instructions = vim.trim(require('CopilotChat.instructions.custom_instructions')) - for _, instruction in ipairs(find_custom_instructions(source.cwd())) do + for _, instruction in ipairs(find_custom_instructions(source.cwd(), config)) do config.system_prompt = vim.trim(config.system_prompt) .. '\n' .. custom_instructions:gsub('{FILENAME}', instruction.filename):gsub('{CONTENT}', instruction.content) From 068e2570ff94bf734d0333eaf567a41dad061f43 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 18 Jan 2026 15:53:29 +0000 Subject: [PATCH 208/250] 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 132166c0..79b1c94f 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 January 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 January 18 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 07dcc188bc488b2dafa9324bd42088640bee3d19 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 18 Jan 2026 18:55:34 +0100 Subject: [PATCH 209/250] fix(config): correct AGENTS.md filename casing (#1511) The instruction file 'AGENTS.MD' was renamed to 'AGENTS.md' to match the actual file name and ensure it is properly detected in the working directory. This resolves issues on case-sensitive file systems. --- lua/CopilotChat/config.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 83d82dc2..1e0adda3 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -109,7 +109,7 @@ return { -- Instruction files to look for in current working directory instruction_files = { '.github/copilot-instructions.md', - 'AGENTS.MD', + 'AGENTS.md', }, selection = 'visual', -- Selection source From 1225fe849d17d4a7893b12a400c608a2554149f4 Mon Sep 17 00:00:00 2001 From: Xinyu Xiang <149765160+pxwg@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:54:33 +0800 Subject: [PATCH 210/250] feat(copilot): optimize copilot quota usage for tool calling * update: new function in copilot provider for agent initiator * refactor: remove initiator into prepare_input function --- lua/CopilotChat/client.lua | 9 ++++++++- lua/CopilotChat/config/providers.lua | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 93e1c91d..19ceca73 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -511,7 +511,14 @@ function Client:ask(opts) end local headers = self:authenticate(provider_name) - local request = provider.prepare_input(generate_ask_request(opts.system_prompt, history, generated_messages), options) + + local request, extra_headers = + provider.prepare_input(generate_ask_request(opts.system_prompt, history, generated_messages), options) + + if extra_headers then + headers = vim.tbl_extend('force', headers, extra_headers) + end + local is_stream = request.stream local args = { diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index aac7b36c..896d782d 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -641,10 +641,21 @@ M.copilot = { end, prepare_input = function(inputs, opts) + local request if opts.model.use_responses then - return prepare_responses_input(inputs, opts) + request = prepare_responses_input(inputs, opts) + else + request = prepare_chat_input(inputs, opts) end - return prepare_chat_input(inputs, opts) + + if inputs and #inputs > 0 then + local last_msg = inputs[#inputs] + if last_msg.role == constants.ROLE.TOOL then + return request, { ['x-initiator'] = 'agent' } + end + end + + return request end, prepare_output = function(output, opts) From 8e96dd3b04413e331afeca4ae0d65ac2d7960363 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Feb 2026 11:54:51 +0000 Subject: [PATCH 211/250] 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 79b1c94f..3464df4a 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 January 18 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 February 02 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 6933b82e0b6dac53274cd8bff8fcd986a2070d77 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:56:55 +0100 Subject: [PATCH 212/250] docs: add pxwg as a contributor for code (#1521) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index c4ad3996..d7be9b1f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -487,6 +487,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/3404755?v=4", "profile": "https://github.com/kharandziuk", "contributions": ["code"] + }, + { + "login": "pxwg", + "name": "Xinyu Xiang", + "avatar_url": "https://avatars.githubusercontent.com/u/149765160?v=4", + "profile": "https://github.com/pxwg", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 209034a1..48f26e73 100644 --- a/README.md +++ b/README.md @@ -619,6 +619,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Tobias Wölfel
Tobias Wölfel

💻 Alexander Garcia
Alexander Garcia

💻 Max Kharandziuk
Max Kharandziuk

💻 + Xinyu Xiang
Xinyu Xiang

💻 From 5c5e6c2f0b81c1ae69f0d80ede010839993019e8 Mon Sep 17 00:00:00 2001 From: junqizhang Date: Mon, 2 Feb 2026 11:02:29 -0500 Subject: [PATCH 213/250] fix: support file paths with spaces in block headers (#1504) The block header pattern used %S+ (non-whitespace characters) to match file paths, which failed when paths contained spaces. This prevented code blocks from being parsed and made diff acceptance () non-functional for files in directories with spaces. Changes: - Add new pattern with lazy match (.-) to support paths with spaces - Keep original pattern as fallback for compatibility - Pattern now correctly parses headers like: python path=/path/with spaces/file.py start_line=1 end_line=10 Fixes the issue where blocks count remained 0 even when Treesitter correctly identified block_header and block_content nodes. Tested with paths containing spaces and verified that: - Blocks are now correctly parsed and added to message.section.blocks - Diff acceptance mappings (accept_diff, show_diff, etc.) now work - Backward compatibility maintained for paths without spaces --- lua/CopilotChat/ui/chat.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 45ac51a6..db1abd27 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -39,6 +39,7 @@ local function match_block_header(header) end local patterns = { + '^(%w+)%s+path=(.-)%s+start_line=(%d+)%s+end_line=(%d+)$', '^(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', '^(%w+)$', } From 967dc0a7bd424ddfe7bf9ac1ecb5d3ac1bc9a83b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Feb 2026 16:02:52 +0000 Subject: [PATCH 214/250] 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 3464df4a..158c58b6 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -612,7 +612,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 99a11909a690d756ad864a0f496769d023f2f78b Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:04:42 +0100 Subject: [PATCH 215/250] docs: add junqizhang as a contributor for code (#1523) * docs: update .all-contributorsrc [skip ci] * docs: update README.md [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 3 +++ 2 files changed, 10 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index d7be9b1f..7711217f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -494,6 +494,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/149765160?v=4", "profile": "https://github.com/pxwg", "contributions": ["code"] + }, + { + "login": "junqizhang", + "name": "junqizhang", + "avatar_url": "https://avatars.githubusercontent.com/u/22600124?v=4", + "profile": "https://github.com/junqizhang", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 48f26e73..f99de93b 100644 --- a/README.md +++ b/README.md @@ -621,6 +621,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Max Kharandziuk
Max Kharandziuk

💻 Xinyu Xiang
Xinyu Xiang

💻 + + junqizhang
junqizhang

💻 + From 4d5dc1c3a68841c7cba088db351f38954f9a11d0 Mon Sep 17 00:00:00 2001 From: Xinyu Xiang <149765160+pxwg@users.noreply.github.com> Date: Tue, 3 Feb 2026 03:23:06 +0800 Subject: [PATCH 216/250] feat(copilot): Support "Auto" model mode (Smart Model Selection) similar to VS Code (#1518) * update: add auto into model completions * update: add auto model selection module * fix: remove unnecessary notifications * ui: show current model at header * refactor: remove function to provider configs * fix: rename duplicate function name * fix: remove route_model provider to a better position * fix: add the 'auto' model option as an model in copilot provider * refactor: move model selector to specific provider * cleanup some stuff Signed-off-by: Tomas Slusny --------- Signed-off-by: Tomas Slusny Co-authored-by: Tomas Slusny --- lua/CopilotChat/client.lua | 16 ++++++++++ lua/CopilotChat/config/providers.lua | 47 ++++++++++++++++++++++++++-- lua/CopilotChat/ui/chat.lua | 3 ++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 19ceca73..98bbf388 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -319,6 +319,16 @@ function Client:ask(opts) error('Provider not found: ' .. provider_name) end + if provider.resolve_model then + local headers = self:authenticate(provider_name) + local resolved_model = provider.resolve_model(headers, opts.model) + opts.model = resolved_model + model_config = models[opts.model] + if not model_config then + error('Resolved model not found: ' .. opts.model) + end + end + local options = { model = vim.tbl_extend('force', model_config, { id = opts.model:gsub(':' .. provider_name .. '$', ''), @@ -389,6 +399,7 @@ function Client:ask(opts) local errored = nil local finished = false local token_count = 0 + local out_model = nil local response_content_buffer = stringbuffer() local response_reasoning_buffer = stringbuffer() @@ -451,6 +462,10 @@ function Client:ask(opts) response_reasoning_buffer:put(out.reasoning) end + if out.model then + out_model = out.model + end + if opts.on_progress then opts.on_progress({ role = constants.ROLE.ASSISTANT, @@ -589,6 +604,7 @@ function Client:ask(opts) content = response_text, reasoning = response_reasoning, tool_calls = #tool_calls:values() > 0 and tool_calls:values() or nil, + model = out_model, }, token_count = token_count, token_max_count = max_tokens, diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 896d782d..3df37667 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -197,7 +197,7 @@ local function get_github_models_token(tag) end --- Prepare input for Responses API ----@param inputs table +---@param inputs CopilotChat.client.Message[] ---@param opts CopilotChat.config.providers.Options ---@return table local function prepare_responses_input(inputs, opts) @@ -257,7 +257,7 @@ local function prepare_responses_input(inputs, opts) end --- Prepare input for Chat Completions API ----@param inputs table +---@param inputs CopilotChat.client.Message[] ---@param opts CopilotChat.config.providers.Options ---@return table local function prepare_chat_input(inputs, opts) @@ -353,6 +353,7 @@ local function prepare_responses_output(output) local finish_reason = nil local total_tokens = nil local tool_calls = {} + local model = nil -- Handle errors local error_msg = output.error or (output.response and output.response.error) @@ -366,6 +367,7 @@ local function prepare_responses_output(output) finish_reason = 'error: ' .. tostring(error_msg), total_tokens = nil, tool_calls = {}, + model = nil, } end @@ -398,6 +400,9 @@ local function prepare_responses_output(output) if response.usage then total_tokens = response.usage.total_tokens end + if response.model then + model = response.model + end finish_reason = 'stop' end elseif output.type == 'response.failed' then @@ -429,6 +434,9 @@ local function prepare_responses_output(output) if response.usage then total_tokens = response.usage.total_tokens end + if response.model then + model = response.model + end finish_reason = response.status == 'completed' and 'stop' or nil end @@ -438,6 +446,7 @@ local function prepare_responses_output(output) finish_reason = finish_reason, total_tokens = total_tokens, tool_calls = tool_calls, + model = model, } end @@ -477,6 +486,7 @@ local function prepare_chat_output(output) local reasoning = message and (message.reasoning or message.reasoning_content) local usage = choice.usage and choice.usage.total_tokens or output.usage and output.usage.total_tokens local finish_reason = choice.finish_reason or choice.done_reason or output.finish_reason or output.done_reason + local model = choice.model or output.model return { content = content, @@ -484,6 +494,7 @@ local function prepare_chat_output(output) finish_reason = finish_reason, total_tokens = usage, tool_calls = tool_calls, + model = model, } end @@ -498,13 +509,15 @@ end ---@field finish_reason string? ---@field total_tokens number? ---@field tool_calls table +---@field model string? ---@class CopilotChat.config.providers.Provider ---@field disabled nil|boolean ---@field get_headers nil|fun():table,number? ---@field get_info nil|fun(headers:table):string[] ---@field get_models nil|fun(headers:table):table ----@field prepare_input nil|fun(inputs:table, opts:CopilotChat.config.providers.Options):table +---@field resolve_model nil|fun(headers:table, model: string):string +---@field prepare_input nil|fun(inputs:CopilotChat.client.Message[], opts:CopilotChat.config.providers.Options):table,table? ---@field prepare_output nil|fun(output:table, opts:CopilotChat.config.providers.Options):CopilotChat.config.providers.Output ---@field get_url nil|fun(opts:CopilotChat.config.providers.Options):string @@ -529,6 +542,7 @@ M.copilot = { ['Editor-Version'] = EDITOR_VERSION, ['Editor-Plugin-Version'] = 'CopilotChat.nvim/*', ['Copilot-Integration-Id'] = 'vscode-chat', + ['x-github-api-version'] = '2025-10-01', }, response.body.expires_at end, @@ -637,9 +651,36 @@ M.copilot = { end end + -- Auto model selector + table.insert(models, { + id = 'auto', + name = 'Auto (Copilot)', + description = 'Auto selects the best model for your request.', + }) + return models end, + resolve_model = function(headers, model) + if model ~= 'auto' then + return model + end + + local url = 'https://api.githubcopilot.com/models/session' + local response, err = curl.post(url, { + headers = headers, + body = { auto_mode = { model_hints = { 'auto' } } }, + json_response = true, + json_request = true, + }) + + if err then + error(err) + end + + return response.body.selected_model + end, + prepare_input = function(inputs, opts) local request if opts.model.use_responses then diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index db1abd27..04fa5095 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -771,6 +771,9 @@ function Chat:render() -- Overlay section header with nice display local header_value = self.headers[message.role] local header_line = message.section.start_line - 2 + if message.model then + header_value = header_value .. ' (' .. message.model .. ')' + end vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, header_line, 0, { conceal = '', From 69199d46b56f67a226789da256264c6291c4e63d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Feb 2026 23:15:33 +0000 Subject: [PATCH 217/250] 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 158c58b6..2bf50eda 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -612,7 +612,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻This project follows the all-contributors specification. Contributions of any kind are welcome! From b7313ea5be03405cb988f69491becc8c8b5d98ac Mon Sep 17 00:00:00 2001 From: Calum Lynch <89159592+Tlunch@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:49:03 +0000 Subject: [PATCH 218/250] fix(prompts): avoid %20 being treated as special sequence when relacing dirname (#1525) * Wrap source.cwd in function tackles ticket #1524 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * CWD function handed directly #1524 Assumption that source.cwd function will not accept any arguments in the future as it currently doesn't allows simplification --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- lua/CopilotChat/prompts.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/prompts.lua b/lua/CopilotChat/prompts.lua index e8e9ed28..71ac08be 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -343,7 +343,7 @@ function M.resolve_prompt(prompt, config) config.system_prompt = config.system_prompt:gsub('{OS_NAME}', vim.uv.os_uname().sysname) config.system_prompt = config.system_prompt:gsub('{LANGUAGE}', config.language) - config.system_prompt = config.system_prompt:gsub('{DIR}', source.cwd()) + config.system_prompt = config.system_prompt:gsub('{DIR}', source.cwd) end return config, prompt From 7cac6a24a6853d459efdd3af3d5f69bc9fe49226 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 12 Feb 2026 10:49:22 +0000 Subject: [PATCH 219/250] 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 2bf50eda..2b19f3d1 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 February 02 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 February 12 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 743d6005fb412c85309d3f3aa45f18f3a2fb2098 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:49:48 +0100 Subject: [PATCH 220/250] docs: add Tlunch as a contributor for code (#1526) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 7711217f..30b1babb 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -501,6 +501,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/22600124?v=4", "profile": "https://github.com/junqizhang", "contributions": ["code"] + }, + { + "login": "Tlunch", + "name": "Calum Lynch", + "avatar_url": "https://avatars.githubusercontent.com/u/89159592?v=4", + "profile": "http://card.calumhub.xyz", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index f99de93b..ba899180 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d junqizhang
junqizhang

💻 + Calum Lynch
Calum Lynch

💻 From b20108ac925d17d221ec171f17a23a1ad20c4289 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 21:35:15 +0100 Subject: [PATCH 221/250] [pre-commit.ci] pre-commit autoupdate (#1532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/JohnnyMorganz/StyLua: v2.3.1 → v2.4.0](https://github.com/JohnnyMorganz/StyLua/compare/v2.3.1...v2.4.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac8e688c..b2eca2d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,6 @@ repos: hooks: - id: prettier - repo: https://github.com/JohnnyMorganz/StyLua - rev: v2.3.1 + rev: v2.4.0 hooks: - id: stylua-github From ecea24a9a067c6f176b8e13eb11251c739caf055 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 9 Mar 2026 20:35:38 +0000 Subject: [PATCH 222/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 2b19f3d1..75d92a7b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 February 12 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 March 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -612,7 +612,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻Calum Lynch💻This project follows the all-contributors specification. Contributions of any kind are welcome! From c5aa16708694c6f56c89362d3dfcec8aec585783 Mon Sep 17 00:00:00 2001 From: sirjls Date: Mon, 23 Mar 2026 13:25:40 +0100 Subject: [PATCH 223/250] feat: support dynamic `*.business.githubcopilot.com` base URL resolution (#1536) Co-authored-by: ssparreb --- lua/CopilotChat/config/providers.lua | 57 ++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 3df37667..08887410 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -196,6 +196,22 @@ local function get_github_models_token(tag) return github_device_flow(tag, '178c6fc778ccc68e1d6a', 'read:user copilot') end +--- Resolve the Copilot API base URL from token endpoint response. +--- Falls back to the default api.githubcopilot.com if no business endpoint is found. +---@param token_body table The decoded JSON body from the token endpoint +---@return string base_url The base URL (no trailing slash) +local function resolve_copilot_base_url(token_body) + -- The token response may include an `endpoints` table with an `api` field + -- pointing to the correct base URL for business/enterprise accounts, + -- e.g. https://api.business.githubcopilot.com + if token_body and token_body.endpoints and token_body.endpoints.api then + local url = token_body.endpoints.api + -- Strip trailing slash if present + return url:gsub('/$', '') + end + return 'https://api.githubcopilot.com' +end + --- Prepare input for Responses API ---@param inputs CopilotChat.client.Message[] ---@param opts CopilotChat.config.providers.Options @@ -537,12 +553,19 @@ M.copilot = { error(err) end + -- Resolve the base URL from the token response so that business/enterprise + -- accounts using *.business.githubcopilot.com are handled automatically. + local base_url = resolve_copilot_base_url(response.body) + return { ['Authorization'] = 'Bearer ' .. response.body.token, ['Editor-Version'] = EDITOR_VERSION, ['Editor-Plugin-Version'] = 'CopilotChat.nvim/*', ['Copilot-Integration-Id'] = 'vscode-chat', ['x-github-api-version'] = '2025-10-01', + -- Store the resolved base URL in a custom header so that get_models, + -- resolve_model, and get_url can read it without making another request. + ['x-copilot-base-url'] = base_url, }, response.body.expires_at end, @@ -598,9 +621,16 @@ M.copilot = { end, get_models = function(headers) - local response, err = curl.get('https://api.githubcopilot.com/models', { + -- Use the resolved base URL carried in the custom header, falling back to + -- the default if it is absent (e.g. during tests or manual calls). + local base_url = headers['x-copilot-base-url'] or 'https://api.githubcopilot.com' + + -- Build request headers without our internal routing header. + local request_headers = vim.tbl_extend('force', headers, { ['x-copilot-base-url'] = nil }) + + local response, err = curl.get(base_url .. '/models', { json_response = true, - headers = headers, + headers = request_headers, }) if err then @@ -628,6 +658,9 @@ M.copilot = { policy = not model['policy'] or model['policy']['state'] == 'enabled', version = model.version, use_responses = use_responses, + -- Carry the base URL into the model so get_url and resolve_model + -- can use it without needing access to the headers again. + base_url = base_url, } end) :totable() @@ -643,8 +676,8 @@ M.copilot = { for _, model in ipairs(models) do if not model.policy then - pcall(curl.post, 'https://api.githubcopilot.com/models/' .. model.id .. '/policy', { - headers = headers, + pcall(curl.post, base_url .. '/models/' .. model.id .. '/policy', { + headers = request_headers, json_request = true, body = { state = 'enabled' }, }) @@ -656,6 +689,7 @@ M.copilot = { id = 'auto', name = 'Auto (Copilot)', description = 'Auto selects the best model for your request.', + base_url = base_url, }) return models @@ -666,9 +700,12 @@ M.copilot = { return model end - local url = 'https://api.githubcopilot.com/models/session' + local base_url = headers['x-copilot-base-url'] or 'https://api.githubcopilot.com' + local request_headers = vim.tbl_extend('force', headers, { ['x-copilot-base-url'] = nil }) + + local url = base_url .. '/models/session' local response, err = curl.post(url, { - headers = headers, + headers = request_headers, body = { auto_mode = { model_hints = { 'auto' } } }, json_response = true, json_request = true, @@ -707,10 +744,14 @@ M.copilot = { end, get_url = function(opts) + -- Use the base URL stored on the model (populated by get_models), falling + -- back to the default for backwards compatibility. + local base_url = (opts and opts.model and opts.model.base_url) or 'https://api.githubcopilot.com' + if opts and opts.model and opts.model.use_responses then - return 'https://api.githubcopilot.com/responses' + return base_url .. '/responses' end - return 'https://api.githubcopilot.com/chat/completions' + return base_url .. '/chat/completions' end, } From 7c263e2e14000781baa622a7b5ebb14a9203f32f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 23 Mar 2026 12:26:01 +0000 Subject: [PATCH 224/250] 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 75d92a7b..653a5bca 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 March 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 March 23 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 9fec6c3c2a4069b1f649e116c10e6fbcf1463764 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:32:24 +0100 Subject: [PATCH 225/250] docs: add sirjls as a contributor for code (#1537) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 30b1babb..84757b79 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -508,6 +508,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/89159592?v=4", "profile": "http://card.calumhub.xyz", "contributions": ["code"] + }, + { + "login": "sirjls", + "name": "sirjls", + "avatar_url": "https://avatars.githubusercontent.com/u/270346599?v=4", + "profile": "https://github.com/sirjls", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index ba899180..9a54c75e 100644 --- a/README.md +++ b/README.md @@ -624,6 +624,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d junqizhang
junqizhang

💻 Calum Lynch
Calum Lynch

💻 + sirjls
sirjls

💻 From d4d63a2542f41ec9cc5baca4799f244554ee8cfd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 29 Mar 2026 20:21:02 +0200 Subject: [PATCH 226/250] fix(ui): prevent errors when restoring invalid window (#1539) - Add a check in Overlay:restore to ensure the window is valid before attempting to set the buffer, preventing errors if the window was closed. - Scope the close keymap in chat buffer to the buffer only, avoiding global keymap pollution. Closes #1535 --- lua/CopilotChat/ui/chat.lua | 2 +- lua/CopilotChat/ui/overlay.lua | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 04fa5095..8744c97d 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -161,7 +161,7 @@ local Chat = class(function(self, config, on_buf_create) function(bufnr) vim.keymap.set('n', config.mappings.close.normal, function() self.chat_overlay:restore(self.winnr, self.bufnr) - end) + end, { buffer = bufnr }) vim.api.nvim_create_autocmd({ 'BufHidden', 'BufDelete' }, { buffer = bufnr, diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 547394b8..36589ccd 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -116,6 +116,10 @@ function Overlay:restore(winnr, bufnr) self.on_hide(self.bufnr) end + if not vim.api.nvim_win_is_valid(winnr) then + return + end + vim.api.nvim_win_set_buf(winnr, bufnr) if self.cursor then From aeb6ebbdd9b7662114d873da299de06019fcb68a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 29 Mar 2026 18:21:26 +0000 Subject: [PATCH 227/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 653a5bca..151bea3a 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 March 23 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 March 29 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -612,7 +612,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻Calum Lynch💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻Calum Lynch💻sirjls💻This project follows the all-contributors specification. Contributions of any kind are welcome! From ca9a42863b0963e14f4830a2193d297716ce5ed3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 29 Mar 2026 20:23:51 +0200 Subject: [PATCH 228/250] fix(utils): handle 1xx HTTP status codes in curl utils (#1540) Update the curl utility to treat 1xx (informational) HTTP status codes, such as 100 (Continue), as non-errors in both GET and POST requests. Previously, only 2xx (success) codes were considered valid, which could cause issues with streaming or intermediate responses. This change improves compatibility with APIs that use informational status codes. Closes #1508 --- lua/CopilotChat/utils/curl.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/utils/curl.lua b/lua/CopilotChat/utils/curl.lua index 87e9b89d..2c2cf60e 100644 --- a/lua/CopilotChat/utils/curl.lua +++ b/lua/CopilotChat/utils/curl.lua @@ -48,7 +48,10 @@ M.get = async.wrap(function(url, opts, callback) args.callback = function(response) log.debug('GET response:', response) - if response and not vim.startswith(tostring(response.status), '20') then + -- HTTP status codes: 1xx (informational), 2xx (success) + -- Status 100 (Continue) is common with streaming responses + local status_str = tostring(response.status) + if response and not vim.startswith(status_str, '1') and not vim.startswith(status_str, '20') then callback(response, response.body) return end @@ -96,7 +99,10 @@ M.post = async.wrap(function(url, opts, callback) log.debug('Failed to remove temp file:', temp_file_path, err) end end - if response and not vim.startswith(tostring(response.status), '20') then + -- HTTP status codes: 1xx (informational), 2xx (success) + -- Status 100 (Continue) is common with streaming responses + local status_str = tostring(response.status) + if response and not vim.startswith(status_str, '1') and not vim.startswith(status_str, '20') then callback(response, response.body) return end From d2d2574863529cb76b62b028cb5c3196ef5796d6 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 29 Mar 2026 20:30:57 +0200 Subject: [PATCH 229/250] refactor(files): simplify file filtering and default to text (#1541) - Remove filetype-based filtering from file list, now only filters out empty entries. - Add simple binary detection in get_file by rejecting files with null bytes. - filetype() now defaults to 'text' if detection fails, letting content validation handle unreadable files. This improves robustness and performance by avoiding unnecessary filetype checks and handling binary files more gracefully. Closes #1533 --- lua/CopilotChat/resources.lua | 4 ++++ lua/CopilotChat/utils/files.lua | 26 ++++++++++---------------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/lua/CopilotChat/resources.lua b/lua/CopilotChat/resources.lua index 57e12e33..22c97c4b 100644 --- a/lua/CopilotChat/resources.lua +++ b/lua/CopilotChat/resources.lua @@ -27,6 +27,10 @@ function M.get_file(filename) if not content or content == '' then return nil end + -- Simple binary detection: reject files with null bytes + if content:find('\0') then + return nil + end data = { content = content, _modified = modified, diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua index 28184585..7da2be10 100644 --- a/lua/CopilotChat/utils/files.lua +++ b/lua/CopilotChat/utils/files.lua @@ -9,23 +9,11 @@ M.scan_args = { } local function filter_files(files, max_count) - local filetype = require('plenary.filetype') - + -- Filter out empty entries files = vim.tbl_filter(function(file) - if file == nil or file == '' then - return false - end - - local ft = filetype.detect(file, { - fs_access = false, - }) - - if ft == '' or not ft then - return false - end - - return true + return file ~= nil and file ~= '' end, files) + if max_count and max_count > 0 then files = vim.list_slice(files, 1, max_count) end @@ -268,7 +256,13 @@ function M.filetype(filename) }) if ft == '' or not ft and not vim.in_fast_event() then - return vim.filetype.match({ filename = filename }) + ft = vim.filetype.match({ filename = filename }) + end + + -- If filetype still not detected, default to 'text' + -- Let content validation handle whether it's actually readable + if not ft or ft == '' then + return 'text' end return ft From 13f727d0b8d84d0acc15dbadce7b02a322879900 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:51:27 +0200 Subject: [PATCH 230/250] [pre-commit.ci] pre-commit autoupdate (#1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/JohnnyMorganz/StyLua: v2.4.0 → v2.4.1](https://github.com/JohnnyMorganz/StyLua/compare/v2.4.0...v2.4.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2eca2d3..ae6b39ed 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,6 @@ repos: hooks: - id: prettier - repo: https://github.com/JohnnyMorganz/StyLua - rev: v2.4.0 + rev: v2.4.1 hooks: - id: stylua-github From 0b3133ffbb470b1616c47170b544d0b9a3bbcf5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 6 Apr 2026 20:51:50 +0000 Subject: [PATCH 231/250] 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 151bea3a..61b1f9f5 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 March 29 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 April 06 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 734353f14cbcaeb726e3f190324135a39dce056b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 15 Apr 2026 15:37:29 +0200 Subject: [PATCH 232/250] docs: update architecture and add AGENTS.md (#1546) - Add AGENTS.md with detailed project overview, layout, and gotchas. - Revise CONTRIBUTING.md to clarify core, UI, features, and utilities. - Expand descriptions for each module and directory. - Improve structure for easier onboarding and contribution. --- AGENTS.md | 75 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 112 +++++++++++++++++++++++++++--------------------- 2 files changed, 139 insertions(+), 48 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..560393bb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AGENTS.md + +## Overview + +Neovim plugin (pure Lua) providing GitHub Copilot Chat integration. Requires Neovim 0.10.0+, curl 8.0.0+, plenary.nvim. + +## Commands + +```bash +# Run tests (headless Neovim + plenary test harness) +make test + +# Format check (what CI runs) +stylua --check . +``` + +`make test` runs `nvim --headless --clean -u ./scripts/test.lua`, which clones plenary.nvim into `.dependencies/` on first run, then executes all `tests/*_spec.lua` files via plenary's busted-style harness. + +## Project layout + +``` +plugin/CopilotChat.lua — Neovim plugin entry: commands, highlights, autocmds +lua/CopilotChat/ + init.lua — Main module: setup(), ask(), open/close/toggle, save/load + client.lua — Copilot API client (auth, streaming, tool calls) + config.lua — Default configuration schema + config/ — Sub-configs: functions, mappings, prompts, providers + constants.lua — Shared constants (roles, etc.) + completion.lua — Completion source + functions.lua — Built-in functions/tools exposed to the LLM + prompts.lua — Built-in prompt definitions + resources.lua — Resource handling + select.lua — Selection strategies (visual, buffer, diagnostics, git diff) + tiktoken.lua — Token counting via native tiktoken lib + health.lua — :checkhealth integration + notify.lua — Notification utilities + instructions/ — System prompt templates injected into LLM conversations (not agent guidance) + ui/ — Chat window, overlay, spinner + utils.lua — General utilities + utils/ — Utility modules: class, curl, diff, files, orderedmap, stringbuffer +queries/ — Treesitter queries for copilot-chat filetype +tests/ — Plenary busted-style specs (*_spec.lua) +scripts/ + test.lua — Test runner bootstrap (sets up plenary) + minimal.lua — Minimal reproduction config +doc/CopilotChat.txt — Auto-generated vimdoc (do NOT edit; generated from README by panvimdoc in CI) +``` + +## Style and formatting + +- **Lua formatter:** StyLua — 2-space indent, 120 column width, single quotes preferred, Unix line endings. Config in `.stylua.toml`. +- **Pre-commit hooks:** Prettier (markdown/json/yaml) + StyLua (Lua). CI will fail if StyLua check fails. +- **No linter** (no luacheck/selene configured). +- Type annotations use EmmyLua/LuaCATS `---@class`, `---@param`, `---@return` style. + +## Testing + +- Framework: plenary.nvim busted-style (`describe`, `it`, `before_each`, `after_each`, `assert`). +- Test files live in `tests/` and must be named `*_spec.lua`. +- CI runs tests against Neovim nightly with LuaJIT 2.1 and LuaRocks 3.12.2. +- Tests are unit-level (class, diff, utils, orderedmap, stringbuffer, functions, init). No integration tests requiring Copilot auth. + +## CI and releases + +- CI (`ci.yml`): lint (StyLua) + test (plenary) on all PRs; vimdoc generation on main only. +- Releases via release-please (`simple` type). Version tracked in `version.txt`. +- `doc/CopilotChat.txt` is auto-committed by CI — do not edit manually. +- `CHANGELOG.md` is managed by release-please — do not edit manually. + +## Key gotchas + +- The module is loaded as `require('CopilotChat')` (capital C's) — this matches the `lua/CopilotChat/` directory name. Case matters. +- `init.lua` uses lazy self-initialization via `__index` metamethod — accessing any field triggers `setup()` if not already called. +- `.dependencies/` is gitignored and auto-populated by the test runner (plenary clone). +- `build/` is gitignored and holds downloaded tiktoken native libraries. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3be55bac..9fe83edc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,67 +50,83 @@ Go to the CopilotChat.nvim in your GitHub account, select your branch, and click ![structure.drawio](https://github.com/CopilotC-Nvim/CopilotChat.nvim/assets/5115805/e7517736-0152-47a3-8cb9-36a5dffcb6cc) -### Main components +### Core -- [init.lua](/lua/CopilotChat/init.lua): This file initializes Copilot Chat - plugin. It includes functions for appending to the chat window, showing help, - completing, getting selection, opening and closing the chat window, asking - questions to the Copilot model, resetting the chat window, enabling/disabling - debug, and setting up the plugin. +- [init.lua](/lua/CopilotChat/init.lua): Main module. Plugin initialization + (`setup()`), chat lifecycle (`ask()`, `open()`, `close()`, `toggle()`, + `reset()`), save/load, and sticky prompt processing. -- [config.lua](/lua/CopilotChat/config.lua): This file contains default - configuration for Copilot Chat plugin. +- [client.lua](/lua/CopilotChat/client.lua): Copilot API client. Handles + authentication, model listing, streaming requests, and tool call execution. -- [copilot.lua](/lua/CopilotChat/copilot.lua): This file contains the core - functionality of the Copilot. It includes functions for generating unique IDs, - finding configuration paths, authenticating, asking questions to the Copilot, - generating embeddings, and managing the running job. +- [config.lua](/lua/CopilotChat/config.lua): Default configuration schema. -- [chat.lua](/lua/CopilotChat/chat.lua): This file manages the chat window. It - includes functions for creating, validating, appending to, clearing, opening, - closing, and focusing on the chat window. +- [config/](/lua/CopilotChat/config/): Sub-configs for + [functions](/lua/CopilotChat/config/functions.lua), + [mappings](/lua/CopilotChat/config/mappings.lua), + [prompts](/lua/CopilotChat/config/prompts.lua), and + [providers](/lua/CopilotChat/config/providers.lua). -- [diff.lua](/lua/CopilotChat/diff.lua): This file manages the diff window. It - includes functions for creating, validating, showing, and restoring the diff - window. +- [constants.lua](/lua/CopilotChat/constants.lua): Shared constants (plugin + name, roles). -- [select.lua](/lua/CopilotChat/select.lua): This file contains functions for - selecting and processing different types of data such as visual selection, - unnamed register, whole buffer, current line, diagnostics, and git diff. +### Chat and UI -- [context.lua](/lua/CopilotChat/context.lua): This file is responsible for - building an outline for a buffer and finding items for a query. It uses spatial - distance and relatedness to rank data. +- [ui/chat.lua](/lua/CopilotChat/ui/chat.lua): Chat window management. + Creating, appending to, clearing, opening, closing, and focusing the chat + window. Handles fold expressions and section parsing. -- [actions.lua](/lua/CopilotChat/actions.lua): This file manages the actions - that can be performed. It includes functions for getting help actions, prompt - actions, and picking an action from a list of actions using `vim.ui.select`. +- [ui/overlay.lua](/lua/CopilotChat/ui/overlay.lua): Overlay buffer used for + displaying diff previews and other transient content. -- [tiktoken.lua](/lua/CopilotChat/tiktoken.lua): This file manages integration - with Tiktoken library and is used for counting tokens. It includes functions - for setting up Tiktoken, checking its availability, encoding prompts, and - counting prompts. +- [ui/spinner.lua](/lua/CopilotChat/ui/spinner.lua): Loading spinner indicator + for the chat window. -- [health.lua](/lua/CopilotChat/health.lua): This file checks the health of the - plugin by checking if commands exist, checking if Lua libraries are installed, - and checking if a Treesitter parsers are available. +### Features -- [spinner.lua](/lua/CopilotChat/spinner.lua): This file manages a spinner that - is used for indicating loading status in chat window. +- [prompts.lua](/lua/CopilotChat/prompts.lua): Prompt resolution, custom + instruction loading, system prompt building, and sticky/resource/tool + parsing from user input. -- [utils.lua](/lua/CopilotChat/utils.lua): This file contains utility functions - for creating classes, getting the log file path, checking if the current - version of Neovim is stable, and joining multiple async functions. +- [functions.lua](/lua/CopilotChat/functions.lua): Built-in functions/tools + exposed to the LLM (e.g., file editing, searching). -- [debuginfo.lua](/lua/CopilotChat/debuginfo.lua): This file is used for - creating `:CopilotChatDebugInfo` command. +- [resources.lua](/lua/CopilotChat/resources.lua): Resource handling for file + and URL content retrieval with caching. -### Integrations +- [completion.lua](/lua/CopilotChat/completion.lua): Completion source for the + chat window (`@tools`, `/prompts`, `#resources`, `$models`). -- [telescope.lua](/lua/CopilotChat/integrations/telescope.lua): This file - integrates the Telescope plugin with CopilotChat. It includes a function for - picking an action from a list of actions. +- [select.lua](/lua/CopilotChat/select.lua): Selection strategies for providing + context (visual selection, buffer, diagnostics, git diff, etc.). -- [fzflua.lua](/lua/CopilotChat/integrations/fzflua.lua): This file integrates - the fzf-lua plugin with CopilotChat. It includes a function for picking an - action from a list of actions. +- [tiktoken.lua](/lua/CopilotChat/tiktoken.lua): Token counting via native + tiktoken library. + +- [instructions/](/lua/CopilotChat/instructions/): System prompt templates + injected into LLM conversations (edit formats, tool use instructions, custom + instructions wrapper). + +### Utilities + +- [utils.lua](/lua/CopilotChat/utils.lua): General utility functions. + +- [utils/](/lua/CopilotChat/utils/): Utility modules — + [class.lua](/lua/CopilotChat/utils/class.lua) (OOP helper), + [curl.lua](/lua/CopilotChat/utils/curl.lua) (HTTP requests), + [diff.lua](/lua/CopilotChat/utils/diff.lua) (unified diff parsing and + application), + [files.lua](/lua/CopilotChat/utils/files.lua) (file I/O and filetype + detection), + [orderedmap.lua](/lua/CopilotChat/utils/orderedmap.lua) (insertion-ordered + map), + [stringbuffer.lua](/lua/CopilotChat/utils/stringbuffer.lua) (efficient string + concatenation). + +### Other + +- [health.lua](/lua/CopilotChat/health.lua): `:checkhealth` integration. + Verifies commands, libraries, and Treesitter parsers. + +- [notify.lua](/lua/CopilotChat/notify.lua): Pub/sub notification system for + status and message events. From e16829011dd39d415b1f62e572055df36ec0486e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 15 Apr 2026 13:37:53 +0000 Subject: [PATCH 233/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 61b1f9f5..39c4d170 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,5 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2026 April 06 +*CopilotChat.txt* + For NVIM v0.8.0 Last change: 2026 April 15 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -191,7 +192,7 @@ CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* - gc Show info about current chat - gh Show help message - [!WARNING] Some plugins (e.g. `copilot.vim`) may also map common keys like + [!WARNING] Some plugins (e.g. `copilot.vim`) may also map common keys like `` in insert mode. To avoid conflicts, disable Copilot’s default `` mapping with: >lua @@ -341,11 +342,11 @@ Types of copilot highlights: - `CopilotChatSelection` - Selection highlight in source buffer - `CopilotChatStatus` - Status and spinner in chat buffer - `CopilotChatHelp` - Help text in chat buffer -- `CopilotChatResource` - Resource highlight in chat buffer (e.g. `#file`, `#gitdiff`) -- `CopilotChatTool` - Tool call highlight in chat buffer (e.g. `@copilot`) -- `CopilotChatPrompt` - Prompt highlight in chat buffer (e.g. `/Explain`, `/Review`) -- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) -- `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) +- `CopilotChatResource` - Resource highlight in chat buffer (e.g. `#file`, `#gitdiff`) +- `CopilotChatTool` - Tool call highlight in chat buffer (e.g. `@copilot`) +- `CopilotChatPrompt` - Prompt highlight in chat buffer (e.g. `/Explain`, `/Review`) +- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) +- `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) From 4759ecdd307d36503f30920411121b664188ef4c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 15 Apr 2026 16:06:34 +0200 Subject: [PATCH 234/250] fix(core): resolve various bugs and improve robustness (#1547) - Fix incorrect format string in RESOURCE_SHORT_FORMAT - Correct tool call key usage and merging in client - Use correct filetype to mimetype conversion in functions - Simplify and fix buffer finding/creation logic in mappings - Correct logic for loading GitHub token from gh CLI - Fix prompt selection and sticky line processing in init - Add notify.clear to setup for proper listener management - Add clear method to notify to reset listeners - Fix tool call highlighting in chat UI - Use proper BufEnter autocmd invocation in overlay - Fix diff hunk variable naming in utils.diff - Remove unnecessary quoting of grep patterns in utils.files These changes address several bugs, improve code clarity, and enhance the reliability of buffer, prompt, and notification handling. Closes #1515 Closes #1455 --- lua/CopilotChat/client.lua | 9 ++++----- lua/CopilotChat/config/functions.lua | 2 +- lua/CopilotChat/config/mappings.lua | 30 ++++++++++++---------------- lua/CopilotChat/config/providers.lua | 7 ++++--- lua/CopilotChat/init.lua | 23 ++++++++++++++------- lua/CopilotChat/notify.lua | 5 +++++ lua/CopilotChat/ui/chat.lua | 4 ++-- lua/CopilotChat/ui/overlay.lua | 4 +++- lua/CopilotChat/utils/diff.lua | 6 +++--- lua/CopilotChat/utils/files.lua | 4 ++-- 10 files changed, 53 insertions(+), 41 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 98bbf388..e2801844 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -65,7 +65,7 @@ local orderedmap = require('CopilotChat.utils.orderedmap') local stringbuffer = require('CopilotChat.utils.stringbuffer') --- Constants -local RESOURCE_SHORT_FORMAT = '# %s\n```%s start_line=% end_line=%s\n%s\n```' +local RESOURCE_SHORT_FORMAT = '# %s\n```%s start_line=%s 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 @@ -445,9 +445,10 @@ function Client:ask(opts) if out.tool_calls then for _, tool_call in ipairs(out.tool_calls) do - local val = tool_calls:get(tool_call.index) + local key = tool_call.id or tool_call.index + local val = tool_calls:get(key) if not val then - tool_calls:set(tool_call.index, tool_call) + tool_calls:set(key, tool_call) else val.arguments = val.arguments .. tool_call.arguments end @@ -573,12 +574,10 @@ function Client:ask(opts) end error(error_msg) - return end if errored then error(errored) - return end local response_text = response_content_buffer:tostring() diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index f9063047..4d849bbe 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -247,7 +247,7 @@ return { { uri = 'neovim://selection', name = selection.filename, - mimetype = files.mimetype_to_filetype(selection.filetype), + mimetype = files.filetype_to_mimetype(selection.filetype), data = data, annotations = { start_line = selection.start_line, diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index fb4b6388..a72529f9 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -14,23 +14,19 @@ local function prepare_diff_buffer(filename, source) filename = vim.api.nvim_buf_get_name(source.bufnr) end + -- Try to find matching buffer first local diff_bufnr = nil - - -- If buffer is not found, try to load it - if not diff_bufnr then - -- Try to find matching buffer first - for _, buf in ipairs(vim.api.nvim_list_bufs()) do - if files.filename_same(vim.api.nvim_buf_get_name(buf), filename) then - diff_bufnr = buf - break - end + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if files.filename_same(vim.api.nvim_buf_get_name(buf), filename) then + diff_bufnr = buf + break end + end - -- If still not found, create a new buffer - if not diff_bufnr then - diff_bufnr = vim.fn.bufadd(filename) - vim.fn.bufload(diff_bufnr) - end + -- If not found, create a new buffer + if not diff_bufnr then + diff_bufnr = vim.fn.bufadd(filename) + vim.fn.bufload(diff_bufnr) end -- If source exists, update it to point to the diff buffer @@ -243,10 +239,10 @@ return { }) end end - - vim.fn.setqflist(items) - vim.cmd('copen') end + + vim.fn.setqflist(items) + vim.cmd('copen') end, }, diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 08887410..c817f6f8 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -183,7 +183,7 @@ local function get_github_models_token(tag) end -- loading token from gh cli if available - if vim.fn.executable('gh') == 0 then + if vim.fn.executable('gh') == 1 then local result = utils.system({ 'gh', 'auth', 'token', '-h', 'github.com' }) if result and result.code == 0 and result.stdout then local gh_token = vim.trim(result.stdout) @@ -400,9 +400,10 @@ local function prepare_responses_output(output) -- Complete output item (including tool calls) local item = output.item if item and item.type == 'function_call' then + local index = output.output_index or (#tool_calls + 1) table.insert(tool_calls, { - id = item.call_id or ('tooluse_' .. (#tool_calls + 1)), - index = #tool_calls + 1, + id = item.call_id or ('tooluse_' .. index), + index = index, name = item.name or '', arguments = item.arguments or '', }) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 9125bb4a..ac7302bc 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -22,8 +22,12 @@ local M = setmetatable({}, { -- Lazy initialize local initialized = rawget(t, 'initialized') if not initialized then - rawset(t, 'initialized', true) - rawget(t, 'setup')() + local ok, err = pcall(rawget(t, 'setup')) + if ok then + rawset(t, 'initialized', true) + else + require('plenary.log').error('CopilotChat setup failed:', err) + end end return rawget(t, key) @@ -54,8 +58,12 @@ local function process_sticky(prompt, config) end -- Find sticky lines in new prompt to remove them + in_code_block = false for i, line in ipairs(lines) do - if vim.startswith(line, '> ') then + if line:match('^```') then + in_code_block = not in_code_block + end + if vim.startswith(line, '> ') and not in_code_block then table.insert(sticky_indices, i) end end @@ -349,8 +357,8 @@ end --- Select a prompt template to use. ---@param config CopilotChat.config.Shared? function M.select_prompt(config) - local prompts = prompts.list_prompts() - local keys = vim.tbl_keys(prompts) + local prompt_list = prompts.list_prompts() + local keys = vim.tbl_keys(prompt_list) table.sort(keys) local choices = vim @@ -358,8 +366,8 @@ function M.select_prompt(config) :map(function(name) return { name = name, - description = prompts[name].description, - prompt = prompts[name].prompt, + description = prompt_list[name].description, + prompt = prompt_list[name].prompt, } end) :filter(function(choice) @@ -696,6 +704,7 @@ function M.setup(config) end) -- Initialize chat + require('CopilotChat.notify').clear() if M.chat then M.chat:close() M.chat:delete() diff --git a/lua/CopilotChat/notify.lua b/lua/CopilotChat/notify.lua index 99aa499a..b15b209a 100644 --- a/lua/CopilotChat/notify.lua +++ b/lua/CopilotChat/notify.lua @@ -32,4 +32,9 @@ function M.listen(event_name, callback) table.insert(M.listeners[event_name], callback) end +--- Clear all listeners +function M.clear() + M.listeners = {} +end + return M diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 8744c97d..0f3b4871 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -863,10 +863,10 @@ function Chat:render() -- Highlight tools in the last user message local assistant_msg = self:get_message(constants.ROLE.ASSISTANT) if assistant_msg and assistant_msg.tool_calls and #assistant_msg.tool_calls > 0 then - for i, line in ipairs(utils.split_lines(message.content)) do + for j, line in ipairs(utils.split_lines(message.content)) do for _, tool_call in ipairs(assistant_msg.tool_calls) do if line:match(string.format('#%s:%s', tool_call.name, vim.pesc(tool_call.id))) then - local l = message.section.start_line + i + local l = message.section.start_line + j vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatAnnotationHeader', l, 0, #line) if not utils.empty(tool_call.arguments) then vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l, 0, { diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 36589ccd..32157685 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -128,7 +128,9 @@ function Overlay:restore(winnr, bufnr) -- Manually trigger BufEnter event as nvim_win_set_buf does not trigger it vim.schedule(function() - vim.cmd(string.format('doautocmd BufEnter %s', bufnr)) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_exec_autocmds('BufEnter', { buffer = bufnr }) + end end) end diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index c0cf2f85..23199930 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -161,16 +161,16 @@ function M.apply_unified_diff(diff_text, original_content) end local new_lines = vim.split(new_content, '\n', { trimempty = true }) - local hunks = vim.diff( + local diff_hunks = vim.diff( original_content, new_content, { algorithm = 'myers', ctxlen = 10, interhunkctxlen = 10, ignore_whitespace_change = true, result_type = 'indices' } ) - if not hunks or #hunks == 0 then + if not diff_hunks or #diff_hunks == 0 then return new_lines, applied, nil, nil end local first, last - for _, hunk in ipairs(hunks) do + for _, hunk in ipairs(diff_hunks) do local hunk_start = hunk[1] local hunk_end = hunk[1] + hunk[2] - 1 if not first or hunk_start < first then diff --git a/lua/CopilotChat/utils/files.lua b/lua/CopilotChat/utils/files.lua index 7da2be10..24b7b003 100644 --- a/lua/CopilotChat/utils/files.lua +++ b/lua/CopilotChat/utils/files.lua @@ -164,7 +164,7 @@ M.grep = async.wrap(function(path, opts, callback) if opts.pattern then table.insert(cmd, '-e') - table.insert(cmd, "'" .. opts.pattern .. "'") + table.insert(cmd, opts.pattern) end elseif vim.fn.executable('grep') == 1 then table.insert(cmd, 'grep') @@ -172,7 +172,7 @@ M.grep = async.wrap(function(path, opts, callback) if opts.pattern then table.insert(cmd, '-e') - table.insert(cmd, "'" .. opts.pattern .. "'") + table.insert(cmd, opts.pattern) end end From 969c220c76ad8cbf65463849fca67d390067d7f3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 15 Apr 2026 16:11:11 +0200 Subject: [PATCH 235/250] fix(config): include hidden files in file enum glob (#1548) The file enumeration function now includes hidden files by setting `hidden = true` in the glob options. This ensures that all files, including dotfiles, are considered during enumeration. Closes #1516 --- lua/CopilotChat/config/functions.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 4d849bbe..991759dd 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -64,6 +64,7 @@ return { enum = function(source) return files.glob(source.cwd(), { max_count = 0, + hidden = true, }) end, }, From 8b58670b69eb85f764b653081b42c9ed147583a1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 15 Apr 2026 16:17:36 +0200 Subject: [PATCH 236/250] fix(init): simplify lazy initialization logic (#1549) Refactors the lazy initialization in the CopilotChat main module to call the setup function directly and set the 'initialized' flag before invocation. Removes the pcall error handling and logging, as setup should not fail silently. This makes the initialization logic clearer and more predictable. --- lua/CopilotChat/init.lua | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index ac7302bc..9cea5d2d 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -22,12 +22,8 @@ local M = setmetatable({}, { -- Lazy initialize local initialized = rawget(t, 'initialized') if not initialized then - local ok, err = pcall(rawget(t, 'setup')) - if ok then - rawset(t, 'initialized', true) - else - require('plenary.log').error('CopilotChat setup failed:', err) - end + rawset(t, 'initialized', true) + rawget(t, 'setup')() end return rawget(t, key) From 4f643c7ecd395e06c3648c706fff8121e195eaa1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 15 Apr 2026 22:56:17 +0200 Subject: [PATCH 237/250] fix(prompts): use ordered map for enabled tools (#1551) And add notify test See #1550 Signed-off-by: Tomas Slusny --- lua/CopilotChat/prompts.lua | 7 +- tests/notify_spec.lua | 126 ++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 tests/notify_spec.lua diff --git a/lua/CopilotChat/prompts.lua b/lua/CopilotChat/prompts.lua index 71ac08be..461a0770 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -3,6 +3,7 @@ local constants = require('CopilotChat.constants') local functions = require('CopilotChat.functions') local notify = require('CopilotChat.notify') local files = require('CopilotChat.utils.files') +local orderedmap = require('CopilotChat.utils.orderedmap') local utils = require('CopilotChat.utils') local WORD = '([^%s:]+)' @@ -66,7 +67,7 @@ function M.resolve_tools(prompt, config) tools[tool.name] = tool end - local enabled_tools = {} + local enabled_tools = orderedmap() local tool_matches = utils.to_table(config.tools) -- Check for @tool pattern to find enabled tools @@ -82,12 +83,12 @@ function M.resolve_tools(prompt, config) for _, match in ipairs(tool_matches) do for name, tool in pairs(config.functions) do if name == match or tool.group == match then - table.insert(enabled_tools, tools[name]) + enabled_tools:set(name, tools[name]) end end end - return enabled_tools, prompt + return enabled_tools:values(), prompt end --- Call and resolve function calls from the prompt. diff --git a/tests/notify_spec.lua b/tests/notify_spec.lua new file mode 100644 index 00000000..f0064056 --- /dev/null +++ b/tests/notify_spec.lua @@ -0,0 +1,126 @@ +local notify = require('CopilotChat.notify') + +describe('CopilotChat.notify', function() + before_each(function() + -- Clear all listeners before each test + notify.clear() + end) + + describe('publish and listen', function() + it('calls listener when event is published', function() + local called = false + local received_data = nil + + notify.listen('test_event', function(data) + called = true + received_data = data + end) + + notify.publish('test_event', 'test_data') + + assert.is_true(called) + assert.equals('test_data', received_data) + end) + + it('supports multiple listeners for same event', function() + local count = 0 + + notify.listen('test_event', function(data) + count = count + 1 + end) + notify.listen('test_event', function(data) + count = count + 10 + end) + + notify.publish('test_event', 'data') + + assert.equals(11, count) + end) + + it('does not call listeners for different events', function() + local called = false + + notify.listen('event_a', function(data) + called = true + end) + + notify.publish('event_b', 'data') + + assert.is_false(called) + end) + + it('passes correct data to listeners', function() + local received = nil + + notify.listen('test_event', function(data) + received = data + end) + + notify.publish('test_event', { foo = 'bar', num = 123 }) + + assert.are.same({ foo = 'bar', num = 123 }, received) + end) + + it('handles nil and empty data', function() + local received = 'not_called' + + notify.listen('test_event', function(data) + received = data + end) + + notify.publish('test_event', nil) + assert.is_nil(received) + + notify.publish('test_event', '') + assert.equals('', received) + end) + + it('handles publishing to events with no listeners', function() + -- Should not error + assert.has_no.errors(function() + notify.publish('nonexistent_event', 'data') + end) + end) + end) + + describe('clear', function() + it('removes all listeners', function() + local called = false + + notify.listen('test_event', function(data) + called = true + end) + + notify.clear() + notify.publish('test_event', 'data') + + assert.is_false(called) + end) + + it('allows adding new listeners after clear', function() + local called = false + + notify.listen('test_event', function(data) + called = true + end) + notify.clear() + + notify.listen('test_event', function(data) + called = true + end) + notify.publish('test_event', 'data') + + assert.is_true(called) + end) + end) + + describe('constants', function() + it('defines STATUS constant', function() + assert.equals('status', notify.STATUS) + end) + + it('defines MESSAGE constant', function() + assert.equals('message', notify.MESSAGE) + end) + end) +end) From 1372a57cf387533f5ba64986c551ade9b4213443 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 16 Apr 2026 00:24:21 +0200 Subject: [PATCH 238/250] fix(provider,client): improve tool call merging and filtering (#1552) - Refactor tool call merging logic to use id, index, or name as key, ensuring more robust accumulation of streaming tool call deltas. - Update tool call filtering to exclude entries without a name, preventing incomplete tool calls from being returned. - Remove fallback default values for id, index, and name in provider output, ensuring only valid tool call data is included. - Minor formatting adjustment in init.lua. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 25 ++++++++++++++++++++----- lua/CopilotChat/config/providers.lua | 18 +++++++----------- lua/CopilotChat/init.lua | 1 + 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index e2801844..e8383b8c 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -445,12 +445,22 @@ function Client:ask(opts) if out.tool_calls then for _, tool_call in ipairs(out.tool_calls) do - local key = tool_call.id or tool_call.index - local val = tool_calls:get(key) - if not val then + local key = tool_call.id or tool_call.index or tool_call.name or (#tool_calls:values() + 1) + local existing = tool_calls:get(key) + + if not existing then tool_calls:set(key, tool_call) else - val.arguments = val.arguments .. tool_call.arguments + existing.arguments = existing.arguments .. tool_call.arguments + if tool_call.id then + existing.id = tool_call.id + end + if tool_call.index then + existing.index = tool_call.index + end + if tool_call.name then + existing.name = tool_call.name + end end end end @@ -597,12 +607,17 @@ function Client:ask(opts) response_reasoning = response_reasoning_buffer:tostring() end + -- Filter out tool calls that don't have names (streaming deltas used only for accumulation) + local final_tool_calls = vim.tbl_filter(function(tc) + return tc.name ~= nil + end, tool_calls:values()) + return { message = { role = constants.ROLE.ASSISTANT, content = response_text, reasoning = response_reasoning, - tool_calls = #tool_calls:values() > 0 and tool_calls:values() or nil, + tool_calls = #final_tool_calls > 0 and final_tool_calls or nil, model = out_model, }, token_count = token_count, diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index c817f6f8..548f6abd 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -397,14 +397,12 @@ local function prepare_responses_output(output) content = output.delta.text end elseif output.type == 'response.output_item.done' then - -- Complete output item (including tool calls) local item = output.item if item and item.type == 'function_call' then - local index = output.output_index or (#tool_calls + 1) table.insert(tool_calls, { - id = item.call_id or ('tooluse_' .. index), - index = index, - name = item.name or '', + id = item.call_id, + index = output.output_index, + name = item.name, arguments = item.arguments or '', }) end @@ -436,9 +434,9 @@ local function prepare_responses_output(output) if msg.tool_calls then for i, tool_call in ipairs(msg.tool_calls) do table.insert(tool_calls, { - id = tool_call.call_id or ('tooluse_' .. i), + id = tool_call.call_id, index = i, - name = tool_call.name or '', + name = tool_call.name, arguments = tool_call.arguments or '', }) end @@ -481,11 +479,9 @@ local function prepare_chat_output(output) for i, tool_call in ipairs(message.tool_calls) do local fn = tool_call['function'] if fn then - local index = tool_call.index or i - local id = utils.empty(tool_call.id) and ('tooluse_' .. index) or tool_call.id table.insert(tool_calls, { - id = id, - index = index, + id = tool_call.id, + index = tool_call.index or i, name = fn.name, arguments = fn.arguments or '', }) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 9cea5d2d..1b7d0570 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -546,6 +546,7 @@ function M.ask(prompt, config) M.chat:add_message(response, true) M.chat.token_count = token_count M.chat.token_max_count = token_max_count + finish() end end)) From ed28b01dd333bd69741c7df23e01cbe0bce2b557 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 16 Apr 2026 00:39:57 +0200 Subject: [PATCH 239/250] fix(client): ensure tool call keys are strings (#1553) Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index e8383b8c..585e01aa 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -445,7 +445,7 @@ function Client:ask(opts) if out.tool_calls then for _, tool_call in ipairs(out.tool_calls) do - local key = tool_call.id or tool_call.index or tool_call.name or (#tool_calls:values() + 1) + local key = tostring(tool_call.index or tool_call.id or tool_call.name or #tool_calls:values() + 1) local existing = tool_calls:get(key) if not existing then From bcfbbc2ff60246cb1abfabe7e58a7f03efc26a9b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 16 Apr 2026 03:59:45 +0200 Subject: [PATCH 240/250] fix(prompt): use correct variable in select_prompt (#1555) Replaces usage of the undefined 'prompts' variable with 'prompt_list' in the select_prompt function. This ensures the correct prompt data is used when invoking the ask method, preventing potential runtime errors. Closes #1554 --- lua/CopilotChat/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 1b7d0570..b5052845 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -378,7 +378,7 @@ function M.select_prompt(config) end, }, function(choice) if choice then - M.ask(prompts[choice.name].prompt, vim.tbl_extend('force', prompts[choice.name], config or {})) + M.ask(prompt_list[choice.name].prompt, vim.tbl_extend('force', prompt_list[choice.name], config or {})) end end) end From d1d767c3a5b80ab8f581d567f9921fda061f6ce2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 02:00:06 +0000 Subject: [PATCH 241/250] 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 39c4d170..24b6300d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,5 +1,5 @@ *CopilotChat.txt* - For NVIM v0.8.0 Last change: 2026 April 15 + For NVIM v0.8.0 Last change: 2026 April 16 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 1af80ec73e3f7eabd4e9bd9595646acf46a0b192 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 16 Apr 2026 10:31:49 +0200 Subject: [PATCH 242/250] feat(trusted-tools): auto-execute trusted tool calls (#1556) Add `trusted_tools` config option to allow automatic execution of trusted tool calls without manual approval. By default, all tool calls require approval, but users can now trust specific tools, groups, or all tools. Update README and type annotations to document the new behavior. Trusted tools are determined by function definition, group, or name. Closes #1534 --- README.md | 176 ++++++++++++++++++--------- lua/CopilotChat/config.lua | 2 + lua/CopilotChat/config/functions.lua | 1 + lua/CopilotChat/config/mappings.lua | 23 ---- lua/CopilotChat/init.lua | 137 +++++++++++++++++++-- lua/CopilotChat/prompts.lua | 144 ++++++++++++++-------- lua/CopilotChat/ui/chat.lua | 23 ++-- lua/CopilotChat/ui/overlay.lua | 10 +- 8 files changed, 359 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 9a54c75e..b37e2cfe 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. - 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your [GitHub Copilot settings](https://github.com/settings/copilot/features) and the models provided by GitHub's API. -- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with your explicit approval +- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with manual approval or automatic execution for trusted tools - 🔒 **Privacy First** - Only shares what you explicitly request - no background data collection - 📝 **Interactive Chat** - Interactive UI with completion, diffs, and quickfix integration - 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context @@ -92,7 +92,7 @@ EOF # Core Concepts - **Resources** (`#`) - Add specific content (files, git diffs, URLs) to your prompt -- **Tools** (`@`) - Give LLM access to functions it can call with your approval +- **Tools** (`@`) - Give LLM access to functions it can call during the chat, with manual approval by default - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks @@ -114,7 +114,15 @@ EOF > You are a helpful coding assistant ``` -When you use `@copilot`, the LLM can call functions like `bash`, `edit`, `file`, `glob`, `grep`, `gitdiff` etc. You'll see the proposed function call and can approve/reject it before execution. +When you use `@copilot`, the LLM can call functions from the `copilot` group such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. + +- By default, proposed tool calls wait for your approval. +- You can configure `trusted_tools` to automatically run specific tools or groups. +- Resources added with `#...` are resolved immediately and shared as context. +- Tool call results are sent back to the model as plain output, while manual resources keep their `##` references in chat. + +> [!WARNING] +> `trusted_tools = true` allows the model to run every enabled tool without asking. Only use it if you fully trust the tool set and workspace. # Usage @@ -136,21 +144,20 @@ When you use `@copilot`, the LLM can call functions like `bash`, `edit`, `file`, ## Chat Key Mappings -| Insert | Normal | Action | -| ------- | ------- | ------------------------------------------ | -| `` | - | Trigger/accept completion menu for tokens | -| `` | `q` | Close the chat window | -| `` | `` | Reset and clear the chat window | -| `` | `` | Submit the current prompt | -| - | `grr` | Toggle sticky prompt for line under cursor | -| `` | `` | Accept nearest diff | -| - | `gj` | Jump to section of nearest diff | -| - | `gqa` | Add all answers from chat to quickfix list | -| - | `gqd` | Add all diffs from chat to quickfix list | -| - | `gy` | Yank nearest diff to register | -| - | `gd` | Show diff between source and nearest diff | -| - | `gc` | Show info about current chat | -| - | `gh` | Show help message | +| Insert | Normal | Action | +| ------- | ------- | ----------------------------------------- | +| `` | - | Trigger/accept completion menu for tokens | +| `` | `q` | Close the chat window | +| `` | `` | Reset and clear the chat window | +| `` | `` | Submit the current prompt | +| `` | `` | Accept nearest diff | +| - | `gj` | Jump to section of nearest diff | +| - | `gqa` | Add all answers from chat to quickfix | +| - | `gqd` | Add all diffs from chat to quickfix | +| - | `gy` | Yank nearest diff to register | +| - | `gd` | Show diff between source and nearest diff | +| - | `gc` | Show info about current chat | +| - | `gh` | Show help message | > [!WARNING] > Some plugins (e.g. `copilot.vim`) may also map common keys like `` in insert mode. @@ -167,23 +174,24 @@ When you use `@copilot`, the LLM can call functions like `bash`, `edit`, `file`, All predefined functions belong to the `copilot` group. -| Function | Type | Description | Example Usage | -| ----------- | -------- | ------------------------------------------------------ | -------------------- | -| `bash` | Tool | Executes a bash command and returns output | `@copilot` only | -| `buffer` | Resource | Retrieves content from buffer(s) with diagnostics | `#buffer:active` | -| `clipboard` | Resource | Provides access to system clipboard content | `#clipboard` | -| `edit` | Tool | Applies a unified diff to a file | `@copilot` only | -| `file` | Resource | Reads content from a specified file path | `#file:path/to/file` | -| `gitdiff` | Resource | Retrieves git diff information | `#gitdiff:staged` | -| `glob` | Resource | Lists filenames matching a pattern in workspace | `#glob:**/*.lua` | -| `grep` | Resource | Searches for a pattern across files in workspace | `#grep:TODO` | -| `selection` | Resource | Includes the current visual selection with diagnostics | `#selection` | -| `url` | Resource | Fetches content from a specified URL | `#url:https://...` | +| Function | Manual `#...` | Description | Example Usage | +| ----------- | ------------- | ------------------------------------------------------ | -------------------- | +| `bash` | No | Executes a bash command and returns output | `@copilot` | +| `buffer` | Yes | Retrieves content from buffer(s) with diagnostics | `#buffer:active` | +| `clipboard` | Yes | Provides access to system clipboard content | `#clipboard` | +| `edit` | No | Applies a unified diff to a file | `@copilot` | +| `file` | Yes | Reads content from a specified file path | `#file:path/to/file` | +| `gitdiff` | Yes | Retrieves git diff information | `#gitdiff:staged` | +| `glob` | Yes | Lists filenames matching a pattern in workspace | `#glob:**/*.lua` | +| `grep` | Yes | Searches for a pattern across files in workspace | `#grep:TODO` | +| `selection` | Yes | Includes the current visual selection with diagnostics | `#selection` | +| `url` | Yes | Fetches content from a specified URL | `#url:https://...` | + +`#...` resolves a function immediately and adds its output as chat context. -**Type Legend:** +`@copilot` shares the enabled functions with the model so it can choose when to call them. -- **Resource**: Can be used manually via `#function` syntax -- **Tool**: Can only be called by LLM via `@copilot` (for safety/complexity reasons) +Only `bash` and `edit` are tool-only. The rest can be used both as manual resources and as callable tools. ## Predefined Prompts @@ -209,6 +217,7 @@ Most users only need to configure a few options: { model = 'gpt-4.1', -- AI model to use temperature = 0.1, -- Lower = focused, higher = creative + trusted_tools = nil, -- Require approval for all tool calls window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float' width = 0.5, -- 50% of screen width @@ -241,12 +250,14 @@ Most users only need to configure a few options: } ``` +`window.layout` also supports `'replace'` to reuse the current window. + ## Buffer Behavior ```lua -- Auto-command to customize chat buffer behavior vim.api.nvim_create_autocmd('BufEnter', { - pattern = 'copilot-*', + pattern = 'copilot-chat', callback = function() vim.opt_local.relativenumber = false vim.opt_local.number = false @@ -278,6 +289,7 @@ Types of copilot highlights: - `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) - `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) +- `CopilotChatAnnotationHeader` - Annotation header highlight in chat buffer ## Prompts @@ -304,14 +316,44 @@ Define your own prompts in the configuration: ## Functions +Use `trusted_tools` to control which tool calls are executed automatically: + +```lua +{ + trusted_tools = nil, -- default: require approval for all tool calls + + -- trust all functions in a group + -- trusted_tools = 'copilot', + + -- trust specific functions by name or groups by name + -- trusted_tools = { 'file', 'glob', 'grep' }, + + -- trust every enabled tool call + -- trusted_tools = true, +} +``` + +A tool is trusted when any of these match: + +- Its function definition sets `trusted = true` +- Its function name appears in `trusted_tools` +- Its function group appears in `trusted_tools` +- `trusted_tools = true` + +For most setups, trusting a few read-only functions such as `file`, `glob`, or `grep` is safer than trusting everything. + +> [!WARNING] +> Trusted tools run without asking for confirmation. Be especially careful with tools like `bash` and `edit`, which can change your workspace. + Define your own functions in the configuration with input handling and schema: ```lua { functions = { birthday = { - description = "Retrieves birthday information for a person", - uri = "birthday://{name}", + description = 'Retrieves birthday information for a person', + uri = 'birthday://{name}', + trusted = false, schema = { type = 'object', required = { 'name' }, @@ -329,14 +371,16 @@ Define your own functions in the configuration with input handling and schema: uri = 'birthday://' .. input.name, mimetype = 'text/plain', data = input.name .. ' birthday info', - } + }, } - end - } + end, + }, } } ``` +If a function has a `uri`, it can be used manually with `#birthday:Alice`. Functions without a `uri` are tool-only and can only be called by the model. + ## Providers Add custom AI providers: @@ -345,9 +389,9 @@ Add custom AI providers: { providers = { my_provider = { - get_url = function(opts) return "https://api.example.com/chat" end, - get_headers = function() return { ["Authorization"] = "Bearer " .. api_key } end, - get_models = function() return { { id = "gpt-4.1", name = "GPT-4.1 model" } } end, + get_url = function(opts) return 'https://api.example.com/chat' end, + get_headers = function() return { ['Authorization'] = 'Bearer ' .. api_key } end, + get_models = function() return { { id = 'gpt-4.1', name = 'GPT-4.1 model' } } end, prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, } @@ -363,7 +407,7 @@ Add custom AI providers: disabled?: boolean, -- Optional: Extra info about the provider displayed in info panel - get_info?(): string[] + get_info?(headers: table): string[] -- Optional: Get extra request headers with optional expiration time get_headers?(): table, number?, @@ -379,20 +423,23 @@ Add custom AI providers: -- Optional: Get available models get_models?(headers: table): table, + + -- Optional: Resolve a user-facing model id to a provider model id + resolve_model?(headers: table, model: string): string, } ``` **Built-in providers:** - `copilot` - GitHub Copilot (default) -- `github_models` - GitHub Marketplace models (disabled by default) +- `github_models` - GitHub Models (disabled by default) # API Reference ## Core ```lua -local chat = require("CopilotChat") +local chat = require('CopilotChat') -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config @@ -422,7 +469,7 @@ chat.log_level(level) -- Set log level (debug, info, etc.) You can also access the chat window UI methods through the `chat.chat` object: ```lua -local window = require("CopilotChat").chat +local window = require('CopilotChat').chat -- Chat UI State window:visible() -- Check if chat window is visible @@ -441,8 +488,8 @@ window:start() -- Start writing to chat window window:finish() -- Finish writing to chat window -- Source Management -window.get_source() -- Get the current source buffer and window -window.set_source(winnr) -- Set the source window +window:get_source() -- Get the current source buffer and window +window:set_source(winnr) -- Set the source window -- Navigation window:follow() -- Move cursor to end of chat content @@ -455,10 +502,11 @@ window:overlay(opts) -- Show overlay with specified options ## Prompt parser ```lua -local parser = require("CopilotChat.prompts") +local parser = require('CopilotChat.prompts') parser.resolve_prompt() -- Resolve prompt references -parser.resolve_tools() -- Resolve tools that are available for automatic use by LLM +parser.resolve_tools() -- Resolve tools shared with the model via @... +parser.resolve_functions() -- Resolve manual function/resource references via #... parser.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) ``` @@ -466,22 +514,26 @@ parser.resolve_model() -- Resolve model from prompt (WARN: async, requi ```lua -- Open chat, ask a question and handle response -require("CopilotChat").open() -require("CopilotChat").ask("#buffer Explain this code", { +require('CopilotChat').open() +require('CopilotChat').ask('#buffer Explain this code', { callback = function(response) - vim.notify("Got response: " .. response:sub(1, 50) .. "...") - return response + vim.notify('Got response: ' .. vim.trim(response.content):sub(1, 50) .. '...') end, }) -- Save and load chat history -require("CopilotChat").save("my_debugging_session") -require("CopilotChat").load("my_debugging_session") +require('CopilotChat').save('my_debugging_session') +require('CopilotChat').load('my_debugging_session') -- Use custom sticky and model -require("CopilotChat").ask("How can I optimize this?", { - model = "gpt-4.1", - sticky = {"#buffer", "#gitdiff:staged"} +require('CopilotChat').ask('How can I optimize this?', { + model = 'gpt-4.1', + sticky = { '#buffer', '#gitdiff:staged' }, +}) + +-- Automatically trust a small read-only tool set +require('CopilotChat').setup({ + trusted_tools = { 'file', 'glob', 'grep' }, }) ``` @@ -512,6 +564,12 @@ To run tests: make test ``` +To run the same formatting check as CI: + +```bash +stylua --check . +``` + ## Contributing 1. Fork the repository diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 1e0adda3..a54a66f3 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -19,6 +19,7 @@ ---@field tools string|table|nil ---@field resources string|table|nil ---@field sticky string|table|nil +---@field trusted_tools boolean|string|table|nil ---@field diff 'block'|'unified'? ---@field language string? ---@field temperature number? @@ -64,6 +65,7 @@ return { tools = nil, -- Default tool or array of tools (or groups) to share with LLM (can be specified manually in prompt via @). resources = 'selection', -- Default resources to share with LLM (can be specified manually in prompt via #). sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat (can be specified manually in prompt via >). + trusted_tools = nil, -- Trust tool calls from specific functions or groups, or all trusted tools when true (e.g., {'buffer', 'file'} or 'copilot'). diff = 'block', -- Default diff format to use, 'block' or 'unified'. language = 'English', -- Default language to use for answers diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 991759dd..0ec22b10 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -44,6 +44,7 @@ end ---@field description string? ---@field schema table? ---@field group string? +---@field trusted boolean? ---@field uri string? ---@field resolve fun(input: table, source: CopilotChat.ui.chat.Source):CopilotChat.client.Resource[] diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index a72529f9..c3028f8a 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -290,7 +290,6 @@ return { async.run(function() local config, prompt = prompts.resolve_prompt(message.content) local system_prompt = config.system_prompt - local resolved_resources = prompts.resolve_functions(prompt, config) local selected_tools = prompts.resolve_tools(prompt, config) local selected_model = prompts.resolve_model(prompt, config) local infos = client:info() @@ -357,28 +356,6 @@ return { table.insert(lines, '') end - if not utils.empty(resolved_resources) then - table.insert(lines, '**Resources**') - table.insert(lines, '') - end - - 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 or resource.uri, #resource_lines) - if #resource_lines > 10 then - header = header .. ' (truncated)' - end - - table.insert(lines, header) - table.insert(lines, '```' .. files.mimetype_to_filetype(resource.mimetype)) - for _, line in ipairs(preview) do - table.insert(lines, line) - end - table.insert(lines, '```') - table.insert(lines, '') - end - chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index b5052845..f6a2cc3b 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -2,6 +2,7 @@ local async = require('plenary.async') local log = require('plenary.log') local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') +local functions = require('CopilotChat.functions') local prompts = require('CopilotChat.prompts') local select = require('CopilotChat.select') local utils = require('CopilotChat.utils') @@ -30,6 +31,37 @@ local M = setmetatable({}, { end, }) +---@param config CopilotChat.config.Shared +---@param tool_name string +---@return boolean +local function is_trusted_tool(config, tool_name) + local tool_spec = config.functions[tool_name] + if not tool_spec then + return false + end + + if tool_spec.trusted then + return true + end + + local trusted_tools = config.trusted_tools + if trusted_tools == true then + return true + end + + for _, trusted_pattern in ipairs(utils.to_table(trusted_tools)) do + if tool_name == trusted_pattern then + return true + end + + if tool_spec.group == trusted_pattern then + return true + end + end + + return false +end + --- Process sticky values from prompt and config --- Extracts stickies from prompt, adds config-based stickies, stores them, returns clean prompt ---@param prompt string @@ -116,7 +148,7 @@ end --- Finish writing to chat buffer. ---@param start_of_chat boolean? -local function finish(start_of_chat) +local function finish(start_of_chat, remaining_tool_calls) if start_of_chat then local sticky = {} if M.config.sticky then @@ -128,8 +160,11 @@ local function finish(start_of_chat) end local prompt_content = '' - local assistant_message = M.chat:get_message(constants.ROLE.ASSISTANT) - local tool_calls = assistant_message and assistant_message.tool_calls or {} + local tool_calls = remaining_tool_calls + if not tool_calls then + local assistant_message = M.chat:get_message(constants.ROLE.ASSISTANT) + tool_calls = assistant_message and assistant_message.tool_calls or {} + end local current_sticky = M.chat:get_sticky() if not utils.empty(current_sticky) then @@ -430,16 +465,9 @@ function M.ask(prompt, config) config, prompt = prompts.resolve_prompt(prompt, config) local system_prompt = config.system_prompt or '' local selected_tools, prompt = prompts.resolve_tools(prompt, config) - local resolved_resources, resolved_tools, resolved_stickies, prompt = prompts.resolve_functions(prompt, config) + local resolved_resources, resolved_tools, prompt = prompts.resolve_functions(prompt, config) local selected_model, prompt = prompts.resolve_model(prompt, config) - -- Store resolved stickies to chat - local current_sticky = M.chat:get_sticky() - for _, sticky in ipairs(resolved_stickies) do - table.insert(current_sticky, sticky) - end - M.chat:set_sticky(current_sticky) - prompt = vim.trim(prompt) if not config.headless then @@ -547,6 +575,93 @@ function M.ask(prompt, config) M.chat.token_count = token_count M.chat.token_max_count = token_max_count + -- Execute trusted tool calls automatically + if response.tool_calls and #response.tool_calls > 0 then + local trusted_tool_calls = {} + local untrusted_tool_calls = {} + + for _, tool_call in ipairs(response.tool_calls) do + if is_trusted_tool(config, tool_call.name) then + table.insert(trusted_tool_calls, tool_call) + else + table.insert(untrusted_tool_calls, tool_call) + end + end + + if #trusted_tool_calls > 0 then + async.run(handle_error(config, function() + local trusted_tool_results = {} + local source = M.chat:get_source() + + for _, tool_call in ipairs(trusted_tool_calls) do + local input = {} + if not utils.empty(tool_call.arguments) then + input = utils.json_decode(tool_call.arguments) + end + + local ok, output = prompts.execute_tool_call(tool_call.name, input, config, source) + local result = prompts.format_tool_output(ok, output) + + table.insert(trusted_tool_results, { + id = tool_call.id, + result = result, + }) + end + + if not utils.empty(trusted_tool_results) then + utils.schedule_main() + for _, tool in ipairs(trusted_tool_results) do + M.chat:add_message({ + id = tool.id, + role = constants.ROLE.TOOL, + tool_call_id = tool.id, + content = '\n' .. tool.result .. '\n', + }) + end + + if #untrusted_tool_calls > 0 then + finish(nil, untrusted_tool_calls) + else + local continue_response = client:ask({ + headless = config.headless, + history = M.chat:get_messages(), + resources = resolved_resources, + tools = selected_tools, + system_prompt = system_prompt, + model = selected_model, + temperature = config.temperature, + on_progress = vim.schedule_wrap(function(message) + if not config.headless then + M.chat:add_message(message) + end + end), + }) + + if continue_response then + local continue_message = continue_response.message + continue_message.content = vim.trim(continue_message.content) + if utils.empty(continue_message.content) then + continue_message.content = '' + else + continue_message.content = '\n' .. continue_message.content .. '\n' + end + + utils.schedule_main() + M.chat:add_message(continue_message, true) + M.chat.token_count = continue_response.token_count + M.chat.token_max_count = continue_response.token_max_count + end + + finish() + end + else + finish() + end + end)) + return + end + end + finish() end end)) diff --git a/lua/CopilotChat/prompts.lua b/lua/CopilotChat/prompts.lua index 461a0770..f5c76525 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -91,10 +91,67 @@ function M.resolve_tools(prompt, config) return enabled_tools:values(), prompt end +--- Execute a tool call and return the raw output. +---@param name string Tool name +---@param input table|string Input arguments +---@param config CopilotChat.config.Shared +---@param source CopilotChat.client.Source +---@return boolean ok +---@return any output +---@async +function M.execute_tool_call(name, input, config, source) + local tool = config.functions[name] + if not tool or not tool.resolve then + return false, 'Tool not found: ' .. name + end + + local schema = nil + for _, t in ipairs(functions.parse_tools(config.functions)) do + if t.name == name then + schema = t.schema + break + end + end + + local ok, output + if config.stop_on_function_failure then + output = tool.resolve(functions.parse_input(input, schema), source) + ok = true + else + ok, output = pcall(tool.resolve, functions.parse_input(input, schema), source) + end + + return ok, output +end + +--- Format tool output as plain text. +---@param ok boolean +---@param output any +---@return string +function M.format_tool_output(ok, output) + local result = '' + if not ok then + result = utils.make_string(output) + elseif type(output) ~= 'table' then + result = utils.make_string(output) + else + for _, content in ipairs(output) do + if content then + local data = content.data or content.uri + if data then + result = result .. (utils.empty(result) and '' or '\n') .. data + end + end + end + end + + return result +end + --- Call and resolve function calls from the prompt. ---@param prompt string? ---@param config CopilotChat.config.Shared? ----@return table, table, table, string +---@return table, table, string ---@async function M.resolve_functions(prompt, config) config, prompt = M.resolve_prompt(prompt, config) @@ -102,11 +159,6 @@ function M.resolve_functions(prompt, config) local chat = require('CopilotChat').chat local source = chat:get_source() - local tools = {} - for _, tool in ipairs(functions.parse_tools(config.functions)) do - tools[tool.name] = tool - end - if config.resources then local resources = utils.to_table(config.resources) local lines = utils.split_lines(prompt) @@ -119,7 +171,6 @@ function M.resolve_functions(prompt, config) local resolved_resources = {} local resolved_tools = {} - local resolved_stickies = {} local tool_calls = {} utils.schedule_main() @@ -199,58 +250,51 @@ function M.resolve_functions(prompt, config) return nil end - local schema = tools[name] and tools[name].schema or nil - local ok, output - if config.stop_on_function_failure then - output = tool.resolve(functions.parse_input(input, schema), source) - ok = true - else - ok, output = pcall(tool.resolve, functions.parse_input(input, schema), source) + local ok, output = M.execute_tool_call(name, input, config, source) + + if tool_id then + table.insert(resolved_tools, { + id = tool_id, + result = M.format_tool_output(ok, output), + }) + + return '' end - local result = '' if not ok then - result = utils.make_string(output) - else - for _, content in ipairs(output) do - if content then - local content_out = nil - if content.uri then - if - not vim.tbl_contains(resolved_resources, function(resource) - return resource.uri == content.uri - end, { predicate = true }) - then - content_out = '##' .. content.uri - table.insert(resolved_resources, content) - end - - if tool_id then - table.insert(resolved_stickies, '##' .. content.uri) - end - else - content_out = content.data + return utils.make_string(output) + end + + if type(output) ~= 'table' then + return utils.make_string(output) + end + + local result = '' + for _, content in ipairs(output) do + if content then + local content_out = nil + if content.uri then + if + not vim.tbl_contains(resolved_resources, function(resource) + return resource.uri == content.uri + end, { predicate = true }) + then + content_out = '##' .. content.uri + table.insert(resolved_resources, content) end + else + content_out = content.data + end - if content_out then - if not utils.empty(result) then - result = result .. '\n' - end - result = result .. content_out + if content_out then + if not utils.empty(result) then + result = result .. '\n' end + result = result .. content_out end end end - if tool_id then - table.insert(resolved_tools, { - id = tool_id, - result = result, - }) - - return '' - end - return result end @@ -266,7 +310,7 @@ function M.resolve_functions(prompt, config) end end - return resolved_resources, resolved_tools, resolved_stickies, prompt + return resolved_resources, resolved_tools, prompt end --- Resolve the final prompt and config from prompt template. diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 0f3b4871..f20d08b3 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -99,9 +99,9 @@ end ---@field content string ---@class CopilotChat.ui.chat.Section ----@field start_line number ----@field end_line number ----@field blocks table +---@field start_line integer +---@field end_line integer +---@field blocks CopilotChat.ui.chat.Block[] ---@class CopilotChat.ui.chat.Message : CopilotChat.client.Message ---@field id string? @@ -113,7 +113,7 @@ end --- @field cwd fun():string ---@class CopilotChat.ui.chat.Chat : CopilotChat.ui.overlay.Overlay ----@field winnr number? +---@field winnr integer? ---@field config CopilotChat.config.Shared ---@field token_count number? ---@field token_max_count number? @@ -125,7 +125,7 @@ end ---@field private chat_overlay CopilotChat.ui.overlay.Overlay ---@field private last_changedtick number? ---@field private source CopilotChat.ui.chat.Source ----@field private sticky table +---@field private sticky string[] 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) @@ -243,7 +243,7 @@ function Chat:get_block(role, cursor) end --- Get list of all chat messages ----@return table +---@return CopilotChat.ui.chat.Message[] function Chat:get_messages() self:parse() return self.messages:values() @@ -269,7 +269,12 @@ function Chat:get_message(role, cursor) for _, message in ipairs(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 + if + matches_role + and section + 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 @@ -288,13 +293,13 @@ function Chat:get_message(role, cursor) end --- Get the current sticky array. ----@return table +---@return string[] function Chat:get_sticky() return self.sticky end --- Set the sticky array. ----@param sticky table +---@param sticky string[] function Chat:set_sticky(sticky) self.sticky = sticky end diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 32157685..ace646c4 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -2,7 +2,7 @@ local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') ---@class CopilotChat.ui.overlay.Overlay : Class ----@field bufnr number? +---@field bufnr integer? ---@field protected name string ---@field protected help string ---@field private cursor integer[]? @@ -23,11 +23,11 @@ end) --- Show the overlay buffer ---@param text string ----@param winnr number +---@param winnr integer ---@param filetype? string ---@param syntax string? ----@param on_show? fun(bufnr: number) ----@param on_hide? fun(bufnr: number) +---@param on_show? fun(bufnr: integer) +---@param on_hide? fun(bufnr: integer) function Overlay:show(text, winnr, filetype, syntax, on_show, on_hide) if not text or text == '' then return @@ -75,7 +75,7 @@ function Overlay:delete() end --- Create the overlay buffer ----@return number +---@return integer ---@protected function Overlay:create() local bufnr = vim.api.nvim_create_buf(false, true) From be326102bdc5aa365b373489e2302b39d1fdbff3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 08:32:10 +0000 Subject: [PATCH 243/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 179 ++++++++++++++++++++++++++++++-------------- 1 file changed, 121 insertions(+), 58 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 24b6300d..55ceca49 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -41,7 +41,7 @@ CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. - 🤖 **Multiple AI Models** - GitHub Copilot (including GPT-4o, Gemini 2.5 Pro, Claude 4 Sonnet, Claude 3.7 Sonnet, Claude 3.5 Sonnet, o3-mini, o4-mini) + custom providers (Ollama, Mistral.ai). The exact list of available models depends on your GitHub Copilot settings and the models provided by GitHub’s API. -- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with your explicit approval +- 🔧 **Tool Calling** - LLM can call workspace functions (file reading, git operations, search) with manual approval or automatic execution for trusted tools - 🔒 **Privacy First** - Only shares what you explicitly request - no background data collection - 📝 **Interactive Chat** - Interactive UI with completion, diffs, and quickfix integration - 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context @@ -126,7 +126,7 @@ VIM-PLUG *CopilotChat-vim-plug* 2. Core Concepts *CopilotChat-core-concepts* - **Resources** (`#`) - Add specific content (files, git diffs, URLs) to your prompt -- **Tools** (`@`) - Give LLM access to functions it can call with your approval +- **Tools** (`@`) - Give LLM access to functions it can call during the chat, with manual approval by default - **Sticky Prompts** (`> `) - Persist context across single chat session - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks @@ -149,10 +149,17 @@ EXAMPLES *CopilotChat-examples* > You are a helpful coding assistant < -When you use `@copilot`, the LLM can call functions like `bash`, `edit`, -`file`, `glob`, `grep`, `gitdiff` etc. You’ll see the proposed function call -and can approve/reject it before execution. +When you use `@copilot`, the LLM can call functions from the `copilot` group +such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. +- By default, proposed tool calls wait for your approval. +- You can configure `trusted_tools` to automatically run specific tools or groups. +- Resources added with `#...` are resolved immediately and shared as context. +- Tool call results are sent back to the model as plain output, while manual resources keep their `##` references in chat. + + + [!WARNING] `trusted_tools = true` allows the model to run every enabled tool + without asking. Only use it if you fully trust the tool set and workspace. ============================================================================== 3. Usage *CopilotChat-usage* @@ -177,16 +184,15 @@ COMMANDS *CopilotChat-commands* CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* Insert Normal Action - -------- -------- -------------------------------------------- + -------- -------- ------------------------------------------- - Trigger/accept completion menu for tokens q Close the chat window Reset and clear the chat window Submit the current prompt - - grr Toggle sticky prompt for line under cursor Accept nearest diff - gj Jump to section of nearest diff - - gqa Add all answers from chat to quickfix list - - gqd Add all diffs from chat to quickfix list + - gqa Add all answers from chat to quickfix + - gqd Add all diffs from chat to quickfix - gy Yank nearest diff to register - gd Show diff between source and nearest diff - gc Show info about current chat @@ -205,39 +211,44 @@ PREDEFINED FUNCTIONS *CopilotChat-predefined-functions* All predefined functions belong to the `copilot` group. - ------------------------------------------------------------------------------------- - Function Type Description Example Usage - ----------- ---------- ----------------------------------------- -------------------- - bash Tool Executes a bash command and returns @copilot only - output + ---------------------------------------------------------------------------------- + Function Manual Description Example Usage + #... + ----------- --------- --------------------------------------- -------------------- + bash No Executes a bash command and returns @copilot + output - buffer Resource Retrieves content from buffer(s) with #buffer:active - diagnostics + buffer Yes Retrieves content from buffer(s) with #buffer:active + diagnostics - clipboard Resource Provides access to system clipboard #clipboard - content + clipboard Yes Provides access to system clipboard #clipboard + content - edit Tool Applies a unified diff to a file @copilot only + edit No Applies a unified diff to a file @copilot - file Resource Reads content from a specified file path #file:path/to/file + file Yes Reads content from a specified file #file:path/to/file + path - gitdiff Resource Retrieves git diff information #gitdiff:staged + gitdiff Yes Retrieves git diff information #gitdiff:staged - glob Resource Lists filenames matching a pattern in #glob:**/*.lua - workspace + glob Yes Lists filenames matching a pattern in #glob:**/*.lua + workspace - grep Resource Searches for a pattern across files in #grep:TODO - workspace + grep Yes Searches for a pattern across files in #grep:TODO + workspace - selection Resource Includes the current visual selection #selection - with diagnostics + selection Yes Includes the current visual selection #selection + with diagnostics - url Resource Fetches content from a specified URL #url:https://... - ------------------------------------------------------------------------------------- -**Type Legend:** + url Yes Fetches content from a specified URL #url:https://... + ---------------------------------------------------------------------------------- +`#...` resolves a function immediately and adds its output as chat context. -- **Resource**: Can be used manually via `#function` syntax -- **Tool**: Can only be called by LLM via `@copilot` (for safety/complexity reasons) +`@copilot` shares the enabled functions with the model so it can choose when to +call them. + +Only `bash` and `edit` are tool-only. The rest can be used both as manual +resources and as callable tools. PREDEFINED PROMPTS *CopilotChat-predefined-prompts* @@ -276,6 +287,7 @@ Most users only need to configure a few options: { model = 'gpt-4.1', -- AI model to use temperature = 0.1, -- Lower = focused, higher = creative + trusted_tools = nil, -- Require approval for all tool calls window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float' width = 0.5, -- 50% of screen width @@ -309,13 +321,15 @@ WINDOW & APPEARANCE *CopilotChat-window-&-appearance* } < +`window.layout` also supports `'replace'` to reuse the current window. + BUFFER BEHAVIOR *CopilotChat-buffer-behavior* >lua -- Auto-command to customize chat buffer behavior vim.api.nvim_create_autocmd('BufEnter', { - pattern = 'copilot-*', + pattern = 'copilot-chat', callback = function() vim.opt_local.relativenumber = false vim.opt_local.number = false @@ -348,6 +362,7 @@ Types of copilot highlights: - `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-4.1`) - `CopilotChatUri` - URI highlight in chat buffer (e.g. `##https://...`) - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) +- `CopilotChatAnnotationHeader` - Annotation header highlight in chat buffer PROMPTS *CopilotChat-prompts* @@ -376,14 +391,45 @@ Define your own prompts in the configuration: FUNCTIONS *CopilotChat-functions* +Use `trusted_tools` to control which tool calls are executed automatically: + +>lua + { + trusted_tools = nil, -- default: require approval for all tool calls + + -- trust all functions in a group + -- trusted_tools = 'copilot', + + -- trust specific functions by name or groups by name + -- trusted_tools = { 'file', 'glob', 'grep' }, + + -- trust every enabled tool call + -- trusted_tools = true, + } +< + +A tool is trusted when any of these match: + +- Its function definition sets `trusted = true` +- Its function name appears in `trusted_tools` +- Its function group appears in `trusted_tools` +- `trusted_tools = true` + +For most setups, trusting a few read-only functions such as `file`, `glob`, or +`grep` is safer than trusting everything. + + + [!WARNING] Trusted tools run without asking for confirmation. Be especially + careful with tools like `bash` and `edit`, which can change your workspace. Define your own functions in the configuration with input handling and schema: >lua { functions = { birthday = { - description = "Retrieves birthday information for a person", - uri = "birthday://{name}", + description = 'Retrieves birthday information for a person', + uri = 'birthday://{name}', + trusted = false, schema = { type = 'object', required = { 'name' }, @@ -401,14 +447,17 @@ Define your own functions in the configuration with input handling and schema: uri = 'birthday://' .. input.name, mimetype = 'text/plain', data = input.name .. ' birthday info', - } + }, } - end - } + end, + }, } } < +If a function has a `uri`, it can be used manually with `#birthday:Alice`. +Functions without a `uri` are tool-only and can only be called by the model. + PROVIDERS *CopilotChat-providers* @@ -418,9 +467,9 @@ Add custom AI providers: { providers = { my_provider = { - get_url = function(opts) return "https://api.example.com/chat" end, - get_headers = function() return { ["Authorization"] = "Bearer " .. api_key } end, - get_models = function() return { { id = "gpt-4.1", name = "GPT-4.1 model" } } end, + get_url = function(opts) return 'https://api.example.com/chat' end, + get_headers = function() return { ['Authorization'] = 'Bearer ' .. api_key } end, + get_models = function() return { { id = 'gpt-4.1', name = 'GPT-4.1 model' } } end, prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, } @@ -436,7 +485,7 @@ Add custom AI providers: disabled?: boolean, -- Optional: Extra info about the provider displayed in info panel - get_info?(): string[] + get_info?(headers: table): string[] -- Optional: Get extra request headers with optional expiration time get_headers?(): table, number?, @@ -452,13 +501,16 @@ Add custom AI providers: -- Optional: Get available models get_models?(headers: table): table, + + -- Optional: Resolve a user-facing model id to a provider model id + resolve_model?(headers: table, model: string): string, } < **Built-in providers:** - `copilot` - GitHub Copilot (default) -- `github_models` - GitHub Marketplace models (disabled by default) +- `github_models` - GitHub Models (disabled by default) ============================================================================== @@ -468,7 +520,7 @@ Add custom AI providers: CORE *CopilotChat-core* >lua - local chat = require("CopilotChat") + local chat = require('CopilotChat') -- Basic Chat Functions chat.ask(prompt, config) -- Ask a question with optional config @@ -499,7 +551,7 @@ CHAT WINDOW *CopilotChat-chat-window* You can also access the chat window UI methods through the `chat.chat` object: >lua - local window = require("CopilotChat").chat + local window = require('CopilotChat').chat -- Chat UI State window:visible() -- Check if chat window is visible @@ -518,8 +570,8 @@ You can also access the chat window UI methods through the `chat.chat` object: window:finish() -- Finish writing to chat window -- Source Management - window.get_source() -- Get the current source buffer and window - window.set_source(winnr) -- Set the source window + window:get_source() -- Get the current source buffer and window + window:set_source(winnr) -- Set the source window -- Navigation window:follow() -- Move cursor to end of chat content @@ -533,10 +585,11 @@ You can also access the chat window UI methods through the `chat.chat` object: PROMPT PARSER *CopilotChat-prompt-parser* >lua - local parser = require("CopilotChat.prompts") + local parser = require('CopilotChat.prompts') parser.resolve_prompt() -- Resolve prompt references - parser.resolve_tools() -- Resolve tools that are available for automatic use by LLM + parser.resolve_tools() -- Resolve tools shared with the model via @... + parser.resolve_functions() -- Resolve manual function/resource references via #... parser.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) < @@ -545,22 +598,26 @@ EXAMPLE USAGE *CopilotChat-example-usage* >lua -- Open chat, ask a question and handle response - require("CopilotChat").open() - require("CopilotChat").ask("#buffer Explain this code", { + require('CopilotChat').open() + require('CopilotChat').ask('#buffer Explain this code', { callback = function(response) - vim.notify("Got response: " .. response:sub(1, 50) .. "...") - return response + vim.notify('Got response: ' .. vim.trim(response.content):sub(1, 50) .. '...') end, }) -- Save and load chat history - require("CopilotChat").save("my_debugging_session") - require("CopilotChat").load("my_debugging_session") + require('CopilotChat').save('my_debugging_session') + require('CopilotChat').load('my_debugging_session') -- Use custom sticky and model - require("CopilotChat").ask("How can I optimize this?", { - model = "gpt-4.1", - sticky = {"#buffer", "#gitdiff:staged"} + require('CopilotChat').ask('How can I optimize this?', { + model = 'gpt-4.1', + sticky = { '#buffer', '#gitdiff:staged' }, + }) + + -- Automatically trust a small read-only tool set + require('CopilotChat').setup({ + trusted_tools = { 'file', 'glob', 'grep' }, }) < @@ -595,6 +652,12 @@ To run tests: make test < +To run the same formatting check as CI: + +>bash + stylua --check . +< + CONTRIBUTING *CopilotChat-contributing* From e73cb24a65da405675b5d9097388ee8c0b9e04ed Mon Sep 17 00:00:00 2001 From: Vladimir Kolchurin <18503099+kolchurinvv@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:39:40 +0200 Subject: [PATCH 244/250] fix(providers): no top_p n and temperature for -codex gpt models * providers - no top_p n and temperature for -codex gpt models * Update lua/CopilotChat/config/providers.lua * Update lua/CopilotChat/config/providers.lua --------- Co-authored-by: Tomas Slusny --- lua/CopilotChat/config/providers.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 548f6abd..fce20e3f 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -278,6 +278,7 @@ end ---@return table local function prepare_chat_input(inputs, opts) local is_o1 = vim.startswith(opts.model.id, 'o1') + local is_codex = opts.model.id:find('codex') ~= nil inputs = vim.tbl_map(function(input) local output = { @@ -324,7 +325,7 @@ local function prepare_chat_input(inputs, opts) end, opts.tools) end - if not is_o1 then + if not is_o1 and not is_codex then out.n = 1 out.top_p = 1 out.temperature = opts.temperature From ee142480a5f13226d1d2e6b9bff1642f0b175795 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:40:22 +0200 Subject: [PATCH 245/250] docs: add kolchurinvv as a contributor for code (#1557) * docs: update README.md [skip ci] * docs: update .all-contributorsrc [skip ci] * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .all-contributorsrc | 7 +++++++ README.md | 1 + 2 files changed, 8 insertions(+) diff --git a/.all-contributorsrc b/.all-contributorsrc index 84757b79..e502fd86 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -515,6 +515,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/270346599?v=4", "profile": "https://github.com/sirjls", "contributions": ["code"] + }, + { + "login": "kolchurinvv", + "name": "Vladimir Kolchurin", + "avatar_url": "https://avatars.githubusercontent.com/u/18503099?v=4", + "profile": "https://github.com/kolchurinvv", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index b37e2cfe..d552f3af 100644 --- a/README.md +++ b/README.md @@ -683,6 +683,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d junqizhang
junqizhang

💻 Calum Lynch
Calum Lynch

💻 sirjls
sirjls

💻 + Vladimir Kolchurin
Vladimir Kolchurin

💻 From 3afd032553da5c70ee934e290db40793d138f180 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 16 Apr 2026 19:43:05 +0200 Subject: [PATCH 246/250] docs(readme): improve resource and tool usage docs (#1558) * docs(readme): improve resource and tool usage docs - Add tip for using to autocomplete resources and options - Clarify and expand resource examples for buffer, file, gitdiff, and url - Add section on tool usage with markdown examples - Update key mappings table and add pro tip for usage - Clarify tool trust configuration and recommend safe defaults - Improve descriptions and available options for predefined functions - General rewording and formatting for clarity and usability Closes #1530 Closes #1488 Signed-off-by: Tomas Slusny * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Signed-off-by: Tomas Slusny Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 155 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 91 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index d552f3af..62364cf3 100644 --- a/README.md +++ b/README.md @@ -97,32 +97,8 @@ EOF - **Models** (`$`) - Specify which AI model to use for the chat - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks -## Examples - -```markdown -# Add specific file to context - -#file:src/main.lua - -# Give LLM access to workspace tools - -@copilot What files are in this project? - -# Sticky prompt that persists - -> #buffer:active -> You are a helpful coding assistant -``` - -When you use `@copilot`, the LLM can call functions from the `copilot` group such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. - -- By default, proposed tool calls wait for your approval. -- You can configure `trusted_tools` to automatically run specific tools or groups. -- Resources added with `#...` are resolved immediately and shared as context. -- Tool call results are sent back to the model as plain output, while manual resources keep their `##` references in chat. - -> [!WARNING] -> `trusted_tools = true` allows the model to run every enabled tool without asking. Only use it if you fully trust the tool set and workspace. +> [!TIP] +> Press `` after typing `#` or `@` to see available options and auto-complete. This is the easiest way to discover what's available! # Usage @@ -144,54 +120,54 @@ When you use `@copilot`, the LLM can call functions from the `copilot` group suc ## Chat Key Mappings -| Insert | Normal | Action | -| ------- | ------- | ----------------------------------------- | -| `` | - | Trigger/accept completion menu for tokens | -| `` | `q` | Close the chat window | -| `` | `` | Reset and clear the chat window | -| `` | `` | Submit the current prompt | -| `` | `` | Accept nearest diff | -| - | `gj` | Jump to section of nearest diff | -| - | `gqa` | Add all answers from chat to quickfix | -| - | `gqd` | Add all diffs from chat to quickfix | -| - | `gy` | Yank nearest diff to register | -| - | `gd` | Show diff between source and nearest diff | -| - | `gc` | Show info about current chat | -| - | `gh` | Show help message | - -> [!WARNING] -> Some plugins (e.g. `copilot.vim`) may also map common keys like `` in insert mode. -> To avoid conflicts, disable Copilot's default `` mapping with: +| Insert | Normal | Action | +| ------- | ------- | ---------------------------------------------------- | +| `` | - | **Autocomplete resources/files/options** (use this!) | +| `` | `q` | Close the chat window | +| `` | `` | Reset and clear the chat window | +| `` | `` | Submit the current prompt | +| `` | `` | Accept nearest diff | +| - | `gj` | Jump to section of nearest diff | +| - | `gqa` | Add all answers from chat to quickfix | +| - | `gqd` | Add all diffs from chat to quickfix | +| - | `gy` | Yank nearest diff to register | +| - | `gd` | Show diff between source and nearest diff | +| - | `gc` | Show info about current chat | +| - | `gh` | Show help message | + +**💡 Pro tip:** After typing `#`, `@`, `#buffer:`, or `#file:`, press `` to see available options. This is the fastest way to work! + +> [!NOTE] +> **Tab key not working?** Some plugins (e.g. `copilot.vim`) also map `` in insert mode. +> To fix conflicts, disable the other plugin's `` mapping: > > ```lua +> -- For copilot.vim > vim.g.copilot_no_tab_map = true > vim.keymap.set('i', '', 'copilot#Accept("\\")', { expr = true, replace_keycodes = false }) > ``` > -> You can also customize CopilotChat keymaps in your config. +> Or customize CopilotChat keymaps in your config. ## Predefined Functions All predefined functions belong to the `copilot` group. -| Function | Manual `#...` | Description | Example Usage | -| ----------- | ------------- | ------------------------------------------------------ | -------------------- | -| `bash` | No | Executes a bash command and returns output | `@copilot` | -| `buffer` | Yes | Retrieves content from buffer(s) with diagnostics | `#buffer:active` | -| `clipboard` | Yes | Provides access to system clipboard content | `#clipboard` | -| `edit` | No | Applies a unified diff to a file | `@copilot` | -| `file` | Yes | Reads content from a specified file path | `#file:path/to/file` | -| `gitdiff` | Yes | Retrieves git diff information | `#gitdiff:staged` | -| `glob` | Yes | Lists filenames matching a pattern in workspace | `#glob:**/*.lua` | -| `grep` | Yes | Searches for a pattern across files in workspace | `#grep:TODO` | -| `selection` | Yes | Includes the current visual selection with diagnostics | `#selection` | -| `url` | Yes | Fetches content from a specified URL | `#url:https://...` | - -`#...` resolves a function immediately and adds its output as chat context. - -`@copilot` shares the enabled functions with the model so it can choose when to call them. - -Only `bash` and `edit` are tool-only. The rest can be used both as manual resources and as callable tools. +| Function | Manual `#...` | Description | Available Options | +| ----------- | ------------- | ------------------------------------------------------ | --------------------------------------------------------------------- | +| `bash` | No | Executes a bash command and returns output | Tool-only (use `@copilot`) | +| `buffer` | Yes | Retrieves content from buffer(s) with diagnostics | `active`, `visible`, `listed`, `quickfix`, buffer number, or filename | +| `clipboard` | Yes | Provides access to system clipboard content | No options | +| `edit` | No | Applies a unified diff to a file | Tool-only (use `@copilot`) | +| `file` | Yes | Reads content from a specified file path | Any file path (use `` for completion) | +| `gitdiff` | Yes | Retrieves git diff information | `unstaged` (default), `staged`, or commit SHA | +| `glob` | Yes | Lists filenames matching a pattern in workspace | Any glob pattern (default: `**/*`) | +| `grep` | Yes | Searches for a pattern across files in workspace | Any search pattern | +| `selection` | Yes | Includes the current visual selection with diagnostics | No options | +| `url` | Yes | Fetches content from a specified URL | Any HTTPS URL | + +- **`#`** - Embeds output directly in your message (e.g., `#buffer:listed`, `#file:src/main.lua`) +- **`@`** - Makes function(s) available for LLM to call when needed (e.g., `@copilot`, `@file`) ## Predefined Prompts @@ -205,6 +181,55 @@ Only `bash` and `edit` are tool-only. The rest can be used both as manual resour | `Tests` | Generate tests for selected code | | `Commit` | Generate commit message with commitizen convention from staged changes | +## Resource Usage + +```markdown +# Current buffer + +#buffer:active + +# All open buffers (replaces old #buffers) + +#buffer:listed + +# All visible buffers + +#buffer:visible + +# Specific file + +#file:src/main.lua + +# Git changes + +#gitdiff:staged + +# URL content + +#url:https://example.com/docs +``` + +## Tool Usage + +When you use `@copilot`, the LLM can call functions from the `copilot` group such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. + +```markdown +# Give LLM access to workspace tools + +@copilot What files are in this project? + +# Sticky context with tools + +> #buffer:listed +> @copilot +> Refactor the authentication code +``` + +By default, tool calls require manual approval. Configure `trusted_tools` to automatically run specific tools (see [Functions](#functions)). + +> [!WARNING] +> `trusted_tools = true` allows the model to run every enabled tool without asking. Only use it if you fully trust the tool set and workspace. + # Configuration For all available configuration options, see [`lua/CopilotChat/config.lua`](lua/CopilotChat/config.lua). @@ -333,6 +358,8 @@ Use `trusted_tools` to control which tool calls are executed automatically: } ``` +**How tool trust works:** + A tool is trusted when any of these match: - Its function definition sets `trusted = true` @@ -340,7 +367,7 @@ A tool is trusted when any of these match: - Its function group appears in `trusted_tools` - `trusted_tools = true` -For most setups, trusting a few read-only functions such as `file`, `glob`, or `grep` is safer than trusting everything. +**Recommended setup:** Trust read-only functions like `file`, `glob`, or `grep` for a smoother workflow without compromising safety. > [!WARNING] > Trusted tools run without asking for confirmation. Be especially careful with tools like `bash` and `edit`, which can change your workspace. From 997903d05c082b76588ef6364d7282360dc9ac0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 16 Apr 2026 17:43:29 +0000 Subject: [PATCH 247/250] chore(doc): auto generate docs --- doc/CopilotChat.txt | 181 ++++++++++++++++++++++++++++---------------- 1 file changed, 114 insertions(+), 67 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 55ceca49..e80f9739 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -11,12 +11,13 @@ Table of Contents *CopilotChat-table-of-contents* - lazy.nvim |CopilotChat-lazy.nvim| - vim-plug |CopilotChat-vim-plug| 2. Core Concepts |CopilotChat-core-concepts| - - Examples |CopilotChat-examples| 3. Usage |CopilotChat-usage| - Commands |CopilotChat-commands| - Chat Key Mappings |CopilotChat-chat-key-mappings| - Predefined Functions |CopilotChat-predefined-functions| - Predefined Prompts |CopilotChat-predefined-prompts| + - Resource Usage |CopilotChat-resource-usage| + - Tool Usage |CopilotChat-tool-usage| 4. Configuration |CopilotChat-configuration| - Quick Setup |CopilotChat-quick-setup| - Window & Appearance |CopilotChat-window-&-appearance| @@ -132,34 +133,8 @@ VIM-PLUG *CopilotChat-vim-plug* - **Prompts** (`/PromptName`) - Use predefined prompt templates for common tasks -EXAMPLES *CopilotChat-examples* - ->markdown - # Add specific file to context - - #file:src/main.lua - - # Give LLM access to workspace tools - - @copilot What files are in this project? - - # Sticky prompt that persists - - > #buffer:active - > You are a helpful coding assistant -< - -When you use `@copilot`, the LLM can call functions from the `copilot` group -such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. - -- By default, proposed tool calls wait for your approval. -- You can configure `trusted_tools` to automatically run specific tools or groups. -- Resources added with `#...` are resolved immediately and shared as context. -- Tool call results are sent back to the model as plain output, while manual resources keep their `##` references in chat. - - - [!WARNING] `trusted_tools = true` allows the model to run every enabled tool - without asking. Only use it if you fully trust the tool set and workspace. + [!TIP] Press `` after typing `#` or `@` to see available options and + auto-complete. This is the easiest way to discover what’s available! ============================================================================== 3. Usage *CopilotChat-usage* @@ -183,72 +158,89 @@ COMMANDS *CopilotChat-commands* CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* + ------------------------------------------------------------------------- Insert Normal Action - -------- -------- ------------------------------------------- - - Trigger/accept completion menu for tokens + -------- -------- ------------------------------------------------------- + - Autocomplete resources/files/options (use this!) + q Close the chat window + Reset and clear the chat window + Submit the current prompt + Accept nearest diff + - gj Jump to section of nearest diff + - gqa Add all answers from chat to quickfix + - gqd Add all diffs from chat to quickfix + - gy Yank nearest diff to register + - gd Show diff between source and nearest diff + - gc Show info about current chat + - gh Show help message + ------------------------------------------------------------------------- +**💡 Pro tip:** After typing `#`, `@`, `#buffer:`, or `#file:`, press `` +to see available options. This is the fastest way to work! + - [!WARNING] Some plugins (e.g. `copilot.vim`) may also map common keys like - `` in insert mode. To avoid conflicts, disable Copilot’s default `` - mapping with: + [!NOTE] **Tab key not working?** Some plugins (e.g. `copilot.vim`) also map + `` in insert mode. To fix conflicts, disable the other plugin’s `` + mapping: >lua + -- For copilot.vim vim.g.copilot_no_tab_map = true vim.keymap.set('i', '', 'copilot#Accept("\\")', { expr = true, replace_keycodes = false }) < - You can also customize CopilotChat keymaps in your config. + Or customize CopilotChat keymaps in your config. PREDEFINED FUNCTIONS *CopilotChat-predefined-functions* All predefined functions belong to the `copilot` group. - ---------------------------------------------------------------------------------- - Function Manual Description Example Usage - #... - ----------- --------- --------------------------------------- -------------------- - bash No Executes a bash command and returns @copilot - output - - buffer Yes Retrieves content from buffer(s) with #buffer:active - diagnostics - - clipboard Yes Provides access to system clipboard #clipboard - content + --------------------------------------------------------------------------------- + Function Manual Description Available Options + #... + ----------- -------- -------------------------- --------------------------------- + bash No Executes a bash command Tool-only (use @copilot) + and returns output - edit No Applies a unified diff to a file @copilot + buffer Yes Retrieves content from active, visible, listed, + buffer(s) with diagnostics quickfix, buffer number, or + filename - file Yes Reads content from a specified file #file:path/to/file - path + clipboard Yes Provides access to system No options + clipboard content - gitdiff Yes Retrieves git diff information #gitdiff:staged + edit No Applies a unified diff to Tool-only (use @copilot) + a file - glob Yes Lists filenames matching a pattern in #glob:**/*.lua - workspace + file Yes Reads content from a Any file path (use for + specified file path completion) - grep Yes Searches for a pattern across files in #grep:TODO - workspace + gitdiff Yes Retrieves git diff unstaged (default), staged, or + information commit SHA - selection Yes Includes the current visual selection #selection - with diagnostics + glob Yes Lists filenames matching a Any glob pattern (default: **/*) + pattern in workspace - url Yes Fetches content from a specified URL #url:https://... - ---------------------------------------------------------------------------------- -`#...` resolves a function immediately and adds its output as chat context. + grep Yes Searches for a pattern Any search pattern + across files in workspace -`@copilot` shares the enabled functions with the model so it can choose when to -call them. + selection Yes Includes the current No options + visual selection with + diagnostics -Only `bash` and `edit` are tool-only. The rest can be used both as manual -resources and as callable tools. + url Yes Fetches content from a Any HTTPS URL + specified URL + --------------------------------------------------------------------------------- +- **#** - Embeds output directly in your message (e.g., `#buffer:listed`, `#file:src/main.lua`) +- **@** - Makes function(s) available for LLM to call when needed (e.g., `@copilot`, `@file`) PREDEFINED PROMPTS *CopilotChat-predefined-prompts* @@ -272,6 +264,59 @@ PREDEFINED PROMPTS *CopilotChat-predefined-prompts* changes ------------------------------------------------------------------------- +RESOURCE USAGE *CopilotChat-resource-usage* + +>markdown + # Current buffer + + #buffer:active + + # All open buffers (replaces old #buffers) + + #buffer:listed + + # All visible buffers + + #buffer:visible + + # Specific file + + #file:src/main.lua + + # Git changes + + #gitdiff:staged + + # URL content + + #url:https://example.com/docs +< + + +TOOL USAGE *CopilotChat-tool-usage* + +When you use `@copilot`, the LLM can call functions from the `copilot` group +such as `bash`, `edit`, `file`, `glob`, `grep`, and `gitdiff`. + +>markdown + # Give LLM access to workspace tools + + @copilot What files are in this project? + + # Sticky context with tools + + > #buffer:listed + > @copilot + > Refactor the authentication code +< + +By default, tool calls require manual approval. Configure `trusted_tools` to +automatically run specific tools (see |CopilotChat-functions|). + + + [!WARNING] `trusted_tools = true` allows the model to run every enabled tool + without asking. Only use it if you fully trust the tool set and workspace. + ============================================================================== 4. Configuration *CopilotChat-configuration* @@ -408,6 +453,8 @@ Use `trusted_tools` to control which tool calls are executed automatically: } < +**How tool trust works:** + A tool is trusted when any of these match: - Its function definition sets `trusted = true` @@ -415,8 +462,8 @@ A tool is trusted when any of these match: - Its function group appears in `trusted_tools` - `trusted_tools = true` -For most setups, trusting a few read-only functions such as `file`, `glob`, or -`grep` is safer than trusting everything. +**Recommended setup:** Trust read-only functions like `file`, `glob`, or `grep` +for a smoother workflow without compromising safety. [!WARNING] Trusted tools run without asking for confirmation. Be especially @@ -676,7 +723,7 @@ See CONTRIBUTING.md for detailed guidelines. Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻Calum Lynch💻sirjls💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖Katsuhiko Nishimra💻Erno Hopearuoho💻Shaun Garwood💻neutrinoA4💻 📖Jack Muratore💻Adriel Velazquez💻 📖Tomas Slusny💻 📖Nisal📖Tobias Gårdhus📖Petr Dlouhý📖Dylan Madisetti💻Aaron Weisberg💻 📖Jose Tlacuilo💻 📖Kevin Traver💻 📖dTry💻Arata Furukawa💻Ling💻Ivan Frolov💻Folke Lemaitre💻 📖GitMurf💻Dmitrii Lipin💻jinzhongjia📖guill💻Sjon-Paul Brown💻Renzo Mondragón💻 📖fjchen7💻Radosław Woźniak💻JakubPecenka💻thomastthai📖Tomáš Janoušek💻Toddneal Stallworth📖Sergey Alexandrov💻Léopold Mebazaa💻JunKi Jin💻abdennourzahaf📖Josiah💻Tony Fischer💻 📖Kohei Wada💻Sebastian Yaghoubi📖johncming💻Rokas Brazdžionis💻Sola📖 💻Mani Chandra💻Nischal Basuti📖Teo Ljungberg💻Joe Price💻Yufan You📖 💻Manish Kumar💻Anton Ždanov📖 💻Fredrik Averpil💻Aaron D Borden💻Md. Iftakhar Awal Chowdhury💻 📖Danilo Horta💻Mihamina Rakotomandimby📖 💻Ajmal S💻Samiul Islam💻Rui Costa💻CTCHEN💻Tobias Wölfel💻Alexander Garcia💻Max Kharandziuk💻Xinyu Xiang💻junqizhang💻Calum Lynch💻sirjls💻Vladimir Kolchurin💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 5378035c2eb4089de245473dec1768e0986543bd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 16 Apr 2026 21:30:52 +0200 Subject: [PATCH 248/250] refactor(utils): move notify module to utils directory (#1559) Relocate the notify module from the root CopilotChat directory to lua/CopilotChat/utils/notify.lua for better organization and consistency. Update all require statements to use the new path. No functional changes; this is a structural refactor to improve codebase maintainability. Signed-off-by: Tomas Slusny --- CONTRIBUTING.md | 17 ++++++----------- lua/CopilotChat/client.lua | 2 +- lua/CopilotChat/config/providers.lua | 2 +- lua/CopilotChat/init.lua | 2 +- lua/CopilotChat/prompts.lua | 2 +- lua/CopilotChat/tiktoken.lua | 2 +- lua/CopilotChat/ui/chat.lua | 2 +- lua/CopilotChat/ui/spinner.lua | 2 +- lua/CopilotChat/{ => utils}/notify.lua | 0 tests/notify_spec.lua | 2 +- 10 files changed, 14 insertions(+), 19 deletions(-) rename lua/CopilotChat/{ => utils}/notify.lua (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fe83edc..13013e9b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,22 +111,17 @@ Go to the CopilotChat.nvim in your GitHub account, select your branch, and click - [utils.lua](/lua/CopilotChat/utils.lua): General utility functions. -- [utils/](/lua/CopilotChat/utils/): Utility modules — +- [utils/](/lua/CopilotChat/utils/): Utility modules [class.lua](/lua/CopilotChat/utils/class.lua) (OOP helper), [curl.lua](/lua/CopilotChat/utils/curl.lua) (HTTP requests), - [diff.lua](/lua/CopilotChat/utils/diff.lua) (unified diff parsing and - application), - [files.lua](/lua/CopilotChat/utils/files.lua) (file I/O and filetype - detection), - [orderedmap.lua](/lua/CopilotChat/utils/orderedmap.lua) (insertion-ordered - map), - [stringbuffer.lua](/lua/CopilotChat/utils/stringbuffer.lua) (efficient string - concatenation). + [diff.lua](/lua/CopilotChat/utils/diff.lua) (unified diff parsing and application), + [files.lua](/lua/CopilotChat/utils/files.lua) (file I/O and filetype detection), + [notify.lua](/lua/CopilotChat/utils/notify.lua) (pub/sub notification system for status and message events) + [orderedmap.lua](/lua/CopilotChat/utils/orderedmap.lua) (insertion-ordered map), + [stringbuffer.lua](/lua/CopilotChat/utils/stringbuffer.lua) (efficient string concatenation). ### Other - [health.lua](/lua/CopilotChat/health.lua): `:checkhealth` integration. Verifies commands, libraries, and Treesitter parsers. -- [notify.lua](/lua/CopilotChat/notify.lua): Pub/sub notification system for - status and message events. diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 585e01aa..7bbc65d8 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -55,7 +55,7 @@ local log = require('plenary.log') local constants = require('CopilotChat.constants') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local tiktoken = require('CopilotChat.tiktoken') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index fce20e3f..44233f3d 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,7 +1,7 @@ local log = require('plenary.log') local plenary_utils = require('plenary.async.util') local constants = require('CopilotChat.constants') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') local files = require('CopilotChat.utils.files') diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index f6a2cc3b..e4dfdc3a 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -816,7 +816,7 @@ function M.setup(config) end) -- Initialize chat - require('CopilotChat.notify').clear() + require('CopilotChat.utils.notify').clear() if M.chat then M.chat:close() M.chat:delete() diff --git a/lua/CopilotChat/prompts.lua b/lua/CopilotChat/prompts.lua index f5c76525..7c4e60ce 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -1,7 +1,7 @@ local client = require('CopilotChat.client') local constants = require('CopilotChat.constants') local functions = require('CopilotChat.functions') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local files = require('CopilotChat.utils.files') local orderedmap = require('CopilotChat.utils.orderedmap') local utils = require('CopilotChat.utils') diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index abe1ce1d..f7ea0de7 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,4 +1,4 @@ -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local curl = require('CopilotChat.utils.curl') local class = require('CopilotChat.utils.class') diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index f20d08b3..b77c290b 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -1,7 +1,7 @@ local Overlay = require('CopilotChat.ui.overlay') local Spinner = require('CopilotChat.ui.spinner') local constants = require('CopilotChat.constants') -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') local orderedmap = require('CopilotChat.utils.orderedmap') diff --git a/lua/CopilotChat/ui/spinner.lua b/lua/CopilotChat/ui/spinner.lua index 44c77b80..06091a16 100644 --- a/lua/CopilotChat/ui/spinner.lua +++ b/lua/CopilotChat/ui/spinner.lua @@ -1,4 +1,4 @@ -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') local utils = require('CopilotChat.utils') local class = require('CopilotChat.utils.class') diff --git a/lua/CopilotChat/notify.lua b/lua/CopilotChat/utils/notify.lua similarity index 100% rename from lua/CopilotChat/notify.lua rename to lua/CopilotChat/utils/notify.lua diff --git a/tests/notify_spec.lua b/tests/notify_spec.lua index f0064056..020c9391 100644 --- a/tests/notify_spec.lua +++ b/tests/notify_spec.lua @@ -1,4 +1,4 @@ -local notify = require('CopilotChat.notify') +local notify = require('CopilotChat.utils.notify') describe('CopilotChat.notify', function() before_each(function() From 6fe5dfc0959639361f85df9f51105d114a456c5d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 26 Apr 2026 18:33:35 +0200 Subject: [PATCH 249/250] chore: fix number types in lua docs (#1561) * chore: fix number types in lua docs Signed-off-by: Tomas Slusny * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Signed-off-by: Tomas Slusny Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 1 - lua/CopilotChat/select.lua | 18 +++++++++--------- lua/CopilotChat/ui/chat.lua | 4 ++-- lua/CopilotChat/utils/diff.lua | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13013e9b..393f93c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,4 +124,3 @@ Go to the CopilotChat.nvim in your GitHub account, select your branch, and click - [health.lua](/lua/CopilotChat/health.lua): `:checkhealth` integration. Verifies commands, libraries, and Treesitter parsers. - diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index 425bf2a5..84722e9d 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -1,10 +1,10 @@ ---@class CopilotChat.select.Selection ---@field content string ----@field start_line number ----@field end_line number +---@field start_line integer +---@field end_line integer ---@field filename string ---@field filetype string ----@field bufnr number +---@field bufnr integer local log = require('plenary.log') local utils = require('CopilotChat.utils') @@ -51,7 +51,7 @@ function M.marks() end --- Highlight selection in target buffer or clear it ----@param bufnr number +---@param bufnr integer ---@param clear boolean? function M.highlight(bufnr, clear) local selection_ns = vim.api.nvim_create_namespace('copilot-chat-selection') @@ -76,7 +76,7 @@ function M.highlight(bufnr, clear) end --- Get the selection from the target buffer ----@param bufnr number +---@param bufnr integer ---@return CopilotChat.select.Selection? function M.get(bufnr) if not utils.buf_valid(bufnr) then @@ -113,10 +113,10 @@ function M.get(bufnr) end --- Sets the selection to specific lines in buffer or clears it ----@param bufnr number ----@param winnr number? ----@param start_line number? ----@param end_line number? +---@param bufnr integer +---@param winnr integer? +---@param start_line integer? +---@param end_line integer? function M.set(bufnr, winnr, start_line, end_line) if not utils.buf_valid(bufnr) then return diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index b77c290b..7f14475e 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -108,8 +108,8 @@ end ---@field section CopilotChat.ui.chat.Section? --- @class CopilotChat.ui.chat.Source ---- @field bufnr number? ---- @field winnr number? +--- @field bufnr integer? +--- @field winnr integer? --- @field cwd fun():string ---@class CopilotChat.ui.chat.Chat : CopilotChat.ui.overlay.Overlay diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index 23199930..6a2384a6 100644 --- a/lua/CopilotChat/utils/diff.lua +++ b/lua/CopilotChat/utils/diff.lua @@ -138,7 +138,7 @@ end --- Apply unified diff to a table of lines and return new lines ---@param diff_text string ---@param original_content string ----@return table, boolean, integer, integer +---@return string[], boolean, integer?, integer? function M.apply_unified_diff(diff_text, original_content) local hunks = parse_hunks(diff_text) local new_content = original_content From 137d3bc527518f5ea982c43c43084496732365c3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Apr 2026 16:33:54 +0000 Subject: [PATCH 250/250] 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 e80f9739..ec5ad985 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,5 +1,5 @@ *CopilotChat.txt* - For NVIM v0.8.0 Last change: 2026 April 16 + For NVIM v0.8.0 Last change: 2026 April 26 ============================================================================== Table of Contents *CopilotChat-table-of-contents*