From 3d01e2a7da36e5f811bf56ebfc1553bce433f461 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 25 Feb 2025 10:52:54 +0100 Subject: [PATCH 001/589] docs: update Claude version to 3.7 in README Update README to reflect that CopilotChat.nvim supports Claude 3.7 Sonnet instead of the older 3.5 version. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c129e76f..3fe60302 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: -- 🤖 Native GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.5 Sonnet, Gemini 2.0 Flash, and more) +- 🤖 Native GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system - 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection - 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, and more) From 5a4dfa0dc3b3abdc532d06abc967465fb1d828f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Feb 2025 09:54:15 +0000 Subject: [PATCH 002/589] 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 4e91330e..dadc3255 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 23 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 25 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -33,7 +33,7 @@ Table of Contents *CopilotChat-table-of-contents* CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: -- 🤖 Native GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.5 Sonnet, Gemini 2.0 Flash, and more) +- 🤖 Native GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system - 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection - 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, and more) From 53b0cb7430f94dd74f7f5d7a07813a007161c6fb Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 25 Feb 2025 12:39:31 +0100 Subject: [PATCH 003/589] docs(prompts): clarify code block format in modify prompt Explicitly specify that code blocks in the modify prompt should be wrapped in triple backticks with the appropriate language. This ensures consistent formatting and better syntax highlighting for code snippets in responses. --- 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 d47589ae..20a07a5b 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -63,7 +63,7 @@ Your task is to modify the provided code according to the user's request. Follow 1. Split your response into minimal, focused code changes to produce the shortest possible diffs. -2. IMPORTANT: Every code block MUST have a header with this exact format: +2. IMPORTANT: Every code block MUST be wrapped in triple backticks with the appropriate language and have a header with this exact format: [file:]() line:- where: - start_line = first line to be replaced @@ -88,7 +88,7 @@ Your task is to modify the provided code according to the user's request. Follow - End with "**`[Response truncated] Please ask for the remaining changes.`**" - Next response should continue with the next code block -Remember: Each block must represent an exact replacement of the specified line range. +Remember: Each block must represent an exact replacement of the specified line range and MUST be wrapped in triple backticks. ]] ---@type table From 3a562fd17e2fcd14d790a7c0c701b47d6724da60 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 25 Feb 2025 16:04:23 +0100 Subject: [PATCH 004/589] refactor(prompts): improve clarity of copilot prompts Refines the prompting instructions across multiple components: - Restructured COPILOT_EXPLAIN bullet points for better clarity - Simplified COPILOT_GENERATE instructions while maintaining functionality - Made the prompt more concise and better organized - Improved readability with consistent formatting --- lua/CopilotChat/config/prompts.lua | 49 ++++++++++++++---------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 20a07a5b..1ca2f22d 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -25,12 +25,13 @@ You will receive code snippets that include line number prefixes - use these to local COPILOT_EXPLAIN = [[ You are a programming instructor focused on clear, practical explanations. When explaining code: -- Balance high-level concepts with implementation details -- Highlight key programming principles and patterns -- Address any code diagnostics or warnings -- Focus on non-obvious parts rather than explaining basic syntax -- Use short, focused paragraphs -- Include performance implications where relevant +- Provide concise high-level overview first +- Highlight non-obvious implementation details +- Identify patterns and programming principles +- Address any existing diagnostics or warnings +- Focus on complex parts rather than basic syntax +- Use short paragraphs with clear structure +- Mention performance considerations where relevant ]] .. base local COPILOT_REVIEW = COPILOT_INSTRUCTIONS @@ -59,36 +60,30 @@ If no issues found, confirm the code is well-written and explain why. local COPILOT_GENERATE = COPILOT_INSTRUCTIONS .. [[ -Your task is to modify the provided code according to the user's request. Follow these instructions precisely: +Your task is to modify the provided code according to the user's request. -1. Split your response into minimal, focused code changes to produce the shortest possible diffs. +When presenting code changes: -2. IMPORTANT: Every code block MUST be wrapped in triple backticks with the appropriate language and have a header with this exact format: +1. For each change, first provide a header outside code blocks with format: [file:]() line:- - where: - - start_line = first line to be replaced - - end_line = last line to be replaced - Line numbers MUST exactly match the original file positions. -3. Return ONLY the modified code blocks - no explanations or comments. +2. Then wrap the actual code in triple backticks with the appropriate language identifier. -4. Each code block should contain: - - Complete coherent units of code that will replace lines start_line through end_line - - Exact indentation matching the source - - All lines needed for the change, no eliding with comments - - Strip any line number prefixes from the output code +3. Keep changes minimal and focused to produce short diffs. -5. When fixing code, check and address any diagnostics issues. +4. Include complete replacement code for the specified line range with: + - Proper indentation matching the source + - All necessary lines (no eliding with comments) + - No line number prefixes in the code -6. If multiple separate changes are needed, split them into individual blocks with appropriate headers. +5. Address any diagnostics issues when fixing code. -7. If response would be too long: - - Never cut off in the middle of a code block +6. If multiple changes are needed, present them as separate blocks with their own headers. + +7. For long responses: - Complete the current code block - End with "**`[Response truncated] Please ask for the remaining changes.`**" - - Next response should continue with the next code block - -Remember: Each block must represent an exact replacement of the specified line range and MUST be wrapped in triple backticks. + - Continue in the next response ]] ---@type table @@ -173,6 +168,6 @@ return { }, Commit = { - prompt = '> #git:staged\n\nWrite commit message for the change with commitizen convention. Make sure the title has maximum 50 characters and message is wrapped at 72 characters. Wrap the whole message in code block with language gitcommit.', + prompt = '> #git:staged\n\nWrite 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.', }, } From 8c76318d075811e38bc5821f0afc198da6c4400a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 17:44:31 +0100 Subject: [PATCH 005/589] refactor: improve token file reading with utils Replace direct vim file reading and JSON decoding functions with utility functions for better error handling and code consistency. This change makes the token retrieval more robust by safely handling file reading and JSON parsing failures. --- lua/CopilotChat/config/providers.lua | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index b51b307e..547e6c3a 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -86,12 +86,15 @@ local function get_github_token() } for _, file_path in ipairs(file_paths) do - if vim.fn.filereadable(file_path) == 1 then - local userdata = vim.fn.json_decode(vim.fn.readfile(file_path)) - for key, value in pairs(userdata) do - if string.find(key, 'github.com') then - cached_github_token = value.oauth_token - return value.oauth_token + local file_data = utils.read_file(file_path) + if file_data then + local parsed_data = utils.json_decode(file_data) + if parsed_data then + for key, value in pairs(parsed_data) do + if string.find(key, 'github.com') then + cached_github_token = value.oauth_token + return value.oauth_token + end end end end From 30795d96f3c666ed0fe55e94e8af226ac09c0b2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 16:45:46 +0000 Subject: [PATCH 006/589] 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 dadc3255..a2245de9 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 25 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 26 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From a68645977882a012999d482f9a92bc7e7f95df3b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 17:48:47 +0100 Subject: [PATCH 007/589] fix: replace vim.fn.expand with vim.fs.normalize Using vim.fs.normalize provides better path normalization across different operating systems. This change improves path handling consistency in file operations for the CopilotChat plugin. There's also a typo fix in the function name from 'normalie' to 'normalize' at one location. --- lua/CopilotChat/init.lua | 4 ++-- lua/CopilotChat/utils.lua | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index bb0bcc7d..c609c8b7 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -785,7 +785,7 @@ function M.save(name, history_path) end local history = vim.json.encode(client.history) - history_path = vim.fn.expand(history_path) + history_path = vim.fs.normalize(history_path) vim.fn.mkdir(history_path, 'p') history_path = history_path .. '/' .. name .. '.json' local file = io.open(history_path, 'w') @@ -814,7 +814,7 @@ function M.load(name, history_path) return end - history_path = vim.fn.expand(history_path) .. '/' .. name .. '.json' + history_path = vim.fs.normalize(history_path) .. '/' .. name .. '.json' local file = io.open(history_path, 'r') if not file then return diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index caf179a4..5fdd18f8 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -114,17 +114,17 @@ end --- Finds the path to the user's config directory ---@return string? function M.config_path() - local config = vim.fn.expand('$XDG_CONFIG_HOME') + local config = vim.fs.normalize('$XDG_CONFIG_HOME') if config and vim.fn.isdirectory(config) > 0 then return config end if vim.fn.has('win32') > 0 then - config = vim.fn.expand('$LOCALAPPDATA') + config = vim.fs.normalie('$LOCALAPPDATA') if not config or vim.fn.isdirectory(config) == 0 then - config = vim.fn.expand('$HOME/AppData/Local') + config = vim.fs.normalize('$HOME/AppData/Local') end else - config = vim.fn.expand('$HOME/.config') + config = vim.fs.normalize('$HOME/.config') end if config and vim.fn.isdirectory(config) > 0 then return config From decc13d31794b9d9de80d3c3fb52d1648e084cfb Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 19:59:55 +0100 Subject: [PATCH 008/589] refactor(providers): move config_path to providers module Replace utils.config_path() with local function in providers module. Fix typo in normalie -> normalize and replace isdirectory check with file_exists for consistency. Remove unnecessary async scheduler call in get_github_token function. --- lua/CopilotChat/config/providers.lua | 22 +++++++++++++++++++--- lua/CopilotChat/utils.lua | 20 -------------------- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 547e6c3a..9ac82f77 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -56,6 +56,24 @@ local EDITOR_VERSION = 'Neovim/' local cached_github_token = nil +local function config_path() + local config = vim.fs.normalize('$XDG_CONFIG_HOME') + if config and utils.file_exists(config) then + return config + end + if vim.fn.has('win32') > 0 then + config = vim.fs.normalize('$LOCALAPPDATA') + if not config or not utils.file_exists(config) then + config = vim.fs.normalize('$HOME/AppData/Local') + end + else + config = vim.fs.normalize('$HOME/.config') + end + if config and utils.file_exists(config) then + return config + end +end + --- Get the github copilot oauth cached token (gu_ token) ---@return string local function get_github_token() @@ -63,8 +81,6 @@ local function get_github_token() return cached_github_token end - async.util.scheduler() - -- loading token from the environment only in GitHub Codespaces local token = os.getenv('GITHUB_TOKEN') local codespaces = os.getenv('CODESPACES') @@ -74,7 +90,7 @@ local function get_github_token() end -- loading token from the file - local config_path = utils.config_path() + local config_path = config_path() if not config_path then error('Failed to find config path for GitHub token') end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 5fdd18f8..75240a63 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -111,26 +111,6 @@ function M.temp_file(text) return temp_file end ---- Finds the path to the user's config directory ----@return string? -function M.config_path() - local config = vim.fs.normalize('$XDG_CONFIG_HOME') - if config and vim.fn.isdirectory(config) > 0 then - return config - end - if vim.fn.has('win32') > 0 then - config = vim.fs.normalie('$LOCALAPPDATA') - if not config or vim.fn.isdirectory(config) == 0 then - config = vim.fs.normalize('$HOME/AppData/Local') - end - else - config = vim.fs.normalize('$HOME/.config') - end - if config and vim.fn.isdirectory(config) > 0 then - return config - end -end - --- Blend a color with the neovim background ---@param color_name string The color name ---@param blend number The blend percentage From 0436fe0ab994b61232984795b8a442c2f02ef719 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 20:14:24 +0100 Subject: [PATCH 009/589] refactor: replace vim.loop with vim.uv and simplify file checks Replace deprecated vim.loop with vim.uv throughout the codebase. Remove custom file_exists utility function in favor of direct vim.uv.fs_stat calls for file existence checks. This change improves code consistency and leverages built-in Neovim API functions rather than custom implementations. --- lua/CopilotChat/config/prompts.lua | 2 +- lua/CopilotChat/config/providers.lua | 7 +++---- lua/CopilotChat/tiktoken.lua | 2 +- lua/CopilotChat/ui/spinner.lua | 2 +- lua/CopilotChat/utils.lua | 7 ------- 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 1ca2f22d..296605f4 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -14,7 +14,7 @@ Keep your answers short and impersonal. The user works in an IDE called Neovim which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal. The user is working on a %s machine. Please respond with system specific commands if applicable. ]], - vim.loop.os_uname().sysname + vim.uv.os_uname().sysname ) local COPILOT_INSTRUCTIONS = [[ diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 9ac82f77..80e2eec0 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,4 +1,3 @@ -local async = require('plenary.async') local utils = require('CopilotChat.utils') ---@class CopilotChat.Provider.model @@ -58,18 +57,18 @@ local cached_github_token = nil local function config_path() local config = vim.fs.normalize('$XDG_CONFIG_HOME') - if config and utils.file_exists(config) then + if config and vim.uv.fs_stat(config) then return config end if vim.fn.has('win32') > 0 then config = vim.fs.normalize('$LOCALAPPDATA') - if not config or not utils.file_exists(config) then + if not config or not vim.uv.fs_stat(config) then config = vim.fs.normalize('$HOME/AppData/Local') end else config = vim.fs.normalize('$HOME/.config') end - if config and utils.file_exists(config) then + if config and vim.uv.fs_stat(config) then return config end end diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 0e1d0108..c3ce661b 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -21,7 +21,7 @@ local function load_tiktoken_data(tokenizer) vim.fn.mkdir(tostring(cache_dir), 'p') local cache_path = cache_dir .. '/' .. tiktoken_url:match('.+/(.+)') - if utils.file_exists(cache_path) then + if vim.uv.fs_stat(cache_path) then return cache_path end diff --git a/lua/CopilotChat/ui/spinner.lua b/lua/CopilotChat/ui/spinner.lua index e734198d..710b7e60 100644 --- a/lua/CopilotChat/ui/spinner.lua +++ b/lua/CopilotChat/ui/spinner.lua @@ -38,7 +38,7 @@ function Spinner:start() return end - self.timer = vim.loop.new_timer() + self.timer = vim.uv.new_timer() self.timer:start( 0, 100, diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 75240a63..765b1199 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -425,13 +425,6 @@ M.scan_dir = async.wrap(function(path, opts, callback) ) end, 3) ---- Check if a file exists ----@param path string The file path -M.file_exists = function(path) - local err, stat = async.uv.fs_stat(path) - return err == nil and stat ~= nil -end - --- Get last modified time of a file ---@param path string The file path ---@return number? From 7d220ac7c452e2b389d0ad325a804975df010316 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 21:27:37 +0100 Subject: [PATCH 010/589] refactor: replace vim.fn functions with vim.fs equivalents Remove dependency on vim.fn.fnamemodify for file operations by using the more modern vim.fs API instead. This change: - replaces vim.fn.fnamemodify with vim.fs.normalize for path comparison - replaces vim.fn.fnamemodify with vim.fs.basename for filename extraction - removes unnecessary async scheduler call in client.lua Part of the effort to reduce reliance on Vim functions. --- lua/CopilotChat/client.lua | 1 - lua/CopilotChat/utils.lua | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 6c709f93..c19c77bf 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -425,7 +425,6 @@ function Client:ask(prompt, opts) notify.publish(notify.STATUS, 'Generating request') - async.util.scheduler() local references = {} for _, embed in ipairs(embeddings) do table.insert(references, { diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 765b1199..e6defe07 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -184,7 +184,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 @@ -216,7 +216,7 @@ end ---@param filepath string The file path ---@return string function M.filename(filepath) - return vim.fn.fnamemodify(filepath, ':t') + return vim.fs.basename(filepath) end --- Get the file path From f09da2ac0c1c4bb455af20ed380afc02cf4e4b2c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 23:02:16 +0100 Subject: [PATCH 011/589] docs: add picker integration instructions to README Add documentation section explaining how to configure various picker plugins to work with the : functionality. Include setup instructions for fzf-lua, telescope, snacks.picker, and mini.pick. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 3fe60302..c5c5b013 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,15 @@ Define your own contexts in the configuration with input handling and resolution } ``` +### Integration with pickers + +For `:` pickers to work correctly, you need to replace `vim.ui.select` with your desired picker. + +- [fzf-lua](https://github.com/ibhagwan/fzf-lua?tab=readme-ov-file#neovim-api) - call `require('fzf-lua').register_ui_select()` +- [telescope](https://github.com/nvim-telescope/telescope-ui-select.nvim?tab=readme-ov-file#telescope-setup-and-configuration) - setup `telescope-ui-select.nvim` plugin +- [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#%EF%B8%8F-config) - enable `ui_select` config +- [mini.pick](https://github.com/echasnovski/mini.pick/blob/main/lua/mini/pick.lua#L1229) - set `vim.ui.select = require('mini.pick').ui_select` + ## Selections Selections determine the source content for chat interactions. Configure them globally or per-prompt. From a80f67b310001864f2f763ecc327b8300b430bb1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 22:03:00 +0000 Subject: [PATCH 012/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index a2245de9..3ccb628b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -396,6 +396,17 @@ resolution: < +INTEGRATION WITH PICKERS ~ + +For `:` pickers to work correctly, you need to replace `vim.ui.select` +with your desired picker. + +- fzf-lua - call `require('fzf-lua').register_ui_select()` +- telescope - setup `telescope-ui-select.nvim` plugin +- snacks.picker - enable `ui_select` config +- mini.pick - set `vim.ui.select = require('mini.pick').ui_select` + + SELECTIONS *CopilotChat-selections* Selections determine the source content for chat interactions. Configure them From 39ce1cae8830982ca93aa9d93f9ad26d96e4a556 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 23:28:37 +0100 Subject: [PATCH 013/589] feat: add CopilotChatPrompts command Add new command to easily browse and select from available prompt actions. This replaces the previous manual approach of requiring users to create actions through the API, providing a more user-friendly interface similar to the existing models and agents selection commands. This is preparation to deprecated CopilotChat.actions and CopilotChat.integrations Signed-off-by: Tomas Slusny --- README.md | 9 +-------- lua/CopilotChat/init.lua | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c5c5b013..170c49bf 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Commands are used to control the chat interface: | `:CopilotChatReset` | Reset chat window | | `:CopilotChatSave ?` | Save chat history | | `:CopilotChatLoad ?` | Load chat history | +| `:CopilotChatPrompts` | View/select prompt templates | | `:CopilotChatModels` | View/select available models | | `:CopilotChatAgents` | View/select available agents | | `:CopilotChat` | Use specific prompt template | @@ -664,17 +665,9 @@ chat.ask("Explain this code.", { }) -- Utilities -chat.prompts() -- Get all available prompts chat.response() -- Get last response chat.log_level("debug") -- Set log level - --- Actions -local actions = require("CopilotChat.actions") -actions.pick(actions.prompt_actions({ - selection = require("CopilotChat.select").visual, -})) - -- Update config chat.setup({ model = 'gpt-4', diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index c609c8b7..6ef592e3 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -626,6 +626,37 @@ function M.select_agent() end) end +--- Select a prompt template to use. +function M.select_prompt() + local prompts = M.prompts() + local keys = vim.tbl_keys(prompts) + table.sort(keys) + + local choices = vim + .iter(keys) + :map(function(name) + return { + name = name, + prompt = prompts[name].prompt, + } + end) + :filter(function(choice) + return choice.prompt + end) + :totable() + + vim.ui.select(choices, { + prompt = 'Select prompt action> ', + format_item = function(item) + return string.format('%s: %s', item.name, item.prompt:gsub('\n', ' ')) + end, + }, function(choice) + if choice then + M.ask(prompts[choice.name].prompt, prompts[choice.name]) + end + end) +end + --- Ask a question to the Copilot model. ---@param prompt string? ---@param config CopilotChat.config.shared? @@ -1025,6 +1056,9 @@ function M.setup(config) range = true, }) + vim.api.nvim_create_user_command('CopilotChatPrompts', function() + M.select_prompt() + end, { force = true }) vim.api.nvim_create_user_command('CopilotChatModels', function() M.select_model() end, { force = true }) From 7e75b206640946163bd7fcd7f5f2a3ce84624c2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 22:32:46 +0000 Subject: [PATCH 014/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 3ccb628b..61c75e5b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -159,6 +159,7 @@ Commands are used to control the chat interface: :CopilotChatReset Reset chat window :CopilotChatSave ? Save chat history :CopilotChatLoad ? Load chat history + :CopilotChatPrompts View/select prompt templates :CopilotChatModels View/select available models :CopilotChatAgents View/select available agents :CopilotChat Use specific prompt template @@ -732,17 +733,9 @@ Types of copilot highlights: }) -- Utilities - chat.prompts() -- Get all available prompts chat.response() -- Get last response chat.log_level("debug") -- Set log level - - -- Actions - local actions = require("CopilotChat.actions") - actions.pick(actions.prompt_actions({ - selection = require("CopilotChat.select").visual, - })) - -- Update config chat.setup({ model = 'gpt-4', From ab814c2be5e3fa5f63ecf6f2c077b25836d95331 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 23:52:00 +0100 Subject: [PATCH 015/589] feat: add configuration to select_prompt function Updates the select_prompt function to accept an optional configuration parameter that can be passed to the ask method. This allows specifying additional settings like callbacks when using prompt templates. Also improves documentation in README.md with more detailed explanations and examples of new functionality. --- README.md | 24 +++++++++++++++++++++--- lua/CopilotChat/init.lua | 8 ++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 170c49bf..97a38ac6 100644 --- a/README.md +++ b/README.md @@ -643,19 +643,23 @@ Types of copilot highlights: local chat = require("CopilotChat") -- Window Management -chat.open() -- Open chat window -chat.open({ -- Open with custom options + +-- Open chat window with optional config +chat.open({ window = { layout = 'float', title = 'Custom Chat', }, }) + chat.close() -- Close chat window chat.toggle() -- Toggle chat window chat.reset() -- Reset chat window +chat.stop() -- Stop current output -- Chat Interaction -chat.ask("Explain this code.") -- Basic question + +-- Ask a question with optional config chat.ask("Explain this code.", { selection = require("CopilotChat.select").buffer, context = { 'buffers', 'files' }, @@ -664,6 +668,20 @@ chat.ask("Explain this code.", { end, }) +chat.select_model() -- Open model selector +chat.select_agent() -- Open agent selector + +-- Open prompt selector with optional config +chat.select_prompt({ + callback = function(response) + print("Response:", response) + end, +}) + +-- History Management +chat.save("my_chat", "my_history_path") -- Save chat history with optional history path +chat.load("my_chat", "my_history_path") -- Load chat history with optional history path + -- Utilities chat.response() -- Get last response chat.log_level("debug") -- Set log level diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 6ef592e3..0e781df5 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -627,7 +627,8 @@ function M.select_agent() end --- Select a prompt template to use. -function M.select_prompt() +---@param config CopilotChat.config.shared? +function M.select_prompt(config) local prompts = M.prompts() local keys = vim.tbl_keys(prompts) table.sort(keys) @@ -652,7 +653,10 @@ function M.select_prompt() end, }, function(choice) if choice then - M.ask(prompts[choice.name].prompt, prompts[choice.name]) + M.ask( + prompts[choice.name].prompt, + vim.tbl_extend('force', prompts[choice.name], config or {}) + ) end end) end From 1360f0b498490836023bfe0a471b0caf8111fe2e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 22:53:06 +0000 Subject: [PATCH 016/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 61c75e5b..1bf703a3 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -711,19 +711,23 @@ Types of copilot highlights: local chat = require("CopilotChat") -- Window Management - chat.open() -- Open chat window - chat.open({ -- Open with custom options + + -- Open chat window with optional config + chat.open({ window = { layout = 'float', title = 'Custom Chat', }, }) + chat.close() -- Close chat window chat.toggle() -- Toggle chat window chat.reset() -- Reset chat window + chat.stop() -- Stop current output -- Chat Interaction - chat.ask("Explain this code.") -- Basic question + + -- Ask a question with optional config chat.ask("Explain this code.", { selection = require("CopilotChat.select").buffer, context = { 'buffers', 'files' }, @@ -732,6 +736,20 @@ Types of copilot highlights: end, }) + chat.select_model() -- Open model selector + chat.select_agent() -- Open agent selector + + -- Open prompt selector with optional config + chat.select_prompt({ + callback = function(response) + print("Response:", response) + end, + }) + + -- History Management + chat.save("my_chat", "my_history_path") -- Save chat history with optional history path + chat.load("my_chat", "my_history_path") -- Load chat history with optional history path + -- Utilities chat.response() -- Get last response chat.log_level("debug") -- Set log level From d9462bb170c81ad1487107cfe3cc0afca49601b0 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Feb 2025 23:56:53 +0100 Subject: [PATCH 017/589] feat(docs): improve picker integration documentation Move picker integration details to a more prominent position in the README and expand with additional information about which plugin features rely on pickers. This makes the configuration requirements clearer for users and improves discoverability. Signed-off-by: Tomas Slusny --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 97a38ac6..d268c68c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,22 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - Arch Linux: Install from official repositories - Other systems: Use system package manager or official installer +## Integration with pickers + +For various plugin pickers to work correctly, you need to replace `vim.ui.select` with your desired picker (as the default `vim.ui.select` is very basic). Here are some examples: + +- [fzf-lua](https://github.com/ibhagwan/fzf-lua?tab=readme-ov-file#neovim-api) - call `require('fzf-lua').register_ui_select()` +- [telescope](https://github.com/nvim-telescope/telescope-ui-select.nvim?tab=readme-ov-file#telescope-setup-and-configuration) - setup `telescope-ui-select.nvim` plugin +- [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#%EF%B8%8F-config) - enable `ui_select` config +- [mini.pick](https://github.com/echasnovski/mini.pick/blob/main/lua/mini/pick.lua#L1229) - set `vim.ui.select = require('mini.pick').ui_select` + +Plugin features that use picker: + +- `:CopilotChatPrompts` - for selecting prompts +- `:CopilotChatModels` - for selecting models +- `:CopilotChatAgents` - for selecting agents +- `#:` - for selecting context input + # Installation ## [lazy.nvim](https://github.com/folke/lazy.nvim) @@ -186,7 +202,7 @@ For example, to change the submit prompt mapping or show_diff full diff option: ### Predefined Prompts -Predefined prompt templates for common tasks. Reference them with `/PromptName` in chat or use `:CopilotChat`: +Predefined prompt templates for common tasks. Reference them with `/PromptName` in chat, use `:CopilotChat` or `:CopilotChatPrompts` to select them: | Prompt | Description | | ---------- | ------------------------------------------------ | @@ -345,15 +361,6 @@ Define your own contexts in the configuration with input handling and resolution } ``` -### Integration with pickers - -For `:` pickers to work correctly, you need to replace `vim.ui.select` with your desired picker. - -- [fzf-lua](https://github.com/ibhagwan/fzf-lua?tab=readme-ov-file#neovim-api) - call `require('fzf-lua').register_ui_select()` -- [telescope](https://github.com/nvim-telescope/telescope-ui-select.nvim?tab=readme-ov-file#telescope-setup-and-configuration) - setup `telescope-ui-select.nvim` plugin -- [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#%EF%B8%8F-config) - enable `ui_select` config -- [mini.pick](https://github.com/echasnovski/mini.pick/blob/main/lua/mini/pick.lua#L1229) - set `vim.ui.select = require('mini.pick').ui_select` - ## Selections Selections determine the source content for chat interactions. Configure them globally or per-prompt. From 7399e026efa56836745b0bfef05a95bdc3b1e002 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 22:59:02 +0000 Subject: [PATCH 018/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 1bf703a3..241c0933 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -5,6 +5,7 @@ Table of Contents *CopilotChat-table-of-contents* 1. Requirements |CopilotChat-requirements| - Optional Dependencies |CopilotChat-optional-dependencies| + - Integration with pickers |CopilotChat-integration-with-pickers| 2. Installation |CopilotChat-installation| - lazy.nvim |CopilotChat-lazy.nvim| - vim-plug |CopilotChat-vim-plug| @@ -69,6 +70,25 @@ OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* - Other systems: Use system package manager or official installer +INTEGRATION WITH PICKERS *CopilotChat-integration-with-pickers* + +For various plugin pickers to work correctly, you need to replace +`vim.ui.select` with your desired picker (as the default `vim.ui.select` is +very basic). Here are some examples: + +- fzf-lua - call `require('fzf-lua').register_ui_select()` +- telescope - setup `telescope-ui-select.nvim` plugin +- snacks.picker - enable `ui_select` config +- mini.pick - set `vim.ui.select = require('mini.pick').ui_select` + +Plugin features that use picker: + +- `:CopilotChatPrompts` - for selecting prompts +- `:CopilotChatModels` - for selecting models +- `:CopilotChatAgents` - for selecting agents +- `#:` - for selecting context input + + ============================================================================== 2. Installation *CopilotChat-installation* @@ -229,7 +249,8 @@ PROMPTS *CopilotChat-prompts* PREDEFINED PROMPTS ~ Predefined prompt templates for common tasks. Reference them with `/PromptName` -in chat or use `:CopilotChat`: +in chat, use `:CopilotChat` or `:CopilotChatPrompts` to select +them: Prompt Description ---------- -------------------------------------------------- @@ -397,17 +418,6 @@ resolution: < -INTEGRATION WITH PICKERS ~ - -For `:` pickers to work correctly, you need to replace `vim.ui.select` -with your desired picker. - -- fzf-lua - call `require('fzf-lua').register_ui_select()` -- telescope - setup `telescope-ui-select.nvim` plugin -- snacks.picker - enable `ui_select` config -- mini.pick - set `vim.ui.select = require('mini.pick').ui_select` - - SELECTIONS *CopilotChat-selections* Selections determine the source content for chat interactions. Configure them From 484e4368d18f930fa3cd431349896a9d0c6d0758 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 27 Feb 2025 00:17:44 +0100 Subject: [PATCH 019/589] feat(ui): add option to control references display mode Add new configuration option `references_display` that allows users to control how references are displayed in chat responses. Two modes are supported: - 'virtual': displays references as virtual text (default) - 'write': writes references directly into the chat buffer as markdown links This gives users more flexibility in how reference information is presented and interacted with in the chat interface. --- README.md | 1 + lua/CopilotChat/config.lua | 10 ++++++---- lua/CopilotChat/init.lua | 7 +++++++ lua/CopilotChat/ui/chat.lua | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d268c68c..b65d6ca4 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,7 @@ Below are all available configuration options with their default values: show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) + references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer auto_follow_cursor = true, -- Auto-follow cursor in chat 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 diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 87129772..3906f08d 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -2,9 +2,9 @@ local prompts = require('CopilotChat.config.prompts') local select = require('CopilotChat.select') ---@class CopilotChat.config.window ----@field layout string? ----@field relative string? ----@field border string? +---@field layout 'vertical'|'horizontal'|'float'|'replace'? +---@field relative 'editor'|'win'|'cursor'|'mouse'? +---@field border 'none'|'single'|'double'|'rounded'|'solid'|'shadow'? ---@field width number? ---@field height number? ---@field row number? @@ -28,6 +28,7 @@ local select = require('CopilotChat.select') ---@field show_folds boolean? ---@field highlight_selection boolean? ---@field highlight_headers boolean? +---@field references_display 'virtual'|'write'? ---@field auto_follow_cursor boolean? ---@field auto_insert_mode boolean? ---@field insert_at_end boolean? @@ -36,7 +37,7 @@ local select = require('CopilotChat.select') --- CopilotChat default configuration ---@class CopilotChat.config : CopilotChat.config.shared ---@field debug boolean? ----@field log_level string? +---@field log_level 'trace'|'debug'|'info'|'warn'|'error'|'fatal'? ---@field proxy string? ---@field allow_insecure boolean? ---@field chat_autocomplete boolean? @@ -89,6 +90,7 @@ return { show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) + references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer auto_follow_cursor = true, -- Auto-follow cursor in chat 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 diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0e781df5..1bb3ee0f 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -761,6 +761,13 @@ function M.ask(prompt, config) end if not config.headless then + if not utils.empty(references) and config.references_display == 'write' then + state.chat:append('\n\n**`References`**:') + for _, ref in ipairs(references) do + state.chat:append(string.format('\n[%s](%s)', ref.name, ref.url)) + end + end + finish() end if config.callback then diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 1d1625bd..20d3556a 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -252,7 +252,7 @@ function Chat:render() self:show_help(msg, last_section.start_line - last_section.end_line - 1) - if not utils.empty(self.references) then + if not utils.empty(self.references) and self.config.references_display == 'virtual' then msg = 'References:\n' for _, ref in ipairs(self.references) do msg = msg .. ' ' .. ref.name .. '\n' From af4777e656b990779cd99436a33d165731b26f86 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Feb 2025 23:19:46 +0000 Subject: [PATCH 020/589] 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 241c0933..223e96c8 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -544,6 +544,7 @@ Below are all available configuration options with their default values: show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) + references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer auto_follow_cursor = true, -- Auto-follow cursor in chat 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 From 272bb3461b5ea21469d0274dd807da24dc16ab57 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 27 Feb 2025 01:05:45 +0100 Subject: [PATCH 021/589] refactor(models): simplify model listing by using model_picker_enabled Remove version field from models in favor of using just ID for model identification. Simplify model and agent listing with consistent sorting by provider and ID. Add model_picker_enabled flag for Copilot models to filter models that should appear in the model picker. --- lua/CopilotChat/client.lua | 54 ++++++++++++---------------- lua/CopilotChat/config/providers.lua | 5 +-- 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index c19c77bf..0d414513 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -296,13 +296,8 @@ function Client:fetch_models() if ok then for _, model in ipairs(provider_models) do model.provider = provider_name - if not model.version then - model.version = model.id - end - if models[model.id] then model.id = model.id .. ':' .. provider_name - model.version = model.version .. ':' .. provider_name end models[model.id] = model end @@ -688,46 +683,41 @@ end ---@return table function Client:list_models() local models = self:fetch_models() + local result = vim.tbl_keys(models) - -- First deduplicate by version, keeping shortest ID - local version_map = {} - for id, model in pairs(models) do - local version = model.version or model.id - if not version_map[version] or #id < #version_map[version] then - version_map[version] = id - end - end - - local result = vim.tbl_values(version_map) table.sort(result, function(a, b) - local a_model = models[a] - local b_model = models[b] - if a_model.provider ~= b_model.provider then - return a_model.provider < b_model.provider -- sort by version first + a = models[a] + b = models[b] + if a.provider ~= b.provider then + return a.provider < b.provider end - return a_model.version < b_model.version -- then by provider + return a.id < b.id end) - local out = {} - for _, id in ipairs(result) do - table.insert(out, vim.tbl_extend('force', models[id], { id = id })) - end - return out + return vim.tbl_map(function(id) + return models[id] + end, result) end --- List available agents ---@return table function Client:list_agents() local agents = self:fetch_agents() - local result = vim.tbl_keys(agents) - table.sort(result) - local out = {} - table.insert(out, { id = 'none', name = 'None', description = 'No agent', provider = 'none' }) - for _, id in ipairs(result) do - table.insert(out, vim.tbl_extend('force', agents[id], { id = id })) - end + table.sort(result, function(a, b) + a = agents[a] + b = agents[b] + if a.provider ~= b.provider then + return a.provider < b.provider + end + return a.id < b.id + end) + + local out = vim.tbl_map(function(id) + return agents[id] + end, result) + table.insert(out, 1, { id = 'none', name = 'None', description = 'No agent', provider = 'none' }) return out end diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 80e2eec0..443d9af5 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -3,7 +3,6 @@ local utils = require('CopilotChat.utils') ---@class CopilotChat.Provider.model ---@field id string ---@field name string ----@field version string? ---@field tokenizer string? ---@field max_input_tokens number? ---@field max_output_tokens number? @@ -177,13 +176,12 @@ M.copilot = { local models = vim .iter(response.body.data) :filter(function(model) - return model['capabilities']['type'] == 'chat' + return model.model_picker_enabled and model.capabilities.type == 'chat' end) :map(function(model) return { id = model.id, name = model.name, - version = model.version, tokenizer = model.capabilities.tokenizer, max_input_tokens = model.capabilities.limits.max_prompt_tokens, max_output_tokens = model.capabilities.limits.max_output_tokens, @@ -328,7 +326,6 @@ M.github_models = { return { id = model.name, name = model.displayName, - version = model.name .. '-' .. model.version, tokenizer = 'o200k_base', max_input_tokens = model.modelLimits.textLimits.inputContextWindow, max_output_tokens = model.modelLimits.textLimits.maxOutputTokens, From ed8b1332feb214873a5827fc38dd3465ecf214ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 27 Feb 2025 00:07:29 +0000 Subject: [PATCH 022/589] 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 223e96c8..bc4a4084 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 26 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 27 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 7899a13994720051a5901a35fc1744b64561da05 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 27 Feb 2025 01:58:43 +0100 Subject: [PATCH 023/589] fix: improve filename comparison with absolute paths Uses vim.fs.abspath in filename_same function to properly compare file paths that might be relative or have different representations but point to the same file. --- 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 e6defe07..4b5dd227 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -184,7 +184,7 @@ function M.filename_same(file1, file2) if not file1 or not file2 then return false end - return vim.fs.normalize(file1) == vim.fs.normalize(file2) + return vim.fs.abspath(vim.fs.normalize(file1)) == vim.fs.abspath(vim.fs.normalize(file2)) end --- Get the filetype of a file From 9acb72247e8062d747ecbf914e14d1c08f8aebc7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 27 Feb 2025 02:35:48 +0100 Subject: [PATCH 024/589] refactor: use utils functions for filename handling Replace direct vim.fn.fnamemodify calls with utility functions for consistent filename handling across the codebase. This improves maintainability by centralizing path manipulation logic in utils module. --- lua/CopilotChat/context.lua | 2 +- lua/CopilotChat/select.lua | 9 +++++---- lua/CopilotChat/ui/chat.lua | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 22a7a282..cb67bc4c 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -172,7 +172,7 @@ local function data_ranked_by_symbols(query, data, min_similarity) local max_score = 0 for _, entry in ipairs(data) do - local basename = vim.fn.fnamemodify(entry.filename, ':t'):gsub('%..*$', '') + local basename = utils.filename(entry.filename):gsub('%..*$', '') -- Get trigrams for basename and compound version local file_trigrams = get_trigrams(basename) diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index bb01ff0d..e75e4271 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -13,6 +13,7 @@ ---@field bufnr number ---@field diagnostics table? +local utils = require('CopilotChat.utils') local M = {} --- Get diagnostics in a given range @@ -70,7 +71,7 @@ function M.visual(source) return { content = lines_content, - filename = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ':p:.'), + filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), filetype = vim.bo[bufnr].filetype, start_line = start_line, end_line = finish_line, @@ -91,7 +92,7 @@ function M.buffer(source) local out = { content = table.concat(lines, '\n'), - filename = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ':p:.'), + filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), filetype = vim.bo[bufnr].filetype, start_line = 1, end_line = #lines, @@ -116,7 +117,7 @@ function M.line(source) local out = { content = line, - filename = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ':p:.'), + filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), filetype = vim.bo[bufnr].filetype, start_line = cursor[1], end_line = cursor[1], @@ -152,7 +153,7 @@ function M.unnamed(source) return { content = lines_content, - filename = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(bufnr), ':p:.'), + filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), filetype = vim.bo[bufnr].filetype, start_line = start_line, end_line = finish_line, diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 20d3556a..6415e6a1 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -28,7 +28,7 @@ local function match_header(header) for _, pattern in ipairs(HEADER_PATTERNS) do local filename, start_line, end_line = header:match(pattern) if filename then - return vim.fn.fnamemodify(filename, ':p:.'), + return utils.filepath(filename), tonumber(start_line) or 1, tonumber(end_line) or tonumber(start_line) or 1 end From d661d7ee0042df088a3cd45798301b4c2d19ce1b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 27 Feb 2025 20:15:55 +0100 Subject: [PATCH 025/589] fix: properly handle errors in async contexts Added scheduler() call to ensure errors are properly handled in async contexts. Also improved buffer validation by simplifying redundant checks in diff.lua since utils.buf_valid already performs the necessary validation. --- lua/CopilotChat/init.lua | 1 + lua/CopilotChat/ui/diff.lua | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 1bb3ee0f..a6dc2e56 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -776,6 +776,7 @@ function M.ask(prompt, config) end) if not ok then + async.util.scheduler() log.error(err) if not config.headless then show_error(err) diff --git a/lua/CopilotChat/ui/diff.lua b/lua/CopilotChat/ui/diff.lua index 25afbc4a..e6590404 100644 --- a/lua/CopilotChat/ui/diff.lua +++ b/lua/CopilotChat/ui/diff.lua @@ -58,7 +58,7 @@ function Diff:show(diff, winnr, full_diff) -- Create modified version by applying the change local modified = {} - if diff.bufnr and utils.buf_valid(diff.bufnr) then + if utils.buf_valid(diff.bufnr) then modified = vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) end local change_lines = vim.split(diff.change, '\n') @@ -79,7 +79,7 @@ function Diff:show(diff, winnr, full_diff) Overlay.show(self, table.concat(modified, '\n'), winnr, diff.filetype) - if diff.bufnr and vim.api.nvim_buf_is_valid(diff.bufnr) then + if utils.buf_valid(diff.bufnr) then vim.cmd('diffthis') vim.api.nvim_set_current_win(vim.fn.bufwinid(diff.bufnr)) vim.api.nvim_win_set_cursor(0, { diff.start_line, 0 }) From 21ed5413ab3932c0d9c7f6fadbb0c4d2266d2fde Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 27 Feb 2025 23:11:42 +0100 Subject: [PATCH 026/589] feat: enable range support for CopilotChatPrompts Add range support to the CopilotChatPrompts command to allow selecting text ranges when using the command, enhancing the flexibility of prompt selection. --- 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 a6dc2e56..f03b702c 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1070,7 +1070,7 @@ function M.setup(config) vim.api.nvim_create_user_command('CopilotChatPrompts', function() M.select_prompt() - end, { force = true }) + end, { force = true, range = true }) vim.api.nvim_create_user_command('CopilotChatModels', function() M.select_model() end, { force = true }) From f8d47ce9adace3c1fe9fa7737bc1fde861bb8317 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 28 Feb 2025 00:30:54 +0100 Subject: [PATCH 027/589] feat(doc): mark integrations APIs as deprecated Add @deprecated tags to integration-related functions, directing users to use the newer CopilotChat.select_prompt interface instead. This improves API consistency and prepares for future changes. --- lua/CopilotChat/actions.lua | 2 ++ lua/CopilotChat/integrations/fzflua.lua | 1 + lua/CopilotChat/integrations/snacks.lua | 1 + lua/CopilotChat/integrations/telescope.lua | 1 + 4 files changed, 5 insertions(+) diff --git a/lua/CopilotChat/actions.lua b/lua/CopilotChat/actions.lua index 6e593a1c..2ab795b7 100644 --- a/lua/CopilotChat/actions.lua +++ b/lua/CopilotChat/actions.lua @@ -9,6 +9,7 @@ local M = {} --- User prompt actions ---@param config CopilotChat.config.shared?: The chat configuration ---@return CopilotChat.integrations.actions?: The prompt actions +---@deprecated Use |CopilotChat.select_prompt| instead function M.prompt_actions(config) local actions = {} for name, prompt in pairs(chat.prompts()) do @@ -25,6 +26,7 @@ end --- Pick an action from a list of actions ---@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ---@param opts table?: vim.ui.select options +---@deprecated Use |CopilotChat.select_prompt| instead function M.pick(pick_actions, opts) if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then return diff --git a/lua/CopilotChat/integrations/fzflua.lua b/lua/CopilotChat/integrations/fzflua.lua index ad423459..174d0139 100644 --- a/lua/CopilotChat/integrations/fzflua.lua +++ b/lua/CopilotChat/integrations/fzflua.lua @@ -7,6 +7,7 @@ local M = {} --- Pick an action from a list of actions ---@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ---@param opts table?: fzf-lua options +---@deprecated Use |CopilotChat.select_prompt| instead function M.pick(pick_actions, opts) if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then return diff --git a/lua/CopilotChat/integrations/snacks.lua b/lua/CopilotChat/integrations/snacks.lua index 7d2ca01d..14a2daaa 100644 --- a/lua/CopilotChat/integrations/snacks.lua +++ b/lua/CopilotChat/integrations/snacks.lua @@ -7,6 +7,7 @@ local M = {} --- Pick an action from a list of actions ---@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ---@param opts table?: snacks options +---@deprecated Use |CopilotChat.select_prompt| instead function M.pick(pick_actions, opts) if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then return diff --git a/lua/CopilotChat/integrations/telescope.lua b/lua/CopilotChat/integrations/telescope.lua index e636aeb6..5e14d913 100644 --- a/lua/CopilotChat/integrations/telescope.lua +++ b/lua/CopilotChat/integrations/telescope.lua @@ -13,6 +13,7 @@ local M = {} --- Pick an action from a list of actions ---@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ---@param opts table?: Telescope options +---@deprecated Use |CopilotChat.select_prompt| instead function M.pick(pick_actions, opts) if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then return From a31e05d0bdbc7104bbe2006690d00bf66fd5a1b8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 28 Feb 2025 17:31:39 +0100 Subject: [PATCH 028/589] docs: add documentation for overriding buf/win options Signed-off-by: Tomas Slusny --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b65d6ca4..edfe486a 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,6 @@ Below are all available configuration options with their default values: }, show_help = true, -- Shows help message as virtual lines when waiting for user input - show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer @@ -629,6 +628,9 @@ vim.api.nvim_create_autocmd('BufEnter', { pattern = 'copilot-*', callback = function() -- Set buffer-local options + vim.opt_local.relativenumber = false + vim.opt_local.number = false + vim.opt_local.conceallevel = 0 end }) ``` From f7bd923e8af0d7ca03868afbb5bc28801b97f588 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Feb 2025 16:32:51 +0000 Subject: [PATCH 029/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index bc4a4084..9019e73d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 27 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 28 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -541,7 +541,6 @@ Below are all available configuration options with their default values: }, show_help = true, -- Shows help message as virtual lines when waiting for user input - show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer @@ -697,6 +696,9 @@ You can set local options for plugin buffers like this: pattern = 'copilot-*', callback = function() -- Set buffer-local options + vim.opt_local.relativenumber = false + vim.opt_local.number = false + vim.opt_local.conceallevel = 0 end }) < From c45b148addb27066c79d0d7ef438829a64008ce9 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 28 Feb 2025 18:24:49 +0100 Subject: [PATCH 030/589] refactor: replace plenary scheduler with internal implementation Remove dependency on plenary.async.util.scheduler() by implementing a custom schedule_main() function that only schedules when necessary. This allows the plugin to work without requiring plenary's scheduling mechanism. Also fixes JSON decoding by ensuring response body is properly converted to string. --- lua/CopilotChat/config/contexts.lua | 3 +-- lua/CopilotChat/config/mappings.lua | 4 ++-- lua/CopilotChat/context.lua | 12 ++++++------ lua/CopilotChat/init.lua | 12 ++++++------ lua/CopilotChat/tiktoken.lua | 5 ++--- lua/CopilotChat/utils.lua | 18 ++++++++++++++++-- 6 files changed, 33 insertions(+), 21 deletions(-) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index b137f53c..5d5ac65b 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -1,4 +1,3 @@ -local async = require('plenary.async') local context = require('CopilotChat.context') local utils = require('CopilotChat.utils') @@ -63,7 +62,7 @@ return { max_files = 1000, }) - async.util.scheduler() + utils.schedule_main() vim.ui.select(files, { prompt = 'Select a file> ', }, callback) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index c04895b5..22c80cec 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -354,7 +354,7 @@ return { local _, selected_agent = pcall(copilot.resolve_agent, prompt, config) local _, selected_model = pcall(copilot.resolve_model, prompt, config) - async.util.scheduler() + utils.schedule_main() table.insert(lines, '**Logs**: `' .. chat.config.log_path .. '`') table.insert(lines, '**History**: `' .. chat.config.history_path .. '`') table.insert(lines, '**Temp Files**: `' .. vim.fn.fnamemodify(os.tmpname(), ':h') .. '`') @@ -429,7 +429,7 @@ return { table.insert(lines, '') end - async.util.scheduler() + utils.schedule_main() overlay:show(vim.trim(table.concat(lines, '\n')) .. '\n', chat.winnr, 'markdown') end) end, diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index cb67bc4c..28cfbd65 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -412,7 +412,7 @@ function M.files(winnr, with_content, search_options) -- Read file contents if with_content then - async.util.scheduler() + utils.schedule_main() files = vim.tbl_filter( function(file) @@ -445,7 +445,7 @@ function M.file(filename) return nil end - async.util.scheduler() + utils.schedule_main() local ft = utils.filetype(filename) if not ft then return nil @@ -458,7 +458,7 @@ end ---@param bufnr number ---@return CopilotChat.context.embed? function M.buffer(bufnr) - async.util.scheduler() + utils.schedule_main() if not utils.buf_valid(bufnr) then return nil @@ -483,7 +483,7 @@ end ---@param buf_type string ---@return table function M.buffers(buf_type) - async.util.scheduler() + utils.schedule_main() return vim.tbl_map( M.buffer, @@ -593,7 +593,7 @@ end ---@param register string ---@return CopilotChat.context.embed? function M.register(register) - async.util.scheduler() + utils.schedule_main() local lines = vim.fn.getreg(register) if not lines or lines == '' then @@ -610,7 +610,7 @@ end --- Get the content of the quickfix list ---@return table function M.quickfix() - async.util.scheduler() + utils.schedule_main() local items = vim.fn.getqflist() if not items or #items == 0 then diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index f03b702c..94b890f8 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -496,7 +496,7 @@ function M.complete_items(callback) return a.kind < b.kind end) - async.util.scheduler() + utils.schedule_main() callback(items) end) end @@ -577,7 +577,7 @@ function M.select_model() } end, models) - async.util.scheduler() + utils.schedule_main() vim.ui.select(choices, { prompt = 'Select a model> ', format_item = function(item) @@ -608,7 +608,7 @@ function M.select_agent() } end, agents) - async.util.scheduler() + utils.schedule_main() vim.ui.select(choices, { prompt = 'Select an agent> ', format_item = function(item) @@ -714,7 +714,7 @@ function M.ask(prompt, config) pcall(context.filter_embeddings, prompt, selected_model, config.headless, embeddings) if not query_ok then - async.util.scheduler() + utils.schedule_main() log.error(filtered_embeddings) if not config.headless then show_error(filtered_embeddings, has_output) @@ -739,7 +739,7 @@ function M.ask(prompt, config) end), }) - async.util.scheduler() + utils.schedule_main() if not ask_ok then log.error(response) @@ -776,7 +776,7 @@ function M.ask(prompt, config) end) if not ok then - async.util.scheduler() + utils.schedule_main() log.error(err) if not config.headless then show_error(err) diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index c3ce661b..eb572b35 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -1,4 +1,3 @@ -local async = require('plenary.async') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local current_tokenizer = nil @@ -11,7 +10,7 @@ end --- Load tiktoken data from cache or download it ---@param tokenizer string The tokenizer to load local function load_tiktoken_data(tokenizer) - async.util.scheduler() + utils.schedule_main() local tiktoken_url = 'https://openaipublic.blob.core.windows.net/encodings/' .. tokenizer @@ -57,7 +56,7 @@ M.load = function(tokenizer) local pat_str = "(?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+" - async.util.scheduler() + utils.schedule_main() tiktoken_core.new(path, special_tokens, pat_str) current_tokenizer = tokenizer end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 4b5dd227..db808512 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -344,7 +344,7 @@ M.curl_get = async.wrap(function(url, opts, callback) return end - local body, err = M.json_decode(response.body) + local body, err = M.json_decode(tostring(response.body)) if err then callback(response, err) else @@ -388,7 +388,7 @@ M.curl_post = async.wrap(function(url, opts, callback) return end - local body, err = M.json_decode(response.body) + local body, err = M.json_decode(tostring(response.body)) if err then callback(response, err) else @@ -464,6 +464,20 @@ M.system = async.wrap(function(cmd, callback) vim.system(cmd, { text = true }, callback) end, 2) +--- Schedule a function only when needed (not on main thread) +---@param callback function The callback +M.schedule_main = async.wrap(function(callback) + if vim.in_fast_event() then + -- In a fast event, need to schedule + vim.schedule(function() + callback() + end) + else + -- Already on main thread, call directly + callback() + end +end, 1) + --- Get the info for a key. ---@param name string ---@param surround string|nil From 3c3bb1ecf770c02aa9f131725f167349291ab899 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 28 Feb 2025 19:10:49 +0100 Subject: [PATCH 031/589] fix: switch buffer only after adjusting window options This makes sure bufenter is called for `replace` layout properly Closes #356 Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 6415e6a1..f028ff67 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -480,7 +480,6 @@ function Chat:open(config) end vim.cmd(cmd) self.winnr = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(self.winnr, self.bufnr) vim.api.nvim_set_current_win(orig) elseif layout == 'horizontal' then local orig = vim.api.nvim_get_current_win() @@ -490,11 +489,9 @@ function Chat:open(config) end vim.cmd(cmd) self.winnr = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(self.winnr, self.bufnr) vim.api.nvim_set_current_win(orig) elseif layout == 'replace' then self.winnr = vim.api.nvim_get_current_win() - vim.api.nvim_win_set_buf(self.winnr, self.bufnr) end vim.wo[self.winnr].wrap = true @@ -510,6 +507,7 @@ function Chat:open(config) vim.wo[self.winnr].foldcolumn = '0' end + vim.api.nvim_win_set_buf(self.winnr, self.bufnr) self:render() end From 630a74739e946390ffb183682a86ec370a5ae294 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 28 Feb 2025 19:38:23 +0100 Subject: [PATCH 032/589] fix: improve chat window state handling - Fix selection update to check for buffer equality - Fix active chat detection to verify visibility first - Fix window layout comparison to handle when config is nil - Ensure config is set after window layout check to prevent errors --- lua/CopilotChat/init.lua | 9 +++++++-- lua/CopilotChat/ui/chat.lua | 7 ++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 94b890f8..b8035585 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -202,9 +202,14 @@ end ---@param config CopilotChat.config.shared function M.update_selection(config) local prev_winnr = vim.fn.win_getid(vim.fn.winnr('#')) - if prev_winnr ~= state.chat.winnr and vim.fn.win_gettype(prev_winnr) == '' then + local prev_bufnr = vim.api.nvim_win_get_buf(prev_winnr) + if + prev_winnr ~= state.chat.winnr + and prev_bufnr ~= state.chat.bufnr + and vim.fn.win_gettype(prev_winnr) == '' + then state.source = { - bufnr = vim.api.nvim_win_get_buf(prev_winnr), + bufnr = prev_bufnr, winnr = prev_winnr, } end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index f028ff67..7ace09bc 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -371,7 +371,7 @@ end ---@return boolean function Chat:active() - return vim.api.nvim_get_current_win() == self.winnr + return self:visible() and vim.api.nvim_get_current_win() == self.winnr end ---@return number, number, number @@ -442,17 +442,18 @@ end ---@param config CopilotChat.config.shared function Chat:open(config) self:validate() - self.config = config local window = config.window or {} local layout = window.layout local width = window.width > 1 and window.width or math.floor(vim.o.columns * window.width) local height = window.height > 1 and window.height or math.floor(vim.o.lines * window.height) - if self.config.window.layout ~= layout then + if self.config and self.config.window and self.config.window.layout ~= layout then self:close() end + self.config = config + if self:visible() then return end From 769335972ddc74752d5f4effb8e35a0301340971 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 1 Mar 2025 14:16:52 +0100 Subject: [PATCH 033/589] fix: do not use vim.fs.abspath Not available in latest neovim release yet Closes #886 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 db808512..3ed0cae3 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -184,7 +184,7 @@ function M.filename_same(file1, file2) if not file1 or not file2 then return false end - return vim.fs.abspath(vim.fs.normalize(file1)) == vim.fs.abspath(vim.fs.normalize(file2)) + return vim.fn.fnamemodify(file1, ':p') == vim.fn.fnamemodify(file2, ':p') end --- Get the filetype of a file From 071e49ced62c04b4f377d2d58fbda3959ca74e30 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Mar 2025 13:18:40 +0000 Subject: [PATCH 034/589] 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 9019e73d..ea11f201 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 February 28 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 01 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From da50ece82702c2f42440c3f8b913fd53286c50f5 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 1 Mar 2025 16:11:41 +0100 Subject: [PATCH 035/589] feat: add vim.ui.select health check Add health check to verify if vim.ui.select is using the default implementation or has been overridden by a custom implementation. This helps users identify if they could improve their experience by using a better picker integration. Also improved formatting consistency in treesitter warning messages. --- lua/CopilotChat/health.lua | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index c3cf182a..64ef2e6e 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -97,6 +97,15 @@ function M.check() ) end + local select_source = debug.getinfo(vim.ui.select).source + if select_source:match('vim/ui%.lua$') then + warn( + 'vim.ui.select: using default implementation, which may not provide the best user experience. See `:h CopilotChat-integration-with-pickers`.' + ) + else + ok('vim.ui.select: overridden by `' .. select_source .. '`') + end + if lualib_installed('tiktoken_core') then ok('tiktoken_core: installed') else @@ -109,7 +118,7 @@ function M.check() ok('treesitter[markdown]: installed') else warn( - 'treesitter[markdown]: missing, optional for better chat highlighting. Install "nvim-treesitter/nvim-treesitter" plugin and run ":TSInstall markdown".' + 'treesitter[markdown]: missing, optional for better chat highlighting. Install `nvim-treesitter/nvim-treesitter` plugin and run `:TSInstall markdown`.' ) end @@ -117,7 +126,7 @@ function M.check() ok('treesitter[diff]: installed') else warn( - 'treesitter[diff]: missing, optional for better diff highlighting. Install "nvim-treesitter/nvim-treesitter" plugin and run ":TSInstall diff".' + 'treesitter[diff]: missing, optional for better diff highlighting. Install `nvim-treesitter/nvim-treesitter` plugin and run `:TSInstall diff`.' ) end end From 3ff0856d44f209f8e0517fc548a9b56d2d175233 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 1 Mar 2025 18:46:32 +0100 Subject: [PATCH 036/589] feat: use adaptive variant for Stargazers chart Update the Stargazers chart URL to include the adaptive variant parameter for better visualization on different themes and device displays. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index edfe486a..5c8f40ac 100644 --- a/README.md +++ b/README.md @@ -832,4 +832,4 @@ This project follows the [all-contributors](https://github.com/all-contributors/ # Stargazers -[![Stargazers over time](https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg)](https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim) +[![Stargazers over time](https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg?variant=adaptive)](https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim) From 791098a72c203b09d494958b5d457a09d51f49bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 1 Mar 2025 17:47:42 +0000 Subject: [PATCH 037/589] 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 ea11f201..ff482465 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -845,7 +845,7 @@ Contributions of any kind are welcome! 1. *@jellydn*: 2. *@deathbeam*: -3. *Stargazers over time*: https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg +3. *Stargazers over time*: https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg?variant=adaptive Generated by panvimdoc From 9d9aa1c890e9d97060622478b4c6e26a4230be58 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 2 Mar 2025 03:56:44 +0100 Subject: [PATCH 038/589] refactor: streamline system prompts architecture (#890) * refactor: streamline system prompts architecture Integrate code generation instructions directly into base prompt to simplify the system architecture. Remove the separate COPILOT_GENERATE prompt and update all references to it throughout the codebase. The format instructions for code changes have been moved to the base prompt to ensure consistent formatting across all interaction modes. This change improves maintainability while preserving full functionality. * [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 | 45 +++++++++--------- lua/CopilotChat/config/prompts.lua | 75 ++++++++++++++---------------- 2 files changed, 56 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 5c8f40ac..fda37884 100644 --- a/README.md +++ b/README.md @@ -159,22 +159,22 @@ Commands are used to control the chat interface: Default mappings in the chat interface: -| Insert | Normal | Action | -| ------- | ------- | -------------------------------------------------- | -| `` | `` | Trigger/accept completion menu for tokens | -| `` | `q` | Close the chat window | -| `` | `` | Reset and clear the chat window | -| `` | `` | Submit the current prompt | -| - | `gr` | Toggle sticky prompt for line under cursor | -| `` | `` | Accept nearest diff (best with `COPILOT_GENERATE`) | -| - | `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 | -| - | `gi` | Show info about current chat | -| - | `gc` | Show current chat context | -| - | `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 | +| - | `gr` | 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 | +| - | `gi` | Show info about current chat | +| - | `gc` | Show current chat context | +| - | `gh` | Show help message | The mappings can be customized by setting the `mappings` table in your configuration. Each mapping can have: @@ -235,10 +235,9 @@ System prompts define the AI model's behavior. Reference them with `/PROMPT_NAME | Prompt | Description | | ---------------------- | ------------------------------------------ | -| `COPILOT_INSTRUCTIONS` | Base GitHub Copilot instructions | +| `COPILOT_INSTRUCTIONS` | Base instructions | | `COPILOT_EXPLAIN` | Adds coding tutor behavior | | `COPILOT_REVIEW` | Adds code review behavior with diagnostics | -| `COPILOT_GENERATE` | Adds code generation behavior and rules | Define your own system prompts in the configuration (similar to `prompts`): @@ -541,19 +540,19 @@ Below are all available configuration options with their default values: prompt = '> /COPILOT_REVIEW\n\nReview the selected code.', }, Fix = { - prompt = '> /COPILOT_GENERATE\n\nThere is a problem in this code. Rewrite the code to show it with the bug fixed.', + prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', }, Optimize = { - prompt = '> /COPILOT_GENERATE\n\nOptimize the selected code to improve performance and readability.', + prompt = 'Optimize the selected code to improve performance and readability. Explain your optimization strategy and the benefits of your changes.', }, Docs = { - prompt = '> /COPILOT_GENERATE\n\nPlease add documentation comments to the selected code.', + prompt = 'Please add documentation comments to the selected code.', }, Tests = { - prompt = '> /COPILOT_GENERATE\n\nPlease generate tests for my code.', + prompt = 'Please generate tests for my code.', }, Commit = { - prompt = '> #git:staged\n\nWrite commit message for the change with commitizen convention. Make sure the title has maximum 50 characters and message is wrapped at 72 characters. Wrap the whole message in code block with language gitcommit.', + prompt = '> #git:staged\n\nWrite 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.', }, }, diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 296605f4..c72b4dd9 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -13,17 +13,38 @@ If you are asked to generate content that is harmful, hateful, racist, sexist, l Keep your answers short and impersonal. The user works in an IDE called Neovim which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal. The user is working on a %s machine. Please respond with system specific commands if applicable. +You will receive code snippets that include line number prefixes - use these to maintain correct position references but remove them when generating output. + +When presenting code changes: + +1. For each change, first provide a header outside code blocks with format: + [file:]() line:- + +2. Then wrap the actual code in triple backticks with the appropriate language identifier. + +3. Keep changes minimal and focused to produce short diffs. + +4. Include complete replacement code for the specified line range with: + - Proper indentation matching the source + - All necessary lines (no eliding with comments) + - No line number prefixes in the code + +5. Address any diagnostics issues when fixing code. + +6. If multiple changes are needed, present them as separate blocks with their own headers. ]], vim.uv.os_uname().sysname ) local COPILOT_INSTRUCTIONS = [[ You are a code-focused AI programming assistant that specializes in practical software engineering solutions. -You will receive code snippets that include line number prefixes - use these to maintain correct position references but remove them when generating output. ]] .. base -local COPILOT_EXPLAIN = [[ +local COPILOT_EXPLAIN = base + .. [[ + You are a programming instructor focused on clear, practical explanations. + When explaining code: - Provide concise high-level overview first - Highlight non-obvious implementation details @@ -32,12 +53,16 @@ When explaining code: - Focus on complex parts rather than basic syntax - Use short paragraphs with clear structure - Mention performance considerations where relevant -]] .. base +]] -local COPILOT_REVIEW = COPILOT_INSTRUCTIONS +local COPILOT_REVIEW = base .. [[ -Review the code for readability and maintainability issues. Report problems in this format: + +You are a code reviewer focused on improving code quality and maintainability. + +Format each issue you find precisely as: line=: +OR line=-: Check for: @@ -58,34 +83,6 @@ End with: "**`To clear buffer highlights, please ask a different question.`**" If no issues found, confirm the code is well-written and explain why. ]] -local COPILOT_GENERATE = COPILOT_INSTRUCTIONS - .. [[ -Your task is to modify the provided code according to the user's request. - -When presenting code changes: - -1. For each change, first provide a header outside code blocks with format: - [file:]() line:- - -2. Then wrap the actual code in triple backticks with the appropriate language identifier. - -3. Keep changes minimal and focused to produce short diffs. - -4. Include complete replacement code for the specified line range with: - - Proper indentation matching the source - - All necessary lines (no eliding with comments) - - No line number prefixes in the code - -5. Address any diagnostics issues when fixing code. - -6. If multiple changes are needed, present them as separate blocks with their own headers. - -7. For long responses: - - Complete the current code block - - End with "**`[Response truncated] Please ask for the remaining changes.`**" - - Continue in the next response -]] - ---@type table return { COPILOT_INSTRUCTIONS = { @@ -100,10 +97,6 @@ return { system_prompt = COPILOT_REVIEW, }, - COPILOT_GENERATE = { - system_prompt = COPILOT_GENERATE, - }, - Explain = { prompt = '> /COPILOT_EXPLAIN\n\nWrite an explanation for the selected code as paragraphs of text.', }, @@ -152,19 +145,19 @@ return { }, Fix = { - prompt = '> /COPILOT_GENERATE\n\nThere is a problem in this code. Rewrite the code to show it with the bug fixed.', + prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', }, Optimize = { - prompt = '> /COPILOT_GENERATE\n\nOptimize the selected code to improve performance and readability.', + prompt = 'Optimize the selected code to improve performance and readability. Explain your optimization strategy and the benefits of your changes.', }, Docs = { - prompt = '> /COPILOT_GENERATE\n\nPlease add documentation comments to the selected code.', + prompt = 'Please add documentation comments to the selected code.', }, Tests = { - prompt = '> /COPILOT_GENERATE\n\nPlease generate tests for my code.', + prompt = 'Please generate tests for my code.', }, Commit = { From afa2baefdedec5032c5a5ab8fd7fcf9ec18c3b16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 2 Mar 2025 02:57:10 +0000 Subject: [PATCH 039/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index ff482465..8b1bd0f7 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 01 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 02 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -188,37 +188,22 @@ KEY MAPPINGS *CopilotChat-key-mappings* Default mappings in the chat interface: - ------------------------------------------------------------------------- Insert Normal Action - -------- -------- ------------------------------------------------------- + -------- -------- -------------------------------------------- Trigger/accept completion menu for tokens - q Close the chat window - Reset and clear the chat window - Submit the current prompt - - gr Toggle sticky prompt for line under cursor - - Accept nearest diff (best with COPILOT_GENERATE) - + 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 - - gi Show info about current chat - - gc Show current chat context - - gh Show help message - ------------------------------------------------------------------------- The mappings can be customized by setting the `mappings` table in your configuration. Each mapping can have: @@ -284,10 +269,9 @@ System prompts define the AI model’s behavior. Reference them with Prompt Description ---------------------- -------------------------------------------- - COPILOT_INSTRUCTIONS Base GitHub Copilot instructions + COPILOT_INSTRUCTIONS Base instructions COPILOT_EXPLAIN Adds coding tutor behavior COPILOT_REVIEW Adds code review behavior with diagnostics - COPILOT_GENERATE Adds code generation behavior and rules Define your own system prompts in the configuration (similar to `prompts`): >lua @@ -608,19 +592,19 @@ Below are all available configuration options with their default values: prompt = '> /COPILOT_REVIEW\n\nReview the selected code.', }, Fix = { - prompt = '> /COPILOT_GENERATE\n\nThere is a problem in this code. Rewrite the code to show it with the bug fixed.', + prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', }, Optimize = { - prompt = '> /COPILOT_GENERATE\n\nOptimize the selected code to improve performance and readability.', + prompt = 'Optimize the selected code to improve performance and readability. Explain your optimization strategy and the benefits of your changes.', }, Docs = { - prompt = '> /COPILOT_GENERATE\n\nPlease add documentation comments to the selected code.', + prompt = 'Please add documentation comments to the selected code.', }, Tests = { - prompt = '> /COPILOT_GENERATE\n\nPlease generate tests for my code.', + prompt = 'Please generate tests for my code.', }, Commit = { - prompt = '> #git:staged\n\nWrite commit message for the change with commitizen convention. Make sure the title has maximum 50 characters and message is wrapped at 72 characters. Wrap the whole message in code block with language gitcommit.', + prompt = '> #git:staged\n\nWrite 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.', }, }, From a0b1e415336fa2562ad8e14553f7888b7acb1010 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 2 Mar 2025 04:09:24 +0100 Subject: [PATCH 040/589] refactor: reorder role first in prompts Change the structure of COPILOT_EXPLAIN and COPILOT_REVIEW prompts to lead with the role description before adding the base instructions. This makes the AI's role more prominent in the prompt and potentially improves response quality. --- lua/CopilotChat/config/prompts.lua | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index c72b4dd9..e20f8e0c 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -40,10 +40,9 @@ local COPILOT_INSTRUCTIONS = [[ You are a code-focused AI programming assistant that specializes in practical software engineering solutions. ]] .. base -local COPILOT_EXPLAIN = base - .. [[ - +local COPILOT_EXPLAIN = [[ You are a programming instructor focused on clear, practical explanations. +]] .. base .. [[ When explaining code: - Provide concise high-level overview first @@ -55,10 +54,9 @@ When explaining code: - Mention performance considerations where relevant ]] -local COPILOT_REVIEW = base - .. [[ - +local COPILOT_REVIEW = [[ You are a code reviewer focused on improving code quality and maintainability. +]] .. base .. [[ Format each issue you find precisely as: line=: From f637b9e823120f0ea1cec98816fe8aae85267eea Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 2 Mar 2025 16:18:05 +0100 Subject: [PATCH 041/589] feat(health): add setup check to health module Add a required setup verification step that warns users when the plugin hasn't been properly initialized. This helps troubleshoot common issues where users forget to call setup(). Also reorganize health checks by removing redundant header and moving the debug info suggestion to a more appropriate location. --- lua/CopilotChat/health.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index 64ef2e6e..dac6c16e 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -40,9 +40,6 @@ local function treesitter_parser_available(ft) end function M.check() - start('CopilotChat.nvim') - info('If you are facing any issues, also see :CopilotChatDebugInfo for more information.') - start('CopilotChat.nvim [core]') local vim_version = vim.trim(vim.api.nvim_command_output('version')) @@ -52,6 +49,13 @@ 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') + else + error('setup: not called, required for plugin to work. See `:h CopilotChat-installation`.') + end + start('CopilotChat.nvim [commands]') local curl_version = run_command('curl', '--version') From fc1282ca7d260a340c6a5b5062f13ef575cfcd63 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 2 Mar 2025 22:28:38 +0100 Subject: [PATCH 042/589] fix: clear config when resetting chat When clearing the chat with the clear method, ensure the config is also reset to an empty table along with references and token counts. This prevents stale configuration from persisting across chat resets. --- 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 7ace09bc..8453b2b3 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -431,6 +431,7 @@ end function Chat:clear() self:validate() + self.config = {} self.references = {} self.token_count = nil self.token_max_count = nil From d865d76156915df9cb5526dde126b02985a0a1b4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 3 Mar 2025 23:25:09 +0100 Subject: [PATCH 043/589] fix: prevent errors when accessing window config Previously, the Chat:clear() method was resetting the entire config object to an empty table, causing potential nil access errors when trying to access config.window.layout. This change: 1. Removes the config reset in the clear() method to preserve settings 2. Adds a nil check when accessing config.window to prevent errors Closes #895 Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 8453b2b3..8dc4b6af 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -431,7 +431,6 @@ end function Chat:clear() self:validate() - self.config = {} self.references = {} self.token_count = nil self.token_max_count = nil @@ -523,7 +522,7 @@ function Chat:close(bufnr) utils.return_to_normal_mode() end - if self.config.window.layout == 'replace' then + if self.config.window and self.config.window.layout == 'replace' then if bufnr then self:restore(self.winnr, bufnr) end From 736fabd590df20c9a4301211f3c133d4d2b72deb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 3 Mar 2025 22:26:31 +0000 Subject: [PATCH 044/589] 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 8b1bd0f7..8b9dd534 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 02 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 03 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From b3ebb0ca2a0b2030eef37f603ab2b07eee2837d6 Mon Sep 17 00:00:00 2001 From: Mani Chandra Dulam Date: Tue, 4 Mar 2025 11:53:26 +0530 Subject: [PATCH 045/589] fix: glob_to_regex recursion matching --- 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 3ed0cae3..bea0d096 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -545,7 +545,7 @@ function M.glob_to_regex(glob) pattern = pattern :gsub('%*%*/%*', '.*') -- **/* -> .* :gsub('%*%*', '.*') -- ** -> .* - :gsub('%*', '[^/]*') -- * -> [^/]* + :gsub('([^%.])%*', '%1[^/]*') -- * -> [^/]* (when not preceded by .) :gsub('%?', '.') -- ? -> . return pattern .. '$' From c02074215dc453e92f4f02eefccf2ad640459ee7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 4 Mar 2025 06:37:08 +0000 Subject: [PATCH 046/589] 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 8b9dd534..ee723301 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 03 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 04 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 1a317001711bb38107442c539857ee0fb1c8ab03 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 4 Mar 2025 07:38:47 +0100 Subject: [PATCH 047/589] docs: add ThisIsMani as a contributor for code (#898) * 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 bfda6b81..8404237d 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -354,6 +354,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/3632663?v=4", "profile": "https://github.com/unlimitedsola", "contributions": ["doc", "code"] + }, + { + "login": "ThisIsMani", + "name": "Mani Chandra", + "avatar_url": "https://avatars.githubusercontent.com/u/84711804?v=4", + "profile": "https://github.com/ThisIsMani", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index fda37884..ad369319 100644 --- a/README.md +++ b/README.md @@ -818,6 +818,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Sola
Sola

📖 💻 + Mani Chandra
Mani Chandra

💻 From 3e1ddc7bc311e68590c2ffde0f06d740f4e4acce Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 4 Mar 2025 22:00:35 +0100 Subject: [PATCH 048/589] feat(context): add system context for shell command output Adds a new 'system' context that allows including the output of shell commands in chat context. This feature is useful for incorporating system information or command results directly into Copilot Chat. The implementation: - Updates documentation in README.md - Adds new context entry in contexts.lua with input prompt - Implements M.cmd() function with cross-platform support - Adds status notifications for context operations Signed-off-by: Tomas Slusny --- README.md | 4 ++-- lua/CopilotChat/config/contexts.lua | 14 ++++++++++++++ lua/CopilotChat/config/mappings.lua | 2 -- lua/CopilotChat/context.lua | 29 +++++++++++++++++++++++++++++ lua/CopilotChat/init.lua | 20 +++++++++++++++----- lua/CopilotChat/utils.lua | 9 ++------- 6 files changed, 62 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ad369319..cf9de9f8 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,6 @@ The mappings can be customized by setting the `mappings` table in your configura - `normal`: Key for normal mode - `insert`: Key for insert mode -- `detail`: Description of what the mapping does For example, to change the submit prompt mapping or show_diff full diff option: @@ -323,6 +322,7 @@ Contexts provide additional information to the chat. Add context using `#context | `url` | ✓ (url) | Content from URL | | `register` | ✓ (name) | Content of vim register | | `quickfix` | - | Quickfix list file contents | +| `system` | ✓ (command) | Output of shell command | Examples: @@ -333,6 +333,7 @@ Examples: > #filenames > #git:staged > #url:https://example.com +> #system:`ls -la | grep lua` ``` Define your own contexts in the configuration with input handling and resolution: @@ -575,7 +576,6 @@ Below are all available configuration options with their default values: insert = '', }, toggle_sticky = { - detail = 'Makes line under cursor sticky or deletes sticky line.', normal = 'gr', }, accept_diff = { diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 5d5ac65b..5280f28f 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -172,4 +172,18 @@ return { return context.quickfix() end, }, + + system = { + description = 'Includes output of provided system shell command in chat context. Supports input.', + input = function(callback) + vim.ui.input({ + prompt = 'Enter command> ', + }, callback) + end, + resolve = function(input) + return { + context.system(input), + } + end, + }, } diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 22c80cec..56b13ed9 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -98,7 +98,6 @@ end ---@class CopilotChat.config.mapping ---@field normal string? ---@field insert string? ----@field detail string? ---@field callback fun(overlay: CopilotChat.ui.Overlay, diff: CopilotChat.ui.Diff, chat: CopilotChat.ui.Chat, source: CopilotChat.source) ---@class CopilotChat.config.mapping.yank_diff : CopilotChat.config.mapping @@ -159,7 +158,6 @@ return { }, toggle_sticky = { - detail = 'Makes line under cursor sticky or deletes sticky line.', normal = 'gr', callback = function(overlay, diff, chat) local section = chat:get_closest_section() diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 28cfbd65..406ca39e 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -458,6 +458,7 @@ end ---@param bufnr number ---@return CopilotChat.context.embed? function M.buffer(bufnr) + notify.publish(notify.STATUS, 'Reading buffer ' .. bufnr) utils.schedule_main() if not utils.buf_valid(bufnr) then @@ -593,6 +594,7 @@ end ---@param register string ---@return CopilotChat.context.embed? function M.register(register) + notify.publish(notify.STATUS, 'Reading register ' .. register) utils.schedule_main() local lines = vim.fn.getreg(register) @@ -610,6 +612,7 @@ end --- Get the content of the quickfix list ---@return table function M.quickfix() + notify.publish(notify.STATUS, 'Reading quickfix list') utils.schedule_main() local items = vim.fn.getqflist() @@ -647,6 +650,32 @@ function M.quickfix() return out end +--- Get the output of a system shell command +---@param command string The command to execute +---@return CopilotChat.context.embed? +function M.system(command) + notify.publish(notify.STATUS, 'Executing command: ' .. command) + utils.schedule_main() + + local shell, shell_flag + if vim.fn.has('win32') == 1 then + shell, shell_flag = 'cmd.exe', '/c' + else + shell, shell_flag = 'sh', '-c' + end + + local out = utils.system({ shell, shell_flag, command }) + if not out or out.stdout == '' then + return nil + end + + return { + content = out.stdout, + filename = 'command_output_' .. command:gsub('[^%w]', '_'):sub(1, 20), + filetype = 'text', + } +end + --- Filter embeddings based on the query ---@param prompt string ---@param model string diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index b8035585..4c4622e3 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -7,6 +7,7 @@ local utils = require('CopilotChat.utils') local M = {} local PLUGIN_NAME = 'CopilotChat' local WORD = '([^%s]+)' +local WORD_INPUT = '([^%s:]+:`[^`]+`)' --- @class CopilotChat.source --- @field bufnr number @@ -260,6 +261,10 @@ function M.resolve_embeddings(prompt, config) local split = vim.split(prompt_context, ':') local context_name = table.remove(split, 1) local context_input = vim.trim(table.concat(split, ':')) + if vim.startswith(context_input, '`') and vim.endswith(context_input, '`') then + context_input = context_input:sub(2, -2) + end + if M.config.contexts[context_name] then table.insert(contexts, { name = context_name, @@ -272,11 +277,12 @@ function M.resolve_embeddings(prompt, config) return false end + prompt = prompt:gsub('#' .. WORD_INPUT, function(match) + return parse_context(match) and '' or '#' .. match + end) + prompt = prompt:gsub('#' .. WORD, function(match) - if parse_context(match) then - return '' - end - return '#' .. match + return parse_context(match) and '' or '#' .. match end) if config.context then @@ -392,7 +398,11 @@ function M.trigger_complete(with_context) return end - local value_str = tostring(value) + local value_str = vim.trim(tostring(value)) + if value_str:find('%s') then + value_str = '`' .. value_str .. '`' + end + vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, { value_str }) vim.api.nvim_win_set_cursor(0, { row, col + #value_str }) end, state.source or {}) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index bea0d096..3d67a9d4 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -480,6 +480,7 @@ end, 1) --- Get the info for a key. ---@param name string +---@param key table ---@param surround string|nil ---@return string function M.key_to_info(name, key, surround) @@ -502,13 +503,7 @@ function M.key_to_info(name, key, surround) return out end - out = out .. ' to ' .. name:gsub('_', ' ') - - if key.detail and key.detail ~= '' then - out = out .. '. ' .. key.detail - end - - return out + return out .. ' to ' .. name:gsub('_', ' ') end --- Check if a value is empty From bdddfebb057aca1d91a9f7f8331fa3cbd82d30d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 4 Mar 2025 22:34:39 +0000 Subject: [PATCH 049/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index ee723301..d49cebc6 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -209,7 +209,6 @@ configuration. Each mapping can have: - `normal`: Key for normal mode - `insert`: Key for insert mode -- `detail`: Description of what the mapping does For example, to change the submit prompt mapping or show_diff full diff option: @@ -364,6 +363,7 @@ Contexts provide additional information to the chat. Add context using url ✓ (url) Content from URL register ✓ (name) Content of vim register quickfix - Quickfix list file contents + system ✓ (command) Output of shell command Examples: >markdown @@ -373,6 +373,7 @@ Examples: > #filenames > #git:staged > #url:https://example.com + > #system:`ls -la | grep lua` < Define your own contexts in the configuration with input handling and @@ -627,7 +628,6 @@ Below are all available configuration options with their default values: insert = '', }, toggle_sticky = { - detail = 'Makes line under cursor sticky or deletes sticky line.', normal = 'gr', }, accept_diff = { @@ -814,7 +814,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📖 💻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💻This project follows the all-contributors specification. Contributions of any kind are welcome! From a1de0aaa366d1e7ff4d88483732454b6a398bec3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 24 Feb 2025 19:01:05 +0100 Subject: [PATCH 050/589] refactor!: centralize sticky prompt and selection handling This refactoring improves the CopilotChat plugin's state management by: - Adding `insert_sticky` function to centralize sticky prompt management - Creating `set_selection` to provide a cleaner API for selection handling - Consolidating highlight management in `update_highlights` - Standardizing function signatures and return values - Improving `Chat:set_prompt` functionality - Removing redundant code for better maintainability BREAKING CHANGES: - `M.resolve_prompt` now returns config first and prompt second - `M.update_selection` removed in favor of new `M.set_selection` function - `M.resolve_embeddings` renamed to `M.resolve_context` - Selection setting now only configurable in global config, not per-operation - `get_selection` no longer accepts a config parameter - Selection handling moved from dynamic config to static global config - Changed sticky format and processing approach - Prompts in config now use explicit `sticky` property instead of prefixing Signed-off-by: Tomas Slusny --- README.md | 20 +- lua/CopilotChat/client.lua | 1 - lua/CopilotChat/config.lua | 12 +- lua/CopilotChat/config/mappings.lua | 51 ++--- lua/CopilotChat/config/prompts.lua | 9 +- lua/CopilotChat/init.lua | 320 ++++++++++++++++++---------- lua/CopilotChat/ui/chat.lua | 23 +- 7 files changed, 262 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index cf9de9f8..04ba083e 100644 --- a/README.md +++ b/README.md @@ -453,11 +453,6 @@ Below are all available configuration options with their default values: headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) callback = nil, -- Callback to use when ask response is received - -- default selection - selection = function(source) - return select.visual(source) or select.buffer(source) - end, - -- default window options window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' @@ -499,6 +494,12 @@ Below are all available configuration options with their default values: error_header = '# Error ', -- Header to use for errors separator = '───', -- Separator to use in chat + -- default selection + -- see config/select.lua for implementation + selection = function(source) + return select.visual(source) or select.buffer(source) + end, + -- default providers -- see config/providers.lua for implementation providers = { @@ -535,10 +536,12 @@ Below are all available configuration options with their default values: -- see config/prompts.lua for implementation prompts = { Explain = { - prompt = '> /COPILOT_EXPLAIN\n\nWrite an explanation for the selected code as paragraphs of text.', + prompt = 'Write an explanation for the selected code as paragraphs of text.', + sticky = '/COPILOT_EXPLAIN', }, Review = { - prompt = '> /COPILOT_REVIEW\n\nReview the selected code.', + prompt = 'Review the selected code.', + sticky = '/COPILOT_REVIEW', }, Fix = { prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', @@ -553,7 +556,8 @@ Below are all available configuration options with their default values: prompt = 'Please generate tests for my code.', }, Commit = { - prompt = '> #git:staged\n\nWrite 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.', + 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 = '#git:staged', }, }, diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 0d414513..42bd034c 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -14,7 +14,6 @@ ---@class CopilotChat.Client.agent : CopilotChat.Provider.agent ---@field provider string -local async = require('plenary.async') local log = require('plenary.log') local tiktoken = require('CopilotChat.tiktoken') local notify = require('CopilotChat.notify') diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 3906f08d..c92a39fc 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -22,7 +22,6 @@ local select = require('CopilotChat.select') ---@field temperature number? ---@field headless boolean? ---@field callback fun(response: string, source: CopilotChat.source)? ----@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.selection? ---@field window CopilotChat.config.window? ---@field show_help boolean? ---@field show_folds boolean? @@ -47,6 +46,7 @@ local select = require('CopilotChat.select') ---@field answer_header string? ---@field error_header string? ---@field separator string? +---@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.selection? ---@field providers table? ---@field contexts table? ---@field prompts table? @@ -66,11 +66,6 @@ return { headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) callback = nil, -- Callback to use when ask response is received - -- default selection - selection = function(source) - return select.visual(source) or select.buffer(source) - end, - -- default window options window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' @@ -113,6 +108,11 @@ return { error_header = '## Error ', -- Header to use for errors separator = '───', -- Separator to use in chat + -- default selection + selection = function(source) + return select.visual(source) or select.buffer(source) + end, + -- default providers providers = require('CopilotChat.config.providers'), diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 56b13ed9..4f875db9 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -5,7 +5,6 @@ local utils = require('CopilotChat.utils') ---@param chat CopilotChat.ui.Chat ---@return CopilotChat.ui.Diff.Diff? local function get_diff(chat) - local config = chat.config local block = chat:get_closest_block() -- If no block found, return nil @@ -15,7 +14,7 @@ local function get_diff(chat) -- Initialize variables with selection if available local header = block.header - local selection = copilot.get_selection(config) + 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 @@ -64,35 +63,15 @@ local function get_diff(chat) } end ----@param winnr number ----@param bufnr number ----@param start_line number ----@param end_line number ----@param config CopilotChat.config.shared -local function jump_to_diff(winnr, bufnr, start_line, end_line, config) - 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 }) - copilot.update_selection(config) -end - ---@param diff CopilotChat.ui.Diff.Diff? ----@param config CopilotChat.config.shared -local function apply_diff(diff, config) +local function apply_diff(diff) if not diff or not diff.bufnr then return end - local winnr = vim.fn.win_findbuf(diff.bufnr)[1] - if not winnr then - return - end - local lines = vim.split(diff.change, '\n', { trimempty = false }) vim.api.nvim_buf_set_lines(diff.bufnr, diff.start_line - 1, diff.end_line, false, lines) - jump_to_diff(winnr, diff.bufnr, diff.start_line, diff.start_line + #lines - 1, config) + copilot.set_selection(diff.bufnr, diff.start_line, diff.start_line + #lines - 1) end ---@class CopilotChat.config.mapping @@ -208,7 +187,7 @@ return { normal = '', insert = '', callback = function(overlay, diff, chat, source) - apply_diff(get_diff(chat), chat.config) + apply_diff(get_diff(chat)) end, }, @@ -234,8 +213,7 @@ return { source.bufnr = diff_bufnr vim.api.nvim_win_set_buf(source.winnr, diff_bufnr) - - jump_to_diff(source.winnr, diff_bufnr, diff.start_line, diff.end_line, chat.config) + copilot.set_selection(diff_bufnr, diff.start_line, diff.end_line) end, }, @@ -278,7 +256,7 @@ return { quickfix_diffs = { normal = 'gqd', callback = function(overlay, diff, chat) - local selection = copilot.get_selection(chat.config) + local selection = copilot.get_selection() local items = {} for _, section in ipairs(chat.sections) do @@ -345,16 +323,16 @@ return { end local lines = {} - local prompt, config = copilot.resolve_prompts(section.content, chat.config) + local config, prompt = copilot.resolve_prompt(section.content) local system_prompt = config.system_prompt async.run(function() - local _, selected_agent = pcall(copilot.resolve_agent, prompt, config) - local _, selected_model = pcall(copilot.resolve_model, prompt, config) + local selected_agent = copilot.resolve_agent(prompt, config) + local selected_model = copilot.resolve_model(prompt, config) utils.schedule_main() - table.insert(lines, '**Logs**: `' .. chat.config.log_path .. '`') - table.insert(lines, '**History**: `' .. chat.config.history_path .. '`') + table.insert(lines, '**Logs**: `' .. copilot.config.log_path .. '`') + table.insert(lines, '**History**: `' .. copilot.config.history_path .. '`') table.insert(lines, '**Temp Files**: `' .. vim.fn.fnamemodify(os.tmpname(), ':h') .. '`') table.insert(lines, '') @@ -393,7 +371,7 @@ return { local lines = {} - local selection = copilot.get_selection(chat.config) + local selection = copilot.get_selection() if selection then table.insert(lines, '**Selection**') table.insert(lines, '```' .. selection.filetype) @@ -405,10 +383,7 @@ return { end async.run(function() - local embeddings = {} - if section and not section.answer then - embeddings = copilot.resolve_embeddings(section.content, chat.config) - end + local embeddings = copilot.resolve_context(section.content) for _, embedding in ipairs(embeddings) do local embed_lines = vim.split(embedding.content, '\n') diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index e20f8e0c..d92374c6 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -96,11 +96,13 @@ return { }, Explain = { - prompt = '> /COPILOT_EXPLAIN\n\nWrite an explanation for the selected code as paragraphs of text.', + prompt = 'Write an explanation for the selected code as paragraphs of text.', + sticky = '/COPILOT_EXPLAIN', }, Review = { - prompt = '> /COPILOT_REVIEW\n\nReview the selected code.', + prompt = 'Review the selected code.', + sticky = '/COPILOT_REVIEW', callback = function(response, source) local diagnostics = {} for line in response:gmatch('[^\r\n]+') do @@ -159,6 +161,7 @@ return { }, Commit = { - prompt = '> #git:staged\n\nWrite 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.', + 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 = '#git:staged', }, } diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 4c4622e3..918ed099 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -36,8 +36,100 @@ local state = { overlay = nil, } ---- Update the highlights in chat buffer +--- Insert sticky values from config into prompt +---@param prompt string +---@param config CopilotChat.config.shared +local function insert_sticky(prompt, config, override_sticky) + prompt = vim.trim(prompt or '') + local lines = vim.split(prompt, '\n') + local stickies = utils.ordered_map() + + local sticky_indices = {} + for i, line in ipairs(lines) do + if vim.startswith(line, '> ') then + table.insert(sticky_indices, i) + stickies:set(vim.trim(line:sub(3)), true) + end + end + for i = #sticky_indices, 1, -1 do + table.remove(lines, sticky_indices[i]) + end + + lines = vim.split(vim.trim(table.concat(lines, '\n')), '\n') + + if config.model and config.model ~= M.config.model then + stickies:set('$' .. config.model, true) + end + + if config.agent and config.agent ~= M.config.agent then + stickies:set('@' .. config.agent, true) + end + + if config.context and not vim.deep_equal(config.context, M.config.context) then + if type(config.context) == 'table' then + ---@diagnostic disable-next-line: param-type-mismatch + for _, context in ipairs(config.context) do + stickies:set('#' .. context, true) + end + else + stickies:set('#' .. config.context, true) + end + end + + if config.sticky and (override_sticky or not vim.deep_equal(config.sticky, M.config.sticky)) then + if type(config.sticky) == 'table' then + ---@diagnostic disable-next-line: param-type-mismatch + for _, sticky in ipairs(config.sticky) do + stickies:set(sticky, true) + end + else + stickies:set(config.sticky, true) + end + end + + -- Insert stickies at start of prompt + local prompt_lines = {} + for _, sticky in ipairs(stickies:keys()) do + table.insert(prompt_lines, '> ' .. sticky) + 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 + + return table.concat(prompt_lines, '\n') +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 state.chat.config.highlight_selection and state.chat:active() 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 + if state.highlights_loaded then return end @@ -57,36 +149,6 @@ local function update_highlights() end) end ---- Highlights the selection in the source buffer. ----@param clear boolean ----@param config CopilotChat.config.shared -local function highlight_selection(clear, config) - 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 clear or not config.highlight_selection then - return - end - - local selection = M.get_selection(config) - 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 - ---@param start_of_chat boolean? local function finish(start_of_chat) if not start_of_chat then @@ -95,22 +157,9 @@ local function finish(start_of_chat) state.chat:append(M.config.question_header .. M.config.separator .. '\n\n') - -- Add default sticky prompts after reset + -- Insert sticky values from config into prompt if start_of_chat then - if M.config.sticky then - local last_prompt = state.last_prompt or '' - - if type(M.config.sticky) == 'table' then - ---@diagnostic disable-next-line: param-type-mismatch - for _, sticky in ipairs(M.config.sticky) do - last_prompt = last_prompt .. '\n> ' .. sticky - end - else - last_prompt = last_prompt .. '\n> ' .. M.config.sticky - end - - state.last_prompt = last_prompt - end + state.last_prompt = insert_sticky(state.last_prompt, M.config, true) end -- Reinsert sticky prompts from last prompt @@ -199,9 +248,8 @@ local function map_key(name, bufnr, fn) end end ---- Updates the selection based on previous window ----@param config CopilotChat.config.shared -function M.update_selection(config) +--- Updates the source buffer based on previous window. +local function update_source() local prev_winnr = vim.fn.win_getid(vim.fn.winnr('#')) local prev_bufnr = vim.api.nvim_win_get_buf(prev_winnr) if @@ -214,29 +262,34 @@ function M.update_selection(config) winnr = prev_winnr, } end - - highlight_selection(false, config) end ---- Resolve the prompts from the prompt. ----@param prompt string ----@param config CopilotChat.config.shared ----@return string, CopilotChat.config -function M.resolve_prompts(prompt, config) +--- Resolve the final prompt and config from prompt template. +---@param prompt string? +---@param config CopilotChat.config.shared? +---@return CopilotChat.config.prompt, string +function M.resolve_prompt(prompt, config) + if not prompt then + local section = state.chat:get_prompt() + if section then + prompt = section.content + end + end + local prompts_to_use = M.prompts() local depth = 0 local MAX_DEPTH = 10 - local function resolve(inner_prompt, inner_config) + local function resolve(inner_config, inner_prompt) if depth >= MAX_DEPTH then - return inner_prompt, inner_config + 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_prompt, resolved_config = resolve(p.prompt or '', p) + 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 @@ -245,17 +298,19 @@ function M.resolve_prompts(prompt, config) end) depth = depth - 1 - return inner_prompt, inner_config + return inner_config, inner_prompt end - return resolve(prompt, config) + return resolve(config or M.config, prompt or '') end ---- Resolve the embeddings from the prompt. ----@param prompt string ----@param config CopilotChat.config.shared +--- Resolve the context embeddings from the prompt. +---@param prompt string? +---@param config CopilotChat.config.shared? ---@return table, string -function M.resolve_embeddings(prompt, config) +function M.resolve_context(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) + local contexts = {} local function parse_context(prompt_context) local split = vim.split(prompt_context, ':') @@ -312,14 +367,17 @@ function M.resolve_embeddings(prompt, config) end --- Resolve the agent from the prompt. ----@param prompt string ----@param config CopilotChat.config.shared +---@param prompt string? +---@param config CopilotChat.config.shared? +---@return string, string function M.resolve_agent(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) + local agents = vim.tbl_map(function(agent) return agent.id end, client:list_agents()) - local selected_agent = config.agent + local selected_agent = config.agent or '' prompt = prompt:gsub('@' .. WORD, function(match) if vim.tbl_contains(agents, match) then selected_agent = match @@ -332,14 +390,17 @@ function M.resolve_agent(prompt, config) end --- Resolve the model from the prompt. ----@param prompt string ----@param config CopilotChat.config.shared +---@param prompt string? +---@param config CopilotChat.config.shared? +---@return string, string function M.resolve_model(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) + local models = vim.tbl_map(function(model) return model.id end, client:list_models()) - local selected_model = config.model + local selected_model = config.model or '' prompt = prompt:gsub('%$' .. WORD, function(match) if vim.tbl_contains(models, match) then selected_model = match @@ -352,25 +413,53 @@ function M.resolve_model(prompt, config) end --- Get the selection from the source buffer. ----@param config CopilotChat.config.shared ---@return CopilotChat.select.selection? -function M.get_selection(config) +function M.get_selection() + local selection = M.config.selection local bufnr = state.source and state.source.bufnr local winnr = state.source and state.source.winnr - if - config - and config.selection - and utils.buf_valid(bufnr) - and winnr - and vim.api.nvim_win_is_valid(winnr) - then - return config.selection(state.source) + 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 + --- Trigger the completion for the chat window. ---@param with_context boolean? function M.trigger_complete(with_context) @@ -538,22 +627,19 @@ end --- Open the chat window. ---@param config CopilotChat.config.shared? function M.open(config) - -- If we are already in chat window, do nothing - if state.chat:active() then - return - end - config = vim.tbl_deep_extend('force', M.config, config or {}) - if config.headless then - state.source = { - bufnr = vim.api.nvim_get_current_buf(), - winnr = vim.api.nvim_get_current_win(), - } - return - end - utils.return_to_normal_mode() + state.chat:open(config) + + local section = state.chat:get_prompt() + if section then + local prompt = insert_sticky(section.content, config) + if prompt then + state.chat:set_prompt(prompt) + end + end + state.chat:follow() state.chat:focus() end @@ -680,16 +766,14 @@ end ---@param prompt string? ---@param config CopilotChat.config.shared? function M.ask(prompt, config) - M.open(config) - prompt = vim.trim(prompt or '') if prompt == '' then return end vim.diagnostic.reset(vim.api.nvim_create_namespace('copilot-chat-diagnostics')) - config = vim.tbl_deep_extend('force', state.chat.config, config or {}) config = vim.tbl_deep_extend('force', M.config, config or {}) + prompt = insert_sticky(prompt, config) if not config.headless then if config.clear_chat_on_new_prompt then @@ -698,14 +782,19 @@ function M.ask(prompt, config) finish() end + if not state.chat:active() then + M.open(config) + end + state.last_prompt = prompt - state.chat:clear_prompt() - state.chat:append('\n\n' .. prompt) - state.chat:append('\n\n' .. config.answer_header .. config.separator .. '\n\n') + state.chat:set_prompt(prompt) + state.chat:append('\n\n' .. M.config.answer_header .. M.config.separator .. '\n\n') + else + update_source() end -- Resolve prompt references - local prompt, config = M.resolve_prompts(prompt, config) + config, prompt = M.resolve_prompt(prompt, config) local system_prompt = config.system_prompt or '' -- Remove sticky prefix @@ -717,12 +806,12 @@ function M.ask(prompt, config) )) -- Retrieve the selection - local selection = M.get_selection(config) + local selection = M.get_selection() local ok, err = pcall(async.run, function() local selected_agent, prompt = M.resolve_agent(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) - local embeddings, prompt = M.resolve_embeddings(prompt, config) + local embeddings, prompt = M.resolve_context(prompt, config) local has_output = false local query_ok, filtered_embeddings = @@ -802,6 +891,8 @@ end --- Stop current copilot output and optionally reset the chat ten show the help message. ---@param reset boolean? function M.stop(reset) + local stopped = false + if reset then client:reset() state.chat:clear() @@ -809,17 +900,18 @@ function M.stop(reset) state.last_response = nil -- Clear the selection - if state.source and utils.buf_valid(state.source.bufnr) then - for _, mark in ipairs({ '<', '>', '[', ']' }) do - pcall(vim.api.nvim_buf_del_mark, state.source.bufnr, mark) - end - highlight_selection(true, state.chat.config) + if state.source then + M.set_selection(state.source.bufnr, 0, 0, true) end + + stopped = true else - client:stop() + stopped = client:stop() end - finish(reset) + if stopped then + finish(reset) + end end --- Reset the chat window and show the help message. @@ -992,13 +1084,11 @@ function M.setup(config) buffer = bufnr, callback = function(ev) local is_enter = ev.event == 'BufEnter' - if is_enter then - update_highlights() - M.update_selection(state.chat.config) - else - highlight_selection(true, state.chat.config) + update_source() end + + vim.schedule(update_highlights) end, }) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 8dc4b6af..17ffb65c 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -353,7 +353,8 @@ function Chat:load_history(history) end end -function Chat:clear_prompt() +---@return CopilotChat.ui.Chat.Section? +function Chat:get_prompt() if not self:visible() then return end @@ -364,9 +365,25 @@ function Chat:clear_prompt() return end + return section +end + +---@param prompt string? +function Chat:set_prompt(prompt) + if not self:visible() then + return + end + + local section = self:get_prompt() + if not section then + return + end + + local modifiable = vim.bo[self.bufnr].modifiable vim.bo[self.bufnr].modifiable = true - vim.api.nvim_buf_set_lines(self.bufnr, section.start_line - 1, section.end_line, false, {}) - vim.bo[self.bufnr].modifiable = false + local lines = prompt and vim.split('\n' .. prompt, '\n') or {} + vim.api.nvim_buf_set_lines(self.bufnr, section.start_line - 1, section.end_line, false, lines) + vim.bo[self.bufnr].modifiable = modifiable end ---@return boolean From 7a2743e67a35fb8a513c29c535dcd6d1838cd44d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 10:32:07 +0000 Subject: [PATCH 051/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index d49cebc6..5c957567 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 04 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 05 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -505,11 +505,6 @@ Below are all available configuration options with their default values: headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) callback = nil, -- Callback to use when ask response is received - -- default selection - selection = function(source) - return select.visual(source) or select.buffer(source) - end, - -- default window options window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' @@ -551,6 +546,12 @@ Below are all available configuration options with their default values: error_header = '# Error ', -- Header to use for errors separator = '───', -- Separator to use in chat + -- default selection + -- see config/select.lua for implementation + selection = function(source) + return select.visual(source) or select.buffer(source) + end, + -- default providers -- see config/providers.lua for implementation providers = { @@ -587,10 +588,12 @@ Below are all available configuration options with their default values: -- see config/prompts.lua for implementation prompts = { Explain = { - prompt = '> /COPILOT_EXPLAIN\n\nWrite an explanation for the selected code as paragraphs of text.', + prompt = 'Write an explanation for the selected code as paragraphs of text.', + sticky = '/COPILOT_EXPLAIN', }, Review = { - prompt = '> /COPILOT_REVIEW\n\nReview the selected code.', + prompt = 'Review the selected code.', + sticky = '/COPILOT_REVIEW', }, Fix = { prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', @@ -605,7 +608,8 @@ Below are all available configuration options with their default values: prompt = 'Please generate tests for my code.', }, Commit = { - prompt = '> #git:staged\n\nWrite 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.', + 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 = '#git:staged', }, }, From 19d66ff92baab099cc5617f42b46a2b0d4d1cde8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 5 Mar 2025 11:42:40 +0100 Subject: [PATCH 052/589] feat: allow diff accept to jump buffers like jump_to_diff instead of having safeguard requiring users to press jump to diff first, allow accept diff to also do this by default Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 89 +++++++++++++++++++---------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 4f875db9..220ccb6f 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -2,11 +2,9 @@ local async = require('plenary.async') local copilot = require('CopilotChat') local utils = require('CopilotChat.utils') ----@param chat CopilotChat.ui.Chat +---@param block CopilotChat.ui.Chat.Section.Block? ---@return CopilotChat.ui.Diff.Diff? -local function get_diff(chat) - local block = chat:get_closest_block() - +local function get_diff(block) -- If no block found, return nil if not block then return nil @@ -63,15 +61,43 @@ local function get_diff(chat) } end +--- Prepare a buffer for applying a diff ---@param diff CopilotChat.ui.Diff.Diff? -local function apply_diff(diff) - if not diff or not diff.bufnr then - return +---@param source CopilotChat.source? +---@return CopilotChat.ui.Diff.Diff? +local function prepare_diff_buffer(diff, source) + if not diff then + return diff + end + + local diff_bufnr = diff.bufnr + + -- 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 utils.filename_same(vim.api.nvim_buf_get_name(buf), diff.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(diff.filename) + vim.fn.bufload(diff_bufnr) + end + + diff.bufnr = diff_bufnr end - local lines = vim.split(diff.change, '\n', { trimempty = false }) - 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) + -- If source exists, update it to point to the diff buffer + if source and source.winnr and vim.api.nvim_win_is_valid(source.winnr) then + source.bufnr = diff_bufnr + vim.api.nvim_win_set_buf(source.winnr, diff_bufnr) + end + + return diff end ---@class CopilotChat.config.mapping @@ -187,33 +213,34 @@ return { normal = '', insert = '', callback = function(overlay, diff, chat, source) - apply_diff(get_diff(chat)) + local diff_data = get_diff(chat:get_closest_block()) + diff_data = prepare_diff_buffer(diff_data, source) + if diff_data then + local lines = vim.split(diff_data.change, '\n', { trimempty = false }) + vim.api.nvim_buf_set_lines( + diff_data.bufnr, + diff_data.start_line - 1, + diff_data.end_line, + false, + lines + ) + copilot.set_selection( + diff_data.bufnr, + diff_data.start_line, + diff_data.start_line + #lines - 1 + ) + end end, }, jump_to_diff = { normal = 'gj', callback = function(overlay, diff, chat, source) - if not source or not source.winnr or not vim.api.nvim_win_is_valid(source.winnr) then - return + local diff_data = get_diff(chat:get_closest_block()) + diff_data = prepare_diff_buffer(diff_data, source) + if diff_data then + copilot.set_selection(diff_data.bufnr, diff_data.start_line, diff_data.end_line) end - - local diff = get_diff(chat) - if not diff then - return - end - - local diff_bufnr = diff.bufnr - - -- If buffer is not found, try to load it - if not diff_bufnr then - diff_bufnr = vim.fn.bufadd(diff.filename) - vim.fn.bufload(diff_bufnr) - end - - source.bufnr = diff_bufnr - vim.api.nvim_win_set_buf(source.winnr, diff_bufnr) - copilot.set_selection(diff_bufnr, diff.start_line, diff.end_line) end, }, @@ -305,7 +332,7 @@ return { normal = 'gd', full_diff = false, -- Show full diff instead of unified diff when showing diff window callback = function(overlay, diff, chat) - local content = get_diff(chat) + local content = get_diff(chat:get_closest_block()) if not content then return end From 2dffc4d112836bf142470b507ab3b4c452e0492a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 5 Mar 2025 11:52:33 +0100 Subject: [PATCH 053/589] docs: remove selection from chat.ask example The example in the README now better reflects the current API by removing the explicit selection parameter from the chat.ask configuration object. Signed-off-by: Tomas Slusny --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 04ba083e..feb71c3f 100644 --- a/README.md +++ b/README.md @@ -674,7 +674,6 @@ chat.stop() -- Stop current output -- Ask a question with optional config chat.ask("Explain this code.", { - selection = require("CopilotChat.select").buffer, context = { 'buffers', 'files' }, callback = function(response) print("Response:", response) From df7e76a693cc7252785c3bd53af2167d78759280 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 10:53:46 +0000 Subject: [PATCH 054/589] 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 5c957567..8661c2ce 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -730,7 +730,6 @@ Types of copilot highlights: -- Ask a question with optional config chat.ask("Explain this code.", { - selection = require("CopilotChat.select").buffer, context = { 'buffers', 'files' }, callback = function(response) print("Response:", response) From 8c6bd88e103339eb53b9fb640bc3f3bd71b5e79a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 5 Mar 2025 16:00:39 +0100 Subject: [PATCH 055/589] docs: update provider documentation and references - Clarify that copilot provider is used for chat only, not embeddings - Fix parameter syntax for get_headers function - Correct reference path to select.lua --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index feb71c3f..7e9f9faf 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ Providers are modules that implement integration with different AI providers. ### Built-in Providers -- `copilot` - Default GitHub Copilot provider used for chat and embeddings +- `copilot` - Default GitHub Copilot provider used for chat - `github_models` - Provider for GitHub Marketplace models - `copilot_embeddings` - Provider for Copilot embeddings, not standalone @@ -408,7 +408,7 @@ Custom providers can implement these methods: embed?: string|function, -- Optional: Get extra request headers with optional expiration time - get_headers(?): table, number?, + get_headers?(): table, number?, -- Optional: Get API endpoint URL get_url?(opts: CopilotChat.Provider.options): string, @@ -495,7 +495,7 @@ Below are all available configuration options with their default values: separator = '───', -- Separator to use in chat -- default selection - -- see config/select.lua for implementation + -- see select.lua for implementation selection = function(source) return select.visual(source) or select.buffer(source) end, From 080a1777077f0bf92f73c3742778d338cf961920 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 15:02:17 +0000 Subject: [PATCH 056/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 8661c2ce..5e86e721 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -436,7 +436,7 @@ Providers are modules that implement integration with different AI providers. BUILT-IN PROVIDERS ~ -- `copilot` - Default GitHub Copilot provider used for chat and embeddings +- `copilot` - Default GitHub Copilot provider used for chat - `github_models` - Provider for GitHub Marketplace models - `copilot_embeddings` - Provider for Copilot embeddings, not standalone @@ -454,7 +454,7 @@ Custom providers can implement these methods: embed?: string|function, -- Optional: Get extra request headers with optional expiration time - get_headers(?): table, number?, + get_headers?(): table, number?, -- Optional: Get API endpoint URL get_url?(opts: CopilotChat.Provider.options): string, @@ -547,7 +547,7 @@ Below are all available configuration options with their default values: separator = '───', -- Separator to use in chat -- default selection - -- see config/select.lua for implementation + -- see select.lua for implementation selection = function(source) return select.visual(source) or select.buffer(source) end, From 830eee06a236ffbf9effbde38da903ef96ece296 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 5 Mar 2025 22:27:42 +0100 Subject: [PATCH 057/589] refactor(ui): centralize overlay and diff functionality Refactor the Copilot Chat UI architecture by: - Moving diff functionality into Chat class - Removing the separate Diff class implementation - Simplifying callback signatures in mappings - Making chat instance accessible through the module - Adding proper type definitions for diffs and overlays - Improving overlay behavior and restoring cursor position Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 158 ++++++++++++++++++++-------- lua/CopilotChat/init.lua | 101 +++++++----------- lua/CopilotChat/ui/chat.lua | 31 ++++++ lua/CopilotChat/ui/diff.lua | 114 -------------------- lua/CopilotChat/ui/overlay.lua | 49 +++++++-- 5 files changed, 221 insertions(+), 232 deletions(-) delete mode 100644 lua/CopilotChat/ui/diff.lua diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 220ccb6f..cd5f9201 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -1,3 +1,12 @@ +---@class CopilotChat.ui.Diff.Diff +---@field change string +---@field reference string +---@field filename string +---@field filetype string +---@field start_line number +---@field end_line number +---@field bufnr number? + local async = require('plenary.async') local copilot = require('CopilotChat') local utils = require('CopilotChat.utils') @@ -103,7 +112,7 @@ end ---@class CopilotChat.config.mapping ---@field normal string? ---@field insert string? ----@field callback fun(overlay: CopilotChat.ui.Overlay, diff: CopilotChat.ui.Diff, chat: CopilotChat.ui.Chat, source: CopilotChat.source) +---@field callback fun(source: CopilotChat.source) ---@class CopilotChat.config.mapping.yank_diff : CopilotChat.config.mapping ---@field register string? @@ -128,7 +137,7 @@ end return { complete = { insert = '', - callback = function(overlay, diff, chat) + callback = function() copilot.trigger_complete(true) end, }, @@ -136,7 +145,7 @@ return { close = { normal = 'q', insert = '', - callback = function(overlay, diff, chat) + callback = function() copilot.close() end, }, @@ -144,7 +153,7 @@ return { reset = { normal = '', insert = '', - callback = function(overlay, diff, chat) + callback = function() copilot.reset() end, }, @@ -152,8 +161,8 @@ return { submit_prompt = { normal = '', insert = '', - callback = function(overlay, diff, chat) - local section = chat:get_closest_section() + callback = function() + local section = copilot.chat:get_closest_section() if not section or section.answer then return end @@ -164,8 +173,8 @@ return { toggle_sticky = { normal = 'gr', - callback = function(overlay, diff, chat) - local section = chat:get_closest_section() + callback = function() + local section = copilot.chat:get_closest_section() if not section or section.answer then return end @@ -177,7 +186,7 @@ return { local cursor = vim.api.nvim_win_get_cursor(0) local cur_line = cursor[1] - vim.api.nvim_buf_set_lines(chat.bufnr, cur_line - 1, cur_line, false, {}) + vim.api.nvim_buf_set_lines(copilot.chat.bufnr, cur_line - 1, cur_line, false, {}) if vim.startswith(current_line, '> ') then return @@ -204,7 +213,13 @@ return { insert_line = section.start_line + insert_line - 1 local to_insert = first_one and { '> ' .. current_line, '' } or { '> ' .. current_line } - vim.api.nvim_buf_set_lines(chat.bufnr, insert_line - 1, insert_line - 1, false, to_insert) + vim.api.nvim_buf_set_lines( + copilot.chat.bufnr, + insert_line - 1, + insert_line - 1, + false, + to_insert + ) vim.api.nvim_win_set_cursor(0, cursor) end, }, @@ -212,8 +227,8 @@ return { accept_diff = { normal = '', insert = '', - callback = function(overlay, diff, chat, source) - local diff_data = get_diff(chat:get_closest_block()) + callback = function(source) + local diff_data = get_diff(copilot.chat:get_closest_block()) diff_data = prepare_diff_buffer(diff_data, source) if diff_data then local lines = vim.split(diff_data.change, '\n', { trimempty = false }) @@ -235,8 +250,8 @@ return { jump_to_diff = { normal = 'gj', - callback = function(overlay, diff, chat, source) - local diff_data = get_diff(chat:get_closest_block()) + callback = function(source) + local diff_data = get_diff(copilot.chat:get_closest_block()) diff_data = prepare_diff_buffer(diff_data, source) if diff_data then copilot.set_selection(diff_data.bufnr, diff_data.start_line, diff_data.end_line) @@ -246,28 +261,18 @@ return { quickfix_answers = { normal = 'gqa', - callback = function(overlay, diff, chat) + callback = function() local items = {} - for i, section in ipairs(chat.sections) do + for i, section in ipairs(copilot.chat.sections) do if section.answer then - local prev_section = chat.sections[i - 1] + local prev_section = copilot.chat.sections[i - 1] local text = '' if prev_section then - text = vim.trim( - table.concat( - vim.api.nvim_buf_get_lines( - chat.bufnr, - prev_section.start_line - 1, - prev_section.end_line, - false - ), - ' ' - ) - ) + text = prev_section.content end table.insert(items, { - bufnr = chat.bufnr, + bufnr = copilot.chat.bufnr, lnum = section.start_line, end_lnum = section.end_line, text = text, @@ -282,11 +287,11 @@ return { quickfix_diffs = { normal = 'gqd', - callback = function(overlay, diff, chat) + callback = function() local selection = copilot.get_selection() local items = {} - for _, section in ipairs(chat.sections) do + for _, section in ipairs(copilot.chat.sections) do for _, block in ipairs(section.blocks) do local header = block.header @@ -302,7 +307,7 @@ return { end table.insert(items, { - bufnr = chat.bufnr, + bufnr = copilot.chat.bufnr, lnum = block.start_line, end_lnum = block.end_line, text = text, @@ -318,8 +323,8 @@ return { yank_diff = { normal = 'gy', register = '"', -- Default register to use for yanking - callback = function(overlay, diff, chat) - local block = chat:get_closest_block() + callback = function() + local block = copilot.chat:get_closest_block() if not block then return end @@ -331,20 +336,74 @@ return { show_diff = { normal = 'gd', full_diff = false, -- Show full diff instead of unified diff when showing diff window - callback = function(overlay, diff, chat) - local content = get_diff(chat:get_closest_block()) - if not content then + callback = function() + local diff = get_diff(copilot.chat:get_closest_block()) + if not diff then return end - diff:show(content, chat.winnr, copilot.config.mappings.show_diff.full_diff) + local opts = { + filetype = diff.filetype, + syntax = 'diff', + } + + if copilot.config.mappings.show_diff.full_diff then + -- Create modified version by applying the change + local modified = {} + if utils.buf_valid(diff.bufnr) then + modified = vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) + end + local change_lines = vim.split(diff.change, '\n') + + -- Replace the lines in the modified content + if #modified > 0 then + local start_idx = diff.start_line - 1 + local end_idx = diff.end_line - 1 + for _ = start_idx, end_idx do + table.remove(modified, start_idx) + end + for i, line in ipairs(change_lines) do + table.insert(modified, start_idx + i - 1, line) + end + else + modified = change_lines + end + + opts.text = table.concat(modified, '\n') + + opts.on_show = function() + vim.cmd('diffthis') + vim.api.nvim_set_current_win(vim.fn.bufwinid(diff.bufnr)) + vim.api.nvim_win_set_cursor(0, { diff.start_line, 0 }) + vim.cmd('diffthis') + vim.api.nvim_set_current_win(copilot.chat.winnr) + vim.api.nvim_win_set_cursor(copilot.chat.winnr, { diff.start_line, 0 }) + end + + opts.on_hide = function() + vim.cmd('diffoff') + 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:show_overlay(opts) end, }, show_info = { normal = 'gi', - callback = function(overlay, diff, chat) - local section = chat:get_closest_section() + callback = function() + local section = copilot.chat:get_closest_section() if not section or section.answer then return end @@ -383,15 +442,17 @@ return { table.insert(lines, '') end - overlay:show(vim.trim(table.concat(lines, '\n')) .. '\n', chat.winnr, 'markdown') + copilot.chat:show_overlay({ + text = vim.trim(table.concat(lines, '\n')) .. '\n', + }) end) end, }, show_context = { normal = 'gc', - callback = function(overlay, diff, chat) - local section = chat:get_closest_section() + callback = function() + local section = copilot.chat:get_closest_section() if not section or section.answer then return end @@ -430,14 +491,16 @@ return { end utils.schedule_main() - overlay:show(vim.trim(table.concat(lines, '\n')) .. '\n', chat.winnr, 'markdown') + copilot.chat:show_overlay({ + text = vim.trim(table.concat(lines, '\n')) .. '\n', + }) end) end, }, show_help = { normal = 'gh', - callback = function(overlay, diff, chat) + callback = function() local chat_help = '**`Special tokens`**\n' chat_help = chat_help .. '`@` to select an agent\n' chat_help = chat_help .. '`#` to select a context\n' @@ -463,7 +526,10 @@ return { end end end - overlay:show(chat_help, chat.winnr, 'markdown') + + copilot.chat:show_overlay({ + text = chat_help, + }) end, }, } diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 918ed099..af44374b 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -18,9 +18,6 @@ local WORD_INPUT = '([^%s:]+:`[^`]+`)' --- @field last_prompt string? --- @field last_response string? --- @field highlights_loaded boolean ---- @field chat CopilotChat.ui.Chat? ---- @field diff CopilotChat.ui.Diff? ---- @field overlay CopilotChat.ui.Overlay? local state = { -- Current state tracking source = nil, @@ -29,11 +26,6 @@ local state = { last_prompt = nil, last_response = nil, highlights_loaded = false, - - -- Overlays - chat = nil, - diff = nil, - overlay = nil, } --- Insert sticky values from config into prompt @@ -112,7 +104,7 @@ local function update_highlights() vim.api.nvim_buf_clear_namespace(buf, selection_ns, 0, -1) end - if state.chat.config.highlight_selection and state.chat:active() then + if M.chat.config.highlight_selection and M.chat:active() then local selection = M.get_selection() if not selection @@ -152,10 +144,10 @@ end ---@param start_of_chat boolean? local function finish(start_of_chat) if not start_of_chat then - state.chat:append('\n\n') + M.chat:append('\n\n') end - state.chat:append(M.config.question_header .. M.config.separator .. '\n\n') + M.chat:append(M.config.question_header .. M.config.separator .. '\n\n') -- Insert sticky values from config into prompt if start_of_chat then @@ -168,16 +160,16 @@ local function finish(start_of_chat) local lines = vim.split(state.last_prompt, '\n') for _, line in ipairs(lines) do if vim.startswith(line, '> ') then - state.chat:append(line .. '\n') + M.chat:append(line .. '\n') has_sticky = true end end if has_sticky then - state.chat:append('\n') + M.chat:append('\n') end end - state.chat:finish() + M.chat:finish() end ---@param err string|table|nil @@ -198,10 +190,10 @@ local function show_error(err, append_newline) end if append_newline then - state.chat:append('\n') + M.chat:append('\n') end - state.chat:append(M.config.error_header .. '\n```error\n' .. err .. '\n```') + M.chat:append(M.config.error_header .. '\n```error\n' .. err .. '\n```') finish() end @@ -217,7 +209,7 @@ local function map_key(name, bufnr, fn) if not fn then fn = function() - key.callback(state.overlay, state.diff, state.chat, state.source) + key.callback(state.source) end end @@ -253,8 +245,8 @@ local function update_source() local prev_winnr = vim.fn.win_getid(vim.fn.winnr('#')) local prev_bufnr = vim.api.nvim_win_get_buf(prev_winnr) if - prev_winnr ~= state.chat.winnr - and prev_bufnr ~= state.chat.bufnr + prev_winnr ~= M.chat.winnr + and prev_bufnr ~= M.chat.bufnr and vim.fn.win_gettype(prev_winnr) == '' then state.source = { @@ -270,7 +262,7 @@ end ---@return CopilotChat.config.prompt, string function M.resolve_prompt(prompt, config) if not prompt then - local section = state.chat:get_prompt() + local section = M.chat:get_prompt() if section then prompt = section.content end @@ -630,29 +622,29 @@ function M.open(config) config = vim.tbl_deep_extend('force', M.config, config or {}) utils.return_to_normal_mode() - state.chat:open(config) + M.chat:open(config) - local section = state.chat:get_prompt() + local section = M.chat:get_prompt() if section then local prompt = insert_sticky(section.content, config) if prompt then - state.chat:set_prompt(prompt) + M.chat:set_prompt(prompt) end end - state.chat:follow() - state.chat:focus() + M.chat:follow() + M.chat:focus() end --- Close the chat window. function M.close() - state.chat:close(state.source and state.source.bufnr or nil) + M.chat:close(state.source and state.source.bufnr or nil) end --- Toggle the chat window. ---@param config CopilotChat.config.shared? function M.toggle(config) - if state.chat:visible() then + if M.chat:visible() then M.close() else M.open(config) @@ -782,13 +774,13 @@ function M.ask(prompt, config) finish() end - if not state.chat:active() then + if not M.chat:active() then M.open(config) end state.last_prompt = prompt - state.chat:set_prompt(prompt) - state.chat:append('\n\n' .. M.config.answer_header .. M.config.separator .. '\n\n') + M.chat:set_prompt(prompt) + M.chat:append('\n\n' .. M.config.answer_header .. M.config.separator .. '\n\n') else update_source() end @@ -837,7 +829,7 @@ function M.ask(prompt, config) temperature = config.temperature, on_progress = vim.schedule_wrap(function(token) if not config.headless then - state.chat:append(token) + M.chat:append(token) end has_output = true end), @@ -859,16 +851,16 @@ function M.ask(prompt, config) if not config.headless then state.last_response = response - state.chat.references = references - state.chat.token_count = token_count - state.chat.token_max_count = token_max_count + M.chat.references = references + M.chat.token_count = token_count + M.chat.token_max_count = token_max_count end if not config.headless then if not utils.empty(references) and config.references_display == 'write' then - state.chat:append('\n\n**`References`**:') + M.chat:append('\n\n**`References`**:') for _, ref in ipairs(references) do - state.chat:append(string.format('\n[%s](%s)', ref.name, ref.url)) + M.chat:append(string.format('\n[%s](%s)', ref.name, ref.url)) end end @@ -895,7 +887,7 @@ function M.stop(reset) if reset then client:reset() - state.chat:clear() + M.chat:clear() state.last_prompt = nil state.last_response = nil @@ -979,15 +971,15 @@ function M.load(name, history_path) }) client:reset() - state.chat:clear() - state.chat:load_history(history) + M.chat:clear() + M.chat:load_history(history) log.info('Loaded history from ' .. history_path) if #history > 0 then local last = history[#history] if last and last.role == 'user' then - state.chat:append('\n\n') - state.chat:finish() + M.chat:append('\n\n') + M.chat:finish() return end end @@ -1047,30 +1039,11 @@ function M.setup(config) state.highlights_loaded = false - local overlay_help = utils.key_to_info('close', M.config.mappings.close) - if state.overlay then - state.overlay:delete() - end - state.overlay = require('CopilotChat.ui.overlay')('copilot-overlay', overlay_help, function(bufnr) - map_key('close', bufnr, function() - state.overlay:restore(state.chat.winnr, state.chat.bufnr) - end) - end) - - if state.diff then - state.diff:delete() - end - state.diff = require('CopilotChat.ui.diff')(overlay_help, function(bufnr) - map_key('close', bufnr, function() - state.diff:restore(state.chat.winnr, state.chat.bufnr) - end) - end) - - if state.chat then - state.chat:close(state.source and state.source.bufnr or nil) - state.chat:delete() + if M.chat then + M.chat:close(state.source and state.source.bufnr or nil) + M.chat:delete() end - state.chat = require('CopilotChat.ui.chat')( + M.chat = require('CopilotChat.ui.chat')( M.config.question_header, M.config.answer_header, M.config.separator, diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 17ffb65c..af0a6f3e 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -54,6 +54,13 @@ end ---@field blocks table ---@field content string? +---@class CopilotChat.ui.Chat.overlayopts +---@field text string +---@field filetype string? +---@field syntax string? +---@field on_show fun(bufnr: number) +---@field on_hide fun(bufnr: number) + ---@class CopilotChat.ui.Chat : CopilotChat.ui.Overlay ---@field question_header string ---@field answer_header string @@ -62,6 +69,7 @@ end ---@field winnr number? ---@field spinner CopilotChat.ui.Spinner ---@field sections table +---@field overlay CopilotChat.ui.Overlay ---@field config CopilotChat.config.shared ---@field references table ---@field token_count number? @@ -79,6 +87,20 @@ local Chat = class(function(self, question_header, answer_header, separator, hel self.spinner = nil self.sections = {} + -- Create overlay + self.overlay = Overlay('copilot-overlay', 'q to close', function(bufnr) + vim.keymap.set('n', 'q', function() + self.overlay:restore(self.winnr, self.bufnr) + end) + + vim.api.nvim_create_autocmd({ 'BufHidden', 'BufDelete' }, { + buffer = bufnr, + callback = function() + self.overlay:restore(self.winnr, self.bufnr) + end, + }) + end) + -- Variables self.config = {} self.references = {} @@ -586,4 +608,13 @@ function Chat:finish() end end +---@param opts CopilotChat.ui.Chat.overlayopts +function Chat:show_overlay(opts) + if not self:visible() then + return + end + + self.overlay:show(opts.text, self.winnr, opts.filetype, opts.syntax, opts.on_show, opts.on_hide) +end + return Chat diff --git a/lua/CopilotChat/ui/diff.lua b/lua/CopilotChat/ui/diff.lua deleted file mode 100644 index e6590404..00000000 --- a/lua/CopilotChat/ui/diff.lua +++ /dev/null @@ -1,114 +0,0 @@ -local Overlay = require('CopilotChat.ui.overlay') -local utils = require('CopilotChat.utils') -local class = utils.class - ----@class CopilotChat.ui.Diff.Diff ----@field change string ----@field reference string ----@field filename string ----@field filetype string ----@field start_line number ----@field end_line number ----@field bufnr number? - ----@class CopilotChat.ui.Diff : CopilotChat.ui.Overlay ----@field hl_ns number ----@field diff CopilotChat.ui.Diff.Diff? ----@field augroup number -local Diff = class(function(self, help, on_buf_create) - Overlay.init(self, 'copilot-diff', help, on_buf_create) - self.hl_ns = vim.api.nvim_create_namespace('copilot-chat-highlights') - vim.api.nvim_set_hl(self.hl_ns, '@diff.plus', { bg = utils.blend_color('DiffAdd', 20) }) - vim.api.nvim_set_hl(self.hl_ns, '@diff.minus', { bg = utils.blend_color('DiffDelete', 20) }) - vim.api.nvim_set_hl(self.hl_ns, '@diff.delta', { bg = utils.blend_color('DiffChange', 20) }) - - self.augroup = vim.api.nvim_create_augroup('CopilotChatDiff', { clear = true }) - self.diff = nil -end, Overlay) - ----@param diff CopilotChat.ui.Diff.Diff ----@param winnr number ----@param full_diff boolean -function Diff:show(diff, winnr, full_diff) - self.diff = diff - self:validate() - vim.api.nvim_win_set_hl_ns(winnr, self.hl_ns) - - if not full_diff then - -- Create unified diff view - Overlay.show( - self, - 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, - })), - winnr, - diff.filetype, - 'diff' - ) - - return - end - - -- Create modified version by applying the change - local modified = {} - if utils.buf_valid(diff.bufnr) then - modified = vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) - end - local change_lines = vim.split(diff.change, '\n') - - -- Replace the lines in the modified content - if #modified > 0 then - local start_idx = diff.start_line - 1 - local end_idx = diff.end_line - 1 - for _ = start_idx, end_idx do - table.remove(modified, start_idx) - end - for i, line in ipairs(change_lines) do - table.insert(modified, start_idx + i - 1, line) - end - else - modified = change_lines - end - - Overlay.show(self, table.concat(modified, '\n'), winnr, diff.filetype) - - if utils.buf_valid(diff.bufnr) then - vim.cmd('diffthis') - vim.api.nvim_set_current_win(vim.fn.bufwinid(diff.bufnr)) - vim.api.nvim_win_set_cursor(0, { diff.start_line, 0 }) - vim.cmd('diffthis') - vim.api.nvim_set_current_win(winnr) - vim.api.nvim_win_set_cursor(winnr, { diff.start_line, 0 }) - - -- Link diff buffers lifecycle - vim.api.nvim_create_autocmd('BufWipeout', { - group = self.augroup, - buffer = self.bufnr, - callback = function() - vim.cmd('diffoff') - end, - }) - end -end - ----@param winnr number ----@param bufnr number -function Diff:restore(winnr, bufnr) - vim.cmd('diffoff') - Overlay.restore(self, winnr, bufnr) - vim.api.nvim_win_set_hl_ns(winnr, 0) -end - ----@return CopilotChat.ui.Diff.Diff? -function Diff:get_diff() - return self.diff -end - -return Diff diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index b5d15317..11929022 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -5,14 +5,24 @@ local class = utils.class ---@field name string ---@field help string ---@field help_ns number +---@field hl_ns number ---@field on_buf_create fun(bufnr: number) ---@field bufnr number? +---@field cursor integer[]? +---@field on_hide? fun(bufnr: number) local Overlay = class(function(self, name, help, on_buf_create) self.name = name self.help = help self.help_ns = vim.api.nvim_create_namespace('copilot-chat-help') self.on_buf_create = on_buf_create self.bufnr = nil + self.cursor = nil + self.on_hide = nil + + self.hl_ns = vim.api.nvim_create_namespace('copilot-chat-highlights') + vim.api.nvim_set_hl(self.hl_ns, '@diff.plus', { bg = utils.blend_color('DiffAdd', 20) }) + vim.api.nvim_set_hl(self.hl_ns, '@diff.minus', { bg = utils.blend_color('DiffDelete', 20) }) + vim.api.nvim_set_hl(self.hl_ns, '@diff.delta', { bg = utils.blend_color('DiffChange', 20) }) end) ---@return number @@ -44,38 +54,61 @@ end ---@param winnr number ---@param filetype? string ---@param syntax string? -function Overlay:show(text, winnr, filetype, syntax) +---@param on_show? fun(bufnr: number) +---@param on_hide? fun(bufnr: number) +function Overlay:show(text, winnr, filetype, syntax, on_show, on_hide) if not text or vim.trim(text) == '' then return end self:validate() + vim.api.nvim_win_set_hl_ns(winnr, self.hl_ns) text = text .. '\n' + self.cursor = vim.api.nvim_win_get_cursor(winnr) vim.api.nvim_win_set_buf(winnr, self.bufnr) 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) - vim.api.nvim_win_set_cursor(winnr, { vim.api.nvim_buf_line_count(self.bufnr), 0 }) + vim.api.nvim_win_set_cursor(winnr, { 1, 0 }) - filetype = filetype or 'text' + filetype = filetype or 'markdown' syntax = syntax or filetype -- Dual mode with treesitter (for diffs for example) - local ok, parser = pcall(vim.treesitter.get_parser, self.bufnr, syntax) - if ok and parser then - vim.treesitter.start(self.bufnr, syntax) - vim.bo[self.bufnr].syntax = filetype + if filetype == syntax then + vim.bo[self.bufnr].filetype = filetype else - vim.bo[self.bufnr].syntax = syntax + local ok, parser = pcall(vim.treesitter.get_parser, self.bufnr, syntax) + if ok and parser then + vim.treesitter.start(self.bufnr, syntax) + vim.bo[self.bufnr].syntax = filetype + else + vim.bo[self.bufnr].syntax = syntax + end + end + + if on_show then + on_show(self.bufnr) end + + self.on_hide = on_hide end ---@param winnr number ---@param bufnr number? function Overlay:restore(winnr, bufnr) + if self.on_hide then + self.on_hide(self.bufnr) + end + vim.api.nvim_win_set_buf(winnr, bufnr or 0) + vim.api.nvim_win_set_hl_ns(winnr, 0) + + if self.cursor then + vim.api.nvim_win_set_cursor(winnr, self.cursor) + end end function Overlay:delete() From 71790fffea719dcfe33408c780725226e1827bf3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 5 Mar 2025 23:56:27 +0100 Subject: [PATCH 058/589] refactor(api): improve Chat class organization and privacy This commit restructures the CopilotChat API, particularly the Chat class in ui/chat.lua. Changes include: - Move method definitions to match class-like organization - Add JSDoc comments for public methods - Mark private/protected methods and properties - Rename method 'active()' to 'focused()' for clarity - Move class annotations from file start to declaration - Rename 'overlay' to 'chat_overlay' to avoid property/method conflict - Replace 'show_overlay()' method with simpler 'overlay()' name --- lua/CopilotChat/config/mappings.lua | 25 +- lua/CopilotChat/config/prompts.lua | 11 +- lua/CopilotChat/config/providers.lua | 90 ++-- lua/CopilotChat/init.lua | 27 +- lua/CopilotChat/ui/chat.lua | 593 ++++++++++++++------------- lua/CopilotChat/ui/overlay.lua | 96 +++-- lua/CopilotChat/ui/spinner.lua | 13 +- 7 files changed, 447 insertions(+), 408 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index cd5f9201..69976dac 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -1,4 +1,8 @@ ----@class CopilotChat.ui.Diff.Diff +local async = require('plenary.async') +local copilot = require('CopilotChat') +local utils = require('CopilotChat.utils') + +---@class CopilotChat.config.mappings.diff ---@field change string ---@field reference string ---@field filename string @@ -7,12 +11,9 @@ ---@field end_line number ---@field bufnr number? -local async = require('plenary.async') -local copilot = require('CopilotChat') -local utils = require('CopilotChat.utils') - +--- Get diff data from a block ---@param block CopilotChat.ui.Chat.Section.Block? ----@return CopilotChat.ui.Diff.Diff? +---@return CopilotChat.config.mappings.diff? local function get_diff(block) -- If no block found, return nil if not block then @@ -71,9 +72,9 @@ local function get_diff(block) end --- Prepare a buffer for applying a diff ----@param diff CopilotChat.ui.Diff.Diff? +---@param diff CopilotChat.config.mappings.diff? ---@param source CopilotChat.source? ----@return CopilotChat.ui.Diff.Diff? +---@return CopilotChat.config.mappings.diff? local function prepare_diff_buffer(diff, source) if not diff then return diff @@ -396,7 +397,7 @@ return { })) end - copilot.chat:show_overlay(opts) + copilot.chat:overlay(opts) end, }, @@ -442,7 +443,7 @@ return { table.insert(lines, '') end - copilot.chat:show_overlay({ + copilot.chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) end) @@ -491,7 +492,7 @@ return { end utils.schedule_main() - copilot.chat:show_overlay({ + copilot.chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) end) @@ -527,7 +528,7 @@ return { end end - copilot.chat:show_overlay({ + copilot.chat:overlay({ text = chat_help, }) end, diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index d92374c6..bfd9cdbc 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -1,8 +1,3 @@ ----@class CopilotChat.config.prompt : CopilotChat.config.shared ----@field prompt string? ----@field description string? ----@field mapping string? - local base = string.format( [[ When asked for your name, you must respond with "GitHub Copilot". @@ -14,6 +9,7 @@ Keep your answers short and impersonal. The user works in an IDE called Neovim which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal. The user is working on a %s machine. Please respond with system specific commands if applicable. You will receive code snippets that include line number prefixes - use these to maintain correct position references but remove them when generating output. +If you don't have sufficient context to answer accurately, ask for specific additional information rather than making assumptions. When presenting code changes: @@ -81,6 +77,11 @@ 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.prompt : CopilotChat.config.shared +---@field prompt string? +---@field description string? +---@field mapping string? + ---@type table return { COPILOT_INSTRUCTIONS = { diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 443d9af5..34039fcd 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,50 +1,5 @@ local utils = require('CopilotChat.utils') ----@class CopilotChat.Provider.model ----@field id string ----@field name string ----@field tokenizer string? ----@field max_input_tokens number? ----@field max_output_tokens number? - ----@class CopilotChat.Provider.agent ----@field id string ----@field name string ----@field description string? - ----@class CopilotChat.Provider.embed ----@field index number ----@field embedding table - ----@class CopilotChat.Provider.options ----@field model CopilotChat.Provider.model ----@field agent CopilotChat.Provider.agent? ----@field temperature number? - ----@class CopilotChat.Provider.input ----@field role string ----@field content string - ----@class CopilotChat.Provider.reference ----@field name string ----@field url string - ----@class CopilotChat.Provider.output ----@field content string ----@field finish_reason string? ----@field total_tokens number? ----@field references table? - ----@class CopilotChat.Provider ----@field disabled nil|boolean ----@field get_headers nil|fun():table,number? ----@field get_agents nil|fun(headers:table):table ----@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.Provider.options):table ----@field prepare_output nil|fun(output:table, opts:CopilotChat.Provider.options):CopilotChat.Provider.output ----@field get_url nil|fun(opts:CopilotChat.Provider.options):string - local EDITOR_VERSION = 'Neovim/' .. vim.version().major .. '.' @@ -117,6 +72,51 @@ local function get_github_token() error('Failed to find GitHub token') end +---@class CopilotChat.Provider.model +---@field id string +---@field name string +---@field tokenizer string? +---@field max_input_tokens number? +---@field max_output_tokens number? + +---@class CopilotChat.Provider.agent +---@field id string +---@field name string +---@field description string? + +---@class CopilotChat.Provider.embed +---@field index number +---@field embedding table + +---@class CopilotChat.Provider.options +---@field model CopilotChat.Provider.model +---@field agent CopilotChat.Provider.agent? +---@field temperature number? + +---@class CopilotChat.Provider.input +---@field role string +---@field content string + +---@class CopilotChat.Provider.reference +---@field name string +---@field url string + +---@class CopilotChat.Provider.output +---@field content string +---@field finish_reason string? +---@field total_tokens number? +---@field references table? + +---@class CopilotChat.Provider +---@field disabled nil|boolean +---@field get_headers nil|fun():table,number? +---@field get_agents nil|fun(headers:table):table +---@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.Provider.options):table +---@field prepare_output nil|fun(output:table, opts:CopilotChat.Provider.options):CopilotChat.Provider.output +---@field get_url nil|fun(opts:CopilotChat.Provider.options):string + ---@type table local M = {} diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index af44374b..caee6012 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -4,11 +4,15 @@ local context = require('CopilotChat.context') local client = require('CopilotChat.client') local utils = require('CopilotChat.utils') -local M = {} local PLUGIN_NAME = 'CopilotChat' local WORD = '([^%s]+)' local WORD_INPUT = '([^%s:]+:`[^`]+`)' +---@class CopilotChat +---@field config CopilotChat.config +---@field chat CopilotChat.ui.Chat +local M = {} + --- @class CopilotChat.source --- @field bufnr number --- @field winnr number @@ -104,7 +108,7 @@ local function update_highlights() vim.api.nvim_buf_clear_namespace(buf, selection_ns, 0, -1) end - if M.chat.config.highlight_selection and M.chat:active() then + if M.chat.config.highlight_selection and M.chat:focused() then local selection = M.get_selection() if not selection @@ -141,6 +145,7 @@ local function update_highlights() end) end +--- Finish writing to chat buffer. ---@param start_of_chat boolean? local function finish(start_of_chat) if not start_of_chat then @@ -172,6 +177,7 @@ local function finish(start_of_chat) M.chat:finish() end +--- Show an error in the chat window. ---@param err string|table|nil ---@param append_newline boolean? local function show_error(err, append_newline) @@ -774,7 +780,7 @@ function M.ask(prompt, config) finish() end - if not M.chat:active() then + if not M.chat:focused() then M.open(config) end @@ -972,7 +978,20 @@ function M.load(name, history_path) client:reset() M.chat:clear() - M.chat:load_history(history) + + for i, message in ipairs(history) do + if message.role == 'user' then + if i > 1 then + M.chat:append('\n\n') + end + M.chat:append(M.config.question_header .. M.config.separator .. '\n\n') + M.chat:append(message.content) + elseif message.role == 'assistant' then + M.chat:append('\n\n' .. M.config.answer_header .. M.config.separator .. '\n\n') + M.chat:append(message.content) + end + end + log.info('Loaded history from ' .. history_path) if #history > 0 then diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index af0a6f3e..61af8c25 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -54,95 +54,51 @@ end ---@field blocks table ---@field content string? ----@class CopilotChat.ui.Chat.overlayopts ----@field text string ----@field filetype string? ----@field syntax string? ----@field on_show fun(bufnr: number) ----@field on_hide fun(bufnr: number) - ---@class CopilotChat.ui.Chat : CopilotChat.ui.Overlay ----@field question_header string ----@field answer_header string ----@field separator string ----@field header_ns number ---@field winnr number? ----@field spinner CopilotChat.ui.Spinner ----@field sections table ----@field overlay CopilotChat.ui.Overlay ---@field config CopilotChat.config.shared ----@field references table +---@field sections table +---@field references table ---@field token_count number? ---@field token_max_count number? +---@field private question_header string +---@field private answer_header string +---@field private separator string +---@field private header_ns number +---@field private spinner CopilotChat.ui.Spinner +---@field private chat_overlay CopilotChat.ui.Overlay local Chat = class(function(self, question_header, answer_header, separator, help, on_buf_create) Overlay.init(self, 'copilot-chat', help, on_buf_create) vim.treesitter.language.register('markdown', self.name) + self.winnr = nil + self.sections = {} + self.config = {} + self.references = {} + self.token_count = nil + self.token_max_count = nil + self.question_header = question_header self.answer_header = answer_header self.separator = separator - self.header_ns = vim.api.nvim_create_namespace('copilot-chat-headers') - self.winnr = nil - self.spinner = nil - self.sections = {} - -- Create overlay - self.overlay = Overlay('copilot-overlay', 'q to close', function(bufnr) + self.spinner = Spinner() + self.chat_overlay = Overlay('copilot-overlay', 'q to close', function(bufnr) vim.keymap.set('n', 'q', function() - self.overlay:restore(self.winnr, self.bufnr) + self.chat_overlay:restore(self.winnr, self.bufnr) end) vim.api.nvim_create_autocmd({ 'BufHidden', 'BufDelete' }, { buffer = bufnr, callback = function() - self.overlay:restore(self.winnr, self.bufnr) + self.chat_overlay:restore(self.winnr, self.bufnr) end, }) end) - - -- Variables - self.config = {} - self.references = {} - self.token_count = nil - self.token_max_count = nil end, Overlay) ----@return number -function Chat:create() - local bufnr = Overlay.create(self) - vim.bo[bufnr].syntax = 'markdown' - vim.bo[bufnr].textwidth = 0 - - vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { - buffer = bufnr, - callback = function() - utils.debounce(self.name, function() - self:render() - end, 100) - end, - }) - - if not self.spinner then - self.spinner = Spinner(bufnr) - else - self.spinner.bufnr = bufnr - end - - return bufnr -end - -function Chat:validate() - Overlay.validate(self) - if - self.winnr - and vim.api.nvim_win_is_valid(self.winnr) - and vim.api.nvim_win_get_buf(self.winnr) ~= self.bufnr - then - vim.api.nvim_win_set_buf(self.winnr, self.bufnr) - end -end - +--- Returns whether the chat window is visible. ---@return boolean function Chat:visible() return self.winnr @@ -151,151 +107,13 @@ function Chat:visible() or false end -function Chat:render() - vim.api.nvim_buf_clear_namespace(self.bufnr, self.header_ns, 0, -1) - local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) - local line_count = #lines - - local sections = {} - local current_section = nil - local current_block = nil - - for l, line in ipairs(lines) do - local separator_found = false - - if line == self.answer_header .. self.separator then - separator_found = true - if current_section then - current_section.end_line = l - 1 - current_section.content = vim.trim( - table.concat( - vim.list_slice(lines, current_section.start_line, current_section.end_line), - '\n' - ) - ) - table.insert(sections, current_section) - end - current_section = { - answer = true, - start_line = l + 1, - blocks = {}, - } - elseif line == self.question_header .. self.separator then - separator_found = true - if current_section then - current_section.end_line = l - 1 - current_section.content = vim.trim( - table.concat( - vim.list_slice(lines, current_section.start_line, current_section.end_line), - '\n' - ) - ) - table.insert(sections, current_section) - end - current_section = { - answer = false, - start_line = l + 1, - blocks = {}, - } - elseif l == line_count then - if current_section then - current_section.end_line = l - current_section.content = vim.trim( - table.concat( - vim.list_slice(lines, current_section.start_line, current_section.end_line), - '\n' - ) - ) - table.insert(sections, current_section) - end - end - - -- Highlight separators - if self.config.highlight_headers and separator_found then - local sep = vim.fn.strwidth(line) - vim.fn.strwidth(self.separator) - -- separator line - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, sep, { - virt_text_win_col = sep, - virt_text = { - { string.rep(self.separator, vim.go.columns), 'CopilotChatSeparator' }, - }, - priority = 100, - strict = false, - }) - -- header hl group - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, 0, { - end_col = sep + 1, - hl_group = 'CopilotChatHeader', - priority = 100, - strict = false, - }) - end - - -- Parse code blocks - if current_section and current_section.answer then - local filetype = line:match('^```(%w+)$') - if filetype and not current_block then - local filename, start_line, end_line = match_header(lines[l - 1]) - if not filename then - filename, start_line, end_line = match_header(lines[l - 2]) - end - filename = filename or 'code-block' - - current_block = { - header = { - filename = filename, - start_line = start_line, - end_line = end_line, - filetype = filetype, - }, - start_line = l + 1, - } - 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_section.blocks, current_block) - current_block = nil - end - end - end - - local last_section = sections[#sections] - if last_section and not last_section.answer 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_section.start_line - last_section.end_line - 1) - - if not utils.empty(self.references) and self.config.references_display == 'virtual' then - msg = 'References:\n' - for _, ref in ipairs(self.references) do - msg = msg .. ' ' .. ref.name .. '\n' - end - - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, last_section.start_line - 2, 0, { - hl_mode = 'combine', - priority = 100, - virt_lines_above = true, - virt_lines = vim.tbl_map(function(t) - return { { t, 'CopilotChatHelp' } } - end, vim.split(msg, '\n')), - }) - end - else - self:clear_help() - end - - self.sections = sections +--- Returns whether the chat window is focused. +---@return boolean +function Chat:focused() + return self:visible() and vim.api.nvim_get_current_win() == self.winnr end +--- Get the closest section to the cursor. ---@return CopilotChat.ui.Chat.Section? function Chat:get_closest_section() if not self:visible() then @@ -327,6 +145,7 @@ function Chat:get_closest_section() } end +--- Get the closest code block to the cursor. ---@return CopilotChat.ui.Chat.Section.Block? function Chat:get_closest_block() if not self:visible() then @@ -360,21 +179,7 @@ function Chat:get_closest_block() } end -function Chat:load_history(history) - for i, message in ipairs(history) do - if message.role == 'user' then - if i > 1 then - self:append('\n\n') - end - self:append(self.question_header .. self.separator .. '\n\n') - self:append(message.content) - elseif message.role == 'assistant' then - self:append('\n\n' .. self.answer_header .. self.separator .. '\n\n') - self:append(message.content) - end - end -end - +--- Get the prompt in the chat window. ---@return CopilotChat.ui.Chat.Section? function Chat:get_prompt() if not self:visible() then @@ -390,6 +195,7 @@ function Chat:get_prompt() return section end +--- Set the prompt in the chat window. ---@param prompt string? function Chat:set_prompt(prompt) if not self:visible() then @@ -408,76 +214,31 @@ function Chat:set_prompt(prompt) vim.bo[self.bufnr].modifiable = modifiable end ----@return boolean -function Chat:active() - return self:visible() and vim.api.nvim_get_current_win() == self.winnr -end - ----@return number, number, number -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 - ----@param str string -function Chat:append(str) - self:validate() - vim.bo[self.bufnr].modifiable = true - - if self:active() then - utils.return_to_normal_mode() - end - - if self.spinner then - self.spinner:start() - end +---@class CopilotChat.ui.Chat.show_overlay +---@field text string +---@field filetype string? +---@field syntax string? +---@field on_show? fun(bufnr: number) +---@field on_hide? fun(bufnr: number) - -- Decide if we should follow cursor after appending text. - local should_follow_cursor = self.config.auto_follow_cursor - if should_follow_cursor and self:visible() then - local current_pos = vim.api.nvim_win_get_cursor(self.winnr) - local line_count = vim.api.nvim_buf_line_count(self.bufnr) - -- Follow only if the cursor is currently at the last line. - should_follow_cursor = current_pos[1] == line_count +--- Show the overlay buffer. +---@param opts CopilotChat.ui.Chat.show_overlay +function Chat:overlay(opts) + if not self:visible() then + return end - local last_line, last_column, _ = self:last() - vim.api.nvim_buf_set_text( - self.bufnr, - last_line, - last_column, - last_line, - last_column, - vim.split(str, '\n') + self.chat_overlay:show( + opts.text, + self.winnr, + opts.filetype, + opts.syntax, + opts.on_show, + opts.on_hide ) - - if should_follow_cursor then - self:follow() - end - - vim.bo[self.bufnr].modifiable = false -end - -function Chat:clear() - self:validate() - self.references = {} - self.token_count = nil - self.token_max_count = nil - vim.bo[self.bufnr].modifiable = true - vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, {}) - vim.bo[self.bufnr].modifiable = false end +--- Open the chat window. ---@param config CopilotChat.config.shared function Chat:open(config) self:validate() @@ -551,13 +312,14 @@ function Chat:open(config) self:render() end +--- Close the chat window. ---@param bufnr number? function Chat:close(bufnr) if not self:visible() then return end - if self:active() then + if self:focused() then utils.return_to_normal_mode() end @@ -572,17 +334,19 @@ function Chat:close(bufnr) self.winnr = nil end +--- Focus the chat window. function Chat:focus() if not self:visible() then return end vim.api.nvim_set_current_win(self.winnr) - if self.config.auto_insert_mode and self:active() and vim.bo[self.bufnr].modifiable then + if self.config.auto_insert_mode and self:focused() and vim.bo[self.bufnr].modifiable then vim.cmd('startinsert') end end +--- Follow the cursor to the last line of the chat window. function Chat:follow() if not self:visible() then return @@ -596,6 +360,7 @@ function Chat:follow() vim.api.nvim_win_set_cursor(self.winnr, { last_line + 1, last_column }) end +--- Finish writing to the chat window. function Chat:finish() if not self.spinner then return @@ -603,18 +368,258 @@ function Chat:finish() self.spinner:finish() vim.bo[self.bufnr].modifiable = true - if self.config.auto_insert_mode and self:active() then + if self.config.auto_insert_mode and self:focused() then vim.cmd('startinsert') end end ----@param opts CopilotChat.ui.Chat.overlayopts -function Chat:show_overlay(opts) - if not self:visible() then - return +--- Append text to the chat window. +---@param str string +function Chat:append(str) + self:validate() + vim.bo[self.bufnr].modifiable = true + + if self:focused() then + utils.return_to_normal_mode() + end + + if self.spinner then + self.spinner:start() + end + + -- Decide if we should follow cursor after appending text. + local should_follow_cursor = self.config.auto_follow_cursor + if should_follow_cursor and self:visible() then + local current_pos = vim.api.nvim_win_get_cursor(self.winnr) + local line_count = vim.api.nvim_buf_line_count(self.bufnr) + -- Follow only if the cursor is currently at the last line. + should_follow_cursor = current_pos[1] == line_count + end + + local last_line, last_column, _ = self:last() + vim.api.nvim_buf_set_text( + self.bufnr, + last_line, + last_column, + last_line, + last_column, + vim.split(str, '\n') + ) + + if should_follow_cursor then + self:follow() + end + + vim.bo[self.bufnr].modifiable = false +end + +--- Clear the chat window. +function Chat:clear() + self:validate() + self.references = {} + self.token_count = nil + self.token_max_count = nil + vim.bo[self.bufnr].modifiable = true + vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, {}) + vim.bo[self.bufnr].modifiable = false +end + +--- Create the chat window buffer. +---@protected +function Chat:create() + local bufnr = Overlay.create(self) + vim.bo[bufnr].syntax = 'markdown' + vim.bo[bufnr].textwidth = 0 + + vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { + buffer = bufnr, + callback = function() + utils.debounce(self.name, function() + self:render() + end, 100) + end, + }) + + self.spinner.bufnr = bufnr + return bufnr +end + +--- Validate the chat window. +---@protected +function Chat:validate() + Overlay.validate(self) + if + self.winnr + and vim.api.nvim_win_is_valid(self.winnr) + and vim.api.nvim_win_get_buf(self.winnr) ~= self.bufnr + then + vim.api.nvim_win_set_buf(self.winnr, self.bufnr) + end +end + +--- Render the chat window. +---@protected +function Chat:render() + vim.api.nvim_buf_clear_namespace(self.bufnr, self.header_ns, 0, -1) + local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) + local line_count = #lines + + local sections = {} + local current_section = nil + local current_block = nil + + for l, line in ipairs(lines) do + local separator_found = false + + if line == self.answer_header .. self.separator then + separator_found = true + if current_section then + current_section.end_line = l - 1 + current_section.content = vim.trim( + table.concat( + vim.list_slice(lines, current_section.start_line, current_section.end_line), + '\n' + ) + ) + table.insert(sections, current_section) + end + current_section = { + answer = true, + start_line = l + 1, + blocks = {}, + } + elseif line == self.question_header .. self.separator then + separator_found = true + if current_section then + current_section.end_line = l - 1 + current_section.content = vim.trim( + table.concat( + vim.list_slice(lines, current_section.start_line, current_section.end_line), + '\n' + ) + ) + table.insert(sections, current_section) + end + current_section = { + answer = false, + start_line = l + 1, + blocks = {}, + } + elseif l == line_count then + if current_section then + current_section.end_line = l + current_section.content = vim.trim( + table.concat( + vim.list_slice(lines, current_section.start_line, current_section.end_line), + '\n' + ) + ) + table.insert(sections, current_section) + end + end + + -- Highlight separators + if self.config.highlight_headers and separator_found then + local sep = vim.fn.strwidth(line) - vim.fn.strwidth(self.separator) + -- separator line + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, sep, { + virt_text_win_col = sep, + virt_text = { + { string.rep(self.separator, vim.go.columns), 'CopilotChatSeparator' }, + }, + priority = 100, + strict = false, + }) + -- header hl group + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, 0, { + end_col = sep + 1, + hl_group = 'CopilotChatHeader', + priority = 100, + strict = false, + }) + end + + -- Parse code blocks + if current_section and current_section.answer then + local filetype = line:match('^```(%w+)$') + if filetype and not current_block then + local filename, start_line, end_line = match_header(lines[l - 1]) + if not filename then + filename, start_line, end_line = match_header(lines[l - 2]) + end + filename = filename or 'code-block' + + current_block = { + header = { + filename = filename, + start_line = start_line, + end_line = end_line, + filetype = filetype, + }, + start_line = l + 1, + } + 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_section.blocks, current_block) + current_block = nil + end + end end - self.overlay:show(opts.text, self.winnr, opts.filetype, opts.syntax, opts.on_show, opts.on_hide) + local last_section = sections[#sections] + if last_section and not last_section.answer 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_section.start_line - last_section.end_line - 1) + + if not utils.empty(self.references) and self.config.references_display == 'virtual' then + msg = 'References:\n' + for _, ref in ipairs(self.references) do + msg = msg .. ' ' .. ref.name .. '\n' + end + + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, last_section.start_line - 2, 0, { + hl_mode = 'combine', + priority = 100, + virt_lines_above = true, + virt_lines = vim.tbl_map(function(t) + return { { t, 'CopilotChatHelp' } } + end, vim.split(msg, '\n')), + }) + end + else + self:clear_help() + end + + self.sections = sections +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 diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 11929022..f0a46f87 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -2,54 +2,30 @@ local utils = require('CopilotChat.utils') local class = utils.class ---@class CopilotChat.ui.Overlay : Class ----@field name string ----@field help string ----@field help_ns number ----@field hl_ns number ----@field on_buf_create fun(bufnr: number) ---@field bufnr number? ----@field cursor integer[]? ----@field on_hide? fun(bufnr: number) +---@field protected name string +---@field protected help string +---@field private cursor integer[]? +---@field private on_buf_create fun(bufnr: number) +---@field private on_hide? fun(bufnr: number) +---@field private help_ns number +---@field private hl_ns number local Overlay = class(function(self, name, help, on_buf_create) + self.bufnr = nil self.name = name self.help = help - self.help_ns = vim.api.nvim_create_namespace('copilot-chat-help') - self.on_buf_create = on_buf_create - self.bufnr = nil self.cursor = nil + self.on_buf_create = on_buf_create self.on_hide = nil + self.help_ns = vim.api.nvim_create_namespace('copilot-chat-help') self.hl_ns = vim.api.nvim_create_namespace('copilot-chat-highlights') vim.api.nvim_set_hl(self.hl_ns, '@diff.plus', { bg = utils.blend_color('DiffAdd', 20) }) vim.api.nvim_set_hl(self.hl_ns, '@diff.minus', { bg = utils.blend_color('DiffDelete', 20) }) vim.api.nvim_set_hl(self.hl_ns, '@diff.delta', { bg = utils.blend_color('DiffChange', 20) }) end) ----@return number -function Overlay:create() - local bufnr = vim.api.nvim_create_buf(false, true) - vim.bo[bufnr].filetype = self.name - vim.bo[bufnr].modifiable = false - vim.api.nvim_buf_set_name(bufnr, self.name) - return bufnr -end - ----@return boolean -function Overlay:valid() - return utils.buf_valid(self.bufnr) -end - -function Overlay:validate() - if self:valid() then - return - end - - self.bufnr = self:create() - if self.on_buf_create then - self.on_buf_create(self.bufnr) - end -end - +--- Show the overlay buffer ---@param text string ---@param winnr number ---@param filetype? string @@ -96,8 +72,48 @@ function Overlay:show(text, winnr, filetype, syntax, on_show, on_hide) self.on_hide = on_hide end +--- Delete the overlay buffer +function Overlay:delete() + if self:valid() then + vim.api.nvim_buf_delete(self.bufnr, { force = true }) + end +end + +--- Create the overlay buffer +---@return number +---@protected +function Overlay:create() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.bo[bufnr].filetype = self.name + vim.bo[bufnr].modifiable = false + vim.api.nvim_buf_set_name(bufnr, self.name) + return bufnr +end + +--- Check if the overlay buffer is valid +---@return boolean +---@protected +function Overlay:valid() + return utils.buf_valid(self.bufnr) +end + +--- Validate the overlay buffer +---@protected +function Overlay:validate() + if self:valid() then + return + end + + self.bufnr = self:create() + if self.on_buf_create then + self.on_buf_create(self.bufnr) + end +end + +--- Restore the original buffer ---@param winnr number ---@param bufnr number? +---@protected function Overlay:restore(winnr, bufnr) if self.on_hide then self.on_hide(self.bufnr) @@ -111,14 +127,10 @@ function Overlay:restore(winnr, bufnr) end end -function Overlay:delete() - if self:valid() then - vim.api.nvim_buf_delete(self.bufnr, { force = true }) - end -end - +--- Show help message in the overlay ---@param msg string ---@param offset number +---@protected function Overlay:show_help(msg, offset) if not msg then return @@ -141,6 +153,8 @@ function Overlay:show_help(msg, offset) }) end +--- Clear help message from the overlay +---@protected function Overlay:clear_help() vim.api.nvim_buf_del_extmark(self.bufnr, self.help_ns, 1) end diff --git a/lua/CopilotChat/ui/spinner.lua b/lua/CopilotChat/ui/spinner.lua index 710b7e60..7deaed2c 100644 --- a/lua/CopilotChat/ui/spinner.lua +++ b/lua/CopilotChat/ui/spinner.lua @@ -1,7 +1,6 @@ local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local class = utils.class - local spinner_frames = { '⠋', '⠙', @@ -16,17 +15,17 @@ local spinner_frames = { } ---@class CopilotChat.ui.Spinner : Class ----@field ns number ---@field bufnr number ----@field timer table ----@field index number ---@field status string? +---@field private index number +---@field private timer table +---@field private ns number local Spinner = class(function(self, bufnr) - self.ns = vim.api.nvim_create_namespace('copilot-chat-spinner') self.bufnr = bufnr - self.timer = nil - self.index = 1 self.status = nil + self.index = 1 + self.timer = nil + self.ns = vim.api.nvim_create_namespace('copilot-chat-spinner') notify.listen(notify.STATUS, function(status) self.status = tostring(status) From 73fb30e4f159b336221671bbd26dc20d9d17f270 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 00:22:09 +0100 Subject: [PATCH 059/589] feat: add remember_as_sticky config option Add configuration option to control whether model/agent/context are remembered as sticky prompts when asking questions. This provides more control over the persistence behavior. Also added a 'grx' shortcut to clear all sticky prompts and changed the toggle sticky keymap from 'gr' to 'grr' to avoid conflicts. --- README.md | 1 + lua/CopilotChat/config.lua | 2 ++ lua/CopilotChat/config/mappings.lua | 34 ++++++++++++++++++++++++++++- lua/CopilotChat/init.lua | 10 ++++++--- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7e9f9faf..5c290638 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,7 @@ Below are all available configuration options with their default values: temperature = 0.1, -- GPT result temperature headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) callback = nil, -- Callback to use when ask response is received + remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions -- default window options window = { diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index c92a39fc..e9d60738 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -22,6 +22,7 @@ local select = require('CopilotChat.select') ---@field temperature number? ---@field headless boolean? ---@field callback fun(response: string, source: CopilotChat.source)? +---@field remember_as_sticky boolean? ---@field window CopilotChat.config.window? ---@field show_help boolean? ---@field show_folds boolean? @@ -65,6 +66,7 @@ return { temperature = 0.1, -- GPT result temperature headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) callback = nil, -- Callback to use when ask response is received + remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions -- default window options window = { diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 69976dac..9602da68 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -173,7 +173,7 @@ return { }, toggle_sticky = { - normal = 'gr', + normal = 'grr', callback = function() local section = copilot.chat:get_closest_section() if not section or section.answer then @@ -225,6 +225,38 @@ return { end, }, + clear_stickies = { + normal = 'grx', + callback = function() + local section = copilot.chat:get_closest_section() + if not section or section.answer then + return + end + + local lines = vim.split(section.content, '\n') + 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 + vim.api.nvim_buf_set_lines( + copilot.chat.bufnr, + section.start_line, + section.end_line - 1, + false, + new_lines + ) + end + end, + }, + accept_diff = { normal = '', insert = '', diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index caee6012..17f208c9 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -53,15 +53,19 @@ local function insert_sticky(prompt, config, override_sticky) lines = vim.split(vim.trim(table.concat(lines, '\n')), '\n') - if config.model and config.model ~= M.config.model then + if config.remember_as_sticky and config.model and config.model ~= M.config.model then stickies:set('$' .. config.model, true) end - if config.agent and config.agent ~= M.config.agent then + if config.remember_as_sticky and config.agent and config.agent ~= M.config.agent then stickies:set('@' .. config.agent, true) end - if config.context and not vim.deep_equal(config.context, M.config.context) then + if + config.remember_as_sticky + and config.context + and not vim.deep_equal(config.context, M.config.context) + then if type(config.context) == 'table' then ---@diagnostic disable-next-line: param-type-mismatch for _, context in ipairs(config.context) do From 9a8e277e14643af1cf7a44669fa239acbaeeca6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 23:24:19 +0000 Subject: [PATCH 060/589] 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 5e86e721..38a22657 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -504,6 +504,7 @@ Below are all available configuration options with their default values: temperature = 0.1, -- GPT result temperature headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) callback = nil, -- Callback to use when ask response is received + remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions -- default window options window = { From a6b1bbcefcc493f7ad16811fb9c628a5830de39d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 00:27:41 +0100 Subject: [PATCH 061/589] docs: update documentation after sticky key changes Signed-off-by: Tomas Slusny --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c290638..6c9fa92e 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,8 @@ Default mappings in the chat interface: | `` | `q` | Close the chat window | | `` | `` | Reset and clear the chat window | | `` | `` | Submit the current prompt | -| - | `gr` | Toggle sticky prompt for line under cursor | +| - | `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 | @@ -581,7 +582,10 @@ Below are all available configuration options with their default values: insert = '', }, toggle_sticky = { - normal = 'gr', + normal = 'grr', + }, + clear_stickies = { + normal = 'grx', }, accept_diff = { normal = '', From 4cc01589eb3945810eadf8486fb0fb5743209ece Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 23:28:46 +0000 Subject: [PATCH 062/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 38a22657..49da241f 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -194,7 +194,8 @@ Default mappings in the chat interface: q Close the chat window Reset and clear the chat window Submit the current prompt - - gr Toggle sticky prompt for line under cursor + - 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 @@ -633,7 +634,10 @@ Below are all available configuration options with their default values: insert = '', }, toggle_sticky = { - normal = 'gr', + normal = 'grr', + }, + clear_stickies = { + normal = 'grx', }, accept_diff = { normal = '', From 4965ceeecf3cc20995bd9a5e26be7d1b2d75b99d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 00:36:11 +0100 Subject: [PATCH 063/589] docs(readme): update selections configuration info Update README to clarify that selections for chat interactions can only be configured globally, not per-prompt. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6c9fa92e..4d8d7113 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ Define your own contexts in the configuration with input handling and resolution ## Selections -Selections determine the source content for chat interactions. Configure them globally or per-prompt. +Selections determine the source content for chat interactions. Can be configured only globally. Available selections are located in `local select = require("CopilotChat.select")`: From 929c67eccfef5badb51b309f009c1314c3458a52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 23:36:55 +0000 Subject: [PATCH 064/589] 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 49da241f..81337934 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -406,8 +406,8 @@ resolution: SELECTIONS *CopilotChat-selections* -Selections determine the source content for chat interactions. Configure them -globally or per-prompt. +Selections determine the source content for chat interactions. Can be +configured only globally. Available selections are located in `local select = require("CopilotChat.select")`: From 5a5476b3eba02ad557a3456faec101394d65b11b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 00:45:31 +0100 Subject: [PATCH 065/589] docs: remove copilot-diff from README as it doesnt exist anymore Signed-off-by: Tomas Slusny --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d8d7113..c2af818f 100644 --- a/README.md +++ b/README.md @@ -626,8 +626,7 @@ Below are all available configuration options with their default values: Types of copilot buffers: - `copilot-chat` - Main chat buffer -- `copilot-diff` - Diff overlay buffer -- `copilot-overlay` - Every other overlay buffer (e.g. help, info) +- `copilot-overlay` - Overlay buffers (e.g. help, info, diff) You can set local options for plugin buffers like this: From f73c6fa4a18b71129e92a28660090de3374ee01c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 5 Mar 2025 23:46:24 +0000 Subject: [PATCH 066/589] 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 81337934..5792deea 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -679,8 +679,7 @@ CUSTOMIZING BUFFERS *CopilotChat-customizing-buffers* Types of copilot buffers: - `copilot-chat` - Main chat buffer -- `copilot-diff` - Diff overlay buffer -- `copilot-overlay` - Every other overlay buffer (e.g. help, info) +- `copilot-overlay` - Overlay buffers (e.g. help, info, diff) You can set local options for plugin buffers like this: From a94c0ff7e7400232d2ffb5bc2113cdd4c327d26a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 01:16:51 +0100 Subject: [PATCH 067/589] feat: allow per-chat selection configurations Make selection behavior configurable per-chat instance rather than only globally. This enables different selection strategies for different chat sessions while maintaining backward compatibility with the global configuration. The selection field is moved higher in the config file for better visibility, and documentation is updated to reflect this change. --- README.md | 14 +++++++------- lua/CopilotChat/config.lua | 12 ++++++------ lua/CopilotChat/init.lua | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c2af818f..aa5c2434 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ Define your own contexts in the configuration with input handling and resolution ## Selections -Selections determine the source content for chat interactions. Can be configured only globally. +Selections determine the source content for chat interactions. Available selections are located in `local select = require("CopilotChat.select")`: @@ -455,6 +455,12 @@ Below are all available configuration options with their default values: callback = nil, -- Callback to use when ask response is received remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions + -- default selection + -- see select.lua for implementation + selection = function(source) + return select.visual(source) or select.buffer(source) + end, + -- default window options window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' @@ -496,12 +502,6 @@ Below are all available configuration options with their default values: error_header = '# Error ', -- Header to use for errors separator = '───', -- Separator to use in chat - -- default selection - -- see select.lua for implementation - selection = function(source) - return select.visual(source) or select.buffer(source) - end, - -- default providers -- see config/providers.lua for implementation providers = { diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index e9d60738..058fa634 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -23,6 +23,7 @@ local select = require('CopilotChat.select') ---@field headless boolean? ---@field callback fun(response: string, 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? @@ -47,7 +48,6 @@ local select = require('CopilotChat.select') ---@field answer_header string? ---@field error_header string? ---@field separator string? ----@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.selection? ---@field providers table? ---@field contexts table? ---@field prompts table? @@ -68,6 +68,11 @@ return { callback = nil, -- Callback to use when ask response is received remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions + -- default selection + selection = function(source) + return select.visual(source) or select.buffer(source) + end, + -- default window options window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' @@ -110,11 +115,6 @@ return { error_header = '## Error ', -- Header to use for errors separator = '───', -- Separator to use in chat - -- default selection - selection = function(source) - return select.visual(source) or select.buffer(source) - end, - -- default providers providers = require('CopilotChat.config.providers'), diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 17f208c9..6011bfd0 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -417,7 +417,7 @@ end --- Get the selection from the source buffer. ---@return CopilotChat.select.selection? function M.get_selection() - local selection = M.config.selection + local selection = M.chat.config.selection or M.config.selection local bufnr = state.source and state.source.bufnr local winnr = state.source and state.source.winnr From 3bdb9ca4a29688ca425eb0d524edd43fa859b9d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 6 Mar 2025 00:18:00 +0000 Subject: [PATCH 068/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 5792deea..e111ad97 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 05 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 06 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -406,8 +406,7 @@ resolution: SELECTIONS *CopilotChat-selections* -Selections determine the source content for chat interactions. Can be -configured only globally. +Selections determine the source content for chat interactions. Available selections are located in `local select = require("CopilotChat.select")`: @@ -507,6 +506,12 @@ Below are all available configuration options with their default values: callback = nil, -- Callback to use when ask response is received remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions + -- default selection + -- see select.lua for implementation + selection = function(source) + return select.visual(source) or select.buffer(source) + end, + -- default window options window = { layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' @@ -548,12 +553,6 @@ Below are all available configuration options with their default values: error_header = '# Error ', -- Header to use for errors separator = '───', -- Separator to use in chat - -- default selection - -- see select.lua for implementation - selection = function(source) - return select.visual(source) or select.buffer(source) - end, - -- default providers -- see config/providers.lua for implementation providers = { From b369d1d1af04db7039aa93d33a272d63977eaddd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 01:20:35 +0100 Subject: [PATCH 069/589] fix: properly merge selection configuration The change replaces the previous selection config resolver with a deep merge between the global and chat-specific configurations. This ensures all nested configuration values are properly merged instead of just using one or the other. Signed-off-by: Tomas Slusny --- 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 6011bfd0..8f5307b6 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -417,7 +417,8 @@ end --- Get the selection from the source buffer. ---@return CopilotChat.select.selection? function M.get_selection() - local selection = M.chat.config.selection or M.config.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 From de060aae1e5e60d3473629aae14e564d6e7fc6d3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 01:34:01 +0100 Subject: [PATCH 070/589] docs(readme): update API reference with complete function list Restructure the API documentation into logical sections for better readability and include additional API methods that were previously undocumented. Add new sections for: - Core chat functions - Chat window UI methods - More comprehensive examples This provides users with a clearer overview of the available functionality and how to use the plugin effectively. Signed-off-by: Tomas Slusny --- README.md | 122 ++++++++++++++++++---------- lua/CopilotChat/config/mappings.lua | 2 +- lua/CopilotChat/init.lua | 10 ++- 3 files changed, 87 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index aa5c2434..9faf97c7 100644 --- a/README.md +++ b/README.md @@ -656,64 +656,102 @@ Types of copilot highlights: # API Reference +## Core Chat Functions + ```lua 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_context() -- Resolve context embeddings +chat.resolve_agent() -- Resolve agent from prompt +chat.resolve_model() -- Resolve model from prompt + -- Window Management +chat.open(config) -- Open chat window with optional config +chat.close() -- Close chat window +chat.toggle(config) -- Toggle chat window visibility with optional config +chat.reset() -- Reset the chat +chat.stop() -- Stop current output + +-- Selection Management +chat.get_selection() -- Get the current selection +chat.set_selection(bufnr, start_line, end_line, clear) -- Set or clear selection + +-- Prompt & Context Management +chat.select_prompt(config) -- Open prompt selector with optional config +chat.select_model() -- Open model selector +chat.select_agent() -- Open agent 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(callback) -- Get completion items asynchronously --- Open chat window with optional config -chat.open({ - window = { - layout = 'float', - title = 'Custom Chat', - }, -}) +-- History Management +chat.save(name, history_path) -- Save chat history +chat.load(name, history_path) -- Load chat history + +-- Configuration +chat.setup(config) -- Update configuration +chat.log_level(level) -- Set log level (debug, info, etc.) +``` + +## Chat Window UI API + +You can also access the chat window UI methods through the `chat.chat` object: -chat.close() -- Close chat window -chat.toggle() -- Toggle chat window -chat.reset() -- Reset chat window -chat.stop() -- Stop current output +```lua +local chat = require("CopilotChat") --- Chat Interaction +-- Chat UI State +chat.chat:visible() -- Check if chat window is visible +chat.chat:focused() -- Check if chat window is focused + +-- Content Management +chat.chat:get_prompt() -- Get current prompt from chat window +chat.chat:set_prompt(prompt) -- Set prompt in chat window +chat.chat:append(text) -- Append text to chat window +chat.chat:clear() -- Clear chat window content +chat.chat:finish() -- Finish writing to chat window + +-- Navigation +chat.chat:follow() -- Move cursor to end of chat content +chat.chat:focus() -- Focus the chat window + +-- Advanced Features +chat.chat:get_closest_section() -- Get section closest to cursor +chat.chat:get_closest_block() -- Get code block closest to cursor +chat.chat:overlay(opts) -- Show overlay with specified options +``` --- Ask a question with optional config -chat.ask("Explain this code.", { - context = { 'buffers', 'files' }, +## Example Usage + +```lua +-- Open chat, ask a question and handle response +require("CopilotChat").open() +require("CopilotChat").ask("Explain this code", { callback = function(response) - print("Response:", response) + vim.notify("Got response: " .. response:sub(1, 50) .. "...") end, + context = "#buffer" }) -chat.select_model() -- Open model selector -chat.select_agent() -- Open agent selector +-- Save and load chat history +require("CopilotChat").save("my_debugging_session") +require("CopilotChat").load("my_debugging_session") --- Open prompt selector with optional config -chat.select_prompt({ - callback = function(response) - print("Response:", response) - end, -}) - --- History Management -chat.save("my_chat", "my_history_path") -- Save chat history with optional history path -chat.load("my_chat", "my_history_path") -- Load chat history with optional history path - --- Utilities -chat.response() -- Get last response -chat.log_level("debug") -- Set log level - --- Update config -chat.setup({ - model = 'gpt-4', - window = { - layout = 'float' - } +-- Use custom context and model +require("CopilotChat").ask("How can I optimize this?", { + model = "gpt-4o", + context = {"#buffer", "#git:staged"} }) ``` -# Examples - -For examples, see the [examples wiki page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/wiki/Examples-and-Tips). +For more examples, see the [examples wiki page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/wiki/Examples-and-Tips). # Development diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 9602da68..31066736 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(true) + copilot.trigger_complete() end, }, diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 8f5307b6..4e136cbb 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -464,8 +464,8 @@ function M.set_selection(bufnr, start_line, end_line, clear) end --- Trigger the completion for the chat window. ----@param with_context boolean? -function M.trigger_complete(with_context) +---@param without_context boolean? +function M.trigger_complete(without_context) local info = M.complete_info() local bufnr = vim.api.nvim_get_current_buf() local line = vim.api.nvim_get_current_line() @@ -481,7 +481,7 @@ function M.trigger_complete(with_context) return end - if with_context and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then + if not without_context and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then local found_context = M.config.contexts[prefix:sub(2, -2)] if found_context and found_context.input then async.run(function() @@ -1110,7 +1110,9 @@ function M.setup(config) local char = line:sub(col, col) if vim.tbl_contains(M.complete_info().triggers, char) then - utils.debounce('complete', M.trigger_complete, 100) + utils.debounce('complete', function() + M.trigger_complete(true) + end, 100) end end, }) From 9bf4799153c548cafc90af8f0db9845fc26f0c7e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 6 Mar 2025 00:39:58 +0000 Subject: [PATCH 071/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 149 ++++++++++++++++++++++++++++---------------- 1 file changed, 95 insertions(+), 54 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index e111ad97..cde0ee63 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -23,13 +23,15 @@ Table of Contents *CopilotChat-table-of-contents* - Customizing Buffers |CopilotChat-customizing-buffers| - Customizing Highlights |CopilotChat-customizing-highlights| 5. API Reference |CopilotChat-api-reference| -6. Examples |CopilotChat-examples| -7. Development |CopilotChat-development| + - Core Chat Functions |CopilotChat-core-chat-functions| + - Chat Window UI API |CopilotChat-chat-window-ui-api| + - Example Usage |CopilotChat-example-usage| +6. Development |CopilotChat-development| - Setup |CopilotChat-setup| - Contributing |CopilotChat-contributing| -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 is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: @@ -711,71 +713,110 @@ Types of copilot highlights: ============================================================================== 5. API Reference *CopilotChat-api-reference* + +CORE CHAT FUNCTIONS *CopilotChat-core-chat-functions* + >lua local chat = require("CopilotChat") - -- Window Management + -- 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_context() -- Resolve context embeddings + chat.resolve_agent() -- Resolve agent from prompt + chat.resolve_model() -- Resolve model from prompt - -- Open chat window with optional config - chat.open({ - window = { - layout = 'float', - title = 'Custom Chat', - }, - }) + -- Window Management + chat.open(config) -- Open chat window with optional config + chat.close() -- Close chat window + chat.toggle(config) -- Toggle chat window visibility with optional config + chat.reset() -- Reset the chat + chat.stop() -- Stop current output + + -- Selection Management + chat.get_selection() -- Get the current selection + chat.set_selection(bufnr, start_line, end_line, clear) -- Set or clear selection + + -- Prompt & Context Management + chat.select_prompt(config) -- Open prompt selector with optional config + chat.select_model() -- Open model selector + chat.select_agent() -- Open agent 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(callback) -- Get completion items asynchronously - chat.close() -- Close chat window - chat.toggle() -- Toggle chat window - chat.reset() -- Reset chat window - chat.stop() -- Stop current output + -- History Management + chat.save(name, history_path) -- Save chat history + chat.load(name, history_path) -- Load chat history - -- Chat Interaction + -- Configuration + chat.setup(config) -- Update configuration + chat.log_level(level) -- Set log level (debug, info, etc.) +< + + +CHAT WINDOW UI API *CopilotChat-chat-window-ui-api* + +You can also access the chat window UI methods through the `chat.chat` object: + +>lua + local chat = require("CopilotChat") - -- Ask a question with optional config - chat.ask("Explain this code.", { - context = { 'buffers', 'files' }, + -- Chat UI State + chat.chat:visible() -- Check if chat window is visible + chat.chat:focused() -- Check if chat window is focused + + -- Content Management + chat.chat:get_prompt() -- Get current prompt from chat window + chat.chat:set_prompt(prompt) -- Set prompt in chat window + chat.chat:append(text) -- Append text to chat window + chat.chat:clear() -- Clear chat window content + chat.chat:finish() -- Finish writing to chat window + + -- Navigation + chat.chat:follow() -- Move cursor to end of chat content + chat.chat:focus() -- Focus the chat window + + -- Advanced Features + chat.chat:get_closest_section() -- Get section closest to cursor + chat.chat:get_closest_block() -- Get code block closest to cursor + chat.chat:overlay(opts) -- Show overlay with specified options +< + + +EXAMPLE USAGE *CopilotChat-example-usage* + +>lua + -- Open chat, ask a question and handle response + require("CopilotChat").open() + require("CopilotChat").ask("Explain this code", { callback = function(response) - print("Response:", response) + vim.notify("Got response: " .. response:sub(1, 50) .. "...") end, + context = "#buffer" }) - chat.select_model() -- Open model selector - chat.select_agent() -- Open agent selector + -- Save and load chat history + require("CopilotChat").save("my_debugging_session") + require("CopilotChat").load("my_debugging_session") - -- Open prompt selector with optional config - chat.select_prompt({ - callback = function(response) - print("Response:", response) - end, - }) - - -- History Management - chat.save("my_chat", "my_history_path") -- Save chat history with optional history path - chat.load("my_chat", "my_history_path") -- Load chat history with optional history path - - -- Utilities - chat.response() -- Get last response - chat.log_level("debug") -- Set log level - - -- Update config - chat.setup({ - model = 'gpt-4', - window = { - layout = 'float' - } + -- Use custom context and model + require("CopilotChat").ask("How can I optimize this?", { + model = "gpt-4o", + context = {"#buffer", "#git:staged"} }) < - -============================================================================== -6. Examples *CopilotChat-examples* - -For examples, see the examples wiki page +For more examples, see the examples wiki page . ============================================================================== -7. Development *CopilotChat-development* +6. Development *CopilotChat-development* SETUP *CopilotChat-setup* @@ -815,7 +856,7 @@ See CONTRIBUTING.md for detailed guidelines. ============================================================================== -8. Contributors *CopilotChat-contributors* +7. Contributors *CopilotChat-contributors* Thanks goes to these wonderful people (emoji key ): @@ -826,12 +867,12 @@ Contributions of any kind are welcome! ============================================================================== -9. Stargazers *CopilotChat-stargazers* +8. Stargazers *CopilotChat-stargazers* ============================================================================== -10. Links *CopilotChat-links* +9. Links *CopilotChat-links* 1. *@jellydn*: 2. *@deathbeam*: From 773988055609930480df9cfafc3979b476273e79 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 6 Mar 2025 03:25:31 +0100 Subject: [PATCH 072/589] feat!: mark async functions with annotations Mark async functions with proper `---@async` annotations to clearly indicate which functions require running in an async context. Update complete_items to return directly instead of using a callback pattern, improving code clarity and consistency with other async functions. Functions now properly indicate when they require plenary.async.run in the API documentation. BREAKING CHANGE: chat.complete_items() is now async instead of using callback Signed-off-by: Tomas Slusny --- README.md | 8 +- lua/CopilotChat/init.lua | 151 ++++++++++++++++++++------------------ lua/CopilotChat/utils.lua | 7 ++ 3 files changed, 90 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 9faf97c7..137c2be9 100644 --- a/README.md +++ b/README.md @@ -665,9 +665,9 @@ 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_context() -- Resolve context embeddings -chat.resolve_agent() -- Resolve agent from prompt -chat.resolve_model() -- Resolve model from prompt +chat.resolve_context() -- Resolve context embeddings (WARN: async, requires plenary.async.run) +chat.resolve_agent() -- Resolve agent from prompt (WARN: async, requires plenary.async.run) +chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management chat.open(config) -- Open chat window with optional config @@ -689,7 +689,7 @@ 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(callback) -- Get completion items asynchronously +chat.complete_items() -- Get completion items (WARN: async, requires plenary.async.run) -- History Management chat.save(name, history_path) -- Save chat history diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 4e136cbb..a58ad606 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -134,7 +134,10 @@ local function update_highlights() return end - M.complete_items(function(items) + async.run(function() + local items = M.complete_items() + utils.schedule_main() + for _, item in ipairs(items) do local pattern = vim.fn.escape(item.word, '.-$^*[]') if vim.startswith(item.word, '#') then @@ -310,6 +313,7 @@ end ---@param prompt string? ---@param config CopilotChat.config.shared? ---@return table, string +---@async function M.resolve_context(prompt, config) config, prompt = M.resolve_prompt(prompt, config) @@ -372,6 +376,7 @@ end ---@param prompt string? ---@param config CopilotChat.config.shared? ---@return string, string +---@async function M.resolve_agent(prompt, config) config, prompt = M.resolve_prompt(prompt, config) @@ -395,6 +400,7 @@ end ---@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) @@ -504,7 +510,10 @@ function M.trigger_complete(without_context) return end - M.complete_items(function(items) + async.run(function() + local items = M.complete_items() + utils.schedule_main() + if vim.fn.mode() ~= 'i' then return end @@ -528,84 +537,82 @@ function M.complete_info() end --- Get the completion items for the chat window, for use with custom completion providers ----@param callback function(table) -function M.complete_items(callback) - async.run(function() - local models = client:list_models() - local agents = client:list_agents() - 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 +---@return table +---@async +function M.complete_items() + local models = client:list_models() + local agents = client:list_agents() + local prompts_to_use = M.prompts() + local items = {} - items[#items + 1] = { - word = '/' .. name, - abbr = name, - kind = kind, - info = info, - menu = prompt.description or '', - icase = 1, - dup = 0, - empty = 0, - } + 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 - 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 + items[#items + 1] = { + word = '/' .. name, + abbr = name, + kind = kind, + info = info, + menu = prompt.description or '', + icase = 1, + dup = 0, + empty = 0, + } + end - for _, agent in pairs(agents) do - items[#items + 1] = { - word = '@' .. agent.id, - abbr = agent.id, - kind = agent.provider, - info = agent.description, - menu = agent.name, - 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 - for name, value in pairs(M.config.contexts) do - items[#items + 1] = { - word = '#' .. name, - abbr = name, - kind = 'context', - menu = value.description or '', - icase = 1, - dup = 0, - empty = 0, - } - end + for _, agent in pairs(agents) do + items[#items + 1] = { + word = '@' .. agent.id, + abbr = agent.id, + kind = agent.provider, + info = agent.description, + menu = agent.name, + icase = 1, + dup = 0, + empty = 0, + } + 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) + for name, value in pairs(M.config.contexts) do + items[#items + 1] = { + word = '#' .. name, + abbr = name, + kind = 'context', + menu = value.description or '', + icase = 1, + dup = 0, + empty = 0, + } + end - utils.schedule_main() - callback(items) + 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. diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 3d67a9d4..62fe0669 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -322,6 +322,7 @@ 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) local args = { on_error = function(err) @@ -359,6 +360,7 @@ 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) local args = { callback = callback, @@ -411,6 +413,7 @@ end, 3) --- Scan a directory ---@param path string The directory path ---@param opts table The options +---@async M.scan_dir = async.wrap(function(path, opts, callback) scandir.scan_dir_async( path, @@ -428,6 +431,7 @@ end, 3) --- Get last modified time of a file ---@param path string The file path ---@return number? +---@async M.file_mtime = function(path) local err, stat = async.uv.fs_stat(path) if err or not stat then @@ -438,6 +442,7 @@ end --- Read a file ---@param path string The file path +---@async M.read_file = function(path) local err, fd = async.uv.fs_open(path, 'r', 438) if err or not fd then @@ -460,12 +465,14 @@ 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) --- Schedule a function only when needed (not on main thread) ---@param callback function The callback +---@async M.schedule_main = async.wrap(function(callback) if vim.in_fast_event() then -- In a fast event, need to schedule From 67ae3a376730e3740c62943779f8093103372169 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 6 Mar 2025 02:27:45 +0000 Subject: [PATCH 073/589] 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 cde0ee63..1b2a40c5 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -723,9 +723,9 @@ CORE CHAT FUNCTIONS *CopilotChat-core-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_context() -- Resolve context embeddings - chat.resolve_agent() -- Resolve agent from prompt - chat.resolve_model() -- Resolve model from prompt + chat.resolve_context() -- Resolve context embeddings (WARN: async, requires plenary.async.run) + chat.resolve_agent() -- Resolve agent from prompt (WARN: async, requires plenary.async.run) + chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management chat.open(config) -- Open chat window with optional config @@ -747,7 +747,7 @@ CORE CHAT FUNCTIONS *CopilotChat-core-chat-functions* -- Completion chat.trigger_complete() -- Trigger completion in chat window chat.complete_info() -- Get completion info for custom providers - chat.complete_items(callback) -- Get completion items asynchronously + chat.complete_items() -- Get completion items (WARN: async, requires plenary.async.run) -- History Management chat.save(name, history_path) -- Save chat history From 67b9165ee92cea274fa8648eb1995317264e1a87 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 00:12:43 +0100 Subject: [PATCH 074/589] fix: eliminate reference duplication with ordered map Use ordered_map instead of an array to prevent duplicate references when the same file is referenced multiple times. This improves how references are handled and displayed in the response by: - Including the current selection as a reference - Ensuring each file appears only once in references - Preserving reference order for consistent display Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 42bd034c..a8f42660 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -419,9 +419,15 @@ function Client:ask(prompt, opts) notify.publish(notify.STATUS, 'Generating request') - local references = {} + local references = utils.ordered_map() + if selection and selection.content then + references:set(selection.filename, { + name = utils.filename(selection.filename), + url = selection.filename, + }) + end for _, embed in ipairs(embeddings) do - table.insert(references, { + references:set(embed.filename, { name = utils.filename(embed.filename), url = embed.filename, }) @@ -530,7 +536,7 @@ function Client:ask(prompt, opts) if out.references then for _, reference in ipairs(out.references) do - table.insert(references, reference) + references:set(reference.name, reference) end end @@ -675,7 +681,10 @@ function Client:ask(prompt, opts) log.debug('History size increased to: ', #history) end - return full_response, references, last_message and last_message.total_tokens or 0, max_tokens + return full_response, + references:values(), + last_message and last_message.total_tokens or 0, + max_tokens end --- List available models From 41cb9d52ea26c4600424db6fe9b20c4e40545d5d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 01:28:44 +0100 Subject: [PATCH 075/589] feat: add description field to prompt items Add a description field to prompt items and prefer displaying it over the raw prompt in the UI selection menu. This improves readability and user experience by showing more concise descriptions instead of potentially long prompts. Signed-off-by: Tomas Slusny --- 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 a58ad606..0ff95171 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -749,6 +749,7 @@ function M.select_prompt(config) :map(function(name) return { name = name, + description = prompts[name].description, prompt = prompts[name].prompt, } end) @@ -760,7 +761,7 @@ function M.select_prompt(config) vim.ui.select(choices, { prompt = 'Select prompt action> ', format_item = function(item) - return string.format('%s: %s', item.name, item.prompt:gsub('\n', ' ')) + return string.format('%s: %s', item.name, item.description or item.prompt:gsub('\n', ' ')) end, }, function(choice) if choice then From 59d33ad506a9d8347c29ea121a46d844f92fd94d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 7 Mar 2025 00:29:51 +0000 Subject: [PATCH 076/589] 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 1b2a40c5..1aaffc2b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 06 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 07 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 4759cfc2dc373ebe1adb79e3fd7132c1beb6bfd7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 02:04:06 +0100 Subject: [PATCH 077/589] fix: reset diagnostics when stopping Copilot Chat When resetting Copilot Chat state, diagnostics weren't being cleared from the editor, which could lead to stale diagnostic information remaining visible. This change ensures diagnostics created by Copilot Chat are properly removed when stopping or resetting the chat. Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0ff95171..6001ec57 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -907,6 +907,7 @@ function M.stop(reset) if reset then client:reset() M.chat:clear() + vim.diagnostic.reset(vim.api.nvim_create_namespace('copilot-chat-diagnostics')) state.last_prompt = nil state.last_response = nil From 78a3b588d632471af6a3a6986bdb8e6f1267600a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 02:13:48 +0100 Subject: [PATCH 078/589] docs: add missing system context to README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 137c2be9..29cd4dff 100644 --- a/README.md +++ b/README.md @@ -532,6 +532,8 @@ Below are all available configuration options with their default values: }, quickfix = { }, + system = { + } }, -- default prompts From d826c1827179cde461256d9ce0fb3098bc00e332 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 7 Mar 2025 01:14:54 +0000 Subject: [PATCH 079/589] 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 1aaffc2b..2e14e6f3 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -585,6 +585,8 @@ Below are all available configuration options with their default values: }, quickfix = { }, + system = { + } }, -- default prompts From 4e23f171afd0667860537ae29d3708678114fd45 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 11:50:49 +0100 Subject: [PATCH 080/589] feat: add conversation memory summarization Implement a memory system that automatically summarizes chat history to maintain context across long conversations while managing token usage. The system: - Tracks conversation history and generates summaries when needed - Includes summarized memory in system prompts for context - Only loads recent messages after the last summarization point - Optimizes token usage by accounting for memory in calculations This reduces issues with context length limitations while preserving important information from previous interactions. Inspired by: https://github.com/yetone/avante.nvim/pull/1508 as it was pretty good idea, this PR takes it a bit further Signed-off-by: Tomas Slusny --- README.md | 3 +- lua/CopilotChat/client.lua | 125 ++++++++++++++++++++++++++++++++----- lua/CopilotChat/init.lua | 5 +- 3 files changed, 116 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 29cd4dff..19cc3d80 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: -- 🤖 Native GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) +- 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system - 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection - 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, and more) @@ -26,6 +26,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - 🎯 Powerful prompt system with composable templates and sticky prompts - 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) - ⚡ Efficient token usage with tiktoken optimization +- 📜 Intelligent chat memory management with automatic summarization to handle lengthy conversations # Requirements diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index a8f42660..7f0a1adf 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -1,12 +1,13 @@ ---@class CopilotChat.Client.ask ----@field headless boolean +---@field load_history boolean +---@field store_history boolean ---@field selection CopilotChat.select.selection? ---@field embeddings table? ---@field system_prompt string ---@field model string ----@field agent string +---@field agent string? ---@field temperature number ----@field on_progress fun(response: string):nil +---@field on_progress? fun(response: string):nil ---@class CopilotChat.Client.model : CopilotChat.Provider.model ---@field provider string @@ -14,6 +15,10 @@ ---@class CopilotChat.Client.agent : CopilotChat.Provider.agent ---@field provider string +---@class CopilotChat.Client.memory +---@field content string +---@field last_summarized_index number + local log = require('plenary.log') local tiktoken = require('CopilotChat.tiktoken') local notify = require('CopilotChat.notify') @@ -177,16 +182,29 @@ end --- Generate ask request --- @param history table +--- @param memory CopilotChat.Client.memory? --- @param prompt string --- @param system_prompt string --- @param generated_messages table -local function generate_ask_request(history, prompt, system_prompt, generated_messages) +local function generate_ask_request(history, memory, prompt, system_prompt, generated_messages) local messages = {} local contexts = {} - if system_prompt ~= '' then + local combined_system = system_prompt + if memory and memory.content and memory.content ~= '' then + if combined_system ~= '' then + combined_system = combined_system + .. '\n\n' + .. 'Context from previous conversation:\n' + .. memory.content + else + combined_system = 'Context from previous conversation:\n' .. memory.content + end + end + + if combined_system ~= '' then table.insert(messages, { - content = system_prompt, + content = combined_system, role = 'system', }) end @@ -247,6 +265,7 @@ end ---@field agents table? ---@field current_job string? ---@field headers table? +---@field memory CopilotChat.Client.memory? local Client = class(function(self) self.history = {} self.providers = {} @@ -255,6 +274,7 @@ local Client = class(function(self) self.agents = nil self.current_job = nil self.headers = nil + self.memory = nil end) --- Authenticate with GitHub and get the required headers @@ -357,7 +377,6 @@ function Client:ask(prompt, opts) opts.agent = nil end - local history = opts.headless and {} or self.history local embeddings = opts.embeddings or {} local selection = opts.selection or {} local system_prompt = vim.trim(opts.system_prompt) @@ -369,7 +388,6 @@ function Client:ask(prompt, opts) self.current_job = job_id log.trace('System prompt: ', system_prompt) - log.trace('History: ', #history) log.trace('Selection: ', selection.content) log.debug('Prompt: ', prompt) log.debug('Embeddings: ', #embeddings) @@ -419,6 +437,14 @@ function Client:ask(prompt, opts) notify.publish(notify.STATUS, 'Generating request') + local history = {} + if opts.load_history then + history = self.history + if self.memory and self.memory.last_summarized_index then + history = vim.list_slice(history, self.memory.last_summarized_index + 1) + end + end + local references = utils.ordered_map() if selection and selection.content then references:set(selection.filename, { @@ -438,17 +464,18 @@ function Client:ask(prompt, opts) local embeddings_messages = generate_embeddings_messages(embeddings) if max_tokens then - -- Count tokens from embeddings - local generated_tokens = 0 + -- Count tokens from selection messages + local selection_tokens = 0 for _, message in ipairs(selection_messages) do - generated_tokens = generated_tokens + tiktoken.count(message.content) + selection_tokens = selection_tokens + tiktoken.count(message.content) table.insert(generated_messages, message) end -- Count required tokens that we cannot reduce local prompt_tokens = tiktoken.count(prompt) local system_tokens = tiktoken.count(system_prompt) - local required_tokens = prompt_tokens + system_tokens + generated_tokens + local memory_tokens = self.memory and tiktoken.count(self.memory.content) or 0 + local required_tokens = prompt_tokens + system_tokens + selection_tokens + memory_tokens -- Reserve space for first embedding local reserved_tokens = #embeddings_messages > 0 @@ -542,7 +569,9 @@ function Client:ask(prompt, opts) if out.content then full_response = full_response .. out.content - on_progress(out.content) + if on_progress then + on_progress(out.content) + end end if out.finish_reason then @@ -596,7 +625,7 @@ function Client:ask(prompt, opts) local headers = self:authenticate(provider_name) local request = provider.prepare_input( - generate_ask_request(history, prompt, system_prompt, generated_messages), + generate_ask_request(history, self.memory, prompt, system_prompt, generated_messages), options ) local is_stream = request.stream @@ -666,7 +695,7 @@ function Client:ask(prompt, opts) log.trace('Full response: ', full_response) log.debug('Last message: ', last_message) - if not opts.headless then + if opts.store_history then table.insert(history, { content = prompt, role = 'user', @@ -839,5 +868,71 @@ function Client:load_providers(providers) end end +--- Summarize conversation memory to extract critical information +---@param model string The model to use for summarization +function Client:summarize_memory(model) + -- Exit early if there's not enough history + if #self.history < 4 then + return + end + + -- Get the model config to check token limits + local models = self:fetch_models() + local model_config = models[model] + if not model_config or not model_config.max_input_tokens then + log.warn('Cannot determine token limit for model: ' .. model) + return + end + + local max_tokens = model_config.max_input_tokens + local tokenizer = model_config.tokenizer or 'o200k_base' + tiktoken.load(tokenizer) + + -- Calculate current token usage + local token_count = 0 + for _, msg in ipairs(self.history) do + token_count = token_count + tiktoken.count(msg.content) + end + + -- Check if we need to summarize based on token count threshold + local threshold = math.floor(max_tokens * 0.6) + local last_index = self.memory and self.memory.last_summarized_index or 0 + local new_messages = #self.history - last_index + + -- Only summarize if either token count is high or we have enough new messages + if token_count < threshold and new_messages < 4 then + return + end + + local system_prompt = + [[Summarize the following conversation to extract the most critical information +for effective context in subsequent conversations. Focus on: +1. Technical details: languages, frameworks, libraries, and specific technologies discussed +2. Context: user's project structure, goals, constraints, and preferences +3. Implementation details: patterns, approaches, or solutions that were discussed +4. Important decisions or conclusions reached + +If the conversation includes previous memory summaries, integrate that information carefully. +Be concise but comprehensive, prioritizing technical accuracy over conversational elements. +Format your response as a structured summary, not as a narrative.]] + + notify.publish(notify.STATUS, string.format('Summarizing memory (%d messages)', #self.history)) + + local response = self:ask('Summarize chat history', { + load_history = true, + store_history = false, + model = model, + temperature = 0, + system_prompt = system_prompt, + }) + + if response then + self.memory = { + content = vim.trim(response), + last_summarized_index = #self.history, + } + end +end + --- @type CopilotChat.Client return Client() diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 6001ec57..8523745d 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -837,9 +837,11 @@ function M.ask(prompt, config) return end + pcall(client.summarize_memory, client, selected_model) local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, { - headless = config.headless, + load_history = not config.headless, + store_history = not config.headless, selection = selection, embeddings = filtered_embeddings, system_prompt = system_prompt, @@ -993,6 +995,7 @@ function M.load(name, history_path) client:reset() M.chat:clear() + client.history = history for i, message in ipairs(history) do if message.role == 'user' then if i > 1 then From 8ce5c96aa0c6aa939d55998e88c9e1aebc9d852d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 7 Mar 2025 10:59:04 +0000 Subject: [PATCH 081/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 2e14e6f3..7a00c4e5 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -36,7 +36,7 @@ Table of Contents *CopilotChat-table-of-contents* CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: -- 🤖 Native GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) +- 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system - 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection - 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, and more) @@ -44,6 +44,7 @@ capabilities directly into your editor. It provides: - 🎯 Powerful prompt system with composable templates and sticky prompts - 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) - ⚡ Efficient token usage with tiktoken optimization +- 📜 Intelligent chat memory management with automatic summarization to handle lengthy conversations ============================================================================== From eb3a7986c4f03be37e947fd241fab0fdd0d6600b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 12:32:02 +0100 Subject: [PATCH 082/589] refactor(prompts): use proper fields for prompt settings Instead of sticky use context/system_prompt and rely on remember_as_sticky configuration for this to be remembered. Also add support for resolving system_prompts by name Signed-off-by: Tomas Slusny --- README.md | 6 +++--- lua/CopilotChat/config/prompts.lua | 6 +++--- lua/CopilotChat/init.lua | 13 +++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 19cc3d80..6e652d38 100644 --- a/README.md +++ b/README.md @@ -542,11 +542,11 @@ Below are all available configuration options with their default values: prompts = { Explain = { prompt = 'Write an explanation for the selected code as paragraphs of text.', - sticky = '/COPILOT_EXPLAIN', + system_prompt = 'COPILOT_EXPLAIN', }, Review = { prompt = 'Review the selected code.', - sticky = '/COPILOT_REVIEW', + system_prompt = 'COPILOT_REVIEW', }, Fix = { prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', @@ -562,7 +562,7 @@ Below are all available configuration options with their default values: }, 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 = '#git:staged', + context = 'git:staged', }, }, diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index bfd9cdbc..05775607 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -98,12 +98,12 @@ return { Explain = { prompt = 'Write an explanation for the selected code as paragraphs of text.', - sticky = '/COPILOT_EXPLAIN', + system_prompt = 'COPILOT_EXPLAIN', }, Review = { prompt = 'Review the selected code.', - sticky = '/COPILOT_REVIEW', + system_prompt = 'COPILOT_REVIEW', callback = function(response, source) local diagnostics = {} for line in response:gmatch('[^\r\n]+') do @@ -163,6 +163,6 @@ return { 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 = '#git:staged', + context = 'git:staged', }, } diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 8523745d..1c563096 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -61,6 +61,15 @@ local function insert_sticky(prompt, config, override_sticky) stickies:set('@' .. config.agent, true) end + if + config.remember_as_sticky + and config.system_prompt + and config.system_prompt ~= M.config.system_prompt + and M.config.prompts[config.system_prompt] + then + stickies:set('/' .. config.system_prompt, true) + end + if config.remember_as_sticky and config.context @@ -628,6 +637,10 @@ function M.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 From c85b11db405a8e07999bb95dd7c5724707aabda8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 7 Mar 2025 11:33:53 +0000 Subject: [PATCH 083/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 7a00c4e5..894f763b 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -595,11 +595,11 @@ Below are all available configuration options with their default values: prompts = { Explain = { prompt = 'Write an explanation for the selected code as paragraphs of text.', - sticky = '/COPILOT_EXPLAIN', + system_prompt = 'COPILOT_EXPLAIN', }, Review = { prompt = 'Review the selected code.', - sticky = '/COPILOT_REVIEW', + system_prompt = 'COPILOT_REVIEW', }, Fix = { prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', @@ -615,7 +615,7 @@ Below are all available configuration options with their default values: }, 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 = '#git:staged', + context = 'git:staged', }, }, From ccde7a50e8e35588caefd9165c152952f4c8c64e Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 13:30:47 +0100 Subject: [PATCH 084/589] feat: at least open chat when :CopilotChat input is empty Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 1c563096..265fd377 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1190,7 +1190,12 @@ function M.setup(config) end vim.api.nvim_create_user_command('CopilotChat', function(args) - M.ask(args.args) + local input = args.args + if input and vim.trim(input) ~= '' then + M.ask(input) + else + M.open() + end end, { nargs = '*', force = true, From 1325a421abe507934bf6a4847965d5250f6ba872 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 15:00:04 +0100 Subject: [PATCH 085/589] docs: show documentation also for close in chat Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 31066736..b3fee178 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -552,11 +552,9 @@ return { return a < b end) for _, name in ipairs(chat_keys) do - if name ~= 'close' then - local info = utils.key_to_info(name, copilot.config.mappings[name], '`') - if info ~= '' then - chat_help = chat_help .. info .. '\n' - end + local info = utils.key_to_info(name, copilot.config.mappings[name], '`') + if info ~= '' then + chat_help = chat_help .. info .. '\n' end end From 24617d120a719596eed21555757e6297454aeee0 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 18:08:41 +0100 Subject: [PATCH 086/589] refactor: clean up utils module structure and function format - Move M.curl_args initialization to top of file with default values - Remove unused uuid and machine_id functions - Convert function expressions to proper function declarations - Remove redundant DEFAULT_REQUEST_ARGS constant - Reorder some functions for better organization --- lua/CopilotChat/utils.lua | 57 ++++++++++----------------------------- 1 file changed, 14 insertions(+), 43 deletions(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 62fe0669..7b0f5ed1 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -2,7 +2,9 @@ local async = require('plenary.async') local curl = require('plenary.curl') local scandir = require('plenary.scandir') -local DEFAULT_REQUEST_ARGS = { +local M = {} +M.timers = {} +M.curl_args = { timeout = 30000, raw = { '--retry', @@ -19,10 +21,6 @@ local DEFAULT_REQUEST_ARGS = { }, } -local M = {} -M.timers = {} -M.curl_args = {} - ---@class Class ---@field new fun(...):table ---@field init fun(self, ...) @@ -226,31 +224,6 @@ function M.filepath(filename) return vim.fn.fnamemodify(filename, ':p:.') end ---- Generate a UUID ----@return string -function M.uuid() - local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' - return ( - string.gsub(template, '[xy]', function(c) - local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb) - return string.format('%x', v) - end) - ) -end - ---- Generate machine id ----@return string -function M.machine_id() - local length = 65 - local hex_chars = '0123456789abcdef' - local hex = '' - for _ = 1, length do - local index = math.random(1, #hex_chars) - hex = hex .. hex_chars:sub(index, index) - end - return hex -end - --- Generate a quick hash ---@param str string The string to hash ---@return string @@ -293,18 +266,10 @@ function M.win_cwd(winnr) return dir end ---- Store curl global arguments ----@param args table The arguments ----@return table -M.curl_store_args = function(args) - M.curl_args = vim.tbl_deep_extend('force', M.curl_args, args) - return M.curl_args -end - --- Decode json ---@param body string The json string ---@return table, string? -M.json_decode = function(body) +function M.json_decode(body) local ok, data = pcall(vim.json.decode, body, { luanil = { object = true, @@ -319,6 +284,14 @@ M.json_decode = function(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 @@ -330,7 +303,6 @@ M.curl_get = async.wrap(function(url, opts, callback) end, } - args = vim.tbl_deep_extend('force', DEFAULT_REQUEST_ARGS, args) args = vim.tbl_deep_extend('force', M.curl_args, args) args = vim.tbl_deep_extend('force', args, opts or {}) @@ -369,7 +341,6 @@ M.curl_post = async.wrap(function(url, opts, callback) end, } - args = vim.tbl_deep_extend('force', DEFAULT_REQUEST_ARGS, args) args = vim.tbl_deep_extend('force', M.curl_args, args) args = vim.tbl_deep_extend('force', args, opts or {}) @@ -432,7 +403,7 @@ end, 3) ---@param path string The file path ---@return number? ---@async -M.file_mtime = function(path) +function M.file_mtime(path) local err, stat = async.uv.fs_stat(path) if err or not stat then return nil @@ -443,7 +414,7 @@ end --- Read a file ---@param path string The file path ---@async -M.read_file = function(path) +function M.read_file(path) local err, fd = async.uv.fs_open(path, 'r', 438) if err or not fd then return nil From 908b53d0ac99e47b384f772f28a76949537676e8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 18:33:45 +0100 Subject: [PATCH 087/589] fix: add back uuid Signed-off-by: Tomas Slusny --- lua/CopilotChat/utils.lua | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 7b0f5ed1..35f98c2f 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -224,6 +224,18 @@ function M.filepath(filename) return vim.fn.fnamemodify(filename, ':p:.') end +--- Generate a UUID +---@return string +function M.uuid() + local template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + return ( + string.gsub(template, '[xy]', function(c) + local v = (c == 'x') and math.random(0, 0xf) or math.random(8, 0xb) + return string.format('%x', v) + end) + ) +end + --- Generate a quick hash ---@param str string The string to hash ---@return string From 61c5917471b37ae2dee2487ba2803f062e746e03 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 7 Mar 2025 22:35:07 +0100 Subject: [PATCH 088/589] fix: correct history storage in client The commit fixes two issues: - Updates direct access to history table to use self.history in the client module, removing redundant reassignment - Adds a display for memory content in the chat overlay when available This improves both correctness and usability of the Copilot Chat plugin. --- lua/CopilotChat/client.lua | 7 ++----- lua/CopilotChat/config/mappings.lua | 11 +++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 7f0a1adf..ecce0be5 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -696,18 +696,15 @@ function Client:ask(prompt, opts) log.debug('Last message: ', last_message) if opts.store_history then - table.insert(history, { + table.insert(self.history, { content = prompt, role = 'user', }) - table.insert(history, { + table.insert(self.history, { content = full_response, role = 'assistant', }) - - self.history = history - log.debug('History size increased to: ', #history) end return full_response, diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index b3fee178..ff409593 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -1,5 +1,6 @@ local async = require('plenary.async') local copilot = require('CopilotChat') +local client = require('CopilotChat.client') local utils = require('CopilotChat.utils') ---@class CopilotChat.config.mappings.diff @@ -475,6 +476,16 @@ return { table.insert(lines, '') end + if client.memory then + table.insert(lines, '**Memory**') + table.insert(lines, '```') + for _, line in ipairs(vim.split(vim.trim(client.memory.content), '\n')) do + table.insert(lines, line) + end + table.insert(lines, '```') + table.insert(lines, '') + end + copilot.chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) From 7ba905eefb9f42ac7de20607b29f54b922750de8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 8 Mar 2025 00:41:21 +0100 Subject: [PATCH 089/589] feat: send diagnostics with buffer context as well on top of sending them with selection send them also with buffer context Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 59 ++++++++++++++++++++++++------------- lua/CopilotChat/context.lua | 2 ++ lua/CopilotChat/select.lua | 46 ++++------------------------- lua/CopilotChat/utils.lua | 36 ++++++++++++++++++++++ 4 files changed, 81 insertions(+), 62 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index ecce0be5..23740509 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -101,6 +101,26 @@ local function generate_content_block(content, outline, threshold, start_line) return content end +--- Generate diagnostics message +---@param diagnostics table +---@return string +local function generate_diagnostics(diagnostics) + local out = {} + for _, diagnostic in ipairs(diagnostics) do + table.insert( + out, + string.format( + '%s line=%d-%d: %s', + diagnostic.severity, + diagnostic.start_line, + diagnostic.end_line, + diagnostic.content + ) + ) + end + return table.concat(out, '\n') +end + --- Generate messages for the given selection --- @param selection CopilotChat.select.selection --- @return table @@ -132,24 +152,10 @@ local function generate_selection_messages(selection) ) if selection.diagnostics then - local diagnostics = {} - for _, diagnostic in ipairs(selection.diagnostics) do - table.insert( - diagnostics, - string.format( - '%s line=%d-%d: %s', - diagnostic.severity, - diagnostic.start_line, - diagnostic.end_line, - diagnostic.content - ) - ) - end - out = out .. string.format( "\nDiagnostics in user's active selection:\n%s", - table.concat(diagnostics, '\n') + generate_diagnostics(selection.diagnostics) ) end @@ -167,14 +173,25 @@ end --- @return table local function generate_embeddings_messages(embeddings) return vim.tbl_map(function(embedding) + local out = string.format( + '# FILE:%s CONTEXT\n```%s\n%s\n```', + embedding.filename:upper(), + embedding.filetype or 'text', + generate_content_block(embedding.content, embedding.outline, BIG_FILE_THRESHOLD) + ) + + if embedding.diagnostics then + out = out + .. string.format( + '\nFILE:%s DIAGNOSTICS:\n%s', + embedding.filename:upper(), + generate_diagnostics(embedding.diagnostics) + ) + end + return { context = string.format(CONTEXT_FORMAT, embedding.filename, embedding.filename), - content = string.format( - '# FILE:%s CONTEXT\n```%s\n%s\n```', - embedding.filename:upper(), - embedding.filetype or 'text', - generate_content_block(embedding.content, embedding.outline, BIG_FILE_THRESHOLD) - ), + content = out, role = 'user', } end, embeddings) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 406ca39e..12603057 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -12,6 +12,7 @@ ---@field filename string ---@field filetype string ---@field outline string? +---@field diagnostics table? ---@field symbols table? ---@field embedding table? ---@field score number? @@ -477,6 +478,7 @@ function M.buffer(bufnr) ) out.score = 0.1 + out.diagnostics = utils.diagnostics(bufnr) return out end diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index e75e4271..2254c913 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -1,9 +1,3 @@ ----@class CopilotChat.select.selection.diagnostic ----@field content string ----@field start_line number ----@field end_line number ----@field severity string - ---@class CopilotChat.select.selection ---@field content string ---@field start_line number @@ -11,41 +5,11 @@ ---@field filename string ---@field filetype string ---@field bufnr number ----@field diagnostics table? +---@field diagnostics table? local utils = require('CopilotChat.utils') local M = {} ---- Get diagnostics in a given range ---- @param bufnr number ---- @param start_line number ---- @param end_line number ---- @return table|nil -local function get_diagnostics_in_range(bufnr, start_line, end_line) - local diagnostics = vim.diagnostic.get(bufnr) - local range_diagnostics = {} - local severity = { - [1] = 'ERROR', - [2] = 'WARNING', - [3] = 'INFORMATION', - [4] = 'HINT', - } - - for _, diagnostic in ipairs(diagnostics) do - local lnum = diagnostic.lnum + 1 - if lnum >= start_line and lnum <= end_line then - table.insert(range_diagnostics, { - severity = severity[diagnostic.severity], - content = diagnostic.message, - start_line = lnum, - end_line = diagnostic.end_lnum and diagnostic.end_lnum + 1 or lnum, - }) - end - end - - return #range_diagnostics > 0 and range_diagnostics or nil -end - --- Select and process current visual selection --- @param source CopilotChat.source --- @return CopilotChat.select.selection|nil @@ -76,7 +40,7 @@ function M.visual(source) start_line = start_line, end_line = finish_line, bufnr = bufnr, - diagnostics = get_diagnostics_in_range(bufnr, start_line, finish_line), + diagnostics = utils.diagnostics(bufnr, start_line, finish_line), } end @@ -99,7 +63,7 @@ function M.buffer(source) bufnr = bufnr, } - out.diagnostics = get_diagnostics_in_range(bufnr, out.start_line, out.end_line) + out.diagnostics = utils.diagnostics(bufnr, out.start_line, out.end_line) return out end @@ -124,7 +88,7 @@ function M.line(source) bufnr = bufnr, } - out.diagnostics = get_diagnostics_in_range(bufnr, out.start_line, out.end_line) + out.diagnostics = utils.diagnostics(bufnr, out.start_line, out.end_line) return out end @@ -158,7 +122,7 @@ function M.unnamed(source) start_line = start_line, end_line = finish_line, bufnr = bufnr, - diagnostics = get_diagnostics_in_range(bufnr, start_line, finish_line), + diagnostics = utils.diagnostics(bufnr, start_line, finish_line), } end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 35f98c2f..ae844502 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -536,4 +536,40 @@ function M.glob_to_regex(glob) return pattern .. '$' end +---@class CopilotChat.Diagnostic +---@field content string +---@field start_line number +---@field end_line number +---@field severity string + +--- Get diagnostics in a given range +--- @param bufnr number +--- @param start_line number? +--- @param end_line number? +--- @return table|nil +function M.diagnostics(bufnr, start_line, end_line) + local diagnostics = vim.diagnostic.get(bufnr) + local range_diagnostics = {} + local severity = { + [1] = 'ERROR', + [2] = 'WARNING', + [3] = 'INFORMATION', + [4] = 'HINT', + } + + for _, diagnostic in ipairs(diagnostics) do + local lnum = diagnostic.lnum + 1 + if (not start_line or lnum >= start_line) and (not end_line or lnum <= end_line) then + table.insert(range_diagnostics, { + severity = severity[diagnostic.severity], + content = diagnostic.message, + start_line = lnum, + end_line = diagnostic.end_lnum and diagnostic.end_lnum + 1 or lnum, + }) + end + end + + return #range_diagnostics > 0 and range_diagnostics or nil +end + return M From e33c777cd3dbf3d4fcc9ce2faae6ececd95f4e8f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 8 Mar 2025 04:33:51 +0100 Subject: [PATCH 090/589] fix: improve history updates safety Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 6 ++---- lua/CopilotChat/config/mappings.lua | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 23740509..216127b8 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -456,10 +456,8 @@ function Client:ask(prompt, opts) local history = {} if opts.load_history then - history = self.history - if self.memory and self.memory.last_summarized_index then - history = vim.list_slice(history, self.memory.last_summarized_index + 1) - end + history = + vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) end local references = utils.ordered_map() diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index ff409593..51c28225 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -476,16 +476,6 @@ return { table.insert(lines, '') end - if client.memory then - table.insert(lines, '**Memory**') - table.insert(lines, '```') - for _, line in ipairs(vim.split(vim.trim(client.memory.content), '\n')) do - table.insert(lines, line) - end - table.insert(lines, '```') - table.insert(lines, '') - end - copilot.chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) @@ -503,6 +493,16 @@ return { local lines = {} + if client.memory then + table.insert(lines, '**Memory**') + table.insert(lines, '```markdown') + for _, line in ipairs(vim.split(client.memory.content, '\n')) do + table.insert(lines, line) + end + table.insert(lines, '```') + table.insert(lines, '') + end + local selection = copilot.get_selection() if selection then table.insert(lines, '**Selection**') From 78595bb6153dd835c1bfd797de6ab073c093d4ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 8 Mar 2025 03:48:17 +0000 Subject: [PATCH 091/589] 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 894f763b..55366ead 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 07 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 08 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 062e5444ef0b13c22f815cb4d371d924e1ea64f6 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 8 Mar 2025 07:03:02 +0100 Subject: [PATCH 092/589] refactor(memory): improve conversation memory management The memory summarization mechanism is now integrated directly into token budget handling instead of being called separately before each chat request. This improves efficiency by: 1. Only summarizing when we actually exceed the token limit 2. Properly recalculating token budgets after summarization 3. Adding consistent references tracking for embedded files 4. Moving job ID setting to after initial setup work is complete This change makes memory management more robust and predictable while maintaining the same functionality. --- lua/CopilotChat/client.lua | 89 ++++++++++++++++---------------------- lua/CopilotChat/init.lua | 1 - 2 files changed, 38 insertions(+), 52 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 216127b8..930c6b94 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -161,6 +161,7 @@ local function generate_selection_messages(selection) return { { + name = filename, context = string.format(CONTEXT_FORMAT, filename, filename), content = out, role = 'user', @@ -190,6 +191,7 @@ local function generate_embeddings_messages(embeddings) end return { + name = embedding.filename, context = string.format(CONTEXT_FORMAT, embedding.filename, embedding.filename), content = out, role = 'user', @@ -402,7 +404,6 @@ function Client:ask(prompt, opts) local temperature = opts.temperature local on_progress = opts.on_progress local job_id = utils.uuid() - self.current_job = job_id log.trace('System prompt: ', system_prompt) log.trace('Selection: ', selection.content) @@ -467,23 +468,20 @@ function Client:ask(prompt, opts) url = selection.filename, }) end - for _, embed in ipairs(embeddings) do - references:set(embed.filename, { - name = utils.filename(embed.filename), - url = embed.filename, - }) - end local generated_messages = {} local selection_messages = generate_selection_messages(selection) local embeddings_messages = generate_embeddings_messages(embeddings) + for _, message in ipairs(selection_messages) do + table.insert(generated_messages, message) + end + if max_tokens then -- Count tokens from selection messages local selection_tokens = 0 for _, message in ipairs(selection_messages) do selection_tokens = selection_tokens + tiktoken.count(message.content) - table.insert(generated_messages, message) end -- Count required tokens that we cannot reduce @@ -504,31 +502,52 @@ function Client:ask(prompt, opts) history_tokens = history_tokens + tiktoken.count(msg.content) end - -- If we're over history limit, truncate history from the beginning - while history_tokens > history_limit and #history > 0 do - local removed = table.remove(history, 1) - history_tokens = history_tokens - tiktoken.count(removed.content) + -- If we're over history limit, trigger summarization + if history_tokens > history_limit then + if opts.store_history and #history >= 4 then + self:summarize_memory(model) + + -- Recalculate history and tokens + history = + vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) + history_tokens = 0 + for _, msg in ipairs(history) do + history_tokens = history_tokens + tiktoken.count(msg.content) + end + required_tokens = required_tokens - memory_tokens + memory_tokens = self.memory and tiktoken.count(self.memory.content) or 0 + required_tokens = required_tokens + memory_tokens + else + while history_tokens > history_limit and #history > 0 do + local entry = table.remove(history, 1) + history_tokens = history_tokens - tiktoken.count(entry.content) + end + end end - -- Now add as many files as possible with remaining token budget (back to front) + -- Now add as many files as possible with remaining token budget local remaining_tokens = max_tokens - required_tokens - history_tokens - for i = #embeddings_messages, 1, -1 do - local message = embeddings_messages[i] + for _, message in ipairs(embeddings_messages) do local tokens = tiktoken.count(message.content) if remaining_tokens - tokens >= 0 then remaining_tokens = remaining_tokens - tokens table.insert(generated_messages, message) + references:set(message.name, { + name = utils.filename(message.name), + url = message.name, + }) else break end end else -- Add all embedding messages as we cant limit them - for _, message in ipairs(selection_messages) do - table.insert(generated_messages, message) - end for _, message in ipairs(embeddings_messages) do table.insert(generated_messages, message) + references:set(message.name, { + name = utils.filename(message.name), + url = message.name, + }) end end @@ -637,6 +656,7 @@ function Client:ask(prompt, opts) end notify.publish(notify.STATUS, 'Thinking') + self.current_job = job_id local headers = self:authenticate(provider_name) local request = provider.prepare_input( @@ -883,39 +903,6 @@ end --- Summarize conversation memory to extract critical information ---@param model string The model to use for summarization function Client:summarize_memory(model) - -- Exit early if there's not enough history - if #self.history < 4 then - return - end - - -- Get the model config to check token limits - local models = self:fetch_models() - local model_config = models[model] - if not model_config or not model_config.max_input_tokens then - log.warn('Cannot determine token limit for model: ' .. model) - return - end - - local max_tokens = model_config.max_input_tokens - local tokenizer = model_config.tokenizer or 'o200k_base' - tiktoken.load(tokenizer) - - -- Calculate current token usage - local token_count = 0 - for _, msg in ipairs(self.history) do - token_count = token_count + tiktoken.count(msg.content) - end - - -- Check if we need to summarize based on token count threshold - local threshold = math.floor(max_tokens * 0.6) - local last_index = self.memory and self.memory.last_summarized_index or 0 - local new_messages = #self.history - last_index - - -- Only summarize if either token count is high or we have enough new messages - if token_count < threshold and new_messages < 4 then - return - end - local system_prompt = [[Summarize the following conversation to extract the most critical information for effective context in subsequent conversations. Focus on: diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 265fd377..68c5b256 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -850,7 +850,6 @@ function M.ask(prompt, config) return end - pcall(client.summarize_memory, client, selected_model) local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, { load_history = not config.headless, From 1f91783fdf7dcea25c04dffc8e856ccb7a2db13f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 8 Mar 2025 07:09:24 +0100 Subject: [PATCH 093/589] fix: update reference handling in selection messages Instead of setting a single reference based on the selection object, the code now sets references for each message in the selection_messages array. This ensures that all selected files are properly included in the references map. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 930c6b94..1602ec01 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -462,19 +462,16 @@ function Client:ask(prompt, opts) end local references = utils.ordered_map() - if selection and selection.content then - references:set(selection.filename, { - name = utils.filename(selection.filename), - url = selection.filename, - }) - end - local generated_messages = {} local selection_messages = generate_selection_messages(selection) local embeddings_messages = generate_embeddings_messages(embeddings) for _, message in ipairs(selection_messages) do table.insert(generated_messages, message) + references:set(message.name, { + name = utils.filename(message.name), + url = message.name, + }) end if max_tokens then From e72cedc5687606d617ec85233061c6f750156b97 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 8 Mar 2025 17:43:14 +0100 Subject: [PATCH 094/589] feat: util method to add sticky prompt to chat New `chat.add_sticky(sticky)` method for adding sticky prompts to chat Closes #937 Signed-off-by: Tomas Slusny --- README.md | 1 + lua/CopilotChat/config/mappings.lua | 53 +++++++---------------------- lua/CopilotChat/ui/chat.lua | 49 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 6e652d38..9bdcbfbd 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,7 @@ chat.chat:focused() -- Check if chat window is focused -- Content Management chat.chat:get_prompt() -- Get current prompt from chat window chat.chat:set_prompt(prompt) -- Set prompt in chat window +chat.chat:add_sticky(sticky) -- Add sticky prompt to chat window chat.chat:append(text) -- Append text to chat window chat.chat:clear() -- Clear chat window content chat.chat:finish() -- Finish writing to chat window diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 51c28225..faf367a7 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -176,8 +176,13 @@ return { toggle_sticky = { normal = 'grr', callback = function() - local section = copilot.chat:get_closest_section() - if not section or section.answer then + local section = copilot.chat:get_prompt() + 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 @@ -186,7 +191,6 @@ return { return end - local cursor = vim.api.nvim_win_get_cursor(0) local cur_line = cursor[1] vim.api.nvim_buf_set_lines(copilot.chat.bufnr, cur_line - 1, cur_line, false, {}) @@ -194,43 +198,16 @@ return { return end - local lines = vim.split(section.content, '\n') - local insert_line = 1 - local first_one = true - - for i = insert_line, #lines do - local line = lines[i] - if line and vim.trim(line) ~= '' then - if vim.startswith(line, '> ') then - first_one = false - else - break - end - elseif i >= 2 then - break - end - - insert_line = insert_line + 1 - end - - insert_line = section.start_line + insert_line - 1 - local to_insert = first_one and { '> ' .. current_line, '' } or { '> ' .. current_line } - vim.api.nvim_buf_set_lines( - copilot.chat.bufnr, - insert_line - 1, - insert_line - 1, - false, - to_insert - ) - vim.api.nvim_win_set_cursor(0, cursor) + copilot.chat:add_sticky(current_line) + vim.api.nvim_win_set_cursor(copilot.chat.winnr, cursor) end, }, clear_stickies = { normal = 'grx', callback = function() - local section = copilot.chat:get_closest_section() - if not section or section.answer then + local section = copilot.chat:get_prompt() + if not section then return end @@ -247,13 +224,7 @@ return { end if changed then - vim.api.nvim_buf_set_lines( - copilot.chat.bufnr, - section.start_line, - section.end_line - 1, - false, - new_lines - ) + copilot.chat:set_prompt(vim.trim(table.concat(new_lines, '\n'))) end end, }, diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 61af8c25..21019283 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -214,6 +214,55 @@ function Chat:set_prompt(prompt) vim.bo[self.bufnr].modifiable = modifiable 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_prompt() + if not prompt 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 vim.trim(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 + + if found then + return + end + + insert_line = prompt.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 +end + ---@class CopilotChat.ui.Chat.show_overlay ---@field text string ---@field filetype string? From a3136900435e55b2808008023e8c704089da4530 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 8 Mar 2025 16:49:15 +0000 Subject: [PATCH 095/589] 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 55366ead..cb59d678 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -776,6 +776,7 @@ You can also access the chat window UI methods through the `chat.chat` object: -- Content Management chat.chat:get_prompt() -- Get current prompt from chat window chat.chat:set_prompt(prompt) -- Set prompt in chat window + chat.chat:add_sticky(sticky) -- Add sticky prompt to chat window chat.chat:append(text) -- Append text to chat window chat.chat:clear() -- Clear chat window content chat.chat:finish() -- Finish writing to chat window From 418bf5f9e74d905b582121036b732a753aa8746f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 8 Mar 2025 18:00:32 +0100 Subject: [PATCH 096/589] fix: clear memory when resetting the client Ensures that client memory is properly cleared when calling the reset method. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 1602ec01..ab31baf5 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -880,6 +880,7 @@ end function Client:reset() local stopped = self:stop() self.history = {} + self.memory = nil return stopped end From 648d936328e6f9babb44a560c6a56f91ca9fcce2 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 8 Mar 2025 23:46:31 +0100 Subject: [PATCH 097/589] fix: manually trigger BufEnter when restoring buffers The nvim_win_set_buf API doesn't automatically trigger BufEnter events when switching buffers. This was causing issues with proper state updates in the Copilot Chat UI when navigating between buffers. This commit adds explicit BufEnter triggering and simplifies some related code to ensure proper event handling. --- lua/CopilotChat/init.lua | 3 +-- lua/CopilotChat/ui/overlay.lua | 9 ++++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 68c5b256..5fa9aba5 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1104,8 +1104,7 @@ function M.setup(config) vim.api.nvim_create_autocmd({ 'BufEnter', 'BufLeave' }, { buffer = bufnr, callback = function(ev) - local is_enter = ev.event == 'BufEnter' - if is_enter then + if ev.event == 'BufEnter' then update_source() end diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index f0a46f87..7de993b9 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -115,16 +115,23 @@ end ---@param bufnr number? ---@protected function Overlay:restore(winnr, bufnr) + bufnr = bufnr or 0 + if self.on_hide then self.on_hide(self.bufnr) end - vim.api.nvim_win_set_buf(winnr, bufnr or 0) + vim.api.nvim_win_set_buf(winnr, bufnr) vim.api.nvim_win_set_hl_ns(winnr, 0) if self.cursor then vim.api.nvim_win_set_cursor(winnr, self.cursor) end + + -- Manually trigger BufEnter event as nvim_win_set_buf does not trigger it + vim.schedule(function() + vim.cmd(string.format('doautocmd BufEnter %s', bufnr)) + end) end --- Show help message in the overlay From 621a1c8aecf31db00e855016ffca62bf08d86a09 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 01:23:37 +0100 Subject: [PATCH 098/589] fix(utils): optimize return to normal mode function The function now only calls 'stopinsert' when not already in normal mode, avoiding unnecessary command execution when already in normal mode. Signed-off-by: Tomas Slusny --- lua/CopilotChat/utils.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index ae844502..781ce70e 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -134,8 +134,9 @@ 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 --- Mark a function as deprecated From ddf4aac37519982285e9c88d6f16e5cf94b819c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Mar 2025 00:24:31 +0000 Subject: [PATCH 099/589] 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 cb59d678..0b39c879 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 08 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From e00fc6b8cf00c0261d0f676de31642d4f08e2c19 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 01:34:03 +0100 Subject: [PATCH 100/589] fix: improve handling of empty responses When receiving empty responses from Copilot Chat, the code now properly handles the error case by setting an appropriate error message and displaying it to the user. This prevents silent failures and improves the user experience by providing clear feedback. Also removes redundant condition checking and cleans up code flow for better readability. --- lua/CopilotChat/init.lua | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 5fa9aba5..1e21c957 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -870,7 +870,10 @@ function M.ask(prompt, config) utils.schedule_main() - if not ask_ok then + if not ask_ok or utils.empty(response) then + if utils.empty(response) then + response = 'Failed to get response: empty response' + end log.error(response) if not config.headless then show_error(response, has_output) @@ -878,18 +881,12 @@ function M.ask(prompt, config) return end - if not response then - return - end - if not config.headless then state.last_response = response M.chat.references = references M.chat.token_count = token_count M.chat.token_max_count = token_max_count - end - if not config.headless then if not utils.empty(references) and config.references_display == 'write' then M.chat:append('\n\n**`References`**:') for _, ref in ipairs(references) do @@ -899,6 +896,7 @@ function M.ask(prompt, config) finish() end + if config.callback then config.callback(response, state.source) end From 09d38bed9148553d38d2dc5407293f9fcce491bd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 04:26:53 +0100 Subject: [PATCH 101/589] refactor: improve conversation history summarization Update the conversation summarization feature with: - Rename function from `summarize_memory` to `summarize_history` - Enhance system prompt with better formatting guidelines - Add directive to preserve code samples and configurations - Improve prompt clarity for better technical summaries - Add tracking for unresolved questions/issues These changes make history summarization more effective by producing better structured and more comprehensive technical summaries. --- lua/CopilotChat/client.lua | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index ab31baf5..df7842c6 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -502,7 +502,7 @@ function Client:ask(prompt, opts) -- If we're over history limit, trigger summarization if history_tokens > history_limit then if opts.store_history and #history >= 4 then - self:summarize_memory(model) + self:summarize_history(model) -- Recalculate history and tokens history = @@ -898,24 +898,26 @@ function Client:load_providers(providers) end end ---- Summarize conversation memory to extract critical information +--- Summarize conversation history to extract critical information ---@param model string The model to use for summarization -function Client:summarize_memory(model) - local system_prompt = - [[Summarize the following conversation to extract the most critical information -for effective context in subsequent conversations. Focus on: +function Client:summarize_history(model) + local system_prompt = [[You are an expert programming assistant tasked with memory management. +Your job is to create concise yet comprehensive summaries of technical conversations. +Focus on extracting and preserving: 1. Technical details: languages, frameworks, libraries, and specific technologies discussed 2. Context: user's project structure, goals, constraints, and preferences 3. Implementation details: patterns, approaches, or solutions that were discussed 4. Important decisions or conclusions reached +5. Unresolved questions or issues that need further attention If the conversation includes previous memory summaries, integrate that information carefully. -Be concise but comprehensive, prioritizing technical accuracy over conversational elements. -Format your response as a structured summary, not as a narrative.]] +Prioritize technical accuracy over conversational elements. +Format your response as a structured summary with clear sections using markdown. +Ensure all critical code samples, commands, and configuration snippets are preserved.]] notify.publish(notify.STATUS, string.format('Summarizing memory (%d messages)', #self.history)) - local response = self:ask('Summarize chat history', { + local response = self:ask('Create a technical summary of our conversation for future context', { load_history = true, store_history = false, model = model, From 3ba4a641f5fbc00e0515be19a275521500f3d65f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 14:04:14 +0100 Subject: [PATCH 102/589] fix: ignore SSE comments in parsing stream response According to SSE specification, lines starting with colon (:) are considered comments and should be ignored. This fixes the stream parser to properly handle these lines when processing responses from Copilot. Closes #944 --- lua/CopilotChat/client.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index df7842c6..56554778 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -619,7 +619,8 @@ function Client:ask(prompt, opts) local function parse_stream_line(line, job) line = vim.trim(line) - if vim.startswith(line, 'event:') then + -- Ignore SSE event names and comments + if vim.startswith(line, 'event:') or vim.startswith(line, ':') then return end From feb421e4333f25e0c179cc8e638dfb0b2048f497 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 14:38:06 +0100 Subject: [PATCH 103/589] refactor(chat): simplify section and block return values Return direct objects without wrapping them in new tables, since the original objects already contain all necessary information and fields. This simplifies the code without changing functionality. --- lua/CopilotChat/ui/chat.lua | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 21019283..8b4b934c 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -133,16 +133,7 @@ function Chat:get_closest_section() end end - if not closest_section then - return nil - end - - return { - answer = closest_section.answer, - start_line = closest_section.start_line, - end_line = closest_section.end_line, - content = closest_section.content, - } + return closest_section end --- Get the closest code block to the cursor. @@ -167,16 +158,7 @@ function Chat:get_closest_block() end end - if not closest_block then - return nil - end - - return { - header = closest_block.header, - start_line = closest_block.start_line, - end_line = closest_block.end_line, - content = closest_block.content, - } + return closest_block end --- Get the prompt in the chat window. From 4f1516bc7672ebf2b33777f3e5c76603b1601021 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 14:58:36 +0100 Subject: [PATCH 104/589] fix: improve buffer handling in diff view Use vim.api.nvim_win_call to properly handle buffer and window context when enabling and disabling diff mode. This ensures commands are run in the correct window context rather than affecting the current one, preventing unexpected side effects. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index faf367a7..9c3a0c16 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -377,16 +377,19 @@ return { opts.text = table.concat(modified, '\n') opts.on_show = function() - vim.cmd('diffthis') - vim.api.nvim_set_current_win(vim.fn.bufwinid(diff.bufnr)) - vim.api.nvim_win_set_cursor(0, { diff.start_line, 0 }) - vim.cmd('diffthis') - vim.api.nvim_set_current_win(copilot.chat.winnr) - vim.api.nvim_win_set_cursor(copilot.chat.winnr, { diff.start_line, 0 }) + vim.api.nvim_win_call(vim.fn.bufwinid(diff.bufnr), function() + vim.cmd('diffthis') + end) + + vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.cmd('diffthis') + end) end opts.on_hide = function() - vim.cmd('diffoff') + vim.api.nvim_win_call(copilot.chat.winnr, function() + vim.cmd('diffoff') + end) end else opts.text = tostring(vim.diff(diff.reference, diff.change, { From 82708c152ca8b398646dfd1bc125abab576e47d2 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 18:35:31 +0100 Subject: [PATCH 105/589] fix: properly split chat horizonatally/vertically when existing splits are open Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 8b4b934c..b51a1915 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -310,6 +310,7 @@ function Chat:open(config) if width ~= 0 then cmd = width .. cmd end + cmd = 'botright ' .. cmd vim.cmd(cmd) self.winnr = vim.api.nvim_get_current_win() vim.api.nvim_set_current_win(orig) @@ -319,6 +320,7 @@ function Chat:open(config) if height ~= 0 then cmd = height .. cmd end + cmd = 'botright ' .. cmd vim.cmd(cmd) self.winnr = vim.api.nvim_get_current_win() vim.api.nvim_set_current_win(orig) From f17a7d9541881a32a8773c9ebddd30a1109177ba Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 18:47:12 +0100 Subject: [PATCH 106/589] feat: show source information in chat info panel When displaying chat information, add the source filename to provide better context about where the chat was initiated from. This enhances debugging and improves user experience by making it clear which file is associated with the current chat session. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 9c3a0c16..d664dc17 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -410,7 +410,7 @@ return { show_info = { normal = 'gi', - callback = function() + callback = function(source) local section = copilot.chat:get_closest_section() if not section or section.answer then return @@ -430,6 +430,12 @@ return { table.insert(lines, '**Temp Files**: `' .. vim.fn.fnamemodify(os.tmpname(), ':h') .. '`') table.insert(lines, '') + if source and utils.buf_valid(source.bufnr) then + local source_name = vim.api.nvim_buf_get_name(source.bufnr) + table.insert(lines, '**Source**: `' .. source_name .. '`') + table.insert(lines, '') + end + if selected_model then table.insert(lines, '**Model**: `' .. selected_model .. '`') table.insert(lines, '') From f2205ec226248049286f8c718308120325a39c5d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 19:10:29 +0100 Subject: [PATCH 107/589] fix: improve source buffer selection for headless mode The update_source function now correctly determines the source buffer when running in headless mode by checking if we're in the chat window and using the appropriate window (previous window when in chat, current window otherwise). This makes the plugin work properly in headless environments where window navigation behaves differently. --- lua/CopilotChat/init.lua | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 1e21c957..71a1ea7f 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -262,18 +262,23 @@ local function map_key(name, bufnr, fn) end end ---- Updates the source buffer based on previous window. +--- Updates the source buffer based on previous or current window. local function update_source() - local prev_winnr = vim.fn.win_getid(vim.fn.winnr('#')) - local prev_bufnr = vim.api.nvim_win_get_buf(prev_winnr) + -- Determine which window to use as the source (previous if in chat, current otherwise) + local use_prev_window = M.chat:focused() + local source_winnr = use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) + or vim.api.nvim_get_current_win() + local source_bufnr = vim.api.nvim_win_get_buf(source_winnr) + + -- Check if the window is valid to use as a source if - prev_winnr ~= M.chat.winnr - and prev_bufnr ~= M.chat.bufnr - and vim.fn.win_gettype(prev_winnr) == '' + source_winnr ~= M.chat.winnr + and source_bufnr ~= M.chat.bufnr + and vim.fn.win_gettype(source_winnr) == '' then state.source = { - bufnr = prev_bufnr, - winnr = prev_winnr, + bufnr = source_bufnr, + winnr = source_winnr, } end end From 1c450dbc4baa09c38b72a5bd72aa2ea2ad5e6d78 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 20:09:58 +0100 Subject: [PATCH 108/589] feat(search)!: use ripgrep when available for file scanning (#953) * feat(search)!: use ripgrep when available for file scanning Adds ripgrep integration to improve search performance when scanning directories. Falls back to the existing scandir implementation when ripgrep is not available. Updates the configuration options to use more consistent naming and adds health check for the ripgrep dependency. Also cleans up installation instructions in README. BREAKING CHANGE: Due to previous incorrect glob handling, .lua patterns etc need to be specified as *.lua 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 | 9 +- lua/CopilotChat/config/contexts.lua | 6 +- lua/CopilotChat/context.lua | 8 +- lua/CopilotChat/health.lua | 9 ++ lua/CopilotChat/utils.lua | 201 +++++++++++++++++++++++++--- 5 files changed, 201 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9bdcbfbd..d02f0711 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,8 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - Manual: Download from [lua-tiktoken releases](https://github.com/gptlang/lua-tiktoken/releases) and save as `tiktoken_core.so` in your Lua path - [git](https://git-scm.com/) - For git diff context features - - - Arch Linux: Install from official repositories - - Other systems: Use system package manager or official installer - +- [ripgrep](https://github.com/BurntSushi/ripgrep) - For improved search performance - [lynx](https://lynx.invisible-island.net/) - For improved URL context features - - Arch Linux: Install from official repositories - - Other systems: Use system package manager or official installer ## Integration with pickers @@ -331,7 +326,7 @@ Examples: ```markdown > #buffer > #buffer:2 -> #files:.lua +> #files:\*.lua > #filenames > #git:staged > #url:https://example.com diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 5280f28f..3fb6eb42 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -57,8 +57,6 @@ return { input = function(callback, source) local cwd = utils.win_cwd(source.winnr) local files = utils.scan_dir(cwd, { - add_dirs = false, - respect_gitignore = true, max_files = 1000, }) @@ -83,7 +81,7 @@ return { end, resolve = function(input, source) return context.files(source.winnr, true, { - search_pattern = input and utils.glob_to_regex(input), + glob = input, }) end, }, @@ -97,7 +95,7 @@ return { end, resolve = function(input, source) return context.files(source.winnr, false, { - search_pattern = input and utils.glob_to_regex(input), + glob = input, }) end, }, diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 12603057..18fafa7e 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -72,6 +72,7 @@ local MIN_SYMBOL_SIMILARITY = 0.3 local MIN_SEMANTIC_SIMILARITY = 0.4 local MULTI_FILE_THRESHOLD = 5 local MAX_FILES = 2500 +local MAX_DEPTH = 50 --- Compute the cosine similarity between two vectors ---@param a table @@ -372,7 +373,7 @@ end --- Get list of all files in workspace ---@param winnr number? ---@param with_content boolean? ----@param search_options table? +---@param search_options CopilotChat.utils.scan_dir_opts? ---@return table function M.files(winnr, with_content, search_options) local cwd = utils.win_cwd(winnr) @@ -382,9 +383,8 @@ function M.files(winnr, with_content, search_options) local files = utils.scan_dir( cwd, vim.tbl_extend('force', { - add_dirs = false, - respect_gitignore = true, - max_files = MAX_FILES, + max_count = MAX_FILES, + max_depth = MAX_DEPTH, }, search_options) ) diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index dac6c16e..ace04ad5 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -72,6 +72,15 @@ function M.check() ok('git: ' .. git_version) end + local rg_version = run_command('rg', '--version') + if rg_version == false then + warn( + 'rg: missing, optional for improved search performance. See "https://github.com/BurntSushi/ripgrep".' + ) + else + ok('rg: ' .. rg_version) + end + local lynx_version = run_command('lynx', '-version') if lynx_version == false then warn( diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 781ce70e..cdec18ca 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -394,17 +394,73 @@ M.curl_post = async.wrap(function(url, opts, callback) curl.post(url, args) end, 3) +---@class CopilotChat.utils.scan_dir_opts +---@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 hidden? boolean Whether to include hidden files +---@field no_ignore? boolean Whether to respect or ignore .gitignore + --- Scan a directory ---@param path string The directory path ----@param opts table The options +---@param opts CopilotChat.utils.scan_dir_opts The options ---@async M.scan_dir = async.wrap(function(path, opts, callback) + -- Use ripgrep if available + if vim.fn.executable('rg') == 1 then + local cmd = { 'rg' } + + if opts.glob then + table.insert(cmd, '-g') + table.insert(cmd, opts.glob) + end + + if opts.max_count then + table.insert(cmd, '-m') + table.insert(cmd, tostring(opts.max_count)) + end + + if opts.max_depth then + table.insert(cmd, '-d') + 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 = vim.tbl_filter(function(file) + return file ~= '' + end, vim.split(result.stdout, '\n')) + end + + callback(files) + end) + + 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 = M.glob_to_pattern(opts.glob), + respect_gitignore = not opts.no_ignore, on_exit = function(files) - if opts.max_files then - files = vim.list_slice(files, 1, opts.max_files) + if opts.max_count then + files = vim.list_slice(files, 1, opts.max_count) end callback(files) end, @@ -517,24 +573,135 @@ function M.empty(v) end --- Convert glob pattern to regex pattern ----@param glob string The glob pattern ----@return string? -function M.glob_to_regex(glob) - if not glob or glob == '' then - return nil +--- 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 regex special chars except * and ? - local pattern = glob:gsub('[%^%$%(%)%%%.%[%]%+%-]', '%%%1') + -- escape pattern char + local function escape(c) + return c:match('^%w$') and c or '%' .. c + end - -- Convert glob to regex pattern - pattern = pattern - :gsub('%*%*/%*', '.*') -- **/* -> .* - :gsub('%*%*', '.*') -- ** -> .* - :gsub('([^%.])%*', '%1[^/]*') -- * -> [^/]* (when not preceded by .) - :gsub('%?', '.') -- ? -> . + -- 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 - return pattern .. '$' + -- 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 ---@class CopilotChat.Diagnostic From 03ade4ab16e051245791e9204b21c23b4f680571 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Mar 2025 19:10:16 +0000 Subject: [PATCH 109/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 0b39c879..3a750c34 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -66,11 +66,9 @@ OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` - Manual: Download from lua-tiktoken releases and save as `tiktoken_core.so` in your Lua path - git - For git diff context features - - Arch Linux: Install from official repositories - - Other systems: Use system package manager or official installer +- ripgrep - For improved search + performance - lynx - For improved URL context features - - Arch Linux: Install from official repositories - - Other systems: Use system package manager or official installer INTEGRATION WITH PICKERS *CopilotChat-integration-with-pickers* @@ -373,7 +371,7 @@ Examples: >markdown > #buffer > #buffer:2 - > #files:.lua + > #files:\*.lua > #filenames > #git:staged > #url:https://example.com From ba59c71ba891a206a1ccaa98873d4037eb45d62f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 20:39:46 +0100 Subject: [PATCH 110/589] fix: handle file listing limit properly --- lua/CopilotChat/config/contexts.lua | 4 +--- lua/CopilotChat/utils.lua | 9 ++++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 3fb6eb42..f4829ff4 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -56,9 +56,7 @@ return { description = 'Includes content of provided file in chat context. Supports input.', input = function(callback, source) local cwd = utils.win_cwd(source.winnr) - local files = utils.scan_dir(cwd, { - max_files = 1000, - }) + local files = utils.scan_dir(cwd, {}) utils.schedule_main() vim.ui.select(files, { diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index cdec18ca..2bd8f623 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -415,11 +415,6 @@ M.scan_dir = async.wrap(function(path, opts, callback) table.insert(cmd, opts.glob) end - if opts.max_count then - table.insert(cmd, '-m') - table.insert(cmd, tostring(opts.max_count)) - end - if opts.max_depth then table.insert(cmd, '-d') table.insert(cmd, tostring(opts.max_depth)) @@ -442,6 +437,10 @@ M.scan_dir = async.wrap(function(path, opts, callback) files = vim.tbl_filter(function(file) return file ~= '' end, vim.split(result.stdout, '\n')) + + if opts.max_count then + files = vim.list_slice(files, 1, opts.max_count) + end end callback(files) From 495c8bdd035d9aa62679b9afdb2ecd61897b2771 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 20:44:52 +0100 Subject: [PATCH 111/589] feat: improve default file scanning arguments Define and apply sensible default arguments for file scanning operations across the codebase. This adds a central configuration for max_count, max_depth and no_ignore parameters, making file operations more consistent. The changes also improve the handling of max_count=0, which now correctly disables the file count limit rather than returning an empty result set. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/contexts.lua | 4 +++- lua/CopilotChat/context.lua | 8 +------- lua/CopilotChat/utils.lua | 15 ++++++++++++--- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index f4829ff4..66ed2652 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -56,7 +56,9 @@ return { description = 'Includes content of provided file in chat context. Supports input.', input = function(callback, source) local cwd = utils.win_cwd(source.winnr) - local files = utils.scan_dir(cwd, {}) + local files = utils.scan_dir(cwd, { + max_count = 0, + }) utils.schedule_main() vim.ui.select(files, { diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 18fafa7e..6fdbb503 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -380,13 +380,7 @@ function M.files(winnr, with_content, search_options) notify.publish(notify.STATUS, 'Scanning files') - local files = utils.scan_dir( - cwd, - vim.tbl_extend('force', { - max_count = MAX_FILES, - max_depth = MAX_DEPTH, - }, search_options) - ) + local files = utils.scan_dir(cwd, search_options) notify.publish(notify.STATUS, 'Reading files') diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 2bd8f623..5e52cfc0 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -4,6 +4,13 @@ local scandir = require('plenary.scandir') local M = {} M.timers = {} + +M.scan_args = { + max_count = 2500, + max_depth = 50, + no_ignore = false, +} + M.curl_args = { timeout = 30000, raw = { @@ -403,9 +410,11 @@ end, 3) --- Scan a directory ---@param path string The directory path ----@param opts CopilotChat.utils.scan_dir_opts The options +---@param opts CopilotChat.utils.scan_dir_opts? The options ---@async M.scan_dir = 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' } @@ -438,7 +447,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) return file ~= '' end, vim.split(result.stdout, '\n')) - if opts.max_count then + if opts.max_count and opts.max_count > 0 then files = vim.list_slice(files, 1, opts.max_count) end end @@ -458,7 +467,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) search_pattern = M.glob_to_pattern(opts.glob), respect_gitignore = not opts.no_ignore, on_exit = function(files) - if opts.max_count then + if opts.max_count and opts.max_count > 0 then files = vim.list_slice(files, 1, opts.max_count) end callback(files) From aaf86a992318ab4f4fa3bc6500a95879191ad2af Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 22:04:17 +0100 Subject: [PATCH 112/589] fix(readme): remove Tab mapping in normal mode for chat The Tab key was incorrectly documented as working in normal mode for triggering/accepting the completion menu for tokens in the chat interface. This commit corrects the documentation to show that Tab is only available in insert mode. Signed-off-by: Tomas Slusny --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d02f0711..3526c30a 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ Default mappings in the chat interface: | Insert | Normal | Action | | ------- | ------- | ------------------------------------------ | -| `` | `` | Trigger/accept completion menu for tokens | +| `` | - | Trigger/accept completion menu for tokens | | `` | `q` | Close the chat window | | `` | `` | Reset and clear the chat window | | `` | `` | Submit the current prompt | From 7f085d91ba6cdc517e5604c065db62eff3c1ac2a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Mar 2025 21:05:05 +0000 Subject: [PATCH 113/589] 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 3a750c34..8eb809ef 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -191,7 +191,7 @@ Default mappings in the chat interface: Insert Normal Action -------- -------- -------------------------------------------- - Trigger/accept completion menu for tokens + - Trigger/accept completion menu for tokens q Close the chat window Reset and clear the chat window Submit the current prompt From 4e36f4e03108a4c4a0f849d709afb54359481888 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 9 Mar 2025 22:17:09 +0100 Subject: [PATCH 114/589] fix: allow using system prompt name as string Previously system_prompt could only be set using the full prompt object path. Now users can simply specify the prompt name as a string, and the system will resolve it to the actual prompt content automatically. Signed-off-by: Tomas Slusny --- README.md | 2 +- lua/CopilotChat/config.lua | 5 ++--- lua/CopilotChat/init.lua | 7 ++++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3526c30a..0954519e 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ Below are all available configuration options with their default values: -- Shared config starts here (can be passed to functions at runtime and configured via setup function) - system_prompt = prompts.COPILOT_INSTRUCTIONS.system_prompt, -- 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-4o', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 058fa634..04014ce6 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -1,4 +1,3 @@ -local prompts = require('CopilotChat.config.prompts') local select = require('CopilotChat.select') ---@class CopilotChat.config.window @@ -56,7 +55,7 @@ return { -- Shared config starts here (can be passed to functions at runtime and configured via setup function) - system_prompt = prompts.COPILOT_INSTRUCTIONS.system_prompt, -- 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-4o', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). agent = 'none', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). @@ -122,7 +121,7 @@ return { contexts = require('CopilotChat.config.contexts'), -- default prompts - prompts = prompts, + prompts = require('CopilotChat.config.prompts'), -- default mappings mappings = require('CopilotChat.config.mappings'), diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 71a1ea7f..9c50549c 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -320,7 +320,12 @@ function M.resolve_prompt(prompt, config) return inner_config, inner_prompt end - return resolve(config or M.config, prompt or '') + 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 + return config, prompt end --- Resolve the context embeddings from the prompt. From b094001f80f91411d8743742b1e7b7bfa11e28bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 9 Mar 2025 21:20:24 +0000 Subject: [PATCH 115/589] 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 8eb809ef..b68a7afb 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -495,7 +495,7 @@ Below are all available configuration options with their default values: -- Shared config starts here (can be passed to functions at runtime and configured via setup function) - system_prompt = prompts.COPILOT_INSTRUCTIONS.system_prompt, -- 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-4o', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). From dde5116725850d06b29d7726de1eeafec23cd269 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 10 Mar 2025 00:09:12 +0100 Subject: [PATCH 116/589] refactor(ui): improve help message handling Make both parameters to show_help optional and handle empty messages consistently. Remove redundant clear_help method in favor of calling show_help without parameters to achieve the same result. Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 2 +- lua/CopilotChat/ui/overlay.lua | 20 +++++--------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index b51a1915..f436154d 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -631,7 +631,7 @@ function Chat:render() }) end else - self:clear_help() + self:show_help() end self.sections = sections diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index 7de993b9..da179918 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -135,21 +135,17 @@ function Overlay:restore(winnr, bufnr) end --- Show help message in the overlay ----@param msg string ----@param offset number +---@param msg string? +---@param offset number? ---@protected function Overlay:show_help(msg, offset) - if not msg then - return - end - - msg = vim.trim(msg) - if msg == '' then + 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 + 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, { id = 1, hl_mode = 'combine', @@ -160,10 +156,4 @@ function Overlay:show_help(msg, offset) }) end ---- Clear help message from the overlay ----@protected -function Overlay:clear_help() - vim.api.nvim_buf_del_extmark(self.bufnr, self.help_ns, 1) -end - return Overlay From 9a32dc4e49f02b126a899fc06eec7db8f0b91288 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 10 Mar 2025 01:53:27 +0100 Subject: [PATCH 117/589] refactor: move commands and highlights to plugin file Move commands, highlight definitions and treesitter registration from init.lua to a proper plugin/CopilotChat.lua file. This follows better plugin organization practices and ensures proper loading sequence. Includes: - Moving highlight definitions - Moving commands (CopilotChat*) - Moving autocmd for directory tracking - Moving treesitter language registration - Adding proper plugin loading guard --- lua/CopilotChat/init.lua | 86 +------------------------------- lua/CopilotChat/ui/chat.lua | 1 - plugin/CopilotChat.lua | 99 +++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 86 deletions(-) create mode 100644 plugin/CopilotChat.lua diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 9c50549c..e55e3e21 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1060,6 +1060,7 @@ end ---@param config CopilotChat.config? function M.setup(config) M.config = vim.tbl_deep_extend('force', require('CopilotChat.config'), config or {}) + state.highlights_loaded = false -- Save proxy and insecure settings utils.curl_store_args({ @@ -1077,24 +1078,6 @@ function M.setup(config) M.log_level(M.config.log_level) end - 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, 'CopilotChatKeyword', { link = 'Keyword', default = true }) - vim.api.nvim_set_hl(0, 'CopilotChatInput', { link = 'Special', default = true }) - vim.api.nvim_set_hl(0, 'CopilotChatSelection', { link = 'Visual', default = true }) - 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 } - ) - - state.highlights_loaded = false - if M.chat then M.chat:close(state.source and state.source.bufnr or nil) M.chat:delete() @@ -1194,73 +1177,6 @@ function M.setup(config) end end end - - vim.api.nvim_create_user_command('CopilotChat', function(args) - local input = args.args - if input and vim.trim(input) ~= '' then - M.ask(input) - else - M.open() - end - end, { - nargs = '*', - force = true, - range = true, - }) - - vim.api.nvim_create_user_command('CopilotChatPrompts', function() - M.select_prompt() - end, { force = true, range = true }) - vim.api.nvim_create_user_command('CopilotChatModels', function() - M.select_model() - end, { force = true }) - vim.api.nvim_create_user_command('CopilotChatAgents', function() - M.select_agent() - end, { force = true }) - vim.api.nvim_create_user_command('CopilotChatOpen', function() - M.open() - end, { force = true }) - vim.api.nvim_create_user_command('CopilotChatClose', function() - M.close() - end, { force = true }) - vim.api.nvim_create_user_command('CopilotChatToggle', function() - M.toggle() - end, { force = true }) - vim.api.nvim_create_user_command('CopilotChatStop', function() - M.stop() - end, { force = true }) - vim.api.nvim_create_user_command('CopilotChatReset', function() - M.reset() - end, { force = true }) - - local function complete_load() - local options = vim.tbl_map(function(file) - return vim.fn.fnamemodify(file, ':t:r') - end, vim.fn.glob(M.config.history_path .. '/*', true, true)) - - if not vim.tbl_contains(options, 'default') then - table.insert(options, 1, 'default') - end - - return options - end - vim.api.nvim_create_user_command('CopilotChatSave', function(args) - M.save(args.args) - end, { nargs = '*', force = true, complete = complete_load }) - vim.api.nvim_create_user_command('CopilotChatLoad', function(args) - M.load(args.args) - end, { nargs = '*', force = true, complete = complete_load }) - - -- Store the current directory to window when directory changes - -- I dont think there is a better way to do this that functions - -- with "rooter" plugins, LSP and stuff as vim.fn.getcwd() when - -- i pass window number inside doesnt work - vim.api.nvim_create_autocmd({ 'VimEnter', 'WinEnter', 'DirChanged' }, { - group = vim.api.nvim_create_augroup('CopilotChat', {}), - callback = function() - vim.w.cchat_cwd = vim.fn.getcwd() - end, - }) end return M diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index f436154d..5efd4d58 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -69,7 +69,6 @@ end ---@field private chat_overlay CopilotChat.ui.Overlay local Chat = class(function(self, question_header, answer_header, separator, help, on_buf_create) Overlay.init(self, 'copilot-chat', help, on_buf_create) - vim.treesitter.language.register('markdown', self.name) self.winnr = nil self.sections = {} diff --git a/plugin/CopilotChat.lua b/plugin/CopilotChat.lua new file mode 100644 index 00000000..b4dc5e44 --- /dev/null +++ b/plugin/CopilotChat.lua @@ -0,0 +1,99 @@ +if vim.g.loaded_copilot_chat then + return +end + +local min_version = '0.10.0' +if vim.fn.has('nvim-' .. min_version) ~= 1 then + vim.notify_once( + ('CopilotChat.nvim requires Neovim >= %s'):format(min_version), + vim.log.levels.ERROR + ) + return +end + +local chat = require('CopilotChat') + +-- Setup highlights +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, 'CopilotChatKeyword', { link = 'Keyword', default = true }) +vim.api.nvim_set_hl(0, 'CopilotChatInput', { link = 'Special', default = true }) +vim.api.nvim_set_hl(0, 'CopilotChatSelection', { link = 'Visual', default = true }) +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 } +) + +-- Setup commands +vim.api.nvim_create_user_command('CopilotChat', function(args) + local input = args.args + if input and vim.trim(input) ~= '' then + chat.ask(input) + else + chat.open() + end +end, { + nargs = '*', + force = true, + range = true, +}) +vim.api.nvim_create_user_command('CopilotChatPrompts', function() + chat.select_prompt() +end, { force = true, range = true }) +vim.api.nvim_create_user_command('CopilotChatModels', function() + chat.select_model() +end, { force = true }) +vim.api.nvim_create_user_command('CopilotChatAgents', function() + chat.select_agent() +end, { force = true }) +vim.api.nvim_create_user_command('CopilotChatOpen', function() + chat.open() +end, { force = true }) +vim.api.nvim_create_user_command('CopilotChatClose', function() + chat.close() +end, { force = true }) +vim.api.nvim_create_user_command('CopilotChatToggle', function() + chat.toggle() +end, { force = true }) +vim.api.nvim_create_user_command('CopilotChatStop', function() + chat.stop() +end, { force = true }) +vim.api.nvim_create_user_command('CopilotChatReset', function() + chat.reset() +end, { force = true }) + +local function complete_load() + local options = vim.tbl_map(function(file) + return vim.fn.fnamemodify(file, ':t:r') + end, vim.fn.glob(chat.config.history_path .. '/*', true, true)) + + if not vim.tbl_contains(options, 'default') then + table.insert(options, 1, 'default') + end + + return options +end +vim.api.nvim_create_user_command('CopilotChatSave', function(args) + chat.save(args.args) +end, { nargs = '*', force = true, complete = complete_load }) +vim.api.nvim_create_user_command('CopilotChatLoad', function(args) + chat.load(args.args) +end, { nargs = '*', force = true, complete = complete_load }) + +-- Store the current directory to window when directory changes +-- I dont think there is a better way to do this that functions +-- with "rooter" plugins, LSP and stuff as vim.fn.getcwd() when +-- i pass window number inside doesnt work +vim.api.nvim_create_autocmd({ 'VimEnter', 'WinEnter', 'DirChanged' }, { + group = vim.api.nvim_create_augroup('CopilotChat', {}), + callback = function() + vim.w.cchat_cwd = vim.fn.getcwd() + end, +}) + +-- Setup treesitter +vim.treesitter.language.register('markdown', 'copilot-chat') + +vim.g.loaded_copilot_chat = true From 4f544d93daecfd7ba3cd60cbbe6828a8c0f786f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Mar 2025 00:58:02 +0000 Subject: [PATCH 118/589] 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 b68a7afb..84eab3a5 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 10 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 003d2dcfc94ab64cd40899dc31852ce0749f296b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 10 Mar 2025 13:05:50 +0100 Subject: [PATCH 119/589] fix: make second part of line matching in diffs optional so we can match format like line:61 for single line diffs Signed-off-by: Tomas Slusny --- 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 5efd4d58..0c2eadf2 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -14,8 +14,8 @@ function CopilotChatFoldExpr(lnum, separator) end local HEADER_PATTERNS = { - '%[file:.+%]%((.+)%) line:(%d+)-(%d+)', - '%[file:(.+)%] line:(%d+)-(%d+)', + '%[file:.+%]%((.+)%) line:(%d+)-?(%d*)', + '%[file:(.+)%] line:(%d+)-?(%d*)', } ---@param header? string From 158d35e78d11827cd6168dc1f36aa7c6a5470c68 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 10 Mar 2025 13:55:54 +0100 Subject: [PATCH 120/589] feat(chat): add multi-diff support Enhance chat interface to support filtering sections by type (question or answer) and improve diff handling. The show_diff function now applies all diffs from the same file when in full_diff mode, providing a more complete view of changes. --- lua/CopilotChat/config/mappings.lua | 95 +++++++++++++++++------------ lua/CopilotChat/ui/chat.lua | 13 +++- 2 files changed, 66 insertions(+), 42 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index d664dc17..2097c486 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -164,7 +164,7 @@ return { normal = '', insert = '', callback = function() - local section = copilot.chat:get_closest_section() + local section = copilot.chat:get_closest_section('question') if not section or section.answer then return end @@ -233,34 +233,29 @@ return { normal = '', insert = '', callback = function(source) - local diff_data = get_diff(copilot.chat:get_closest_block()) - diff_data = prepare_diff_buffer(diff_data, source) - if diff_data then - local lines = vim.split(diff_data.change, '\n', { trimempty = false }) - vim.api.nvim_buf_set_lines( - diff_data.bufnr, - diff_data.start_line - 1, - diff_data.end_line, - false, - lines - ) - copilot.set_selection( - diff_data.bufnr, - diff_data.start_line, - diff_data.start_line + #lines - 1 - ) + local diff = get_diff(copilot.chat:get_closest_block()) + diff = prepare_diff_buffer(diff, source) + if not diff then + return end + + local lines = vim.split(diff.change, '\n', { trimempty = false }) + 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) end, }, jump_to_diff = { normal = 'gj', callback = function(source) - local diff_data = get_diff(copilot.chat:get_closest_block()) - diff_data = prepare_diff_buffer(diff_data, source) - if diff_data then - copilot.set_selection(diff_data.bufnr, diff_data.start_line, diff_data.end_line) + local diff = get_diff(copilot.chat:get_closest_block()) + diff = prepare_diff_buffer(diff, source) + if not diff then + return end + + copilot.set_selection(diff.bufnr, diff.start_line, diff.end_line) end, }, @@ -341,8 +336,9 @@ return { show_diff = { normal = 'gd', full_diff = false, -- Show full diff instead of unified diff when showing diff window - callback = function() + callback = function(source) local diff = get_diff(copilot.chat:get_closest_block()) + diff = prepare_diff_buffer(diff, source) if not diff then return end @@ -353,29 +349,48 @@ return { } if copilot.config.mappings.show_diff.full_diff then - -- Create modified version by applying the change - local modified = {} - if utils.buf_valid(diff.bufnr) then - modified = vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) - end - local change_lines = vim.split(diff.change, '\n') + local modified = utils.buf_valid(diff.bufnr) + and vim.api.nvim_buf_get_lines(diff.bufnr, 0, -1, false) + or {} - -- Replace the lines in the modified content + -- Apply all diffs from same file if #modified > 0 then - local start_idx = diff.start_line - 1 - local end_idx = diff.end_line - 1 - for _ = start_idx, end_idx do - table.remove(modified, start_idx) + -- Find all diffs from the same file in this section + local section = copilot.chat:get_closest_section('answer') + local same_file_diffs = {} + if section then + for _, block in ipairs(section.blocks) do + local block_diff = get_diff(block) + if block_diff and block_diff.bufnr == diff.bufnr then + table.insert(same_file_diffs, block_diff) + 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) end - for i, line in ipairs(change_lines) do - table.insert(modified, start_idx + i - 1, line) + + print('Applying %d diffs from %s', #same_file_diffs, diff.filename) + + 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) + 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) + end end + + opts.text = table.concat(modified, '\n') else - modified = change_lines + opts.text = diff.change end - opts.text = table.concat(modified, '\n') - opts.on_show = function() vim.api.nvim_win_call(vim.fn.bufwinid(diff.bufnr), function() vim.cmd('diffthis') @@ -411,7 +426,7 @@ return { show_info = { normal = 'gi', callback = function(source) - local section = copilot.chat:get_closest_section() + local section = copilot.chat:get_closest_section('question') if not section or section.answer then return end @@ -466,7 +481,7 @@ return { show_context = { normal = 'gc', callback = function() - local section = copilot.chat:get_closest_section() + local section = copilot.chat:get_closest_section('question') if not section or section.answer then return end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 0c2eadf2..f722b283 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -113,8 +113,9 @@ function Chat:focused() end --- Get the closest section to the cursor. +---@param type? "answer"|"question" If specified, only considers sections of the given type ---@return CopilotChat.ui.Chat.Section? -function Chat:get_closest_section() +function Chat:get_closest_section(type) if not self:visible() then return nil end @@ -126,7 +127,15 @@ function Chat:get_closest_section() local max_line_below_cursor = -1 for _, section in ipairs(self.sections) do - if section.start_line <= cursor_line and section.start_line > max_line_below_cursor then + local matches_type = not type + or (type == 'answer' and section.answer) + or (type == 'question' and not section.answer) + + if + matches_type + and section.start_line <= cursor_line + and section.start_line > max_line_below_cursor + then max_line_below_cursor = section.start_line closest_section = section end From 5ea7845ef77164192a0d0ca2c6bd3aad85b202a1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 10 Mar 2025 13:58:40 +0100 Subject: [PATCH 121/589] fix: remove debug print statements Remove print statements that were logging information about diffs being applied. These were likely added during development and are no longer needed in the codebase. --- lua/CopilotChat/config/mappings.lua | 2 -- 1 file changed, 2 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 2097c486..3c6d3564 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -372,8 +372,6 @@ return { end) end - print('Applying %d diffs from %s', #same_file_diffs, diff.filename) - for _, file_diff in ipairs(same_file_diffs) do local start_idx = file_diff.start_line local end_idx = file_diff.end_line From 91d02ad6b2a5611aef7d2c20bbe89d516eb47f0d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 10 Mar 2025 17:11:15 +0100 Subject: [PATCH 122/589] feat(debug): add history info to debug output Moves the Memory section and adds a History section to the debug display, showing the conversation history in a truncated format with roles and first lines of content. This helps with debugging conversation context and tracking interaction flow. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 30 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 3c6d3564..bd94d369 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -469,6 +469,26 @@ return { table.insert(lines, '') end + if client.memory then + table.insert(lines, '**Memory**') + table.insert(lines, '```markdown') + for _, line in ipairs(vim.split(client.memory.content, '\n')) do + table.insert(lines, line) + end + table.insert(lines, '```') + table.insert(lines, '') + end + + if not utils.empty(client.history) then + table.insert(lines, ('**History** (#%s, truncated)'):format(#client.history)) + table.insert(lines, '') + + for _, message in ipairs(client.history) do + table.insert(lines, '**' .. message.role .. '**') + table.insert(lines, '`' .. vim.split(message.content, '\n')[1] .. '`') + end + end + copilot.chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) @@ -486,16 +506,6 @@ return { local lines = {} - if client.memory then - table.insert(lines, '**Memory**') - table.insert(lines, '```markdown') - for _, line in ipairs(vim.split(client.memory.content, '\n')) do - table.insert(lines, line) - end - table.insert(lines, '```') - table.insert(lines, '') - end - local selection = copilot.get_selection() if selection then table.insert(lines, '**Selection**') From e4938f818ced9f59a6da4879b9deeaa94086d048 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 10 Mar 2025 23:15:15 +0100 Subject: [PATCH 123/589] fix: disable auto-completion without proper completeopt Prevents completion from triggering automatically in chat buffers when required completeopt settings ('noinsert' and 'popup') are missing. This resolves issues where completion would behave unexpectedly. Also updates the warning message in README.md to clarify this requirement. Signed-off-by: Tomas Slusny --- README.md | 4 ++-- lua/CopilotChat/init.lua | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0954519e..c4f303df 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - [curl](https://curl.se/) - Version 8.0.0+ recommended for best compatibility - [Copilot chat in the IDE](https://github.com/settings/copilot) enabled in GitHub settings -> [!NOTE] -> For Neovim < 0.11.0, add `noinsert` and `popup` to your `completeopt` for proper chat completion behavior. +> [!WARNING] +> For Neovim < 0.11.0, add `noinsert` and `popup` to your `completeopt` otherwise autocompletion will not work. ## Optional Dependencies diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index e55e3e21..2a328010 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1118,6 +1118,15 @@ function M.setup(config) vim.api.nvim_create_autocmd('TextChangedI', { buffer = bufnr, callback = function() + local completeopt = vim.opt.completeopt:get() + if + not vim.tbl_contains(completeopt, 'noinsert') + or not vim.tbl_contains(completeopt, 'popup') + then + -- Don't trigger completion if completeopt is not set to noinsert,popup + return + end + local line = vim.api.nvim_get_current_line() local cursor = vim.api.nvim_win_get_cursor(0) local col = cursor[2] From 335b3eaf3bcb5446e4d9439b240d12fcd7d2fd13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Mar 2025 22:18:17 +0000 Subject: [PATCH 124/589] 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 84eab3a5..653fc4d5 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -55,8 +55,8 @@ capabilities directly into your editor. It provides: - Copilot chat in the IDE enabled in GitHub settings - [!NOTE] For Neovim < 0.11.0, add `noinsert` and `popup` to your `completeopt` - for proper chat completion behavior. + [!WARNING] For Neovim < 0.11.0, add `noinsert` and `popup` to your + `completeopt` otherwise autocompletion will not work. OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* From 4078176cdf3a9f5603da2a803243e5a51fea9609 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 11 Mar 2025 20:58:02 +0100 Subject: [PATCH 125/589] refactor(context): remove redundant toLowerCase calls Remove unnecessary `:lower()` calls when processing query text, as the trigram functionality already handles case normalization. Also added missing function documentation with parameter and return type annotations. --- lua/CopilotChat/context.lua | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 6fdbb503..ae67b068 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -71,8 +71,6 @@ local OFF_SIDE_RULE_LANGUAGES = { local MIN_SYMBOL_SIMILARITY = 0.3 local MIN_SEMANTIC_SIMILARITY = 0.4 local MULTI_FILE_THRESHOLD = 5 -local MAX_FILES = 2500 -local MAX_DEPTH = 50 --- Compute the cosine similarity between two vectors ---@param a table @@ -154,19 +152,24 @@ local function trigram_similarity(set1, set2) return intersection / union end +--- Rank data by symbols and filenames +---@param query string +---@param data table +---@param min_similarity number +---@return table local function data_ranked_by_symbols(query, data, min_similarity) -- Get query trigrams including compound versions local query_trigrams = {} -- Add trigrams for each word - for term in query:lower():gmatch('%w+') do + 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:lower():gsub('[^%w]', '') + local compound_query = query:gsub('[^%w]', '') for trigram in pairs(get_trigrams(compound_query)) do query_trigrams[trigram] = true end From 371620e550efda53fa97cf177448b46dbd100c63 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 11 Mar 2025 19:59:12 +0000 Subject: [PATCH 126/589] 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 653fc4d5..44d89841 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 10 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 11 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 6bd7dd48966f3fa97e3b930771ad24390c2c1843 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 12 Mar 2025 10:26:29 +0100 Subject: [PATCH 127/589] fix(error-handling): simplify error handling conditions The changes remove redundant error handling for empty responses and eliminate an unnecessary call to utils.schedule_main() in the error handling path. This simplifies the code flow while maintaining proper error reporting. --- lua/CopilotChat/init.lua | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 2a328010..65467874 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -880,10 +880,7 @@ function M.ask(prompt, config) utils.schedule_main() - if not ask_ok or utils.empty(response) then - if utils.empty(response) then - response = 'Failed to get response: empty response' - end + if not ask_ok then log.error(response) if not config.headless then show_error(response, has_output) @@ -913,7 +910,6 @@ function M.ask(prompt, config) end) if not ok then - utils.schedule_main() log.error(err) if not config.headless then show_error(err) From 4012712aac2c892294fa12505cb4cc829f161f6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 12 Mar 2025 09:27:24 +0000 Subject: [PATCH 128/589] 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 44d89841..5740cb57 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 11 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 12 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 14f15e64d1d0ebb2213f94405f39ef476a4df07f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 12 Mar 2025 21:06:12 +0100 Subject: [PATCH 129/589] fix: filter nil values from modified array When concatenating the modified array into a string, nil values could cause errors. This change adds a filter step to remove any nil values before concatenation, preventing potential runtime errors. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index bd94d369..7b9205d9 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -384,6 +384,10 @@ return { end end + modified = vim.tbl_filter(function(line) + return line ~= nil + end, modified) + opts.text = table.concat(modified, '\n') else opts.text = diff.change From 9b57765f31ca7e47a6a9fd8bb21a931821c98015 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 13 Mar 2025 19:12:22 +0100 Subject: [PATCH 130/589] feat: add context provider command helpers to prompt Add system prompt section that instructs Copilot to request additional context using available context providers (#command:input format). Available context providers with descriptions are now included in the system prompt, making it easier for users to get relevant context for their queries. The implementation passes context metadata to the LLM and includes proper formatting examples, ensuring that Copilot can effectively guide users to provide the right context when needed. --- lua/CopilotChat/client.lua | 49 ++++++++++++++++++++++++++---- lua/CopilotChat/config/prompts.lua | 1 - lua/CopilotChat/init.lua | 35 +++++++++++++++------ 3 files changed, 68 insertions(+), 17 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 56554778..1d91c086 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -1,6 +1,7 @@ ---@class CopilotChat.Client.ask ---@field load_history boolean ---@field store_history boolean +---@field contexts table? ---@field selection CopilotChat.select.selection? ---@field embeddings table? ---@field system_prompt string @@ -202,14 +203,49 @@ end --- Generate ask request --- @param history table --- @param memory CopilotChat.Client.memory? +--- @param contexts table? --- @param prompt string --- @param system_prompt string --- @param generated_messages table -local function generate_ask_request(history, memory, prompt, system_prompt, generated_messages) +local function generate_ask_request( + history, + memory, + contexts, + prompt, + system_prompt, + generated_messages +) local messages = {} - local contexts = {} + local context_references = {} local combined_system = system_prompt + + if contexts and not vim.tbl_isempty(contexts) then + local help_text = [[ + If you don't have sufficient context to answer accurately, ask for user to provide more context using any of these context providers: + + > #: + + For example: + > #file:path/to/file.js + > #buffers:visible + > #git:staged + + Note: For inputs with spaces, use backticks: #file:`path/to file with spaces.js` + + Available context providers: + ]] + + local context_names = vim.tbl_keys(contexts) + table.sort(context_names) + for _, name in ipairs(context_names) do + local description = contexts[name] + help_text = help_text .. '\n' .. string.format('- #%s: %s', name, description) + end + + combined_system = combined_system .. '\n' .. help_text + end + if memory and memory.content and memory.content ~= '' then if combined_system ~= '' then combined_system = combined_system @@ -235,7 +271,7 @@ local function generate_ask_request(history, memory, prompt, system_prompt, gene }) if message.context then - contexts[message.context] = true + context_references[message.context] = true end end @@ -243,8 +279,8 @@ local function generate_ask_request(history, memory, prompt, system_prompt, gene table.insert(messages, message) end - if not vim.tbl_isempty(contexts) then - prompt = table.concat(vim.tbl_keys(contexts), '\n') .. '\n' .. prompt + if not vim.tbl_isempty(context_references) then + prompt = table.concat(vim.tbl_keys(context_references), '\n') .. '\n' .. prompt end table.insert(messages, { @@ -396,6 +432,7 @@ function Client:ask(prompt, opts) opts.agent = nil end + local contexts = opts.contexts local embeddings = opts.embeddings or {} local selection = opts.selection or {} local system_prompt = vim.trim(opts.system_prompt) @@ -658,7 +695,7 @@ function Client:ask(prompt, opts) local headers = self:authenticate(provider_name) local request = provider.prepare_input( - generate_ask_request(history, self.memory, prompt, system_prompt, generated_messages), + generate_ask_request(history, self.memory, contexts, prompt, system_prompt, generated_messages), options ) local is_stream = request.stream diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 05775607..c6a952b7 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -9,7 +9,6 @@ Keep your answers short and impersonal. The user works in an IDE called Neovim which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal. The user is working on a %s machine. Please respond with system specific commands if applicable. You will receive code snippets that include line number prefixes - use these to maintain correct position references but remove them when generating output. -If you don't have sufficient context to answer accurately, ask for specific additional information rather than making assumptions. When presenting code changes: diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 65467874..2064e05b 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -175,20 +175,26 @@ local function finish(start_of_chat) state.last_prompt = insert_sticky(state.last_prompt, M.config, true) end - -- Reinsert sticky prompts from last prompt + -- Reinsert sticky prompts from last prompt and last response + local lines = {} if state.last_prompt then - local has_sticky = false - local lines = vim.split(state.last_prompt, '\n') - for _, line in ipairs(lines) do - if vim.startswith(line, '> ') then - M.chat:append(line .. '\n') - has_sticky = true - end + lines = vim.split(state.last_prompt, '\n') + end + if state.last_response then + for _, line in ipairs(vim.split(state.last_response, '\n')) do + table.insert(lines, line) end - if has_sticky then - M.chat:append('\n') + end + local has_sticky = false + for _, line in ipairs(lines) do + if vim.startswith(line, '> ') then + M.chat:append(line .. '\n') + has_sticky = true end end + if has_sticky then + M.chat:append('\n') + end M.chat:finish() end @@ -831,6 +837,14 @@ function M.ask(prompt, config) config, prompt = M.resolve_prompt(prompt, config) local system_prompt = config.system_prompt or '' + -- Resolve context name and description + local contexts = {} + for name, context in pairs(M.config.contexts) do + if context.description then + contexts[name] = context.description + end + end + -- Remove sticky prefix prompt = vim.trim(table.concat( vim.tbl_map(function(l) @@ -864,6 +878,7 @@ function M.ask(prompt, config) pcall(client.ask, client, prompt, { load_history = not config.headless, store_history = not config.headless, + contexts = contexts, selection = selection, embeddings = filtered_embeddings, system_prompt = system_prompt, From 42419fa4e433b4b00f2c929f2b322d23abb90b9b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Mar 2025 18:13:55 +0000 Subject: [PATCH 131/589] 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 5740cb57..d81fe324 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 12 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 13 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 99bd1591ae1dfd48d2c3b69247193e6f9cc0ba39 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 13 Mar 2025 19:47:02 +0100 Subject: [PATCH 132/589] feat(context): improve context help and code block handling Enhance the context suggestion UI by: - Adding clearer examples for context providers with descriptions - Fixing indentation in the help text for better readability - Adding logic to detect code blocks to prevent parsing '>' markers inside code examples as context requests - Improving guidance for users when requesting additional context This change makes the context help more user-friendly and prevents false positives when parsing context requests. --- lua/CopilotChat/client.lua | 21 +++++++++++---------- lua/CopilotChat/init.lua | 6 +++++- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 1d91c086..5b82ec44 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -217,24 +217,25 @@ local function generate_ask_request( ) local messages = {} local context_references = {} - local combined_system = system_prompt + -- Include context help if contexts and not vim.tbl_isempty(contexts) then local help_text = [[ - If you don't have sufficient context to answer accurately, ask for user to provide more context using any of these context providers: + If you don't have sufficient context to answer accurately, ask for user to provide more context using any of these context providers: - > #: + > #: - For example: - > #file:path/to/file.js - > #buffers:visible - > #git:staged + For example: + > #file:path/to/file.js (loads a specific file) + > #file:`path/to file with spaces.js` (loads a file with spaces in name) + > #buffers:visible (loads all visible buffers) + > #git:staged (loads git staged changes) - Note: For inputs with spaces, use backticks: #file:`path/to file with spaces.js` + Do not make assumptions about code or files - always request context when needed rather than guessing. + Always use the > format on a new line when requesting more context instead of asking in prose. - Available context providers: - ]] + Available context providers:]] local context_names = vim.tbl_keys(contexts) table.sort(context_names) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 2064e05b..f0e9e01e 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -186,8 +186,12 @@ local function finish(start_of_chat) end end local has_sticky = false + local in_code_block = false for _, 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 M.chat:append(line .. '\n') has_sticky = true end From b44ccd708c29e64f7cc39e3a3c0bcff091ae28cd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 13 Mar 2025 20:20:50 +0100 Subject: [PATCH 133/589] docs: update external provider references and add AI info (#977) * docs: update external provider references and add AI info Add Mistral.ai to the list of supported external providers in multiple locations. Change link from wiki page to discussion page for external providers. Add informational note about AI's ability to request additional context from users. 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 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c4f303df..8f4a4cc7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system - 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection -- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, and more) +- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, Mistral.ai and more) - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts - 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) @@ -321,6 +321,10 @@ Contexts provide additional information to the chat. Add context using `#context | `quickfix` | - | Quickfix list file contents | | `system` | ✓ (command) | Output of shell command | +> [!TIP] +> The AI is aware of these context providers and may request additional context +> if needed by asking you to input a specific context command like `#file:path/to/file.js`. + Examples: ```markdown @@ -426,7 +430,7 @@ Custom providers can implement these methods: ### External Providers -For external providers (Ollama, LM Studio), see the [external providers wiki page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/wiki/External-Providers). +For external providers (Ollama, LM Studio, Mistral.ai), see the [external providers discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/external-providers). # Configuration From 736cbeb653ba0e09cc01ef669443e54c2bd45037 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Mar 2025 19:21:07 +0000 Subject: [PATCH 134/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index d81fe324..bbfed3ef 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -39,7 +39,7 @@ capabilities directly into your editor. It provides: - 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system - 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection -- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, and more) +- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, Mistral.ai and more) - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts - 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) @@ -366,6 +366,10 @@ Contexts provide additional information to the chat. Add context using register ✓ (name) Content of vim register quickfix - Quickfix list file contents system ✓ (command) Output of shell command + + [!TIP] The AI is aware of these context providers and may request additional + context if needed by asking you to input a specific context command like + `#file:path/to/file.js`. Examples: >markdown @@ -477,9 +481,9 @@ Custom providers can implement these methods: EXTERNAL PROVIDERS ~ -For external providers (Ollama, LM Studio), see the external providers wiki -page -. +For external providers (Ollama, LM Studio, Mistral.ai), see the external +providers discussion page +. ============================================================================== From cbacbba1882517e3b244aa1b69cdd5ab311f6325 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 13 Mar 2025 22:38:43 +0100 Subject: [PATCH 135/589] Update README.md --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f4a4cc7..95a40f30 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,10 @@ Define your own contexts in the configuration with input handling and resolution } ``` +### External Contexts + +For external contexts, see the [contexts discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/contexts). + ## Selections Selections determine the source content for chat interactions. @@ -430,7 +434,7 @@ Custom providers can implement these methods: ### External Providers -For external providers (Ollama, LM Studio, Mistral.ai), see the [external providers discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/external-providers). +For external providers (Ollama, LM Studio, Mistral.ai), see the [providers discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/providers). # Configuration From 9e7eb5537166bc68fdd998bbd131a262a6ef81f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Mar 2025 21:39:05 +0000 Subject: [PATCH 136/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index bbfed3ef..cd3cfba4 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -409,6 +409,12 @@ resolution: < +EXTERNAL CONTEXTS ~ + +For external contexts, see the contexts discussion page +. + + SELECTIONS *CopilotChat-selections* Selections determine the source content for chat interactions. @@ -481,9 +487,9 @@ Custom providers can implement these methods: EXTERNAL PROVIDERS ~ -For external providers (Ollama, LM Studio, Mistral.ai), see the external -providers discussion page -. +For external providers (Ollama, LM Studio, Mistral.ai), see the providers +discussion page +. ============================================================================== From f5202ce159087c8c0e2044b5604e6ec5c2e2c30f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 13 Mar 2025 22:55:55 +0100 Subject: [PATCH 137/589] fix: guard against auth and API failures Improve error handling in fetch_models and fetch_agents by adding explicit authentication failure checks and using goto statements to continue loop execution when errors occur. This prevents the code from attempting to process invalid responses. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 54 ++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 20 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 5b82ec44..e1cc7056 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -366,19 +366,26 @@ function Client:fetch_models() local provider = self.providers[provider_name] if not provider.disabled and provider.get_models then notify.publish(notify.STATUS, 'Fetching models from ' .. provider_name) - local headers = self:authenticate(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 ok then - for _, model in ipairs(provider_models) do - model.provider = provider_name - if models[model.id] then - model.id = model.id .. ':' .. provider_name - end - models[model.id] = model - end - else + if not ok then log.warn('Failed to fetch models from ' .. provider_name .. ': ' .. provider_models) + goto continue end + + for _, model in ipairs(provider_models) do + model.provider = provider_name + if models[model.id] then + model.id = model.id .. ':' .. provider_name + end + models[model.id] = model + end + + ::continue:: end end @@ -401,19 +408,26 @@ function Client:fetch_agents() local provider = self.providers[provider_name] if not provider.disabled and provider.get_agents then notify.publish(notify.STATUS, 'Fetching agents from ' .. provider_name) - local headers = self:authenticate(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_agents = pcall(provider.get_agents, headers) - if ok then - for _, agent in ipairs(provider_agents) do - agent.provider = provider_name - if agents[agent.id] then - agent.id = agent.id .. ':' .. provider_name - end - agents[agent.id] = agent - end - else + if not ok then log.warn('Failed to fetch agents from ' .. provider_name .. ': ' .. provider_agents) + goto continue end + + for _, agent in ipairs(provider_agents) do + agent.provider = provider_name + if agents[agent.id] then + agent.id = agent.id .. ':' .. provider_name + end + agents[agent.id] = agent + end + + ::continue:: end end From b03b10d61818874d252453c1c4cb3a968277992f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 00:24:11 +0100 Subject: [PATCH 138/589] refactor(context): simplify context handling with direct methods Reorganize context handling by moving functionality from separate methods into more streamlined direct implementations. Replace context.buffer(), context.file(), etc. with exposed get_* methods that can be called directly. The changes also improve notification transparency by showing which context is being resolved and with what input. This refactoring removes redundant code while maintaining the same functionality, resulting in a more maintainable codebase. Signed-off-by: Tomas Slusny --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 19 +-- .pre-commit-config.yaml | 2 +- lua/CopilotChat/config/contexts.lua | 173 ++++++++++++++++++-- lua/CopilotChat/context.lua | 243 ++-------------------------- lua/CopilotChat/init.lua | 7 + lua/CopilotChat/utils.lua | 4 + 7 files changed, 195 insertions(+), 260 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5752b6c6..d5831a56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,10 @@ name: Ci -on: [push] +on: + push: + branches: [main] + pull_request: + branches: [main] jobs: lint: @@ -17,7 +21,6 @@ jobs: docs: runs-on: ubuntu-latest permissions: - # Give the default GITHUB_TOKEN write permission to commit and push the changed files back to the repository. contents: write name: pandoc to vimdoc if: ${{ github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b0409f6..b21662e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,8 @@ name: Release on: push: - branches: - - release - pull_request: branches: - main - - release permissions: contents: write @@ -17,22 +13,9 @@ jobs: name: release runs-on: ubuntu-latest steps: - - uses: google-github-actions/release-please-action@v4 + - uses: googleapis/release-please-action@v4 id: release with: release-type: simple package-name: CopilotChat.nvim token: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: tag stable versions - if: ${{ steps.release.outputs.release_created }} - run: | - git config user.name github-actions[bot] - git config user.email github-actions[bot]@users.noreply.github.com - git remote add gh-token "https://${{ secrets.GITHUB_TOKEN }}@github.com/google-github-actions/release-please-action.git" - git tag -d stable || true - git push origin :stable || true - git tag -a stable -m "Last Stable Release" - git push origin stable diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1be8c200..1604446b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,4 +6,4 @@ repos: - repo: https://github.com/JohnnyMorganz/StyLua rev: v2.0.2 hooks: - - id: stylua # or stylua-system / stylua-github + - id: stylua-github diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 66ed2652..be31e549 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -33,8 +33,10 @@ return { end, resolve = function(input, source) input = input and tonumber(input) or source.bufnr + + utils.schedule_main() return { - context.buffer(input), + context.get_buffer(input), } end, }, @@ -48,7 +50,16 @@ return { end, resolve = function(input) input = input or 'listed' - return context.buffers(input) + + utils.schedule_main() + return vim.tbl_map( + context.get_buffer, + vim.tbl_filter(function(b) + return utils.buf_valid(b) + and vim.fn.buflisted(b) == 1 + and (input == 'listed' or #vim.fn.win_findbuf(b) > 0) + end, vim.api.nvim_list_bufs()) + ) end, }, @@ -66,8 +77,13 @@ return { }, callback) end, resolve = function(input) + if not input or input == '' then + return {} + end + + utils.schedule_main() return { - context.file(input), + context.get_file(utils.filepath(input)), } end, }, @@ -80,9 +96,33 @@ return { }, callback) end, resolve = function(input, source) - return context.files(source.winnr, true, { + local out = {} + local cwd = utils.win_cwd(source.winnr) + local files = utils.scan_dir(cwd, { glob = input, }) + + utils.schedule_main() + files = vim.tbl_filter( + function(file) + return file.ft ~= nil + end, + vim.tbl_map(function(file) + return { + name = utils.filepath(file), + ft = utils.filetype(file), + } + end, files) + ) + + for _, file in ipairs(files) do + local file_data = context.get_file(file.name, file.ft) + if file_data then + table.insert(out, file_data) + end + end + + return out end, }, @@ -94,9 +134,31 @@ return { }, callback) end, resolve = function(input, source) - return context.files(source.winnr, false, { + local out = {} + local cwd = utils.win_cwd(source.winnr) + local files = utils.scan_dir(cwd, { glob = input, }) + + local chunk_size = 100 + for i = 1, #files, chunk_size do + local chunk = {} + for j = i, math.min(i + chunk_size - 1, #files) do + table.insert(chunk, files[j]) + end + + local chunk_number = math.floor(i / chunk_size) + local chunk_name = chunk_number == 0 and 'file_map' or 'file_map' .. tostring(chunk_number) + + table.insert(out, { + content = table.concat(chunk, '\n'), + filename = chunk_name, + filetype = 'text', + score = 0.1, + }) + end + + return out end, }, @@ -109,8 +171,32 @@ return { end, resolve = function(input, source) input = input or 'unstaged' + local cwd = utils.win_cwd(source.winnr) + local cmd = { + 'git', + '-C', + cwd, + 'diff', + '--no-color', + '--no-ext-diff', + } + + if input == 'staged' then + table.insert(cmd, '--staged') + elseif input == 'unstaged' then + table.insert(cmd, '--') + else + table.insert(cmd, input) + end + + local out = utils.system(cmd) + return { - context.gitdiff(input, source.winnr), + { + content = out.stdout, + filename = 'git_diff_' .. input, + filetype = 'diff', + }, } end, }, @@ -125,7 +211,7 @@ return { end, resolve = function(input) return { - context.url(input), + context.get_url(input), } end, }, @@ -158,8 +244,19 @@ return { end, resolve = function(input) input = input or '+' + + utils.schedule_main() + local lines = vim.fn.getreg(input) + if not lines or lines == '' then + return {} + end + return { - context.register(input), + { + content = lines, + filename = 'vim_register_' .. input, + filetype = '', + }, } end, }, @@ -167,7 +264,41 @@ return { quickfix = { description = 'Includes quickfix list file contents in chat context.', resolve = function() - return context.quickfix() + utils.schedule_main() + + local items = vim.fn.getqflist() + if not items or #items == 0 then + return {} + end + + local unique_files = {} + for _, item in ipairs(items) do + local filename = item.filename or vim.api.nvim_buf_get_name(item.bufnr) + if filename then + unique_files[filename] = true + end + end + + local files = vim.tbl_filter( + function(file) + return file.ft ~= nil + end, + vim.tbl_map(function(file) + return { + name = utils.filepath(file), + ft = utils.filetype(file), + } + end, vim.tbl_keys(unique_files)) + ) + + local out = {} + for _, file in ipairs(files) do + local file_data = context.get_file(file.name, file.ft) + if file_data then + table.insert(out, file_data) + end + end + return out end, }, @@ -179,8 +310,30 @@ return { }, callback) end, resolve = function(input) + if not input or input == '' then + return {} + end + + utils.schedule_main() + + local shell, shell_flag + if vim.fn.has('win32') == 1 then + shell, shell_flag = 'cmd.exe', '/c' + else + shell, shell_flag = 'sh', '-c' + end + + local out = utils.system({ shell, shell_flag, input }) + if not out or out.stdout == '' then + return {} + end + return { - context.system(input), + { + content = out.stdout, + filename = 'command_output_' .. input:gsub('[^%w]', '_'):sub(1, 20), + filetype = 'text', + }, } end, }, diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index ae67b068..366b0bb8 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -268,7 +268,7 @@ end ---@param filename string ---@param ft string ---@return CopilotChat.context.embed -local function build_outline(content, filename, ft) +function M.get_outline(content, filename, ft) ---@type CopilotChat.context.embed local output = { filename = filename, @@ -344,10 +344,15 @@ end --- Get data for a file ---@param filename string ----@param filetype string +---@param filetype string? ---@return CopilotChat.context.embed? -local function get_file(filename, filetype) - notify.publish(notify.STATUS, 'Reading file ' .. filename) +function M.get_file(filename, filetype) + if not filetype then + filetype = utils.filetype(filename) + end + if not filetype then + return nil + end local modified = utils.file_mtime(filename) if not modified then @@ -361,7 +366,7 @@ local function get_file(filename, filetype) local content = utils.read_file(filename) if content then - local outline = build_outline(content, filename, filetype) + local outline = M.get_outline(content, filename, filetype) file_cache[filename] = { outline = outline, modified = modified, @@ -373,92 +378,10 @@ local function get_file(filename, filetype) return nil end ---- Get list of all files in workspace ----@param winnr number? ----@param with_content boolean? ----@param search_options CopilotChat.utils.scan_dir_opts? ----@return table -function M.files(winnr, with_content, search_options) - local cwd = utils.win_cwd(winnr) - - notify.publish(notify.STATUS, 'Scanning files') - - local files = utils.scan_dir(cwd, search_options) - - notify.publish(notify.STATUS, 'Reading files') - - local out = {} - - -- Create file list in chunks - local chunk_size = 100 - for i = 1, #files, chunk_size do - local chunk = {} - for j = i, math.min(i + chunk_size - 1, #files) do - table.insert(chunk, files[j]) - end - - local chunk_number = math.floor(i / chunk_size) - local chunk_name = chunk_number == 0 and 'file_map' or 'file_map' .. tostring(chunk_number) - - table.insert(out, { - content = table.concat(chunk, '\n'), - filename = chunk_name, - filetype = 'text', - score = 0.1, - }) - end - - -- Read file contents - if with_content then - utils.schedule_main() - - files = vim.tbl_filter( - function(file) - return file.ft ~= nil - end, - vim.tbl_map(function(file) - return { - name = utils.filepath(file), - ft = utils.filetype(file), - } - end, files) - ) - - for _, file in ipairs(files) do - local file_data = get_file(file.name, file.ft) - if file_data then - table.insert(out, file_data) - end - end - end - - return out -end - ---- Get the content of a file ----@param filename? string ----@return CopilotChat.context.embed? -function M.file(filename) - if not filename or filename == '' then - return nil - end - - utils.schedule_main() - local ft = utils.filetype(filename) - if not ft then - return nil - end - - return get_file(utils.filepath(filename), ft) -end - ---- Get the content of a buffer +--- Get data for a buffer ---@param bufnr number ---@return CopilotChat.context.embed? -function M.buffer(bufnr) - notify.publish(notify.STATUS, 'Reading buffer ' .. bufnr) - utils.schedule_main() - +function M.get_buffer(bufnr) if not utils.buf_valid(bufnr) then return nil end @@ -468,7 +391,7 @@ function M.buffer(bufnr) return nil end - local out = build_outline( + local out = M.get_outline( table.concat(content, '\n'), utils.filepath(vim.api.nvim_buf_get_name(bufnr)), vim.bo[bufnr].filetype @@ -479,34 +402,16 @@ function M.buffer(bufnr) return out end ---- Get content of all buffers ----@param buf_type string ----@return table -function M.buffers(buf_type) - utils.schedule_main() - - return vim.tbl_map( - M.buffer, - vim.tbl_filter(function(b) - return utils.buf_valid(b) - and vim.fn.buflisted(b) == 1 - and (buf_type == 'listed' or #vim.fn.win_findbuf(b) > 0) - end, vim.api.nvim_list_bufs()) - ) -end - --- Get the content of an URL ---@param url string ---@return CopilotChat.context.embed? -function M.url(url) +function M.get_url(url) if not url or url == '' then return nil end local content = url_cache[url] if not content then - notify.publish(notify.STATUS, 'Fetching ' .. url) - local ok, out = async.util.apcall(utils.system, { 'lynx', '-dump', url }) if ok and out and out.code == 0 then -- Use lynx to fetch content @@ -555,126 +460,6 @@ function M.url(url) } end ---- Get current git diff ----@param type string ----@param winnr number ----@return CopilotChat.context.embed? -function M.gitdiff(type, winnr) - notify.publish(notify.STATUS, 'Fetching git diff') - - local cwd = utils.win_cwd(winnr) - local cmd = { - 'git', - '-C', - cwd, - 'diff', - '--no-color', - '--no-ext-diff', - } - - if type == 'staged' then - table.insert(cmd, '--staged') - elseif type == 'unstaged' then - table.insert(cmd, '--') - else - table.insert(cmd, type) - end - - local out = utils.system(cmd) - - return { - content = out.stdout, - filename = 'git_diff_' .. type, - filetype = 'diff', - } -end - ---- Return contents of specified register ----@param register string ----@return CopilotChat.context.embed? -function M.register(register) - notify.publish(notify.STATUS, 'Reading register ' .. register) - utils.schedule_main() - - local lines = vim.fn.getreg(register) - if not lines or lines == '' then - return nil - end - - return { - content = lines, - filename = 'vim_register_' .. register, - filetype = '', - } -end - ---- Get the content of the quickfix list ----@return table -function M.quickfix() - notify.publish(notify.STATUS, 'Reading quickfix list') - utils.schedule_main() - - local items = vim.fn.getqflist() - if not items or #items == 0 then - return {} - end - - local unique_files = {} - for _, item in ipairs(items) do - local filename = item.filename or vim.api.nvim_buf_get_name(item.bufnr) - if filename then - unique_files[filename] = true - end - end - - local files = vim.tbl_filter( - function(file) - return file.ft ~= nil - end, - vim.tbl_map(function(file) - return { - name = utils.filepath(file), - ft = utils.filetype(file), - } - end, vim.tbl_keys(unique_files)) - ) - - local out = {} - for _, file in ipairs(files) do - local file_data = get_file(file.name, file.ft) - if file_data then - table.insert(out, file_data) - end - end - return out -end - ---- Get the output of a system shell command ----@param command string The command to execute ----@return CopilotChat.context.embed? -function M.system(command) - notify.publish(notify.STATUS, 'Executing command: ' .. command) - utils.schedule_main() - - local shell, shell_flag - if vim.fn.has('win32') == 1 then - shell, shell_flag = 'cmd.exe', '/c' - else - shell, shell_flag = 'sh', '-c' - end - - local out = utils.system({ shell, shell_flag, command }) - if not out or out.stdout == '' then - return nil - end - - return { - content = out.stdout, - filename = 'command_output_' .. command:gsub('[^%w]', '_'):sub(1, 20), - filetype = 'text', - } -end - --- Filter embeddings based on the query ---@param prompt string ---@param model string diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index f0e9e01e..b1382ebb 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 context = require('CopilotChat.context') local client = require('CopilotChat.client') +local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local PLUGIN_NAME = 'CopilotChat' @@ -389,6 +390,12 @@ function M.resolve_context(prompt, config) local embeddings = utils.ordered_map() for _, context_data in ipairs(contexts) do local context_value = M.config.contexts[context_data.name] + notify.publish( + notify.STATUS, + 'Resolving context: ' + .. context_data.name + .. (context_data.input and ' with input: ' .. context_data.input or '') + ) for _, embedding in ipairs(context_value.resolve(context_data.input, state.source or {}, prompt)) do diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 5e52cfc0..b5d2d5b1 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -199,6 +199,10 @@ end function M.filetype(filename) local ft = vim.filetype.match({ filename = filename }) + if not ft and vim.endswith(filename, '.sh') then + return 'sh' + end + -- weird TypeScript bug for vim.filetype.match -- see: https://github.com/neovim/neovim/issues/27265 if not ft then From 28cd256c4210819d7413a882f19c01152dbde570 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 00:36:42 +0100 Subject: [PATCH 139/589] fix: remove unnecessary package-name parameter The package-name parameter wasn't required by the release workflow and has been removed to simplify configuration. Signed-off-by: Tomas Slusny --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b21662e4..a809e59e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,5 +17,4 @@ jobs: id: release with: release-type: simple - package-name: CopilotChat.nvim token: ${{ secrets.GITHUB_TOKEN }} From 7d7666393fa15ed0a78500f8ae1554aaaaf96989 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 01:34:08 +0100 Subject: [PATCH 140/589] refactor(context): standardize backtick syntax for providers Update the context prompt to consistently use backticks around all inputs for context providers. This makes the syntax more uniform and clarifies how to properly use context providers with spaces or special characters in input parameters. Also clarifies the system context provider description to indicate it should be used judiciously. --- lua/CopilotChat/client.lua | 10 +++++----- lua/CopilotChat/config/contexts.lua | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index e1cc7056..ccc9bb06 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -224,13 +224,13 @@ local function generate_ask_request( local help_text = [[ If you don't have sufficient context to answer accurately, ask for user to provide more context using any of these context providers: - > #: + > #:`` For example: - > #file:path/to/file.js (loads a specific file) - > #file:`path/to file with spaces.js` (loads a file with spaces in name) - > #buffers:visible (loads all visible buffers) - > #git:staged (loads git staged changes) + > #file:`path/to/file.js` (loads a specific file) + > #buffers:`visible` (loads all visible buffers) + > #git:`staged` (loads git staged changes) + > #system:`uname -a` (loads system information) Do not make assumptions about code or files - always request context when needed rather than guessing. Always use the > format on a new line when requesting more context instead of asking in prose. diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index be31e549..0d5978b9 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -303,7 +303,7 @@ return { }, system = { - description = 'Includes output of provided system shell command in chat context. Supports input.', + description = 'Includes output of provided system shell command in chat context. Use only when necessary. Supports input.', input = function(callback) vim.ui.input({ prompt = 'Enter command> ', From 60b0f113db848ed43beffbecd42aa36aac3cadce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Mar 2025 00:35:05 +0000 Subject: [PATCH 141/589] 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 cd3cfba4..63f3fdc4 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 13 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 14 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From d88b0f2cc4006baa39de2a1af89e2275c247d8e3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 01:43:36 +0100 Subject: [PATCH 142/589] feat(system): improve system command context error handling Display error messages when system commands fail, showing the stderr output when available, or a generic failure message with the exit code when stderr is empty. Additionally, differentiate between successful and failed commands by using distinct filenames for better context in the Copilot Chat UI. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/contexts.lua | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 0d5978b9..9545e7ad 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -324,14 +324,25 @@ return { end local out = utils.system({ shell, shell_flag, input }) - if not out or out.stdout == '' then + if not out then return {} end + local out_type = 'command_output' + local out_text = out.stdout + if out.code ~= 0 then + out_type = 'command_error' + if out.stderr and out.stderr ~= '' then + out_text = out.stderr + elseif not out_text or out_text == '' then + out_text = 'Command failed with exit code ' .. out.code + end + end + return { { - content = out.stdout, - filename = 'command_output_' .. input:gsub('[^%w]', '_'):sub(1, 20), + content = out_text, + filename = out_type .. '_' .. input:gsub('[^%w]', '_'):sub(1, 20), filetype = 'text', }, } From 6482837ffbfd6be9a55ea78b916ef3b992816c06 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 01:54:49 +0100 Subject: [PATCH 143/589] refactor(context)!: move cwd handling to source object Move window working directory logic from utils.win_cwd() to a method on the source object. This improves encapsulation by keeping the directory handling logic closer to the source object itself, while maintaining the same functionality. The new approach uses a cwd() function on the source object that returns the appropriate directory value, simplifying how context providers access the current working directory. --- lua/CopilotChat/config/contexts.lua | 12 ++++-------- lua/CopilotChat/init.lua | 8 ++++++++ lua/CopilotChat/utils.lua | 16 ---------------- 3 files changed, 12 insertions(+), 24 deletions(-) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 9545e7ad..4d5a3626 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -66,8 +66,7 @@ return { file = { description = 'Includes content of provided file in chat context. Supports input.', input = function(callback, source) - local cwd = utils.win_cwd(source.winnr) - local files = utils.scan_dir(cwd, { + local files = utils.scan_dir(source.cwd(), { max_count = 0, }) @@ -97,8 +96,7 @@ return { end, resolve = function(input, source) local out = {} - local cwd = utils.win_cwd(source.winnr) - local files = utils.scan_dir(cwd, { + local files = utils.scan_dir(source.cwd(), { glob = input, }) @@ -135,8 +133,7 @@ return { end, resolve = function(input, source) local out = {} - local cwd = utils.win_cwd(source.winnr) - local files = utils.scan_dir(cwd, { + local files = utils.scan_dir(source.cwd(), { glob = input, }) @@ -171,11 +168,10 @@ return { end, resolve = function(input, source) input = input or 'unstaged' - local cwd = utils.win_cwd(source.winnr) local cmd = { 'git', '-C', - cwd, + source.cwd(), 'diff', '--no-color', '--no-ext-diff', diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index b1382ebb..761c18d9 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -17,6 +17,7 @@ local M = {} --- @class CopilotChat.source --- @field bufnr number --- @field winnr number +--- @field cwd fun():string --- @class CopilotChat.state --- @field source CopilotChat.source? @@ -290,6 +291,13 @@ local function update_source() state.source = { bufnr = source_bufnr, winnr = source_winnr, + cwd = function() + local dir = vim.w[source_winnr].cchat_cwd + if not dir or dir == '' then + return '.' + end + return dir + end, } end end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index b5d2d5b1..18e21ae7 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -274,22 +274,6 @@ function M.make_string(...) return table.concat(t, ' ') end ---- Get current working directory for target window ----@param winnr number? The buffer number ----@return string -function M.win_cwd(winnr) - if not winnr then - return '.' - end - - local dir = vim.w[winnr].cchat_cwd - if not dir or dir == '' then - return '.' - end - - return dir -end - --- Decode json ---@param body string The json string ---@return table, string? From b0893fff5f2d3b22155f3113381a614fd4f65a8a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 02:38:08 +0100 Subject: [PATCH 144/589] feat: expose source buffer API and support disabling mappings Refactor source buffer handling by exposing public API functions to get and set the source buffer. This allows greater flexibility for plugins or users who need to interact with the source buffer programmatically. Also update the mapping type definitions to explicitly support false/nil values for disabling specific mappings, and add null checks to prevent errors when mappings are disabled. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 30 +++++++------- lua/CopilotChat/init.lua | 61 ++++++++++++++++++----------- lua/CopilotChat/utils.lua | 4 ++ 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 7b9205d9..a0b2616b 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -123,19 +123,19 @@ end ---@field full_diff boolean? ---@class CopilotChat.config.mappings ----@field complete CopilotChat.config.mapping? ----@field close CopilotChat.config.mapping? ----@field reset CopilotChat.config.mapping? ----@field submit_prompt CopilotChat.config.mapping? ----@field toggle_sticky CopilotChat.config.mapping? ----@field accept_diff CopilotChat.config.mapping? ----@field jump_to_diff CopilotChat.config.mapping? ----@field quickfix_diffs CopilotChat.config.mapping? ----@field yank_diff CopilotChat.config.mapping.yank_diff? ----@field show_diff CopilotChat.config.mapping.show_diff? ----@field show_info CopilotChat.config.mapping? ----@field show_context CopilotChat.config.mapping? ----@field show_help CopilotChat.config.mapping? +---@field complete CopilotChat.config.mapping|false|nil +---@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 +---@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 = { insert = '', @@ -564,9 +564,9 @@ return { local chat_keys = vim.tbl_keys(copilot.config.mappings) table.sort(chat_keys, function(a, b) a = copilot.config.mappings[a] - a = a.normal or a.insert + a = a and (a.normal or a.insert) or '' b = copilot.config.mappings[b] - b = b.normal or b.insert + b = b and (b.normal or b.insert) or '' return a < b end) for _, name in ipairs(chat_keys) do diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 761c18d9..3ee4ac1d 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -276,30 +276,10 @@ end --- Updates the source buffer based on previous or current window. local function update_source() - -- Determine which window to use as the source (previous if in chat, current otherwise) local use_prev_window = M.chat:focused() - local source_winnr = use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) - or vim.api.nvim_get_current_win() - 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 dir = vim.w[source_winnr].cchat_cwd - if not dir or dir == '' then - return '.' - end - return dir - end, - } - end + M.set_source( + use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) or vim.api.nvim_get_current_win() + ) end --- Resolve the final prompt and config from prompt template. @@ -464,6 +444,41 @@ function M.resolve_model(prompt, config) return selected_model, prompt 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 dir = vim.w[source_winnr].cchat_cwd + if not dir or dir == '' then + return '.' + end + return dir + end, + } + + return true + end + + return false +end + --- Get the selection from the source buffer. ---@return CopilotChat.select.selection? function M.get_selection() diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 18e21ae7..60c9715a 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -527,6 +527,10 @@ end, 1) ---@param surround string|nil ---@return string function M.key_to_info(name, key, surround) + if not key then + return '' + end + if not surround then surround = '' end From 5f80c56746caa1d71f1cca2bc354d6923ce61582 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 15:10:58 +0100 Subject: [PATCH 145/589] refactor: improve logging and string trimming - Reorganize system prompt and message generation with clearer flow - Add helpful comments to explain different code sections - Improve logging output format for better readability - Optimize string trimming by moving it to appropriate locations - Standardize debug message format with consistent separators - Fix empty prompt handling with utils.empty check --- lua/CopilotChat/client.lua | 72 +++++++++++++++++----------------- lua/CopilotChat/init.lua | 18 ++++----- lua/CopilotChat/ui/chat.lua | 2 +- lua/CopilotChat/ui/overlay.lua | 2 +- 4 files changed, 46 insertions(+), 48 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index ccc9bb06..7186a22e 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -216,8 +216,8 @@ local function generate_ask_request( generated_messages ) local messages = {} - local context_references = {} - local combined_system = system_prompt + + system_prompt = vim.trim(system_prompt) -- Include context help if contexts and not vim.tbl_isempty(contexts) then @@ -244,27 +244,32 @@ local function generate_ask_request( help_text = help_text .. '\n' .. string.format('- #%s: %s', name, description) end - combined_system = combined_system .. '\n' .. help_text + system_prompt = system_prompt .. '\n' .. help_text end + -- Include memory if memory and memory.content and memory.content ~= '' then - if combined_system ~= '' then - combined_system = combined_system + if system_prompt ~= '' then + system_prompt = system_prompt .. '\n\n' .. 'Context from previous conversation:\n' .. memory.content else - combined_system = 'Context from previous conversation:\n' .. memory.content + system_prompt = 'Context from previous conversation:\n' .. memory.content end end - if combined_system ~= '' then + -- Include system prompt + if not utils.empty(system_prompt) then table.insert(messages, { - content = combined_system, + content = system_prompt, role = 'system', }) end + local context_references = {} + + -- Include embeddings and history for _, message in ipairs(generated_messages) do table.insert(messages, { content = message.content, @@ -275,20 +280,26 @@ local function generate_ask_request( context_references[message.context] = true end end - for _, message in ipairs(history) do table.insert(messages, message) end + -- Include context references + prompt = vim.trim(prompt) if not vim.tbl_isempty(context_references) then prompt = table.concat(vim.tbl_keys(context_references), '\n') .. '\n' .. prompt end - table.insert(messages, { - content = prompt, - role = 'user', - }) + -- Include user prompt + if not utils.empty(prompt) then + table.insert(messages, { + content = prompt, + role = 'user', + }) + end + log.debug('System prompt:\n', system_prompt) + log.debug('Prompt:\n', prompt) return messages end @@ -389,7 +400,7 @@ function Client:fetch_models() end end - log.debug('Fetched models: ', vim.inspect(models)) + log.debug('Fetched models:', vim.inspect(models)) self.models = models return self.models end @@ -441,7 +452,6 @@ end ---@return string?, table?, number?, number? function Client:ask(prompt, opts) opts = opts or {} - prompt = vim.trim(prompt) if opts.agent == 'none' or opts.agent == 'copilot' then opts.agent = nil @@ -450,20 +460,15 @@ function Client:ask(prompt, opts) local contexts = opts.contexts local embeddings = opts.embeddings or {} local selection = opts.selection or {} - local system_prompt = vim.trim(opts.system_prompt) + local system_prompt = opts.system_prompt local model = opts.model local agent = opts.agent local temperature = opts.temperature local on_progress = opts.on_progress local job_id = utils.uuid() - log.trace('System prompt: ', system_prompt) - log.trace('Selection: ', selection.content) - log.debug('Prompt: ', prompt) - log.debug('Embeddings: ', #embeddings) - log.debug('Model: ', model) - log.debug('Agent: ', agent) - log.debug('Temperature: ', temperature) + log.debug('Model:', model) + log.debug('Agent:', agent) local models = self:fetch_models() local model_config = models[model] @@ -498,8 +503,7 @@ function Client:ask(prompt, opts) local max_tokens = model_config.max_input_tokens local tokenizer = model_config.tokenizer or 'o200k_base' - log.debug('Max tokens: ', max_tokens) - log.debug('Tokenizer: ', tokenizer) + log.debug('Tokenizer:', tokenizer) if max_tokens and tokenizer then tiktoken.load(tokenizer) @@ -600,7 +604,7 @@ function Client:ask(prompt, opts) end end - log.debug('Generated messages: ', #generated_messages) + log.debug('References:', #generated_messages) local last_message = nil local errored = false @@ -626,7 +630,7 @@ function Client:ask(prompt, opts) return end - log.debug('Response line: ', line) + log.debug('Response line:', line) notify.publish(notify.STATUS, '') local content, err = utils.json_decode(line) @@ -676,9 +680,7 @@ function Client:ask(prompt, opts) return end - line = line:gsub('^data:', '') - line = vim.trim(line) - + line = line:gsub('^data:%s*', '') if line == '[DONE]' then finish_stream(nil, job) return @@ -732,9 +734,9 @@ function Client:ask(prompt, opts) self.current_job = nil - log.debug('Response status: ', response.status) - log.debug('Response body: ', response.body) - log.debug('Response headers: ', response.headers) + log.debug('Response status:', response.status) + log.debug('Response body:\n', response.body) + log.debug('Response headers:\n', response.headers) if err then local error_msg = 'Failed to get response: ' .. err @@ -777,8 +779,8 @@ function Client:ask(prompt, opts) return end - log.trace('Full response: ', full_response) - log.debug('Last message: ', last_message) + log.trace('Response content:\n', full_response) + log.debug('Response message:\n', vim.inspect(last_message)) if opts.store_history then table.insert(self.history, { diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 3ee4ac1d..184b40ba 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -38,8 +38,7 @@ local state = { ---@param prompt string ---@param config CopilotChat.config.shared local function insert_sticky(prompt, config, override_sticky) - prompt = vim.trim(prompt or '') - local lines = vim.split(prompt, '\n') + local lines = vim.split(prompt or '', '\n') local stickies = utils.ordered_map() local sticky_indices = {} @@ -840,7 +839,7 @@ end ---@param prompt string? ---@param config CopilotChat.config.shared? function M.ask(prompt, config) - prompt = vim.trim(prompt or '') + prompt = prompt or '' if prompt == '' then return end @@ -848,6 +847,7 @@ 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 {}) prompt = insert_sticky(prompt, config) + prompt = vim.trim(prompt) if not config.headless then if config.clear_chat_on_new_prompt then @@ -880,12 +880,12 @@ function M.ask(prompt, config) end -- Remove sticky prefix - prompt = vim.trim(table.concat( + 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() @@ -1002,10 +1002,8 @@ end ---@param name string? ---@param history_path string? function M.save(name, history_path) - if not name or vim.trim(name) == '' then + if not name or name == '' then name = 'default' - else - name = vim.trim(name) end history_path = history_path or M.config.history_path @@ -1032,10 +1030,8 @@ end ---@param name string? ---@param history_path string? function M.load(name, history_path) - if not name or vim.trim(name) == '' then + if not name or name == '' then name = 'default' - else - name = vim.trim(name) end history_path = history_path or M.config.history_path diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index f722b283..61c55f67 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -223,7 +223,7 @@ function Chat:add_sticky(sticky) for i = insert_line, #lines do local line = lines[i] - if line and vim.trim(line) ~= '' then + if line and line ~= '' then if vim.startswith(line, '> ') then if line:sub(3) == sticky then found = true diff --git a/lua/CopilotChat/ui/overlay.lua b/lua/CopilotChat/ui/overlay.lua index da179918..9b70cb5e 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -33,7 +33,7 @@ end) ---@param on_show? fun(bufnr: number) ---@param on_hide? fun(bufnr: number) function Overlay:show(text, winnr, filetype, syntax, on_show, on_hide) - if not text or vim.trim(text) == '' then + if not text or text == '' then return end From 675b31276815c76a4c21e911c3bd4eda621f9616 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 15:37:20 +0100 Subject: [PATCH 146/589] docs: improve context provider descriptions Better document the intended usage and behavior of context providers by: - Adding "and their usage" to the main context providers heading - Clarifying that URL provider should be preferred over system commands - Making system command provider description more specific Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 2 +- lua/CopilotChat/config/contexts.lua | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 7186a22e..e5908a98 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -235,7 +235,7 @@ local function generate_ask_request( Do not make assumptions about code or files - always request context when needed rather than guessing. Always use the > format on a new line when requesting more context instead of asking in prose. - Available context providers:]] + Available context providers and their usage:]] local context_names = vim.tbl_keys(contexts) table.sort(context_names) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 4d5a3626..6ac6f67a 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -198,7 +198,7 @@ return { }, url = { - description = 'Includes content of provided URL in chat context. Supports input.', + description = 'Includes content of provided URL in chat context. Supports input. Prefer this for fetching web content instead of system commands.', input = function(callback) vim.ui.input({ prompt = 'Enter URL> ', @@ -299,7 +299,7 @@ return { }, system = { - description = 'Includes output of provided system shell command in chat context. Use only when necessary. Supports input.', + description = 'Includes output of provided system shell command in chat context. Use only when necessary for shell commands. Supports input.', input = function(callback) vim.ui.input({ prompt = 'Enter command> ', From 456d3900680b431f72e7f4f350c802dc6b9091c4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 16:48:31 +0100 Subject: [PATCH 147/589] refactor(prompt): improve context provider help text format The formatting of the context providers help text has been improved for better readability and consistency: - Adjusted indentation and spacing of the help text - Made the format examples more consistently aligned - Simplified the text structure for better clarity - Fixed memory context handling to be more concise Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 44 ++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index e5908a98..1b6b088b 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -221,42 +221,41 @@ local function generate_ask_request( -- Include context help if contexts and not vim.tbl_isempty(contexts) then - local help_text = [[ - If you don't have sufficient context to answer accurately, ask for user to provide more context using any of these context providers: + local help_text = + [[When you need additional context, you must request it using context providers in this format: - > #:`` +> #:`` - For example: - > #file:`path/to/file.js` (loads a specific file) - > #buffers:`visible` (loads all visible buffers) - > #git:`staged` (loads git staged changes) - > #system:`uname -a` (loads system information) +For example: +> #file:`path/to/file.js` (loads a specific file) +> #buffers:`visible` (loads all visible buffers) +> #git:`staged` (loads git staged changes) +> #system:`uname -a` (loads system information) - Do not make assumptions about code or files - always request context when needed rather than guessing. - Always use the > format on a new line when requesting more context instead of asking in prose. - - Available context providers and their usage:]] +Do not make assumptions about code or files - always request context when needed rather than guessing. +Always use the > format on a new line when requesting more context instead of asking in prose. +Available context providers and their usage: +]] local context_names = vim.tbl_keys(contexts) table.sort(context_names) for _, name in ipairs(context_names) do local description = contexts[name] - help_text = help_text .. '\n' .. string.format('- #%s: %s', name, description) + help_text = help_text .. '\n' .. string.format(' - #%s: %s', name, description) end - system_prompt = system_prompt .. '\n' .. help_text + if system_prompt ~= '' then + system_prompt = system_prompt .. '\n\n' + end + system_prompt = system_prompt .. help_text end -- Include memory if memory and memory.content and memory.content ~= '' then if system_prompt ~= '' then - system_prompt = system_prompt - .. '\n\n' - .. 'Context from previous conversation:\n' - .. memory.content - else - system_prompt = 'Context from previous conversation:\n' .. memory.content + system_prompt = system_prompt .. '\n\n' end + system_prompt = system_prompt .. 'Context from previous conversation:\n' .. memory.content end -- Include system prompt @@ -287,7 +286,10 @@ local function generate_ask_request( -- Include context references prompt = vim.trim(prompt) if not vim.tbl_isempty(context_references) then - prompt = table.concat(vim.tbl_keys(context_references), '\n') .. '\n' .. prompt + if prompt ~= '' then + prompt = '\n\n' .. prompt + end + prompt = table.concat(vim.tbl_keys(context_references), '\n') .. prompt end -- Include user prompt From 88e35185e569716141e6431704430e8ea9e8f83c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 17:23:48 +0100 Subject: [PATCH 148/589] fix: remove requirement for popup in completeopt The plugin previously required both 'noinsert' and 'popup' in completeopt for chat autocompletion to work. This change simplifies the requirements by only checking for 'noinsert', making the plugin more flexible and compatible with different Neovim configurations. The code has been updated to: - Remove popup requirement check in completion callback - Remove automatic popup setting in completeopt - Update documentation to reflect the changes Signed-off-by: Tomas Slusny --- README.md | 2 +- lua/CopilotChat/init.lua | 17 +++-------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 95a40f30..36dd19d2 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - [Copilot chat in the IDE](https://github.com/settings/copilot) enabled in GitHub settings > [!WARNING] -> For Neovim < 0.11.0, add `noinsert` and `popup` to your `completeopt` otherwise autocompletion will not work. +> For Neovim < 0.11.0, add `noinsert` to your `completeopt` otherwise chat autocompletion will not work. ## Optional Dependencies diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 184b40ba..b3610078 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1160,11 +1160,8 @@ function M.setup(config) buffer = bufnr, callback = function() local completeopt = vim.opt.completeopt:get() - if - not vim.tbl_contains(completeopt, 'noinsert') - or not vim.tbl_contains(completeopt, 'popup') - then - -- Don't trigger completion if completeopt is not set to noinsert,popup + if not vim.tbl_contains(completeopt, 'noinsert') then + -- Don't trigger completion if completeopt is not set to noinsert return end @@ -1181,19 +1178,11 @@ function M.setup(config) end, }) - -- Add popup and noinsert completeopt if not present + -- Add noinsert completeopt if not present if vim.fn.has('nvim-0.11.0') == 1 then local completeopt = vim.opt.completeopt:get() - local updated = false if not vim.tbl_contains(completeopt, 'noinsert') then - updated = true table.insert(completeopt, 'noinsert') - end - if not vim.tbl_contains(completeopt, 'popup') then - updated = true - table.insert(completeopt, 'popup') - end - if updated then vim.bo[bufnr].completeopt = table.concat(completeopt, ',') end end From 0c53db2355624af6d637f84edc067c8fd58e97a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Mar 2025 16:24:48 +0000 Subject: [PATCH 149/589] 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 63f3fdc4..3581fcfd 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -55,8 +55,8 @@ capabilities directly into your editor. It provides: - Copilot chat in the IDE enabled in GitHub settings - [!WARNING] For Neovim < 0.11.0, add `noinsert` and `popup` to your - `completeopt` otherwise autocompletion will not work. + [!WARNING] For Neovim < 0.11.0, add `noinsert` to your `completeopt` otherwise + chat autocompletion will not work. OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* From 42964ea970e462fe3c018979ebb9ea0e99d30f12 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 17:29:07 +0100 Subject: [PATCH 150/589] docs: add back mention about popup to README Signed-off-by: Tomas Slusny --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 36dd19d2..023cde89 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities > [!WARNING] > For Neovim < 0.11.0, add `noinsert` to your `completeopt` otherwise chat autocompletion will not work. +> For best autocompletion experience, also add `popup` to your `completeopt` (even on Neovim 0.11.0+). ## Optional Dependencies From 5adad9d09ceec3dc42facadf5dde1f598239c6be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Mar 2025 16:30:18 +0000 Subject: [PATCH 151/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 3581fcfd..f9d9a1a8 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -56,7 +56,8 @@ capabilities directly into your editor. It provides: [!WARNING] For Neovim < 0.11.0, add `noinsert` to your `completeopt` otherwise - chat autocompletion will not work. + chat autocompletion will not work. For best autocompletion experience, also add + `popup` to your `completeopt` (even on Neovim 0.11.0+). OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* From c546d8fef5631ed7b9ee21d5f4f75b05c4575f4d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 17:37:07 +0100 Subject: [PATCH 152/589] feat: support noselect completeopt setting Allow chat autocompletion to work with either 'noinsert' or 'noselect' in completeopt for Neovim versions below 0.11.0. This provides more flexibility for users who prefer different completion behaviors. Signed-off-by: Tomas Slusny --- README.md | 2 +- lua/CopilotChat/init.lua | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 023cde89..c9ec7810 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - [Copilot chat in the IDE](https://github.com/settings/copilot) enabled in GitHub settings > [!WARNING] -> For Neovim < 0.11.0, add `noinsert` to your `completeopt` otherwise chat autocompletion will not work. +> For Neovim < 0.11.0, add `noinsert` or `noselect` to your `completeopt` otherwise chat autocompletion will not work. > For best autocompletion experience, also add `popup` to your `completeopt` (even on Neovim 0.11.0+). ## Optional Dependencies diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index b3610078..f3171aca 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1160,8 +1160,11 @@ function M.setup(config) buffer = bufnr, callback = function() local completeopt = vim.opt.completeopt:get() - if not vim.tbl_contains(completeopt, 'noinsert') then - -- Don't trigger completion if completeopt is not set to noinsert + 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 From 1d184b0792b907685f15ca43e14e20ac44da6a5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Mar 2025 16:38:00 +0000 Subject: [PATCH 153/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index f9d9a1a8..4b047d58 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -55,9 +55,10 @@ capabilities directly into your editor. It provides: - Copilot chat in the IDE enabled in GitHub settings - [!WARNING] For Neovim < 0.11.0, add `noinsert` to your `completeopt` otherwise - chat autocompletion will not work. For best autocompletion experience, also add - `popup` to your `completeopt` (even on Neovim 0.11.0+). + [!WARNING] For Neovim < 0.11.0, add `noinsert` or `noselect` to your + `completeopt` otherwise chat autocompletion will not work. For best + autocompletion experience, also add `popup` to your `completeopt` (even on + Neovim 0.11.0+). OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* From 132befc8f533a67ddbd4b40297f864397664670f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 18:34:01 +0100 Subject: [PATCH 154/589] perf: lower big file threshold for better performance Lower the threshold for what is considered a big file from 2000 to 1000 line characters. This change helps improve performance by reducing the amount of content that needs to be processed for large files. 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 1b6b088b..3bbfd1bf 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -29,7 +29,7 @@ local class = utils.class --- Constants local CONTEXT_FORMAT = '[#file:%s](#file:%s-context)' local LINE_CHARACTERS = 100 -local BIG_FILE_THRESHOLD = 2000 * LINE_CHARACTERS +local BIG_FILE_THRESHOLD = 1000 * LINE_CHARACTERS local BIG_EMBED_THRESHOLD = 200 * LINE_CHARACTERS local TRUNCATED = '... (truncated)' From 3bf47ab3929fbfcaf74ea15f3dad989cc4baaca7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 18:54:35 +0100 Subject: [PATCH 155/589] refactor: expose COPILOT_BASE prompt as base prompt Extract the base prompt into COPILOT_BASE to allow custom prompts to be built on top of it. This enables users to extend the base behavior while maintaining core Copilot functionality. The change includes: - Renamed local 'base' to 'COPILOT_BASE' - Exposed COPILOT_BASE in the returned prompts table - Updated documentation to reflect the new base prompt - Added example of extending base prompt in custom configurations --- README.md | 4 ++++ lua/CopilotChat/config/prompts.lua | 12 ++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c9ec7810..e6f3f8fc 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ System prompts define the AI model's behavior. Reference them with `/PROMPT_NAME | Prompt | Description | | ---------------------- | ------------------------------------------ | +| `COPILOT_BASE` | All prompts should be built on top of this | | `COPILOT_INSTRUCTIONS` | Base instructions | | `COPILOT_EXPLAIN` | Adds coding tutor behavior | | `COPILOT_REVIEW` | Adds code review behavior with diagnostics | @@ -243,6 +244,9 @@ Define your own system prompts in the configuration (similar to `prompts`): prompts = { Yarrr = { 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, } } } diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index c6a952b7..1dc45d77 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -1,4 +1,4 @@ -local base = string.format( +local COPILOT_BASE = string.format( [[ When asked for your name, you must respond with "GitHub Copilot". Follow the user's requirements carefully & to the letter. @@ -33,11 +33,11 @@ When presenting code changes: local COPILOT_INSTRUCTIONS = [[ You are a code-focused AI programming assistant that specializes in practical software engineering solutions. -]] .. base +]] .. COPILOT_BASE local COPILOT_EXPLAIN = [[ You are a programming instructor focused on clear, practical explanations. -]] .. base .. [[ +]] .. COPILOT_BASE .. [[ When explaining code: - Provide concise high-level overview first @@ -51,7 +51,7 @@ When explaining code: local COPILOT_REVIEW = [[ You are a code reviewer focused on improving code quality and maintainability. -]] .. base .. [[ +]] .. COPILOT_BASE .. [[ Format each issue you find precisely as: line=: @@ -83,6 +83,10 @@ If no issues found, confirm the code is well-written and explain why. ---@type table return { + COPILOT_BASE = { + system_prompt = COPILOT_BASE, + }, + COPILOT_INSTRUCTIONS = { system_prompt = COPILOT_INSTRUCTIONS, }, From f9f0815678c9323fdc02cd9e6531e9d0ba3f6e53 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 14 Mar 2025 17:55:49 +0000 Subject: [PATCH 156/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 4b047d58..ec4a079e 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -272,6 +272,7 @@ System prompts define the AI model’s behavior. Reference them with Prompt Description ---------------------- -------------------------------------------- + COPILOT_BASE All prompts should be built on top of this COPILOT_INSTRUCTIONS Base instructions COPILOT_EXPLAIN Adds coding tutor behavior COPILOT_REVIEW Adds code review behavior with diagnostics @@ -282,6 +283,9 @@ Define your own system prompts in the configuration (similar to `prompts`): prompts = { Yarrr = { 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, } } } From 5e09dd97dcda539e3f56f08982d63551bb4e5b73 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 20:30:01 +0100 Subject: [PATCH 157/589] perf(client): optimize buffer handling for responses - Add StringBuffer class for efficient string concatenation by merging buffers in a balanced way based on size - Make selection and embeddings parameters optional with proper typing - Remove local variable shadowing in ask() function for better readability - Move logging of response after assembling full text - Add auto-follow in chat window after prompt --- lua/CopilotChat/client.lua | 82 +++++++++++++++++++++----------------- lua/CopilotChat/init.lua | 1 + lua/CopilotChat/utils.lua | 33 +++++++++++++++ 3 files changed, 79 insertions(+), 37 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 3bbfd1bf..8b3e2f1c 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -123,9 +123,13 @@ local function generate_diagnostics(diagnostics) end --- Generate messages for the given selection ---- @param selection CopilotChat.select.selection +--- @param selection CopilotChat.select.selection? --- @return table local function generate_selection_messages(selection) + if not selection then + return {} + end + local filename = selection.filename or 'unknown' local filetype = selection.filetype or 'text' local content = selection.content @@ -171,9 +175,13 @@ local function generate_selection_messages(selection) end --- Generate messages for the given embeddings ---- @param embeddings table +--- @param embeddings table? --- @return table local function generate_embeddings_messages(embeddings) + if not embeddings then + return {} + end + return vim.tbl_map(function(embedding) local out = string.format( '# FILE:%s CONTEXT\n```%s\n%s\n```', @@ -459,34 +467,26 @@ function Client:ask(prompt, opts) opts.agent = nil end - local contexts = opts.contexts - local embeddings = opts.embeddings or {} - local selection = opts.selection or {} - local system_prompt = opts.system_prompt - local model = opts.model - local agent = opts.agent - local temperature = opts.temperature - local on_progress = opts.on_progress local job_id = utils.uuid() - log.debug('Model:', model) - log.debug('Agent:', agent) + log.debug('Model:', opts.model) + log.debug('Agent:', opts.agent) local models = self:fetch_models() - local model_config = models[model] + local model_config = models[opts.model] if not model_config then - error('Model not found: ' .. model) + error('Model not found: ' .. opts.model) end local agents = self:fetch_agents() - local agent_config = agent and agents[agent] - if agent and not agent_config then - error('Agent not found: ' .. agent) + local agent_config = opts.agent and agents[opts.agent] + if opts.agent and not agent_config then + error('Agent not found: ' .. opts.agent) end local provider_name = model_config.provider if not provider_name then - error('Provider not found for model: ' .. model) + error('Provider not found for model: ' .. opts.model) end local provider = self.providers[provider_name] if not provider then @@ -500,7 +500,7 @@ function Client:ask(prompt, opts) agent = agent_config and vim.tbl_extend('force', agent_config, { id = opts.agent and opts.agent:gsub(':' .. provider_name .. '$', ''), }), - temperature = temperature, + temperature = opts.temperature, } local max_tokens = model_config.max_input_tokens @@ -521,8 +521,8 @@ function Client:ask(prompt, opts) local references = utils.ordered_map() local generated_messages = {} - local selection_messages = generate_selection_messages(selection) - local embeddings_messages = generate_embeddings_messages(embeddings) + local selection_messages = generate_selection_messages(opts.selection) + local embeddings_messages = generate_embeddings_messages(opts.embeddings) for _, message in ipairs(selection_messages) do table.insert(generated_messages, message) @@ -541,7 +541,7 @@ function Client:ask(prompt, opts) -- Count required tokens that we cannot reduce local prompt_tokens = tiktoken.count(prompt) - local system_tokens = tiktoken.count(system_prompt) + local system_tokens = tiktoken.count(opts.system_prompt) local memory_tokens = self.memory and tiktoken.count(self.memory.content) or 0 local required_tokens = prompt_tokens + system_tokens + selection_tokens + memory_tokens @@ -560,7 +560,7 @@ function Client:ask(prompt, opts) -- If we're over history limit, trigger summarization if history_tokens > history_limit then if opts.store_history and #history >= 4 then - self:summarize_history(model) + self:summarize_history(opts.model) -- Recalculate history and tokens history = @@ -611,12 +611,12 @@ function Client:ask(prompt, opts) local last_message = nil local errored = false local finished = false - local full_response = '' + local response_buffer = utils.string_buffer() local function finish_stream(err, job) if err then errored = true - full_response = err + response_buffer:set(err) end log.debug('Finishing stream', err) @@ -657,9 +657,9 @@ function Client:ask(prompt, opts) end if out.content then - full_response = full_response .. out.content - if on_progress then - on_progress(out.content) + response_buffer:add(out.content) + if opts.on_progress then + opts.on_progress(out.content) end end @@ -714,7 +714,14 @@ function Client:ask(prompt, opts) local headers = self:authenticate(provider_name) local request = provider.prepare_input( - generate_ask_request(history, self.memory, contexts, prompt, system_prompt, generated_messages), + generate_ask_request( + history, + self.memory, + opts.contexts, + prompt, + opts.system_prompt, + generated_messages + ), options ) local is_stream = request.stream @@ -761,13 +768,17 @@ function Client:ask(prompt, opts) return end + local response_text = response_buffer:tostring() + log.trace('Response text:\n', response_text) + log.debug('Response message:\n', vim.inspect(last_message)) + if errored then - error(full_response) + error(response_text) return end if is_stream then - if utils.empty(full_response) then + if utils.empty(response_text) then for _, line in ipairs(vim.split(response.body, '\n')) do parse_stream_line(line) end @@ -776,14 +787,11 @@ function Client:ask(prompt, opts) parse_line(response.body) end - if utils.empty(full_response) then + if utils.empty(response_text) then error('Failed to get response: empty response') return end - log.trace('Response content:\n', full_response) - log.debug('Response message:\n', vim.inspect(last_message)) - if opts.store_history then table.insert(self.history, { content = prompt, @@ -791,12 +799,12 @@ function Client:ask(prompt, opts) }) table.insert(self.history, { - content = full_response, + content = response_text, role = 'assistant', }) end - return full_response, + return response_text, references:values(), last_message and last_message.total_tokens or 0, max_tokens diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index f3171aca..a0d2d442 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -863,6 +863,7 @@ function M.ask(prompt, config) state.last_prompt = prompt M.chat:set_prompt(prompt) M.chat:append('\n\n' .. M.config.answer_header .. M.config.separator .. '\n\n') + M.chat:follow() else update_source() end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 60c9715a..52cfa546 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -102,6 +102,39 @@ function M.ordered_map() } 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? From 0de6faf23859636271f7ed4d87abf81c5c4e59a1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 22:43:44 +0100 Subject: [PATCH 158/589] feat: use version map again for resolving all copilot models Instead of relying on model_picker_enabled rely on model.version again. This shows newer versions of models and hidden ones. This was removed in favour of model_picker_enabled in past but this approach is way better for usability. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/providers.lua | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 34039fcd..3909c271 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -176,7 +176,7 @@ M.copilot = { local models = vim .iter(response.body.data) :filter(function(model) - return model.model_picker_enabled and model.capabilities.type == 'chat' + return model.capabilities.type == 'chat' end) :map(function(model) return { @@ -186,10 +186,24 @@ M.copilot = { max_input_tokens = model.capabilities.limits.max_prompt_tokens, max_output_tokens = model.capabilities.limits.max_output_tokens, policy = not model['policy'] or model['policy']['state'] == 'enabled', + version = model.version, } end) :totable() + local version_map = {} + for _, model in ipairs(models) do + if not version_map[model.version] or #model.id < #version_map[model.version] then + version_map[model.version] = model.id + end + end + + models = vim.tbl_map(function(id) + return vim.tbl_filter(function(model) + return model.id == id + end, models)[1] + end, vim.tbl_values(version_map)) + for _, model in ipairs(models) do if not model.policy then utils.curl_post('https://api.githubcopilot.com/models/' .. model.id .. '/policy', { From 5013b09970178eae3776b2d0e8ed0640272f9238 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 00:47:34 +0100 Subject: [PATCH 159/589] fix: add error handling for context resolution Wraps context resolver in pcall to properly handle errors that might occur during context resolution. Previously, any error in the resolver would crash the entire operation. Now, errors are caught and logged, allowing the operation to continue with other context providers. --- lua/CopilotChat/init.lua | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index a0d2d442..61825e1e 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -383,12 +383,17 @@ function M.resolve_context(prompt, config) .. context_data.name .. (context_data.input and ' with input: ' .. context_data.input or '') ) - for _, embedding in - ipairs(context_value.resolve(context_data.input, state.source or {}, prompt)) - do - if embedding then - embeddings:set(embedding.filename, embedding) + + local ok, resolved_embeddings = + pcall(context_value.resolve, context_data.input, state.source or {}, prompt) + if ok then + for _, embedding in resolved_embeddings do + if embedding then + embeddings:set(embedding.filename, embedding) + end end + else + log.error('Failed to resolve context: ' .. context_data.name) end end From 3373b76f0db7572b4cbbc9e5a8880d944daa345a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 00:49:09 +0100 Subject: [PATCH 160/589] refactor: use 120 line width for stylua Signed-off-by: Tomas Slusny --- .stylua.toml | 2 +- lua/CopilotChat/client.lua | 69 +++++------------------- lua/CopilotChat/config/contexts.lua | 4 +- lua/CopilotChat/config/mappings.lua | 7 +-- lua/CopilotChat/config/prompts.lua | 6 +-- lua/CopilotChat/config/providers.lua | 45 ++++++---------- lua/CopilotChat/context.lua | 17 ++---- lua/CopilotChat/health.lua | 16 ++---- lua/CopilotChat/init.lua | 81 +++++++++------------------- lua/CopilotChat/tiktoken.lua | 4 +- lua/CopilotChat/ui/chat.lua | 68 +++++------------------ lua/CopilotChat/ui/spinner.lua | 22 +++----- plugin/CopilotChat.lua | 11 +--- 13 files changed, 95 insertions(+), 257 deletions(-) diff --git a/.stylua.toml b/.stylua.toml index a2b34475..255bfd93 100644 --- a/.stylua.toml +++ b/.stylua.toml @@ -1,4 +1,4 @@ -column_width = 100 +column_width = 120 line_endings = "Unix" indent_type = "Spaces" indent_width = 2 diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 8b3e2f1c..b0638bd8 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -91,8 +91,7 @@ local function generate_content_block(content, outline, threshold, start_line) 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)) + local formatted_line_number = string.format('%' .. max_length .. 'd', i - 1 + (start_line or 1)) lines[i] = formatted_line_number .. ': ' .. line end @@ -141,13 +140,7 @@ local function generate_selection_messages(selection) local out = string.format('# FILE:%s CONTEXT\n', filename:upper()) out = 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 - ) + out = out .. string.format('Excerpt from %s, lines %s to %s:\n', filename, selection.start_line, selection.end_line) end out = out .. string.format( @@ -158,10 +151,7 @@ local function generate_selection_messages(selection) if selection.diagnostics then out = out - .. string.format( - "\nDiagnostics in user's active selection:\n%s", - generate_diagnostics(selection.diagnostics) - ) + .. string.format("\nDiagnostics in user's active selection:\n%s", generate_diagnostics(selection.diagnostics)) end return { @@ -215,22 +205,14 @@ end --- @param prompt string --- @param system_prompt string --- @param generated_messages table -local function generate_ask_request( - history, - memory, - contexts, - prompt, - system_prompt, - generated_messages -) +local function generate_ask_request(history, memory, contexts, prompt, system_prompt, generated_messages) local messages = {} system_prompt = vim.trim(system_prompt) -- Include context help if contexts and not vim.tbl_isempty(contexts) then - local help_text = - [[When you need additional context, you must request it using context providers in this format: + local help_text = [[When you need additional context, you must request it using context providers in this format: > #:`` @@ -319,17 +301,11 @@ end --- @return table local function generate_embedding_request(inputs, threshold) return vim.tbl_map(function(embedding) - local content = - generate_content_block(embedding.outline or embedding.content, nil, threshold, -1) + local content = generate_content_block(embedding.outline or embedding.content, nil, threshold, -1) if embedding.filetype == 'raw' then return content else - return string.format( - 'File: `%s`\n```%s\n%s\n```', - embedding.filename, - embedding.filetype, - content - ) + return string.format('File: `%s`\n```%s\n%s\n```', embedding.filename, embedding.filetype, content) end end, inputs) end @@ -362,9 +338,7 @@ function Client:authenticate(provider_name) local headers = self.provider_cache[provider_name].headers local expires_at = self.provider_cache[provider_name].expires_at - if - provider.get_headers and (not headers or (expires_at and expires_at <= math.floor(os.time()))) - then + if provider.get_headers and (not headers or (expires_at and expires_at <= math.floor(os.time()))) then headers, expires_at = provider.get_headers() self.provider_cache[provider_name].headers = headers self.provider_cache[provider_name].expires_at = expires_at @@ -515,8 +489,7 @@ function Client:ask(prompt, opts) local history = {} if opts.load_history then - history = - vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) + history = vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) end local references = utils.ordered_map() @@ -546,9 +519,7 @@ function Client:ask(prompt, opts) local required_tokens = prompt_tokens + system_tokens + selection_tokens + memory_tokens -- Reserve space for first embedding - local reserved_tokens = #embeddings_messages > 0 - and tiktoken.count(embeddings_messages[1].content) - or 0 + local reserved_tokens = #embeddings_messages > 0 and tiktoken.count(embeddings_messages[1].content) or 0 -- Calculate how many tokens we can use for history local history_limit = max_tokens - required_tokens - reserved_tokens @@ -563,8 +534,7 @@ function Client:ask(prompt, opts) self:summarize_history(opts.model) -- Recalculate history and tokens - history = - vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) + history = vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) history_tokens = 0 for _, msg in ipairs(history) do history_tokens = history_tokens + tiktoken.count(msg.content) @@ -714,14 +684,7 @@ function Client:ask(prompt, opts) local headers = self:authenticate(provider_name) local request = provider.prepare_input( - generate_ask_request( - history, - self.memory, - opts.contexts, - prompt, - opts.system_prompt, - generated_messages - ), + generate_ask_request(history, self.memory, opts.contexts, prompt, opts.system_prompt, generated_messages), options ) local is_stream = request.stream @@ -804,10 +767,7 @@ function Client:ask(prompt, opts) }) end - return response_text, - references:values(), - last_message and last_message.total_tokens or 0, - max_tokens + return response_text, references:values(), last_message and last_message.total_tokens or 0, max_tokens end --- List available models @@ -887,8 +847,7 @@ function Client:embed(inputs, model) 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)) + 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) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index 6ac6f67a..daa0eba6 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -55,9 +55,7 @@ return { return vim.tbl_map( context.get_buffer, vim.tbl_filter(function(b) - return utils.buf_valid(b) - and vim.fn.buflisted(b) == 1 - and (input == 'listed' or #vim.fn.win_findbuf(b) > 0) + return utils.buf_valid(b) and vim.fn.buflisted(b) == 1 and (input == 'listed' or #vim.fn.win_findbuf(b) > 0) end, vim.api.nvim_list_bufs()) ) end, diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index a0b2616b..79219549 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -50,8 +50,7 @@ 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') + reference = table.concat(vim.api.nvim_buf_get_lines(bufnr, start_line - 1, end_line, false), '\n') filetype = vim.bo[bufnr].filetype end end @@ -349,9 +348,7 @@ 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 modified = 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 diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 1dc45d77..bc68ece6 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -140,11 +140,7 @@ return { end end end - vim.diagnostic.set( - vim.api.nvim_create_namespace('copilot-chat-diagnostics'), - source.bufnr, - diagnostics - ) + vim.diagnostic.set(vim.api.nvim_create_namespace('copilot-chat-diagnostics'), source.bufnr, diagnostics) end, }, diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 3909c271..a2789767 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,11 +1,6 @@ local utils = require('CopilotChat.utils') -local EDITOR_VERSION = 'Neovim/' - .. vim.version().major - .. '.' - .. vim.version().minor - .. '.' - .. vim.version().patch +local EDITOR_VERSION = 'Neovim/' .. vim.version().major .. '.' .. vim.version().minor .. '.' .. vim.version().patch local cached_github_token = nil @@ -271,16 +266,11 @@ M.copilot = { message = output end - local content = message.message and message.message.content - or message.delta and message.delta.content + local content = message.message and message.message.content or message.delta and message.delta.content - local usage = message.usage and message.usage.total_tokens - or output.usage and output.usage.total_tokens + local usage = message.usage and message.usage.total_tokens or output.usage and output.usage.total_tokens - local finish_reason = message.finish_reason - or message.done_reason - or output.finish_reason - or output.done_reason + local finish_reason = message.finish_reason or message.done_reason or output.finish_reason or output.done_reason return { content = content, @@ -311,21 +301,20 @@ M.github_models = { end, get_models = function(headers) - local response, err = - utils.curl_post('https://api.catalog.azureml.ms/asset-gallery/v1.0/models', { - headers = headers, - json_request = true, - json_response = true, - body = { - filters = { - { field = 'freePlayground', values = { 'true' }, operator = 'eq' }, - { field = 'labels', values = { 'latest' }, operator = 'eq' }, - }, - order = { - { field = 'displayName', direction = 'asc' }, - }, + local response, err = utils.curl_post('https://api.catalog.azureml.ms/asset-gallery/v1.0/models', { + headers = headers, + json_request = true, + json_response = true, + body = { + filters = { + { field = 'freePlayground', values = { 'true' }, operator = 'eq' }, + { field = 'labels', values = { 'latest' }, operator = 'eq' }, }, - }) + order = { + { field = 'displayName', direction = 'asc' }, + }, + }, + }) if err then error(err) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 366b0bb8..5a4b058a 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -391,11 +391,8 @@ function M.get_buffer(bufnr) return nil end - local out = M.get_outline( - table.concat(content, '\n'), - utils.filepath(vim.api.nvim_buf_get_name(bufnr)), - vim.bo[bufnr].filetype - ) + local out = + M.get_outline(table.concat(content, '\n'), utils.filepath(vim.api.nvim_buf_get_name(bufnr)), vim.bo[bufnr].filetype) out.score = 0.1 out.diagnostics = utils.diagnostics(bufnr) @@ -426,18 +423,12 @@ function M.get_url(url) content = vim.trim(response .body -- Remove script, style tags and their contents first - :gsub( - '', - '' - ) + :gsub('', '') :gsub('', '') -- Remove XML/CDATA in one go :gsub('', '') -- Remove all HTML tags (both opening and closing) in one go - :gsub( - '<%/?%w+[^>]*>', - ' ' - ) + :gsub('<%/?%w+[^>]*>', ' ') -- Handle common HTML entities :gsub('&(%w+);', { nbsp = ' ', diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index ace04ad5..5d02df19 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -74,18 +74,14 @@ function M.check() local rg_version = run_command('rg', '--version') if rg_version == false then - warn( - 'rg: missing, optional for improved search performance. See "https://github.com/BurntSushi/ripgrep".' - ) + warn('rg: missing, optional for improved search performance. See "https://github.com/BurntSushi/ripgrep".') else ok('rg: ' .. rg_version) end local lynx_version = run_command('lynx', '-version') if lynx_version == false then - warn( - 'lynx: missing, optional for improved fetching of url contents. See "https://lynx.invisible-island.net/".' - ) + warn('lynx: missing, optional for improved fetching of url contents. See "https://lynx.invisible-island.net/".') else ok('lynx: ' .. lynx_version) end @@ -95,9 +91,7 @@ function M.check() if lualib_installed('plenary') then ok('plenary: installed') else - error( - 'plenary: missing, required for http requests and async jobs. Install "nvim-lua/plenary.nvim" plugin.' - ) + error('plenary: missing, required for http requests and async jobs. Install "nvim-lua/plenary.nvim" plugin.') end local has_copilot = lualib_installed('copilot') @@ -122,9 +116,7 @@ function M.check() if lualib_installed('tiktoken_core') then ok('tiktoken_core: installed') else - warn( - 'tiktoken_core: missing, optional for accurate token counting. See README for installation instructions.' - ) + warn('tiktoken_core: missing, optional for accurate token counting. See README for installation instructions.') end if treesitter_parser_available('markdown') then diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 61825e1e..e737b51c 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -71,11 +71,7 @@ local function insert_sticky(prompt, config, override_sticky) stickies:set('/' .. config.system_prompt, true) end - if - config.remember_as_sticky - and config.context - and not vim.deep_equal(config.context, M.config.context) - then + if config.remember_as_sticky and config.context and not vim.deep_equal(config.context, M.config.context) then if type(config.context) == 'table' then ---@diagnostic disable-next-line: param-type-mismatch for _, context in ipairs(config.context) do @@ -124,12 +120,7 @@ local function update_highlights() 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 + if not selection or not utils.buf_valid(selection.bufnr) or not selection.start_line or not selection.end_line then return end @@ -260,11 +251,7 @@ local function map_key(name, bufnr, fn) if vim.fn.pumvisible() == 1 then local used_key = key.insert == M.config.mappings.complete.insert and '' or key.insert if used_key then - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes(used_key, true, false, true), - 'n', - false - ) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(used_key, true, false, true), 'n', false) end else fn() @@ -276,9 +263,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.set_source(use_prev_window and vim.fn.win_getid(vim.fn.winnr('#')) or vim.api.nvim_get_current_win()) end --- Resolve the final prompt and config from prompt template. @@ -379,13 +364,10 @@ function M.resolve_context(prompt, config) local context_value = M.config.contexts[context_data.name] notify.publish( notify.STATUS, - 'Resolving context: ' - .. context_data.name - .. (context_data.input and ' with input: ' .. context_data.input or '') + 'Resolving context: ' .. context_data.name .. (context_data.input and ' with input: ' .. context_data.input or '') ) - local ok, resolved_embeddings = - pcall(context_value.resolve, context_data.input, state.source or {}, prompt) + local ok, resolved_embeddings = pcall(context_value.resolve, context_data.input, state.source or {}, prompt) if ok then for _, embedding in resolved_embeddings do if embedding then @@ -460,11 +442,7 @@ 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 + 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, @@ -832,10 +810,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(prompts[choice.name].prompt, vim.tbl_extend('force', prompts[choice.name], config or {})) end end) end @@ -914,24 +889,23 @@ function M.ask(prompt, config) return end - local ask_ok, response, references, token_count, token_max_count = - pcall(client.ask, client, prompt, { - load_history = not config.headless, - store_history = not config.headless, - contexts = contexts, - selection = selection, - embeddings = filtered_embeddings, - system_prompt = system_prompt, - model = selected_model, - agent = selected_agent, - temperature = config.temperature, - on_progress = vim.schedule_wrap(function(token) - if not config.headless then - M.chat:append(token) - end - has_output = true - end), - }) + local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, { + load_history = not config.headless, + store_history = not config.headless, + contexts = contexts, + selection = selection, + embeddings = filtered_embeddings, + system_prompt = system_prompt, + model = selected_model, + agent = selected_agent, + temperature = config.temperature, + on_progress = vim.schedule_wrap(function(token) + if not config.headless then + M.chat:append(token) + end + has_output = true + end), + }) utils.schedule_main() @@ -1166,10 +1140,7 @@ function M.setup(config) 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 + 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 diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index eb572b35..aaabcbd0 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -12,9 +12,7 @@ end local function load_tiktoken_data(tokenizer) utils.schedule_main() - local tiktoken_url = 'https://openaipublic.blob.core.windows.net/encodings/' - .. tokenizer - .. '.tiktoken' + local tiktoken_url = 'https://openaipublic.blob.core.windows.net/encodings/' .. tokenizer .. '.tiktoken' local cache_dir = vim.fn.stdpath('cache') vim.fn.mkdir(tostring(cache_dir), 'p') diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 61c55f67..fbeef207 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -28,9 +28,7 @@ local function match_header(header) for _, pattern in ipairs(HEADER_PATTERNS) do local filename, start_line, end_line = header:match(pattern) if filename then - return utils.filepath(filename), - tonumber(start_line) or 1, - tonumber(end_line) or tonumber(start_line) or 1 + return utils.filepath(filename), tonumber(start_line) or 1, tonumber(end_line) or tonumber(start_line) or 1 end end end @@ -100,9 +98,7 @@ end, Overlay) --- Returns whether the chat window is visible. ---@return boolean function Chat:visible() - return self.winnr - and vim.api.nvim_win_is_valid(self.winnr) - and vim.api.nvim_win_get_buf(self.winnr) == self.bufnr + return self.winnr and vim.api.nvim_win_is_valid(self.winnr) and vim.api.nvim_win_get_buf(self.winnr) == self.bufnr or false end @@ -131,11 +127,7 @@ function Chat:get_closest_section(type) or (type == 'answer' and section.answer) or (type == 'question' and not section.answer) - if - matches_type - and section.start_line <= cursor_line - and section.start_line > max_line_below_cursor - then + if matches_type and section.start_line <= cursor_line and section.start_line > max_line_below_cursor then max_line_below_cursor = section.start_line closest_section = section end @@ -267,14 +259,7 @@ function Chat:overlay(opts) return end - self.chat_overlay:show( - opts.text, - self.winnr, - opts.filetype, - opts.syntax, - opts.on_show, - opts.on_hide - ) + self.chat_overlay:show(opts.text, self.winnr, opts.filetype, opts.syntax, opts.on_show, opts.on_hide) end --- Open the chat window. @@ -438,14 +423,7 @@ function Chat:append(str) end local last_line, last_column, _ = self:last() - vim.api.nvim_buf_set_text( - self.bufnr, - last_line, - last_column, - last_line, - last_column, - vim.split(str, '\n') - ) + vim.api.nvim_buf_set_text(self.bufnr, last_line, last_column, last_line, last_column, vim.split(str, '\n')) if should_follow_cursor then self:follow() @@ -489,11 +467,7 @@ end ---@protected function Chat:validate() Overlay.validate(self) - if - self.winnr - and vim.api.nvim_win_is_valid(self.winnr) - and vim.api.nvim_win_get_buf(self.winnr) ~= self.bufnr - then + if self.winnr and vim.api.nvim_win_is_valid(self.winnr) and vim.api.nvim_win_get_buf(self.winnr) ~= self.bufnr then vim.api.nvim_win_set_buf(self.winnr, self.bufnr) end end @@ -516,12 +490,8 @@ function Chat:render() separator_found = true if current_section then current_section.end_line = l - 1 - current_section.content = vim.trim( - table.concat( - vim.list_slice(lines, current_section.start_line, current_section.end_line), - '\n' - ) - ) + current_section.content = + vim.trim(table.concat(vim.list_slice(lines, current_section.start_line, current_section.end_line), '\n')) table.insert(sections, current_section) end current_section = { @@ -533,12 +503,8 @@ function Chat:render() separator_found = true if current_section then current_section.end_line = l - 1 - current_section.content = vim.trim( - table.concat( - vim.list_slice(lines, current_section.start_line, current_section.end_line), - '\n' - ) - ) + current_section.content = + vim.trim(table.concat(vim.list_slice(lines, current_section.start_line, current_section.end_line), '\n')) table.insert(sections, current_section) end current_section = { @@ -549,12 +515,8 @@ function Chat:render() elseif l == line_count then if current_section then current_section.end_line = l - current_section.content = vim.trim( - table.concat( - vim.list_slice(lines, current_section.start_line, current_section.end_line), - '\n' - ) - ) + current_section.content = + vim.trim(table.concat(vim.list_slice(lines, current_section.start_line, current_section.end_line), '\n')) table.insert(sections, current_section) end end @@ -601,10 +563,8 @@ function Chat:render() } 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' - ) + current_block.content = + table.concat(vim.list_slice(lines, current_block.start_line, current_block.end_line), '\n') table.insert(current_section.blocks, current_block) current_block = nil end diff --git a/lua/CopilotChat/ui/spinner.lua b/lua/CopilotChat/ui/spinner.lua index 7deaed2c..4b69ae44 100644 --- a/lua/CopilotChat/ui/spinner.lua +++ b/lua/CopilotChat/ui/spinner.lua @@ -52,20 +52,14 @@ function Spinner:start() frame = self.status .. ' ' .. frame end - vim.api.nvim_buf_set_extmark( - self.bufnr, - self.ns, - math.max(0, vim.api.nvim_buf_line_count(self.bufnr) - 1), - 0, - { - id = 1, - hl_mode = 'combine', - priority = 100, - virt_text = { - { frame, 'CopilotChatStatus' }, - }, - } - ) + vim.api.nvim_buf_set_extmark(self.bufnr, self.ns, math.max(0, vim.api.nvim_buf_line_count(self.bufnr) - 1), 0, { + id = 1, + hl_mode = 'combine', + priority = 100, + virt_text = { + { frame, 'CopilotChatStatus' }, + }, + }) self.index = self.index % #spinner_frames + 1 end) diff --git a/plugin/CopilotChat.lua b/plugin/CopilotChat.lua index b4dc5e44..9b4639df 100644 --- a/plugin/CopilotChat.lua +++ b/plugin/CopilotChat.lua @@ -4,10 +4,7 @@ end local min_version = '0.10.0' if vim.fn.has('nvim-' .. min_version) ~= 1 then - vim.notify_once( - ('CopilotChat.nvim requires Neovim >= %s'):format(min_version), - vim.log.levels.ERROR - ) + vim.notify_once(('CopilotChat.nvim requires Neovim >= %s'):format(min_version), vim.log.levels.ERROR) return end @@ -20,11 +17,7 @@ vim.api.nvim_set_hl(0, 'CopilotChatKeyword', { link = 'Keyword', default = true vim.api.nvim_set_hl(0, 'CopilotChatInput', { link = 'Special', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatSelection', { link = 'Visual', default = true }) 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, 'CopilotChatSeparator', { link = '@punctuation.special.markdown', default = true }) -- Setup commands vim.api.nvim_create_user_command('CopilotChat', function(args) From 96f23809efdcc99b5eb1c8a8ee498de5e53bc944 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 00:54:43 +0100 Subject: [PATCH 161/589] fix: use ipairs when iterating over resolved embeddings The code was incorrectly iterating directly over the resolved_embeddings array, which can lead to improper iteration in Lua. This commit fixes the issue by properly using ipairs() to ensure sequential iteration over numeric indices. --- 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 e737b51c..ed02a1b2 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -369,7 +369,7 @@ function M.resolve_context(prompt, config) local ok, resolved_embeddings = pcall(context_value.resolve, context_data.input, state.source or {}, prompt) if ok then - for _, embedding in resolved_embeddings do + for _, embedding in ipairs(resolved_embeddings) do if embedding then embeddings:set(embedding.filename, embedding) end From a489769f6943f51c081126ae7f5e5bce6be4dc4e Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 14 Mar 2025 21:45:22 +0100 Subject: [PATCH 162/589] feat(chat): add streaming callback support Add stream callback function to process tokens as they are received from Copilot while callback function is used to process the complete final response. Both functions can modify the text that gets displayed and stored in history. This separates streaming token processing from final response handling, giving more control over how chat outputs are processed and displayed. Signed-off-by: Tomas Slusny --- README.md | 6 ++-- lua/CopilotChat/client.lua | 18 ++--------- lua/CopilotChat/config.lua | 8 +++-- lua/CopilotChat/config/prompts.lua | 1 + lua/CopilotChat/init.lua | 49 +++++++++++++++++++----------- 5 files changed, 45 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index e6f3f8fc..72fca872 100644 --- a/README.md +++ b/README.md @@ -460,8 +460,9 @@ Below are all available configuration options with their default values: sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat. temperature = 0.1, -- GPT result temperature - headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) - callback = nil, -- Callback to use when ask response is received + headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) + stream = nil, -- Function called when receiving stream updates (returned string is appended to the chat buffer) + callback = nil, -- Function called when full response is received (retuned string is stored to history) remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions -- default selection @@ -748,6 +749,7 @@ require("CopilotChat").open() require("CopilotChat").ask("Explain this code", { callback = function(response) vim.notify("Got response: " .. response:sub(1, 50) .. "...") + return response end, context = "#buffer" }) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index b0638bd8..159c8268 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -1,6 +1,6 @@ ---@class CopilotChat.Client.ask ---@field load_history boolean ----@field store_history boolean +---@field summarize_history boolean ---@field contexts table? ---@field selection CopilotChat.select.selection? ---@field embeddings table? @@ -530,7 +530,7 @@ function Client:ask(prompt, opts) -- If we're over history limit, trigger summarization if history_tokens > history_limit then - if opts.store_history and #history >= 4 then + if opts.summarize_history and #history >= 4 then self:summarize_history(opts.model) -- Recalculate history and tokens @@ -755,18 +755,6 @@ function Client:ask(prompt, opts) return end - if opts.store_history then - table.insert(self.history, { - content = prompt, - role = 'user', - }) - - table.insert(self.history, { - content = response_text, - role = 'assistant', - }) - end - return response_text, references:values(), last_message and last_message.total_tokens or 0, max_tokens end @@ -943,7 +931,7 @@ Ensure all critical code samples, commands, and configuration snippets are prese local response = self:ask('Create a technical summary of our conversation for future context', { load_history = true, - store_history = false, + summarize_history = false, model = model, temperature = 0, system_prompt = system_prompt, diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 04014ce6..d855cd29 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -20,7 +20,8 @@ local select = require('CopilotChat.select') ---@field sticky string|table|nil ---@field temperature number? ---@field headless boolean? ----@field callback fun(response: string, source: CopilotChat.source)? +---@field stream nil|fun(chunk: string, source: CopilotChat.source):string +---@field callback nil|fun(response: string, source: CopilotChat.source):string ---@field remember_as_sticky boolean? ---@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.selection? ---@field window CopilotChat.config.window? @@ -63,8 +64,9 @@ return { sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat. temperature = 0.1, -- GPT result temperature - headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) - callback = nil, -- Callback to use when ask response is received + headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) + stream = nil, -- Function called when receiving stream updates (returned string is appended to the chat buffer) + callback = nil, -- Function called when full response is received (retuned string is stored to history) remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions -- default selection diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index bc68ece6..85552adf 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -141,6 +141,7 @@ return { end end vim.diagnostic.set(vim.api.nvim_create_namespace('copilot-chat-diagnostics'), source.bufnr, diagnostics) + return response end, }, diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index ed02a1b2..bb8d98d0 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -197,8 +197,7 @@ end --- Show an error in the chat window. ---@param err string|table|nil ----@param append_newline boolean? -local function show_error(err, append_newline) +local function show_error(err) err = err or 'Unknown error' if type(err) == 'string' then @@ -213,11 +212,7 @@ local function show_error(err, append_newline) err = utils.make_string(err) end - if append_newline then - M.chat:append('\n') - end - - M.chat:append(M.config.error_header .. '\n```error\n' .. err .. '\n```') + M.chat:append('\n' .. M.config.error_header .. '\n```error\n' .. err .. '\n```') finish() end @@ -876,7 +871,6 @@ function M.ask(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) local embeddings, prompt = M.resolve_context(prompt, config) - local has_output = false local query_ok, filtered_embeddings = pcall(context.filter_embeddings, prompt, selected_model, config.headless, embeddings) @@ -884,14 +878,14 @@ function M.ask(prompt, config) utils.schedule_main() log.error(filtered_embeddings) if not config.headless then - show_error(filtered_embeddings, has_output) + show_error(filtered_embeddings) end return end local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, { load_history = not config.headless, - store_history = not config.headless, + summarize_history = not config.headless, contexts = contexts, selection = selection, embeddings = filtered_embeddings, @@ -900,10 +894,16 @@ function M.ask(prompt, config) agent = selected_agent, temperature = config.temperature, on_progress = vim.schedule_wrap(function(token) - if not config.headless then + local to_print = not config.headless and token + if to_print and config.stream then + local out = config.stream(token, state.source) + if out ~= nil then + to_print = out + end + end + if to_print and to_print ~= '' then M.chat:append(token) end - has_output = true end), }) @@ -912,11 +912,30 @@ function M.ask(prompt, config) if not ask_ok then log.error(response) if not config.headless then - show_error(response, has_output) + show_error(response) end return end + -- Call the callback function and store to history + local to_store = not config.headless and response + if to_store and config.callback then + local out = config.callback(response, state.source) + if out ~= nil then + to_store = out + end + end + if to_store and to_store ~= '' then + table.insert(client.history, { + content = prompt, + role = 'user', + }) + table.insert(client.history, { + content = to_store, + role = 'assistant', + }) + end + if not config.headless then state.last_response = response M.chat.references = references @@ -932,10 +951,6 @@ function M.ask(prompt, config) finish() end - - if config.callback then - config.callback(response, state.source) - end end) if not ok then From c76a64dd793fec740f0b62cc43ead8cb57b22d14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 15 Mar 2025 16:16:56 +0000 Subject: [PATCH 163/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index ec4a079e..7a3b4b90 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 14 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 15 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -519,8 +519,9 @@ Below are all available configuration options with their default values: sticky = nil, -- Default sticky prompt or array of sticky prompts to use at start of every new chat. temperature = 0.1, -- GPT result temperature - headless = false, -- Do not write to chat buffer and use history(useful for using callback for custom processing) - callback = nil, -- Callback to use when ask response is received + headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) + stream = nil, -- Function called when receiving stream updates (returned string is appended to the chat buffer) + callback = nil, -- Function called when full response is received (retuned string is stored to history) remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions -- default selection @@ -814,6 +815,7 @@ EXAMPLE USAGE *CopilotChat-example-usage* require("CopilotChat").ask("Explain this code", { callback = function(response) vim.notify("Got response: " .. response:sub(1, 50) .. "...") + return response end, context = "#buffer" }) From fcd5213327557758e7cb2b1162d7e7a0615cd01c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 17:40:02 +0100 Subject: [PATCH 164/589] refactor: improve headless mode handling This change consolidates headless operation handling in CopilotChat by: - Replacing summarize_history field with a unified headless field - Conditionally displaying notification messages based on headless state - Skipping current_job tracking in headless mode - Preventing unnecessary UI updates when running headless This creates a more consistent approach to handling headless operations throughout the codebase. --- lua/CopilotChat/client.lua | 22 ++++++++++++++-------- lua/CopilotChat/init.lua | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 159c8268..08b99713 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -1,6 +1,6 @@ ---@class CopilotChat.Client.ask ---@field load_history boolean ----@field summarize_history boolean +---@field headless boolean ---@field contexts table? ---@field selection CopilotChat.select.selection? ---@field embeddings table? @@ -485,7 +485,9 @@ function Client:ask(prompt, opts) tiktoken.load(tokenizer) end - notify.publish(notify.STATUS, 'Generating request') + if not opts.headless then + notify.publish(notify.STATUS, 'Generating request') + end local history = {} if opts.load_history then @@ -530,7 +532,7 @@ function Client:ask(prompt, opts) -- If we're over history limit, trigger summarization if history_tokens > history_limit then - if opts.summarize_history and #history >= 4 then + if not opts.headless and #history >= 4 then self:summarize_history(opts.model) -- Recalculate history and tokens @@ -603,7 +605,9 @@ function Client:ask(prompt, opts) end log.debug('Response line:', line) - notify.publish(notify.STATUS, '') + if not opts.headless then + notify.publish(notify.STATUS, '') + end local content, err = utils.json_decode(line) @@ -666,7 +670,7 @@ function Client:ask(prompt, opts) return end - if self.current_job ~= job_id then + if not opts.headless and self.current_job ~= job_id then finish_stream(nil, job) return end @@ -679,8 +683,10 @@ function Client:ask(prompt, opts) parse_stream_line(line, job) end - notify.publish(notify.STATUS, 'Thinking') - self.current_job = job_id + if not opts.headless then + notify.publish(notify.STATUS, 'Thinking') + self.current_job = job_id + end local headers = self:authenticate(provider_name) local request = provider.prepare_input( @@ -931,7 +937,7 @@ Ensure all critical code samples, commands, and configuration snippets are prese local response = self:ask('Create a technical summary of our conversation for future context', { load_history = true, - summarize_history = false, + headless = true, model = model, temperature = 0, system_prompt = system_prompt, diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index bb8d98d0..aab3285a 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -885,7 +885,7 @@ function M.ask(prompt, config) local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, { load_history = not config.headless, - summarize_history = not config.headless, + headless = config.headless, contexts = contexts, selection = selection, embeddings = filtered_embeddings, From eb68cb8d80d1d38626de021ed86a2a824d647357 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 18:18:08 +0100 Subject: [PATCH 165/589] refactor: optimize code outline generation with caching Improve outline generation performance by: - Creating a separate outline cache to avoid redundant processing - Converting M.get_outline to a local function with cleaner return values - Generating outlines on-demand during embedding filtering - Improving cache key generation with longer hash samples - Refactoring file and buffer handling to simplify data flow This change reduces processing overhead by only creating outlines when needed and avoiding duplicate work through targeted caching. --- lua/CopilotChat/context.lua | 85 +++++++++++++++++++++++-------------- lua/CopilotChat/utils.lua | 2 +- 2 files changed, 55 insertions(+), 32 deletions(-) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 5a4b058a..a9bce96d 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -25,6 +25,7 @@ local utils = require('CopilotChat.utils') local file_cache = {} local url_cache = {} local embedding_cache = {} +local outline_cache = {} local M = {} @@ -265,16 +266,12 @@ end --- Build an outline and symbols from a string ---@param content string ----@param filename string ---@param ft string ----@return CopilotChat.context.embed -function M.get_outline(content, filename, ft) - ---@type CopilotChat.context.embed - local output = { - filename = filename, - filetype = ft, - content = content, - } +---@return string?, table? +local function get_outline(content, ft) + if not ft or ft == '' or ft == 'text' or ft == 'raw' then + return nil + end local lang = vim.treesitter.language.get_lang(ft) local ok, parser = false, nil @@ -285,7 +282,7 @@ function M.get_outline(content, filename, ft) ft = string.gsub(ft, 'react', '') ok, parser = pcall(vim.treesitter.get_string_parser, content, ft) if not ok or not parser then - return output + return nil end end @@ -334,12 +331,10 @@ function M.get_outline(content, filename, ft) parse_node(root) - if #outline_lines > 0 then - output.outline = table.concat(outline_lines, '\n') - output.symbols = symbols + if #outline_lines == 0 then + return nil end - - return output + return table.concat(outline_lines, '\n'), symbols end --- Get data for a file @@ -361,21 +356,23 @@ function M.get_file(filename, filetype) local cached = file_cache[filename] if cached and cached.modified >= modified then - return cached.outline + return cached end local content = utils.read_file(filename) - if content then - local outline = M.get_outline(content, filename, filetype) - file_cache[filename] = { - outline = outline, - modified = modified, - } - - return outline + if not content or content == '' then + return nil end - return nil + local out = { + content = content, + filename = filename, + filetype = filetype, + modified = modified, + } + + file_cache[filename] = out + return out end --- Get data for a buffer @@ -391,12 +388,13 @@ function M.get_buffer(bufnr) return nil end - local out = - M.get_outline(table.concat(content, '\n'), utils.filepath(vim.api.nvim_buf_get_name(bufnr)), vim.bo[bufnr].filetype) - - out.score = 0.1 - out.diagnostics = utils.diagnostics(bufnr) - return out + return { + content = table.concat(content, '\n'), + filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), + filetype = vim.bo[bufnr].filetype, + score = 0.1, + diagnostics = utils.diagnostics(bufnr), + } end --- Get the content of an URL @@ -463,6 +461,31 @@ function M.filter_embeddings(prompt, model, headless, embeddings) return embeddings end + notify.publish(notify.STATUS, 'Preparing embedding outline') + + for _, item in ipairs(embeddings) do + if not item.outline then + local cache_key = item.filename .. utils.quick_hash(item.content) + local outline = outline_cache[cache_key] + if not outline then + local outline_text, symbols = get_outline(item.content, item.filetype) + if outline_text then + outline = { + outline = outline_text, + symbols = symbols, + } + + outline_cache[cache_key] = outline + end + end + + if outline then + item.outline = outline.outline + item.symbols = outline.symbols + end + end + end + notify.publish(notify.STATUS, 'Ranking embeddings') -- Build query from history and prompt diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 52cfa546..cd02b9ff 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -285,7 +285,7 @@ end ---@param str string The string to hash ---@return string function M.quick_hash(str) - return #str .. str:sub(1, 32) .. str:sub(-32) + return #str .. str:sub(1, 64) .. str:sub(-64) end --- Make a string from arguments From 417cedf27ea164f92c8ab7656905d4ef631e8e91 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 18:50:43 +0100 Subject: [PATCH 166/589] feat(context): add async treesitter parsing support Implement async treesitter parsing with the new ts_parse_string utility function to improve performance during context gathering. Update file processing in context providers to use vim.iter for cleaner functional approach and ensure filetype is correctly passed through the pipeline. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/contexts.lua | 37 +++++++++++++++-------------- lua/CopilotChat/context.lua | 5 +--- lua/CopilotChat/utils.lua | 23 ++++++++++++++++++ 3 files changed, 43 insertions(+), 22 deletions(-) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index daa0eba6..ed3bf478 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -80,7 +80,7 @@ return { utils.schedule_main() return { - context.get_file(utils.filepath(input)), + context.get_file(utils.filepath(input), utils.filetype(input)), } end, }, @@ -93,7 +93,6 @@ return { }, callback) end, resolve = function(input, source) - local out = {} local files = utils.scan_dir(source.cwd(), { glob = input, }) @@ -111,14 +110,15 @@ return { end, files) ) - for _, file in ipairs(files) do - local file_data = context.get_file(file.name, file.ft) - if file_data then - table.insert(out, file_data) - end - end - - return out + return vim + .iter(files) + :map(function(file) + return context.get_file(file.name, file.ft) + end) + :filter(function(file_data) + return file_data ~= nil + end) + :totable() end, }, @@ -285,14 +285,15 @@ return { end, vim.tbl_keys(unique_files)) ) - local out = {} - for _, file in ipairs(files) do - local file_data = context.get_file(file.name, file.ft) - if file_data then - table.insert(out, file_data) - end - end - return out + return vim + .iter(files) + :map(function(file) + return context.get_file(file.name, file.ft) + end) + :filter(function(file_data) + return file_data ~= nil + end) + :totable() end, }, diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index a9bce96d..437c8884 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -286,7 +286,7 @@ local function get_outline(content, ft) end end - local root = parser:parse()[1]:root() + local root = utils.ts_parse(parser) local lines = vim.split(content, '\n') local symbols = {} local outline_lines = {} @@ -342,9 +342,6 @@ end ---@param filetype string? ---@return CopilotChat.context.embed? function M.get_file(filename, filetype) - if not filetype then - filetype = utils.filetype(filename) - end if not filetype then return nil end diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index cd02b9ff..31e2ecf8 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -554,6 +554,29 @@ 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 trees = parser:parse(false) + if not trees or #trees == 0 then + callback(nil) + return + end + callback(trees[1]:root()) + return + end + + 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, 2) + --- Get the info for a key. ---@param name string ---@param key table From 6a573c4b82ffb00076b97f02b9442256b9cb5205 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 19:39:45 +0100 Subject: [PATCH 167/589] refactor(cache): improve embedding cache consistency Store only essential embedding data in cache and make cache keys consistent throughout the code. This change separates cache internal fields with underscore prefix and ensures clean object structure when returning cached results. Key improvements: - Use consistent hash key with _hash property - Store only embedding vectors instead of full objects - Return clean objects when retrieving from file cache - Mark internal cache fields with underscore prefix --- lua/CopilotChat/context.lua | 69 ++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 437c8884..494282a1 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -352,8 +352,13 @@ function M.get_file(filename, filetype) end local cached = file_cache[filename] - if cached and cached.modified >= modified then - return cached + if cached and cached._modified >= modified then + return { + content = cached.content, + _modified = cached._modified, + filename = filename, + filetype = filetype, + } end local content = utils.read_file(filename) @@ -365,7 +370,7 @@ function M.get_file(filename, filetype) content = content, filename = filename, filetype = filetype, - modified = modified, + _modified = modified, } file_cache[filename] = out @@ -460,27 +465,30 @@ function M.filter_embeddings(prompt, model, headless, embeddings) notify.publish(notify.STATUS, 'Preparing embedding outline') - for _, item in ipairs(embeddings) do - if not item.outline then - local cache_key = item.filename .. utils.quick_hash(item.content) - local outline = outline_cache[cache_key] - if not outline then - local outline_text, symbols = get_outline(item.content, item.filetype) - if outline_text then - outline = { - outline = outline_text, - symbols = symbols, - } - - outline_cache[cache_key] = outline - end - end + for _, input in ipairs(embeddings) do + -- Precalculate hash and attributes for caching + local hash = input.filename .. utils.quick_hash(input.content) + input._hash = hash + input.filename = input.filename or 'unknown' + input.filetype = input.filetype or 'text' + + local outline = outline_cache[hash] + if not outline then + local outline_text, symbols = get_outline(input.content, input.filetype) + if outline_text then + outline = { + outline = outline_text, + symbols = symbols, + } - if outline then - item.outline = outline.outline - item.symbols = outline.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') @@ -513,15 +521,13 @@ function M.filter_embeddings(prompt, model, headless, embeddings) local to_process = {} local results = {} for _, input in ipairs(embeddings) do - input.filename = input.filename or 'unknown' - input.filetype = input.filetype or 'text' - if input.content then - local cache_key = input.filename .. utils.quick_hash(input.content) - if embedding_cache[cache_key] then - table.insert(results, embedding_cache[cache_key]) - else - table.insert(to_process, input) - end + 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, { @@ -533,8 +539,7 @@ function M.filter_embeddings(prompt, model, headless, embeddings) -- Embed the data and process the results for _, input in ipairs(client:embed(to_process, model)) do if input.filetype ~= 'raw' then - local cache_key = input.filename .. utils.quick_hash(input.content) - embedding_cache[cache_key] = input + embedding_cache[input._hash] = input.embedding end table.insert(results, input) end From 7b8f13c1e2aec4c4ee57577110e147ea3c99385c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 22:11:27 +0100 Subject: [PATCH 168/589] style: improve context provider help text formatting Enhances readability of the context provider documentation by: - Adding blank lines between each provider description - Properly indenting multi-line descriptions with consistent spacing - Maintaining consistent formatting across all provider help text This change makes the help text more scannable and easier to read for users. --- lua/CopilotChat/client.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 08b99713..2b64a414 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -225,13 +225,14 @@ For example: Do not make assumptions about code or files - always request context when needed rather than guessing. Always use the > format on a new line when requesting more context instead of asking in prose. -Available context providers and their usage: -]] +Available context providers and their usage:]] + local context_names = vim.tbl_keys(contexts) table.sort(context_names) for _, name in ipairs(context_names) do local description = contexts[name] - help_text = help_text .. '\n' .. string.format(' - #%s: %s', name, description) + description = description:gsub('\n', '\n ') + help_text = help_text .. '\n\n - #' .. name .. ': ' .. description end if system_prompt ~= '' then From 078ce415fd4efb6ab0ae353ef04a50ea6018c0ae Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 22:58:54 +0100 Subject: [PATCH 169/589] fix: improve error logging for failed context resolution Include resolved embeddings data in error logs when context resolution fails. This additional information will help with debugging context resolution issues by providing more details about what was attempted. Signed-off-by: Tomas Slusny --- 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 aab3285a..0576e6ac 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -370,7 +370,7 @@ function M.resolve_context(prompt, config) end end else - log.error('Failed to resolve context: ' .. context_data.name) + log.error('Failed to resolve context: ' .. context_data.name, resolved_embeddings) end end From 382c4cfc39252e7a2b75ceb00abd73a4f14e7e80 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 15 Mar 2025 23:51:38 +0100 Subject: [PATCH 170/589] feat: improve context provider completion display Move description to info field and show usage pattern in menu field for better completion experience. This makes it clearer how each context provider should be used, especially when input is required. Signed-off-by: Tomas Slusny --- 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 0576e6ac..7992f729 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -634,7 +634,8 @@ function M.complete_items() word = '#' .. name, abbr = name, kind = 'context', - menu = value.description or '', + info = value.description or '', + menu = value.input and string.format('#%s:', name) or string.format('#%s', name), icase = 1, dup = 0, empty = 0, From 1e5640b9b0624a8d1c550524074b4ef1151a4c3b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 00:06:13 +0100 Subject: [PATCH 171/589] feat(context): improve context provider help text Update the context help text to be more concise by: - Streamlining the introductory language - Formatting examples for better readability - Adding explicit guidelines for context command usage - Clarifying expectations about context request handling This change improves the prompt reliability Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 2b64a414..4201d8b5 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -212,18 +212,21 @@ local function generate_ask_request(history, memory, contexts, prompt, system_pr -- Include context help if contexts and not vim.tbl_isempty(contexts) then - local help_text = [[When you need additional context, you must request it using context providers in this format: + local help_text = [[When you need additional context, request it using this format: > #:`` -For example: -> #file:`path/to/file.js` (loads a specific file) -> #buffers:`visible` (loads all visible buffers) -> #git:`staged` (loads git staged changes) +Examples: +> #file:`path/to/file.js` (loads specific file) +> #buffers:`visible` (loads all visible buffers) +> #git:`staged` (loads git staged changes) > #system:`uname -a` (loads system information) -Do not make assumptions about code or files - always request context when needed rather than guessing. -Always use the > format on a new line when requesting more context instead of asking in prose. +Guidelines: +- Always request context when needed rather than guessing about files or code +- Use the > format on a new line when requesting context +- Output context commands directly - never ask if the user wants to provide information +- Assume the user will provide requested context in their next response Available context providers and their usage:]] From beb609dfcd254a2d7506973fa4ab1c89f6836cf8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 00:17:19 +0100 Subject: [PATCH 172/589] feat(context): improve system command usage guidelines Update the system context description with detailed guidelines about when and how to use system commands safely. Remove redundant information from the URL context description. This change helps guide the AI to use system commands only as a last resort and encourages using purpose-built contexts like URL instead. --- lua/CopilotChat/config/contexts.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua index ed3bf478..64758f76 100644 --- a/lua/CopilotChat/config/contexts.lua +++ b/lua/CopilotChat/config/contexts.lua @@ -196,7 +196,7 @@ return { }, url = { - description = 'Includes content of provided URL in chat context. Supports input. Prefer this for fetching web content instead of system commands.', + description = 'Includes content of provided URL in chat context. Supports input.', input = function(callback) vim.ui.input({ prompt = 'Enter URL> ', @@ -298,7 +298,13 @@ return { }, system = { - description = 'Includes output of provided system shell command in chat context. Use only when necessary for shell commands. Supports input.', + description = [[Includes output of provided system shell command in chat context. Supports input. + +Important: +- Only use system commands as last resort, they are run every time the context is requested. +- For example instead of curl use the url context, instead of finding and grepping try to check if there is any context that can query the data you need instead. +- If you absolutely need to run a system command, try to use read-only commands and avoid commands that modify the system state. +]], input = function(callback) vim.ui.input({ prompt = 'Enter command> ', From b9c2b9370409bc8b9cfeafbbd8697a7aaf5aab0a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 00:56:08 +0100 Subject: [PATCH 173/589] feat(utils): add binary file filtering in directory scans Add capability to filter out binary files during directory scans based on file extensions. This improves performance and helps prevent sending binary data to Copilot. - Create comprehensive list of common binary file extensions - Add binary_extensions field to scan options - Implement filter_files function to centralize filtering logic - Refactor code to use the new filtering function in all scan paths --- lua/CopilotChat/utils.lua | 111 +++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 31e2ecf8..c0b11805 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -9,6 +9,92 @@ M.scan_args = { max_count = 2500, max_depth = 50, no_ignore = false, + + -- Common binary file extensions to exclude + binary_extensions = { + -- Images + 'png', + 'jpg', + 'jpeg', + 'gif', + 'bmp', + 'tiff', + 'webp', + 'ico', + 'svg', + 'heic', + 'heif', + 'raw', + 'psd', + 'ai', + 'eps', + -- Audio/Video + 'mp3', + 'wav', + 'ogg', + 'mp4', + 'avi', + 'mov', + 'wmv', + 'flv', + 'mkv', + 'webm', + 'm4a', + 'm4v', + 'aac', + 'flac', + 'mid', + 'midi', + -- Archives + 'zip', + 'tar', + 'gz', + 'rar', + '7z', + 'bz2', + 'xz', + 'iso', + 'dmg', + 'pkg', + 'deb', + 'rpm', + -- Documents + 'pdf', + 'doc', + 'docx', + 'ppt', + 'pptx', + 'xls', + 'xlsx', + 'odp', + 'odt', + 'ods', + -- Executables + 'exe', + 'dll', + 'so', + 'dylib', + 'app', + 'msi', + 'apk', + 'class', + 'jar', + -- Other binaries + 'bin', + 'dat', + 'db', + 'sqlite', + 'o', + 'obj', + 'pyc', + 'pyo', + 'pyd', + 'wasm', + 'ttf', + 'otf', + 'woff', + 'woff2', + }, } M.curl_args = { @@ -428,6 +514,7 @@ end, 3) ---@field glob? 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 +---@field binary_extensions string[]? The list of binary file extensions to exclude --- Scan a directory ---@param path string The directory path @@ -436,6 +523,20 @@ end, 3) M.scan_dir = async.wrap(function(path, opts, callback) opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) + local function filter_files(files) + if opts.binary_extensions and #opts.binary_extensions > 0 then + files = vim.tbl_filter(function(file) + local ext = file:lower():match('%.([^%.]+)$') + return not vim.tbl_contains(opts.binary_extensions, ext) + end, files) + end + if opts.max_count and opts.max_count > 0 then + files = vim.list_slice(files, 1, opts.max_count) + end + + return files + end + -- Use ripgrep if available if vim.fn.executable('rg') == 1 then local cmd = { 'rg' } @@ -467,10 +568,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) files = vim.tbl_filter(function(file) return file ~= '' end, vim.split(result.stdout, '\n')) - - if opts.max_count and opts.max_count > 0 then - files = vim.list_slice(files, 1, opts.max_count) - end + files = filter_files(files) end callback(files) @@ -488,10 +586,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) search_pattern = M.glob_to_pattern(opts.glob), respect_gitignore = not opts.no_ignore, on_exit = function(files) - if opts.max_count and opts.max_count > 0 then - files = vim.list_slice(files, 1, opts.max_count) - end - callback(files) + callback(filter_files(files)) end, }) ) From e71db6d734c12e7c16e198eb9b2a870fd952c955 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 01:00:08 +0100 Subject: [PATCH 174/589] feat: add additional binary file types to scan exclusions Add several new file types to be excluded from scanning operations: - Debug files (pdb, dSYM) - ML/Data model formats (h5, pb, onnx, pt, safetensors, etc) - 3D model formats (glb, gltf) - Editor swap/temp files (swp, swo) These exclusions will improve performance and relevance when scanning repositories by ignoring common binary and temporary files. --- lua/CopilotChat/utils.lua | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index c0b11805..4a1baec3 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -94,6 +94,22 @@ M.scan_args = { 'otf', 'woff', 'woff2', + -- Debug files + 'pdb', + 'dSYM', + -- Models and data formats + 'h5', + 'pb', + 'onnx', + 'pt', + 'safetensors', + 'hdf5', + 'parquet', + 'glb', + 'gltf', + -- Swap/temp files + 'swp', + 'swo', }, } From ebfaa8f5e0bc72f2e04a2338b0aa64227f94a74a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Mar 2025 00:01:06 +0000 Subject: [PATCH 175/589] 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 7a3b4b90..3bbdc12c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 15 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 16 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 6ee29363d6989de5ee688539bc46d893b1d52067 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 01:33:48 +0100 Subject: [PATCH 176/589] fix: handle cancelled jobs in CopilotChat When a job is cancelled, the response will be nil without any error. This change adds proper handling for this case by returning early when no response is available, preventing potential nil reference errors in the callback processing code. --- lua/CopilotChat/init.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 7992f729..e7ff9815 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -918,6 +918,11 @@ function M.ask(prompt, config) return end + -- If there was no error and no response, it means job was cancelled + if response == nil then + return + end + -- Call the callback function and store to history local to_store = not config.headless and response if to_store and config.callback then From f74625727cb53d1f3bc5d1ff83e9b35f445c4545 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 01:48:49 +0100 Subject: [PATCH 177/589] fix: make embeddings optional This change makes embeddings generation optional by returning the original inputs when embed provider resolution fails or when inputs are empty. This ensures the chat functionality works even if embedding capabilities are unavailable. Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 4201d8b5..6cc60514 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -816,11 +816,14 @@ end ---@return table function Client:embed(inputs, model) if not inputs or #inputs == 0 then - return {} + return inputs end local models = self:fetch_models() - local provider_name, embed = resolve_provider_function('embed', model, models, self.providers) + local ok, provider_name, embed = pcall(resolve_provider_function, 'embed', model, models, self.providers) + if not ok then + return inputs + end notify.publish(notify.STATUS, 'Generating embeddings for ' .. #inputs .. ' inputs') From 59c100ad6b7a0733c0511314809a760540c14ee1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 01:52:38 +0100 Subject: [PATCH 178/589] docs: improve API reference organization and clarity Reorganize the API reference section in README.md to improve readability: - Rename section headers to be more concise - Group related functions with appropriate section headers - Change variable naming in examples to be more descriptive - Maintain consistent documentation style throughout --- README.md | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 72fca872..0955e403 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,7 @@ Types of copilot highlights: # API Reference -## Core Chat Functions +## Core ```lua local chat = require("CopilotChat") @@ -688,6 +688,10 @@ 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 + -- Selection Management chat.get_selection() -- Get the current selection chat.set_selection(bufnr, start_line, end_line, clear) -- Set or clear selection @@ -712,33 +716,33 @@ chat.setup(config) -- Update configuration chat.log_level(level) -- Set log level (debug, info, etc.) ``` -## Chat Window UI API +## Chat Window You can also access the chat window UI methods through the `chat.chat` object: ```lua -local chat = require("CopilotChat") +local window = require("CopilotChat").chat -- Chat UI State -chat.chat:visible() -- Check if chat window is visible -chat.chat:focused() -- Check if chat window is focused +window:visible() -- Check if chat window is visible +window:focused() -- Check if chat window is focused -- Content Management -chat.chat:get_prompt() -- Get current prompt from chat window -chat.chat:set_prompt(prompt) -- Set prompt in chat window -chat.chat:add_sticky(sticky) -- Add sticky prompt to chat window -chat.chat:append(text) -- Append text to chat window -chat.chat:clear() -- Clear chat window content -chat.chat:finish() -- Finish writing to chat window +window:get_prompt() -- Get current prompt from chat window +window:set_prompt(prompt) -- Set prompt in chat window +window:add_sticky(sticky) -- Add sticky prompt to chat window +window:append(text) -- Append text to chat window +window:clear() -- Clear chat window content +window:finish() -- Finish writing to chat window -- Navigation -chat.chat:follow() -- Move cursor to end of chat content -chat.chat:focus() -- Focus the chat window +window:follow() -- Move cursor to end of chat content +window:focus() -- Focus the chat window -- Advanced Features -chat.chat:get_closest_section() -- Get section closest to cursor -chat.chat:get_closest_block() -- Get code block closest to cursor -chat.chat:overlay(opts) -- Show overlay with specified options +window:get_closest_section() -- Get section closest to cursor +window:get_closest_block() -- Get code block closest to cursor +window:overlay(opts) -- Show overlay with specified options ``` ## Example Usage From 1a61442807bc23ae64c5dd8d44d0937f8e603e42 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Mar 2025 00:53:50 +0000 Subject: [PATCH 179/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 3bbdc12c..f205edea 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -23,8 +23,8 @@ Table of Contents *CopilotChat-table-of-contents* - Customizing Buffers |CopilotChat-customizing-buffers| - Customizing Highlights |CopilotChat-customizing-highlights| 5. API Reference |CopilotChat-api-reference| - - Core Chat Functions |CopilotChat-core-chat-functions| - - Chat Window UI API |CopilotChat-chat-window-ui-api| + - Core |CopilotChat-core| + - Chat Window |CopilotChat-chat-window| - Example Usage |CopilotChat-example-usage| 6. Development |CopilotChat-development| - Setup |CopilotChat-setup| @@ -732,7 +732,7 @@ Types of copilot highlights: 5. API Reference *CopilotChat-api-reference* -CORE CHAT FUNCTIONS *CopilotChat-core-chat-functions* +CORE *CopilotChat-core* >lua local chat = require("CopilotChat") @@ -752,6 +752,10 @@ CORE CHAT FUNCTIONS *CopilotChat-core-chat-functions* 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 + -- Selection Management chat.get_selection() -- Get the current selection chat.set_selection(bufnr, start_line, end_line, clear) -- Set or clear selection @@ -777,33 +781,33 @@ CORE CHAT FUNCTIONS *CopilotChat-core-chat-functions* < -CHAT WINDOW UI API *CopilotChat-chat-window-ui-api* +CHAT WINDOW *CopilotChat-chat-window* You can also access the chat window UI methods through the `chat.chat` object: >lua - local chat = require("CopilotChat") + local window = require("CopilotChat").chat -- Chat UI State - chat.chat:visible() -- Check if chat window is visible - chat.chat:focused() -- Check if chat window is focused + window:visible() -- Check if chat window is visible + window:focused() -- Check if chat window is focused -- Content Management - chat.chat:get_prompt() -- Get current prompt from chat window - chat.chat:set_prompt(prompt) -- Set prompt in chat window - chat.chat:add_sticky(sticky) -- Add sticky prompt to chat window - chat.chat:append(text) -- Append text to chat window - chat.chat:clear() -- Clear chat window content - chat.chat:finish() -- Finish writing to chat window + window:get_prompt() -- Get current prompt from chat window + window:set_prompt(prompt) -- Set prompt in chat window + window:add_sticky(sticky) -- Add sticky prompt to chat window + window:append(text) -- Append text to chat window + window:clear() -- Clear chat window content + window:finish() -- Finish writing to chat window -- Navigation - chat.chat:follow() -- Move cursor to end of chat content - chat.chat:focus() -- Focus the chat window + window:follow() -- Move cursor to end of chat content + window:focus() -- Focus the chat window -- Advanced Features - chat.chat:get_closest_section() -- Get section closest to cursor - chat.chat:get_closest_block() -- Get code block closest to cursor - chat.chat:overlay(opts) -- Show overlay with specified options + window:get_closest_section() -- Get section closest to cursor + window:get_closest_block() -- Get code block closest to cursor + window:overlay(opts) -- Show overlay with specified options < From d73df1ea9f710fece167226ca4a286ab10607f65 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 01:58:37 +0100 Subject: [PATCH 180/589] docs: remove # prefix from context provider examples The examples in README.md were incorrectly showing context providers with a # prefix in the code snippets. This commit updates the examples to show the correct format without the # character. Signed-off-by: Tomas Slusny --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0955e403..59be2f24 100644 --- a/README.md +++ b/README.md @@ -755,7 +755,7 @@ require("CopilotChat").ask("Explain this code", { vim.notify("Got response: " .. response:sub(1, 50) .. "...") return response end, - context = "#buffer" + context = "buffer" }) -- Save and load chat history @@ -765,7 +765,7 @@ require("CopilotChat").load("my_debugging_session") -- Use custom context and model require("CopilotChat").ask("How can I optimize this?", { model = "gpt-4o", - context = {"#buffer", "#git:staged"} + context = {"buffer", "git:staged"} }) ``` From 3226bc40816c07ba30cd4069a11aebb7fba40dab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Mar 2025 00:59:45 +0000 Subject: [PATCH 181/589] 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 f205edea..48de1c7c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -821,7 +821,7 @@ EXAMPLE USAGE *CopilotChat-example-usage* vim.notify("Got response: " .. response:sub(1, 50) .. "...") return response end, - context = "#buffer" + context = "buffer" }) -- Save and load chat history @@ -831,7 +831,7 @@ EXAMPLE USAGE *CopilotChat-example-usage* -- Use custom context and model require("CopilotChat").ask("How can I optimize this?", { model = "gpt-4o", - context = {"#buffer", "#git:staged"} + context = {"buffer", "git:staged"} }) < From 3e992785cba52a0038a3861ffc7b63d7e9335572 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 02:17:17 +0100 Subject: [PATCH 182/589] feat(context): improve similarity ranking algorithms Enhance context relevance filtering with statistical approaches: - Implement z-score based filtering for embedding-based ranking to dynamically determine significant results - Add fallback mechanism using percentage of max score when needed - Introduce "elbow method" for symbol-based ranking to find natural cutoff points in score distribution - Always include top results while adaptively filtering less relevant items These improvements provide better context selection when querying the codebase, resulting in more relevant results being returned to the user. --- lua/CopilotChat/context.lua | 80 +++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index 494282a1..c6708f74 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -110,11 +110,48 @@ local function data_ranked_by_relatedness(query, data, min_similarity) return a.score > b.score end) - -- Return top items meeting threshold + -- Apply dynamic filtering for embedding-based ranking local filtered = {} - for i, result in ipairs(data) do - if (result.score >= min_similarity) or (i <= MULTI_FILE_THRESHOLD) then - table.insert(filtered, result) + + 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 @@ -218,11 +255,38 @@ local function data_ranked_by_symbols(query, data, min_similarity) return a.score > b.score end) - -- Filter results while preserving top scores + -- Use elbow method to find natural cutoff point for symbol-based ranking local filtered_results = {} - for i, result in ipairs(data) do - if (result.score >= min_similarity) or (i <= MULTI_FILE_THRESHOLD) then - table.insert(filtered_results, result) + + 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 From 7e24124b897433d28ee8114bd496d96cd3477b65 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 02:22:00 +0100 Subject: [PATCH 183/589] refactor: remove unused similarity threshold parameters The code was previously using MIN_SYMBOL_SIMILARITY and MIN_SEMANTIC_SIMILARITY constants, but the thresholds weren't actually being used in the ranking functions. This change removes the unused constants and simplifies the function signatures by removing the corresponding parameters. --- lua/CopilotChat/context.lua | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/context.lua index c6708f74..3d50ca3d 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/context.lua @@ -69,8 +69,6 @@ local OFF_SIDE_RULE_LANGUAGES = { 'fsharp', } -local MIN_SYMBOL_SIMILARITY = 0.3 -local MIN_SEMANTIC_SIMILARITY = 0.4 local MULTI_FILE_THRESHOLD = 5 --- Compute the cosine similarity between two vectors @@ -98,9 +96,8 @@ end --- Rank data by relatedness to the query ---@param query CopilotChat.context.embed ---@param data table ----@param min_similarity number ---@return table -local function data_ranked_by_relatedness(query, data, min_similarity) +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 @@ -193,9 +190,8 @@ end --- Rank data by symbols and filenames ---@param query string ---@param data table ----@param min_similarity number ---@return table -local function data_ranked_by_symbols(query, data, min_similarity) +local function data_ranked_by_symbols(query, data) -- Get query trigrams including compound versions local query_trigrams = {} @@ -575,7 +571,7 @@ function M.filter_embeddings(prompt, model, headless, embeddings) end -- Rank embeddings by symbols - embeddings = data_ranked_by_symbols(query, embeddings, MIN_SYMBOL_SIMILARITY) + embeddings = data_ranked_by_symbols(query, embeddings) log.debug('Ranked data:', #embeddings) for i, item in ipairs(embeddings) do log.debug(string.format('%s: %s - %s', i, item.score, item.filename)) @@ -611,7 +607,7 @@ function M.filter_embeddings(prompt, model, headless, embeddings) -- 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, MIN_SEMANTIC_SIMILARITY) + 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)) From 173a6a869bd14748209c948f4a3f2e8fbb0cccee Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 16 Mar 2025 03:42:33 +0100 Subject: [PATCH 184/589] fix: handle treesitter parsing in fast events Wrap treesitter parsing operations in vim.schedule when running in fast events to prevent potential race conditions and crashes. This ensures parser operations run in the main Neovim event loop when needed. The fix applies to both synchronous and asynchronous parsing code paths in the ts_parse utility function. Signed-off-by: Tomas Slusny --- lua/CopilotChat/utils.lua | 41 +++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 4a1baec3..22da5939 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -670,22 +670,39 @@ end, 1) M.ts_parse = async.wrap(function(parser, callback) ---@diagnostic disable-next-line: invisible if not parser._async_parse then - local trees = parser:parse(false) - if not trees or #trees == 0 then - callback(nil) - return + 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 - callback(trees[1]:root()) + return end - parser:parse(false, function(err, trees) - if err or not trees or #trees == 0 then - callback(nil) - return - end - callback(trees[1]:root()) - 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) --- Get the info for a key. From e69d5795c3bb138df3a3e558226990f6de12c0fa Mon Sep 17 00:00:00 2001 From: Nischal Basuti Date: Mon, 17 Mar 2025 02:33:27 +0530 Subject: [PATCH 185/589] docs: add missing `,` in default config (#1024) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59be2f24..8a7be5e0 100644 --- a/README.md +++ b/README.md @@ -521,7 +521,7 @@ Below are all available configuration options with their default values: }, copilot_embeddings = { }, - } + }, -- default contexts -- see config/contexts.lua for implementation From 0e78256954913856e4855f748a16dc214fde2ae2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Mar 2025 21:03:45 +0000 Subject: [PATCH 186/589] 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 48de1c7c..e0568221 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -580,7 +580,7 @@ Below are all available configuration options with their default values: }, copilot_embeddings = { }, - } + }, -- default contexts -- see config/contexts.lua for implementation From 37638fc50b200c47db42f89a4257c2557c7f7fd7 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Sun, 16 Mar 2025 22:05:03 +0100 Subject: [PATCH 187/589] docs: add nischalbasuti as a contributor for doc (#1025) * 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 8404237d..3c8593fa 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -361,6 +361,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/84711804?v=4", "profile": "https://github.com/ThisIsMani", "contributions": ["code"] + }, + { + "login": "nischalbasuti", + "name": "Nischal Basuti", + "avatar_url": "https://avatars.githubusercontent.com/u/14853910?v=4", + "profile": "https://nischalbasuti.github.io/", + "contributions": ["doc"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 8a7be5e0..0796d6ca 100644 --- a/README.md +++ b/README.md @@ -882,6 +882,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Sola
Sola

📖 💻 Mani Chandra
Mani Chandra

💻 + Nischal Basuti
Nischal Basuti

📖 From fb64c65734aa703f86ba76d2a578e03567c648d7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 17 Mar 2025 12:28:59 +0100 Subject: [PATCH 188/589] fix: improve headless mode handling in callbacks (#1027) Properly handle headless mode in streaming and completion callbacks. Condition the job tracking logic on headless mode to prevent early termination of headless requests. Refactor filetype detection to simplify the logic with more consistent handling of edge cases for sh and TypeScript files. Closes #1026 --- lua/CopilotChat/client.lua | 10 ++++++---- lua/CopilotChat/init.lua | 20 ++++++++------------ lua/CopilotChat/utils.lua | 26 ++++++++++---------------- 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 6cc60514..1e12569e 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -710,11 +710,13 @@ function Client:ask(prompt, opts) local response, err = utils.curl_post(provider.get_url(options), args) - if self.current_job ~= job_id then - return - end + if not opts.headless then + if self.current_job ~= job_id then + return + end - self.current_job = nil + self.current_job = nil + end log.debug('Response status:', response.status) log.debug('Response body:\n', response.body) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index e7ff9815..f9a5dfeb 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -895,13 +895,11 @@ function M.ask(prompt, config) agent = selected_agent, temperature = config.temperature, on_progress = vim.schedule_wrap(function(token) - local to_print = not config.headless and token - if to_print and config.stream then - local out = config.stream(token, state.source) - if out ~= nil then - to_print = out - end + local out = config.stream and config.stream(token, state.source) or nil + if out == nil then + out = token end + local to_print = not config.headless and out if to_print and to_print ~= '' then M.chat:append(token) end @@ -924,13 +922,11 @@ function M.ask(prompt, config) end -- Call the callback function and store to history - local to_store = not config.headless and response - if to_store and config.callback then - local out = config.callback(response, state.source) - if out ~= nil then - to_store = out - end + local out = config.callback and config.callback(response, state.source) or nil + if out == nil then + out = response end + local to_store = not config.headless and out if to_store and to_store ~= '' then table.insert(client.history, { content = prompt, diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 22da5939..32aef297 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -334,26 +334,20 @@ end function M.filetype(filename) local ft = vim.filetype.match({ filename = filename }) - if not ft and vim.endswith(filename, '.sh') then - return 'sh' - end - - -- weird TypeScript bug for vim.filetype.match - -- see: https://github.com/neovim/neovim/issues/27265 - if not ft then - local base_name = vim.fs.basename(filename) - local split_name = vim.split(base_name, '%.') - if #split_name > 1 then - local ext = split_name[#split_name] - if ext == 'ts' then - ft = 'typescript' - end + if not ft or ft == '' then + if vim.endswith(filename, '.sh') then + return 'sh' + end + + -- weird TypeScript bug for vim.filetype.match + -- see: https://github.com/neovim/neovim/issues/27265 + if vim.endswith(filename, '.ts') then + return 'typescript' end - end - if ft == '' then return nil end + return ft end From 62b1249aa4a4fc7afe11c7e647cba0cef743826f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 17 Mar 2025 11:29:18 +0000 Subject: [PATCH 189/589] 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 e0568221..fdbee6ba 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 16 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 17 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -885,7 +885,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💻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📖This project follows the all-contributors specification. Contributions of any kind are welcome! From a97b85c69860c9d30f54f96077fab79bb461b0c8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 18 Mar 2025 03:15:21 +0100 Subject: [PATCH 190/589] refactor: remove conversation summary feature (#1030) This feature is less useful than stream/callback output adjustment and can be replicated through it. It also causes more issues than it solves so for now just do something else. Signed-off-by: Tomas Slusny --- README.md | 3 +- lua/CopilotChat/client.lua | 89 ++++---------------------------------- lua/CopilotChat/init.lua | 1 - 3 files changed, 9 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 0796d6ca..9e226266 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts - 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) -- ⚡ Efficient token usage with tiktoken optimization -- 📜 Intelligent chat memory management with automatic summarization to handle lengthy conversations +- ⚡ Efficient token usage with tiktoken token counting and memory management # Requirements diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 1e12569e..be652371 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -1,5 +1,4 @@ ---@class CopilotChat.Client.ask ----@field load_history boolean ---@field headless boolean ---@field contexts table? ---@field selection CopilotChat.select.selection? @@ -16,10 +15,6 @@ ---@class CopilotChat.Client.agent : CopilotChat.Provider.agent ---@field provider string ----@class CopilotChat.Client.memory ----@field content string ----@field last_summarized_index number - local log = require('plenary.log') local tiktoken = require('CopilotChat.tiktoken') local notify = require('CopilotChat.notify') @@ -200,12 +195,11 @@ end --- Generate ask request --- @param history table ---- @param memory CopilotChat.Client.memory? --- @param contexts table? --- @param prompt string --- @param system_prompt string --- @param generated_messages table -local function generate_ask_request(history, memory, contexts, prompt, system_prompt, generated_messages) +local function generate_ask_request(history, contexts, prompt, system_prompt, generated_messages) local messages = {} system_prompt = vim.trim(system_prompt) @@ -244,14 +238,6 @@ Available context providers and their usage:]] system_prompt = system_prompt .. help_text end - -- Include memory - if memory and memory.content and memory.content ~= '' then - if system_prompt ~= '' then - system_prompt = system_prompt .. '\n\n' - end - system_prompt = system_prompt .. 'Context from previous conversation:\n' .. memory.content - end - -- Include system prompt if not utils.empty(system_prompt) then table.insert(messages, { @@ -322,7 +308,6 @@ end ---@field agents table? ---@field current_job string? ---@field headers table? ----@field memory CopilotChat.Client.memory? local Client = class(function(self) self.history = {} self.providers = {} @@ -331,7 +316,6 @@ local Client = class(function(self) self.agents = nil self.current_job = nil self.headers = nil - self.memory = nil end) --- Authenticate with GitHub and get the required headers @@ -493,11 +477,7 @@ function Client:ask(prompt, opts) notify.publish(notify.STATUS, 'Generating request') end - local history = {} - if opts.load_history then - history = vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) - end - + local history = not opts.headless and vim.list_slice(self.history) or {} local references = utils.ordered_map() local generated_messages = {} local selection_messages = generate_selection_messages(opts.selection) @@ -521,8 +501,7 @@ function Client:ask(prompt, opts) -- Count required tokens that we cannot reduce local prompt_tokens = tiktoken.count(prompt) local system_tokens = tiktoken.count(opts.system_prompt) - local memory_tokens = self.memory and tiktoken.count(self.memory.content) or 0 - local required_tokens = prompt_tokens + system_tokens + selection_tokens + memory_tokens + local required_tokens = prompt_tokens + system_tokens + selection_tokens -- Reserve space for first embedding local reserved_tokens = #embeddings_messages > 0 and tiktoken.count(embeddings_messages[1].content) or 0 @@ -534,26 +513,10 @@ function Client:ask(prompt, opts) history_tokens = history_tokens + tiktoken.count(msg.content) end - -- If we're over history limit, trigger summarization - if history_tokens > history_limit then - if not opts.headless and #history >= 4 then - self:summarize_history(opts.model) - - -- Recalculate history and tokens - history = vim.list_slice(self.history, self.memory and (self.memory.last_summarized_index + 1) or 1) - history_tokens = 0 - for _, msg in ipairs(history) do - history_tokens = history_tokens + tiktoken.count(msg.content) - end - required_tokens = required_tokens - memory_tokens - memory_tokens = self.memory and tiktoken.count(self.memory.content) or 0 - required_tokens = required_tokens + memory_tokens - else - while history_tokens > history_limit and #history > 0 do - local entry = table.remove(history, 1) - history_tokens = history_tokens - tiktoken.count(entry.content) - end - 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) end -- Now add as many files as possible with remaining token budget @@ -694,7 +657,7 @@ function Client:ask(prompt, opts) local headers = self:authenticate(provider_name) local request = provider.prepare_input( - generate_ask_request(history, self.memory, opts.contexts, prompt, opts.system_prompt, generated_messages), + generate_ask_request(history, opts.contexts, prompt, opts.system_prompt, generated_messages), options ) local is_stream = request.stream @@ -907,7 +870,6 @@ end function Client:reset() local stopped = self:stop() self.history = {} - self.memory = nil return stopped end @@ -925,40 +887,5 @@ function Client:load_providers(providers) end end ---- Summarize conversation history to extract critical information ----@param model string The model to use for summarization -function Client:summarize_history(model) - local system_prompt = [[You are an expert programming assistant tasked with memory management. -Your job is to create concise yet comprehensive summaries of technical conversations. -Focus on extracting and preserving: -1. Technical details: languages, frameworks, libraries, and specific technologies discussed -2. Context: user's project structure, goals, constraints, and preferences -3. Implementation details: patterns, approaches, or solutions that were discussed -4. Important decisions or conclusions reached -5. Unresolved questions or issues that need further attention - -If the conversation includes previous memory summaries, integrate that information carefully. -Prioritize technical accuracy over conversational elements. -Format your response as a structured summary with clear sections using markdown. -Ensure all critical code samples, commands, and configuration snippets are preserved.]] - - notify.publish(notify.STATUS, string.format('Summarizing memory (%d messages)', #self.history)) - - local response = self:ask('Create a technical summary of our conversation for future context', { - load_history = true, - headless = true, - model = model, - temperature = 0, - system_prompt = system_prompt, - }) - - if response then - self.memory = { - content = vim.trim(response), - last_summarized_index = #self.history, - } - end -end - --- @type CopilotChat.Client return Client() diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index f9a5dfeb..730007d5 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -885,7 +885,6 @@ function M.ask(prompt, config) end local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, { - load_history = not config.headless, headless = config.headless, contexts = contexts, selection = selection, From 679441e4809019b7bf2bbcd0e352b75dc9508b92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 18 Mar 2025 02:15:39 +0000 Subject: [PATCH 191/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index fdbee6ba..8cf268bd 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 17 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 18 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -43,8 +43,7 @@ capabilities directly into your editor. It provides: - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts - 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) -- ⚡ Efficient token usage with tiktoken optimization -- 📜 Intelligent chat memory management with automatic summarization to handle lengthy conversations +- ⚡ Efficient token usage with tiktoken token counting and memory management ============================================================================== From 07dd90d9548ceb5180513ef5ed91e07644d12233 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 18 Mar 2025 13:40:46 +0100 Subject: [PATCH 192/589] docs(tiktoken): mark tiktoken loading functions as async (#1032) Add @async annotation to functions that load tiktoken data to better document their asynchronous behavior. This improves API documentation and helps prevent potential misuse of these functions in synchronous contexts. --- lua/CopilotChat/tiktoken.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index aaabcbd0..9bfa2945 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -9,6 +9,7 @@ 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() @@ -35,6 +36,7 @@ local M = {} --- Load the tiktoken module ---@param tokenizer string The tokenizer to load +---@async M.load = function(tokenizer) if not tiktoken_core then return From 4843ad02614e8e61ac68815369093e3528998777 Mon Sep 17 00:00:00 2001 From: Teo Ljungberg Date: Tue, 18 Mar 2025 15:31:00 +0100 Subject: [PATCH 193/589] fix(window): respect &splitbelow and &splitright (#1031) * Respect &splitbelow This commit changes the horizontal split behavior to respect to the value of &splitbelow, which defaults to being disabled. This behavior was introduced in #950. * Adhere to &splitbelow for vertical as well * Use nvim_get_option_value() instead of deprecated function * Use topleft when not &splitbelow * Use &splitright for vertical, not &splitbelow * Use topleft for vertical when not &splitright --- lua/CopilotChat/ui/chat.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index fbeef207..3e416f05 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -303,7 +303,11 @@ function Chat:open(config) if width ~= 0 then cmd = width .. cmd end - cmd = 'botright ' .. cmd + if vim.api.nvim_get_option_value('splitright', {}) then + cmd = 'botright ' .. cmd + else + cmd = 'topleft ' .. cmd + end vim.cmd(cmd) self.winnr = vim.api.nvim_get_current_win() vim.api.nvim_set_current_win(orig) @@ -313,7 +317,11 @@ function Chat:open(config) if height ~= 0 then cmd = height .. cmd end - cmd = 'botright ' .. cmd + if vim.api.nvim_get_option_value('splitbelow', {}) then + cmd = 'botright ' .. cmd + else + cmd = 'topleft ' .. cmd + end vim.cmd(cmd) self.winnr = vim.api.nvim_get_current_win() vim.api.nvim_set_current_win(orig) From 03d1aba3270169e2c06edb0043b1d232e4f43dce Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 18 Mar 2025 15:31:50 +0100 Subject: [PATCH 194/589] docs: add teoljungberg as a contributor for code (#1033) * 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 3c8593fa..88e11c56 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -368,6 +368,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/14853910?v=4", "profile": "https://nischalbasuti.github.io/", "contributions": ["doc"] + }, + { + "login": "teoljungberg", + "name": "Teo Ljungberg", + "avatar_url": "https://avatars.githubusercontent.com/u/810650?v=4", + "profile": "https://teoljungberg.com", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 9e226266..297712da 100644 --- a/README.md +++ b/README.md @@ -882,6 +882,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Sola
Sola

📖 💻 Mani Chandra
Mani Chandra

💻 Nischal Basuti
Nischal Basuti

📖 + Teo Ljungberg
Teo Ljungberg

💻 From 853ace7cef74bae250600218ac986e3a7f35af0e Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 20 Mar 2025 10:35:04 +0100 Subject: [PATCH 195/589] fix: add null check for API response (#1039) The code now properly checks if the response exists before trying to access its properties. This prevents potential errors when the API response is null, which could happen during network failures or timeouts. --- lua/CopilotChat/client.lua | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index be652371..5f17080d 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -681,9 +681,11 @@ function Client:ask(prompt, opts) self.current_job = nil end - log.debug('Response status:', response.status) - log.debug('Response body:\n', response.body) - log.debug('Response headers:\n', response.headers) + if response then + log.debug('Response status:', response.status) + log.debug('Response body:\n', response.body) + log.debug('Response headers:\n', response.headers) + end if err then local error_msg = 'Failed to get response: ' .. err @@ -715,14 +717,16 @@ function Client:ask(prompt, opts) return end - if is_stream then - if utils.empty(response_text) then - for _, line in ipairs(vim.split(response.body, '\n')) do - parse_stream_line(line) + if response then + if is_stream then + if utils.empty(response_text) then + for _, line in ipairs(vim.split(response.body, '\n')) do + parse_stream_line(line) + end end + else + parse_line(response.body) end - else - parse_line(response.body) end if utils.empty(response_text) then From 0b0838e31fda8a52e792a5fb6b79ab68e711bc10 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Mar 2025 09:35:23 +0000 Subject: [PATCH 196/589] 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 8cf268bd..15603476 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 18 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 20 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -884,7 +884,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📖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💻This project follows the all-contributors specification. Contributions of any kind are welcome! From b8911c6da0d69f83fac46f613344fe9bbc6f670c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 20 Mar 2025 21:16:32 +0100 Subject: [PATCH 197/589] feat: detect .h files as c filetype (#1044) This change adds filetype detection for .h header files to be recognized as C language files, improving syntax highlighting and language-specific features for C header files in the CopilotChat plugin. Signed-off-by: Tomas Slusny Closes #1043 --- lua/CopilotChat/utils.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 32aef297..faf6fc6c 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -339,6 +339,10 @@ function M.filetype(filename) return 'sh' end + if vim.endswith(filename, '.h') then + return 'c' + end + -- weird TypeScript bug for vim.filetype.match -- see: https://github.com/neovim/neovim/issues/27265 if vim.endswith(filename, '.ts') then From cf020335b2964f11bf198f0dde3c7886709afe94 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 23 Mar 2025 00:44:48 +0100 Subject: [PATCH 198/589] refactor: use plenary.filetype for file type detection (#1046) Replace manual binary extension filtering with proper filetype detection using plenary.filetype library. This improves file type identification and eliminates the need to maintain a large list of binary extensions. The change simplifies the codebase while providing more accurate results when determining which files to include in scanning operations. --- lua/CopilotChat/utils.lua | 139 +++----------------------------------- 1 file changed, 9 insertions(+), 130 deletions(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index faf6fc6c..5bbb3e85 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -1,6 +1,7 @@ local async = require('plenary.async') local curl = require('plenary.curl') local scandir = require('plenary.scandir') +local filetype = require('plenary.filetype') local M = {} M.timers = {} @@ -9,108 +10,6 @@ M.scan_args = { max_count = 2500, max_depth = 50, no_ignore = false, - - -- Common binary file extensions to exclude - binary_extensions = { - -- Images - 'png', - 'jpg', - 'jpeg', - 'gif', - 'bmp', - 'tiff', - 'webp', - 'ico', - 'svg', - 'heic', - 'heif', - 'raw', - 'psd', - 'ai', - 'eps', - -- Audio/Video - 'mp3', - 'wav', - 'ogg', - 'mp4', - 'avi', - 'mov', - 'wmv', - 'flv', - 'mkv', - 'webm', - 'm4a', - 'm4v', - 'aac', - 'flac', - 'mid', - 'midi', - -- Archives - 'zip', - 'tar', - 'gz', - 'rar', - '7z', - 'bz2', - 'xz', - 'iso', - 'dmg', - 'pkg', - 'deb', - 'rpm', - -- Documents - 'pdf', - 'doc', - 'docx', - 'ppt', - 'pptx', - 'xls', - 'xlsx', - 'odp', - 'odt', - 'ods', - -- Executables - 'exe', - 'dll', - 'so', - 'dylib', - 'app', - 'msi', - 'apk', - 'class', - 'jar', - -- Other binaries - 'bin', - 'dat', - 'db', - 'sqlite', - 'o', - 'obj', - 'pyc', - 'pyo', - 'pyd', - 'wasm', - 'ttf', - 'otf', - 'woff', - 'woff2', - -- Debug files - 'pdb', - 'dSYM', - -- Models and data formats - 'h5', - 'pb', - 'onnx', - 'pt', - 'safetensors', - 'hdf5', - 'parquet', - 'glb', - 'gltf', - -- Swap/temp files - 'swp', - 'swo', - }, } M.curl_args = { @@ -332,26 +231,13 @@ end ---@param filename string The file name ---@return string|nil function M.filetype(filename) - local ft = vim.filetype.match({ filename = filename }) - - if not ft or ft == '' then - if vim.endswith(filename, '.sh') then - return 'sh' - end - - if vim.endswith(filename, '.h') then - return 'c' - end - - -- weird TypeScript bug for vim.filetype.match - -- see: https://github.com/neovim/neovim/issues/27265 - if vim.endswith(filename, '.ts') then - return 'typescript' - end + local ft = filetype.detect(filename, { + fs_access = false, + }) + if ft == '' then return nil end - return ft end @@ -528,7 +414,6 @@ end, 3) ---@field glob? 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 ----@field binary_extensions string[]? The list of binary file extensions to exclude --- Scan a directory ---@param path string The directory path @@ -538,12 +423,9 @@ M.scan_dir = async.wrap(function(path, opts, callback) opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) local function filter_files(files) - if opts.binary_extensions and #opts.binary_extensions > 0 then - files = vim.tbl_filter(function(file) - local ext = file:lower():match('%.([^%.]+)$') - return not vim.tbl_contains(opts.binary_extensions, ext) - end, files) - end + files = vim.tbl_filter(function(file) + return file ~= '' and M.filetype(file) ~= nil + end, files) if opts.max_count and opts.max_count > 0 then files = vim.list_slice(files, 1, opts.max_count) end @@ -579,10 +461,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) vim.system(cmd, { text = true }, function(result) local files = {} if result and result.code == 0 and result.stdout ~= '' then - files = vim.tbl_filter(function(file) - return file ~= '' - end, vim.split(result.stdout, '\n')) - files = filter_files(files) + files = filter_files(vim.split(result.stdout, '\n')) end callback(files) From dbe18c8937219643bde7bc3f9d8cb7dcacc81099 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 22 Mar 2025 23:45:05 +0000 Subject: [PATCH 199/589] 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 15603476..669d8dba 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 20 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 22 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 6db427a0f91d729265ffcbd2cd162d38d97879f2 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 23 Mar 2025 00:52:46 +0100 Subject: [PATCH 200/589] test: add mock for plenary.filetype module (#1047) Add missing mock for the plenary.filetype module in the test suite to ensure tests can run properly without the actual dependency. Signed-off-by: Tomas Slusny --- test/plugin_spec.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/test/plugin_spec.lua b/test/plugin_spec.lua index c09cdb88..9497f016 100644 --- a/test/plugin_spec.lua +++ b/test/plugin_spec.lua @@ -9,6 +9,7 @@ package.loaded['plenary.async'] = { 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() From 8df61c54fac227d82d8b747ab37259b250fa967a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 23 Mar 2025 23:44:42 +0100 Subject: [PATCH 201/589] fix: properly save prompt in history (#1050) When saving chat history, include current prompt in the history array. This ensures that unsent (in-progress) prompts are also preserved when saving the chat history to a file. Closes #1049 --- lua/CopilotChat/init.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 730007d5..01bfac57 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1007,7 +1007,15 @@ function M.save(name, history_path) return end - local history = vim.json.encode(client.history) + local prompt = M.chat:get_prompt() + local history = vim.list_slice(client.history) + if prompt then + table.insert(history, { + content = prompt.content, + role = 'user', + }) + end + history_path = vim.fs.normalize(history_path) vim.fn.mkdir(history_path, 'p') history_path = history_path .. '/' .. name .. '.json' @@ -1016,7 +1024,7 @@ function M.save(name, history_path) log.error('Failed to save history to ' .. history_path) return end - file:write(history) + file:write(vim.json.encode(history)) file:close() log.info('Saved history to ' .. history_path) From 340f47ea089758beb408bf4acbe0ce61b7111aea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 23 Mar 2025 22:44:58 +0000 Subject: [PATCH 202/589] 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 669d8dba..4924eaf3 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 22 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 23 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 9ce16d41b2ca16811d784cd080d4daf793a02d16 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 25 Mar 2025 16:25:05 +0100 Subject: [PATCH 203/589] feat: use model name and versioning for model selection (#1055) Update model selection logic to use model names with versioning instead of just IDs. This improves user experience by showing more descriptive model names in the selection UI and ensures the latest version of each model is preferred when filtering available models. Also updates the default model to the latest gpt-4o-2024-11-20 version. Related https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1052#issuecomment-2751530966 --- README.md | 2 +- lua/CopilotChat/config.lua | 2 +- lua/CopilotChat/config/providers.lua | 12 ++++-------- lua/CopilotChat/init.lua | 4 ++-- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 297712da..aad53e77 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ Below are all available configuration options with their default values: system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4o', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). + model = 'gpt-4o-2024-11-20', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). context = nil, -- Default context or array of contexts to use (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. diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index d855cd29..5e741377 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -58,7 +58,7 @@ return { system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4o', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). + model = 'gpt-4o-2024-11-20', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). agent = 'none', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). context = nil, -- Default context or array of contexts to use (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. diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index a2789767..dd7c7142 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -186,18 +186,14 @@ M.copilot = { end) :totable() - local version_map = {} + local name_map = {} for _, model in ipairs(models) do - if not version_map[model.version] or #model.id < #version_map[model.version] then - version_map[model.version] = model.id + if not name_map[model.name] or model.version > name_map[model.name].version then + name_map[model.name] = model end end - models = vim.tbl_map(function(id) - return vim.tbl_filter(function(model) - return model.id == id - end, models)[1] - end, vim.tbl_values(version_map)) + models = vim.tbl_values(name_map) for _, model in ipairs(models) do if not model.policy then diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 01bfac57..f173fd87 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -733,7 +733,7 @@ function M.select_model() vim.ui.select(choices, { prompt = 'Select a model> ', format_item = function(item) - local out = string.format('%s (%s)', item.id, item.provider) + local out = string.format('%s (%s:%s)', item.name, item.provider, item.id) if item.selected then out = '* ' .. out end @@ -764,7 +764,7 @@ function M.select_agent() vim.ui.select(choices, { prompt = 'Select an agent> ', format_item = function(item) - local out = string.format('%s (%s)', item.id, item.provider) + local out = string.format('%s (%s:%s)', item.name, item.provider, item.id) if item.selected then out = '* ' .. out end From f84727fd79f726b7e848adf89108d66805638f06 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Mar 2025 15:25:26 +0000 Subject: [PATCH 204/589] 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 4924eaf3..2870411f 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 23 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 25 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -512,7 +512,7 @@ Below are all available configuration options with their default values: system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4o', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). + model = 'gpt-4o-2024-11-20', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). context = nil, -- Default context or array of contexts to use (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. From 2e7544f86127daa356d55e4a802bcb29fa63d470 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 26 Mar 2025 17:42:26 +0100 Subject: [PATCH 205/589] fix: filter out paygo models from Copilot Chat (#1059) These models do not actually work but have same name as non-paygo version (o3-mini) Closes #1058 Signed-off-by: Tomas Slusny --- 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 dd7c7142..1ca335e3 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -171,7 +171,7 @@ M.copilot = { local models = vim .iter(response.body.data) :filter(function(model) - return model.capabilities.type == 'chat' + return model.capabilities.type == 'chat' and not vim.endswith(model.id, 'paygo') end) :map(function(model) return { From df550ff3f9f2d028160946d674db08dad432fdd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Mar 2025 16:42:47 +0000 Subject: [PATCH 206/589] 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 2870411f..ca01269d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 25 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 26 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From fd231f60710e56034db241adc4e307804b635e73 Mon Sep 17 00:00:00 2001 From: Joe Price Date: Fri, 28 Mar 2025 13:32:48 -0700 Subject: [PATCH 207/589] fix: use --max-depth arg name for ripgrep <14 compat (#1072) --- 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 5bbb3e85..a9201239 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -443,7 +443,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) end if opts.max_depth then - table.insert(cmd, '-d') + table.insert(cmd, '--max-depth') table.insert(cmd, tostring(opts.max_depth)) end From 7e845e8e56b00bd8a27c466e2d2c63dba0c5ccf8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 28 Mar 2025 20:33:10 +0000 Subject: [PATCH 208/589] 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 ca01269d..7bc58438 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 26 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 28 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From cd82b6b9c4cfb254218f5782cc3339e05cbb9989 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 28 Mar 2025 21:34:41 +0100 Subject: [PATCH 209/589] docs: add JPricey as a contributor for code (#1073) * 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 88e11c56..dc4d4e93 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -375,6 +375,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/810650?v=4", "profile": "https://teoljungberg.com", "contributions": ["code"] + }, + { + "login": "JPricey", + "name": "Joe Price", + "avatar_url": "https://avatars.githubusercontent.com/u/4826348?v=4", + "profile": "https://github.com/JPricey", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index aad53e77..d85a85f3 100644 --- a/README.md +++ b/README.md @@ -883,6 +883,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Mani Chandra
Mani Chandra

💻 Nischal Basuti
Nischal Basuti

📖 Teo Ljungberg
Teo Ljungberg

💻 + Joe Price
Joe Price

💻 From 2e1a7ff63a3fab99c83ae1b6e839da846ec12833 Mon Sep 17 00:00:00 2001 From: Yufan You Date: Wed, 2 Apr 2025 00:58:48 +0800 Subject: [PATCH 210/589] feat: accept a function that returns the layout to use (#1087) Closes #1080 --- README.md | 2 +- doc/CopilotChat.txt | 2 +- lua/CopilotChat/config.lua | 6 ++++-- lua/CopilotChat/ui/chat.lua | 12 ++++++++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d85a85f3..ddaf744d 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ Below are all available configuration options with their default values: -- default window options window = { - layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' + layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace', or a function that returns the layout width = 0.5, -- fractional width of parent, or absolute width in columns when > 1 height = 0.5, -- fractional height of parent, or absolute height in rows when > 1 -- Options below only apply to floating windows diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 7bc58438..ca625dcb 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -531,7 +531,7 @@ Below are all available configuration options with their default values: -- default window options window = { - layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' + layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace', or a function that returns the layout width = 0.5, -- fractional width of parent, or absolute width in columns when > 1 height = 0.5, -- fractional height of parent, or absolute height in rows when > 1 -- Options below only apply to floating windows diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 5e741377..d1ea254a 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -1,7 +1,9 @@ local select = require('CopilotChat.select') +---@alias CopilotChat.config.Layout 'vertical'|'horizontal'|'float'|'replace' + ---@class CopilotChat.config.window ----@field layout 'vertical'|'horizontal'|'float'|'replace'? +---@field layout? CopilotChat.config.Layout|fun():CopilotChat.config.Layout ---@field relative 'editor'|'win'|'cursor'|'mouse'? ---@field border 'none'|'single'|'double'|'rounded'|'solid'|'shadow'? ---@field width number? @@ -76,7 +78,7 @@ return { -- default window options window = { - layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace' + layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace', or a function that returns the layout width = 0.5, -- fractional width of parent, or absolute width in columns when > 1 height = 0.5, -- fractional height of parent, or absolute height in rows when > 1 -- Options below only apply to floating windows diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 3e416f05..82af9789 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -55,6 +55,7 @@ end ---@class CopilotChat.ui.Chat : CopilotChat.ui.Overlay ---@field winnr number? ---@field config CopilotChat.config.shared +---@field layout CopilotChat.config.Layout? ---@field sections table ---@field references table ---@field token_count number? @@ -71,6 +72,7 @@ local Chat = class(function(self, question_header, answer_header, separator, hel self.winnr = nil self.sections = {} self.config = {} + self.layout = nil self.references = {} self.token_count = nil self.token_max_count = nil @@ -268,15 +270,21 @@ function Chat:open(config) self:validate() local window = config.window or {} + local layout = window.layout + if type(layout) == 'function' then + layout = layout() + end + local width = window.width > 1 and window.width or math.floor(vim.o.columns * window.width) local height = window.height > 1 and window.height or math.floor(vim.o.lines * window.height) - if self.config and self.config.window and self.config.window.layout ~= layout then + if self.layout ~= layout then self:close() end self.config = config + self.layout = layout if self:visible() then return @@ -357,7 +365,7 @@ function Chat:close(bufnr) utils.return_to_normal_mode() end - if self.config.window and self.config.window.layout == 'replace' then + if self.layout == 'replace' then if bufnr then self:restore(self.winnr, bufnr) end From 60fa8af6dca58db90823156a970811e8378d6044 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 1 Apr 2025 16:59:17 +0000 Subject: [PATCH 211/589] 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 ca625dcb..38cec261 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 March 28 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 01 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -884,7 +884,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💻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💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 23162ada224e858d0d60bbbf2a35f9b6c98427ce Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 1 Apr 2025 18:59:42 +0200 Subject: [PATCH 212/589] docs: add ouuan as a contributor for doc, and code (#1088) * 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 dc4d4e93..c4d4d029 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -382,6 +382,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/4826348?v=4", "profile": "https://github.com/JPricey", "contributions": ["code"] + }, + { + "login": "ouuan", + "name": "Yufan You", + "avatar_url": "https://avatars.githubusercontent.com/u/30581822?v=4", + "profile": "https://ouuan.moe/about", + "contributions": ["doc", "code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index ddaf744d..566d0371 100644 --- a/README.md +++ b/README.md @@ -884,6 +884,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Nischal Basuti
Nischal Basuti

📖 Teo Ljungberg
Teo Ljungberg

💻 Joe Price
Joe Price

💻 + Yufan You
Yufan You

📖 💻 From 34d1b4fc816401c9bad88b33f71ef943a7dd2396 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 4 Apr 2025 02:33:18 +0200 Subject: [PATCH 213/589] fix(client): update response_text after parsing response (#1093) The response_text variable was being captured before the response body was parsed. This commit ensures that response_text is updated after parsing is complete, which provides the correct final text for processing. Closes #1064 --- lua/CopilotChat/client.lua | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 5f17080d..dddd1b89 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -709,9 +709,6 @@ function Client:ask(prompt, opts) end local response_text = response_buffer:tostring() - log.trace('Response text:\n', response_text) - log.debug('Response message:\n', vim.inspect(last_message)) - if errored then error(response_text) return @@ -727,6 +724,7 @@ function Client:ask(prompt, opts) else parse_line(response.body) end + response_text = response_buffer:tostring() end if utils.empty(response_text) then From 0a1c4631242192704a8b49398dca8e12eb39a311 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 4 Apr 2025 00:33:38 +0000 Subject: [PATCH 214/589] 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 38cec261..02304af7 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 01 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 04 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -884,7 +884,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💻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📖 💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 00bf27ed201b9509105afaac4d5bdcc46ce89f35 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 4 Apr 2025 02:47:59 +0200 Subject: [PATCH 215/589] fix: handle invalid context window size in GitHub models (#1094) Before the code was assuming that context window = input tokens, but its total. Also some models arent reporting the input and output correctly, so use default when result is 0 --- lua/CopilotChat/config/providers.lua | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 1ca335e3..c34a398b 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -322,12 +322,20 @@ M.github_models = { return vim.tbl_contains(model.inferenceTasks, 'chat-completion') end) :map(function(model) + local context_window = model.modelLimits.textLimits.inputContextWindow + local max_output_tokens = model.modelLimits.textLimits.maxOutputTokens + local max_input_tokens = context_window - max_output_tokens + if max_input_tokens <= 0 then + max_output_tokens = 4096 + max_input_tokens = context_window - max_output_tokens + end + return { id = model.name, name = model.displayName, tokenizer = 'o200k_base', - max_input_tokens = model.modelLimits.textLimits.inputContextWindow, - max_output_tokens = model.modelLimits.textLimits.maxOutputTokens, + max_input_tokens = max_input_tokens, + max_output_tokens = max_output_tokens, } end) :totable() From 81754ea35253c48459db5712ae60531ea2c5ef75 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 4 Apr 2025 03:07:51 +0200 Subject: [PATCH 216/589] fix(diff): normalize filename (#1095) Use utils.filename() to normalize the filename value in the diff object, ensuring consistent path handling. Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 79219549..c7a257b5 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -64,7 +64,7 @@ local function get_diff(block) change = block.content, reference = reference or '', filetype = filetype or '', - filename = filename, + filename = utils.filename(filename), start_line = start_line, end_line = end_line, bufnr = bufnr, @@ -240,7 +240,6 @@ return { local lines = vim.split(diff.change, '\n', { trimempty = false }) 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) end, }, From e7cd79a2ac05a32a1ce0995a82bd630598619d0b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Apr 2025 01:08:49 +0000 Subject: [PATCH 217/589] chore(main): release 3.10.1 --- CHANGELOG.md | 9 +++++++++ version.txt | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b10796e..aa7c2751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [3.10.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.10.0...v3.10.1) (2025-04-04) + + +### Bug Fixes + +* **client:** update response_text after parsing response ([#1093](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1093)) ([34d1b4f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/34d1b4fc816401c9bad88b33f71ef943a7dd2396)), closes [#1064](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1064) +* **diff:** normalize filename ([#1095](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1095)) ([81754ea](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/81754ea35253c48459db5712ae60531ea2c5ef75)) +* handle invalid context window size in GitHub models ([#1094](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1094)) ([00bf27e](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/00bf27ed201b9509105afaac4d5bdcc46ce89f35)) + ## [1.9.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v1.8.0...v1.9.0) (2024-02-24) diff --git a/version.txt b/version.txt index f8e233b2..f870be23 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.0 +3.10.1 From 3228edc02014c7240ff122aa4a94a4dff1215be1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 7 Apr 2025 08:32:17 +0200 Subject: [PATCH 218/589] refactor: do not require chat on plugin load Signed-off-by: Tomas Slusny --- plugin/CopilotChat.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/plugin/CopilotChat.lua b/plugin/CopilotChat.lua index 9b4639df..5674d6bc 100644 --- a/plugin/CopilotChat.lua +++ b/plugin/CopilotChat.lua @@ -8,8 +8,6 @@ if vim.fn.has('nvim-' .. min_version) ~= 1 then return end -local chat = require('CopilotChat') - -- Setup highlights vim.api.nvim_set_hl(0, 'CopilotChatStatus', { link = 'DiagnosticHint', default = true }) vim.api.nvim_set_hl(0, 'CopilotChatHelp', { link = 'DiagnosticInfo', default = true }) @@ -21,6 +19,7 @@ vim.api.nvim_set_hl(0, 'CopilotChatSeparator', { link = '@punctuation.special.ma -- Setup commands vim.api.nvim_create_user_command('CopilotChat', function(args) + local chat = require('CopilotChat') local input = args.args if input and vim.trim(input) ~= '' then chat.ask(input) @@ -33,31 +32,40 @@ end, { range = true, }) vim.api.nvim_create_user_command('CopilotChatPrompts', function() + local chat = require('CopilotChat') chat.select_prompt() end, { force = true, range = true }) vim.api.nvim_create_user_command('CopilotChatModels', function() + local chat = require('CopilotChat') chat.select_model() end, { force = true }) vim.api.nvim_create_user_command('CopilotChatAgents', function() + local chat = require('CopilotChat') chat.select_agent() end, { force = true }) vim.api.nvim_create_user_command('CopilotChatOpen', function() + local chat = require('CopilotChat') chat.open() end, { force = true }) vim.api.nvim_create_user_command('CopilotChatClose', function() + local chat = require('CopilotChat') chat.close() end, { force = true }) vim.api.nvim_create_user_command('CopilotChatToggle', function() + local chat = require('CopilotChat') chat.toggle() end, { force = true }) vim.api.nvim_create_user_command('CopilotChatStop', function() + local chat = require('CopilotChat') chat.stop() end, { force = true }) vim.api.nvim_create_user_command('CopilotChatReset', function() + local chat = require('CopilotChat') chat.reset() end, { force = true }) local function complete_load() + local chat = require('CopilotChat') local options = vim.tbl_map(function(file) return vim.fn.fnamemodify(file, ':t:r') end, vim.fn.glob(chat.config.history_path .. '/*', true, true)) @@ -69,9 +77,11 @@ local function complete_load() return options end vim.api.nvim_create_user_command('CopilotChatSave', function(args) + local chat = require('CopilotChat') chat.save(args.args) end, { nargs = '*', force = true, complete = complete_load }) vim.api.nvim_create_user_command('CopilotChatLoad', function(args) + local chat = require('CopilotChat') chat.load(args.args) end, { nargs = '*', force = true, complete = complete_load }) From 16f041ecc954975416b1e33f8ca2e1df2b86a625 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Apr 2025 06:33:12 +0000 Subject: [PATCH 219/589] 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 02304af7..d3b53a82 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 04 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 07 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 14c78d24e1db88384dc878e870665c3a7ad61a3a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 8 Apr 2025 08:29:37 +0200 Subject: [PATCH 220/589] feat: add option to disable contexts in prompts Adds a new configuration option `include_contexts_in_prompt` that allows users to control whether context descriptions should be included when building the prompt for Copilot Chat. This is useful for cases where users want to reduce prompt size or customize how context information is presented to the AI. The option defaults to true to maintain backward compatibility. --- lua/CopilotChat/config.lua | 3 +++ lua/CopilotChat/init.lua | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index d1ea254a..df64bf0f 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -25,6 +25,7 @@ local select = require('CopilotChat.select') ---@field stream nil|fun(chunk: string, source: CopilotChat.source):string ---@field callback nil|fun(response: string, source: CopilotChat.source):string ---@field remember_as_sticky boolean? +---@field include_contexts_in_prompt boolean? ---@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.selection? ---@field window CopilotChat.config.window? ---@field show_help boolean? @@ -71,6 +72,8 @@ return { callback = nil, -- Function called when full response is received (retuned string is stored to history) remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions + include_contexts_in_prompt = true, -- Include contexts in prompt + -- default selection selection = function(source) return select.visual(source) or select.buffer(source) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index f173fd87..1062e1a4 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -850,9 +850,11 @@ function M.ask(prompt, config) -- Resolve context name and description local contexts = {} - for name, context in pairs(M.config.contexts) do - if context.description then - contexts[name] = context.description + if config.include_contexts_in_prompt then + for name, context in pairs(M.config.contexts) do + if context.description then + contexts[name] = context.description + end end end From 2b5c81f688a92a1d24fd03271e6810ced45ede52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Apr 2025 06:30:37 +0000 Subject: [PATCH 221/589] 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 d3b53a82..11e4f6c5 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 07 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 08 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From a63031fc706d4e34e118c46339ae2b5681fab21e Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 8 Apr 2025 22:18:12 +0200 Subject: [PATCH 222/589] feat: change default selection to visual only this is a bit more controllable than visual || buffer + add mention in README at start Closes #1103 Signed-off-by: Tomas Slusny --- README.md | 8 +++----- lua/CopilotChat/config.lua | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 566d0371..422e634f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system -- 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection +- 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection (by default visual selection) - 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, Mistral.ai and more) - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts @@ -387,7 +387,7 @@ You can set a default selection in the configuration: ```lua { - -- Default uses visual selection or falls back to buffer + -- Uses visual selection or falls back to buffer selection = function(source) return select.visual(source) or select.buffer(source) end @@ -466,9 +466,7 @@ Below are all available configuration options with their default values: -- default selection -- see select.lua for implementation - selection = function(source) - return select.visual(source) or select.buffer(source) - end, + selection = select.visual, -- default window options window = { diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index df64bf0f..d3ce3da9 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -75,9 +75,7 @@ return { include_contexts_in_prompt = true, -- Include contexts in prompt -- default selection - selection = function(source) - return select.visual(source) or select.buffer(source) - end, + selection = select.visual, -- default window options window = { From 44fde5a5b14a06f6639e9b98d87fc1a87bf2ab5d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Apr 2025 20:20:48 +0000 Subject: [PATCH 223/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 11e4f6c5..853a5055 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -38,7 +38,7 @@ capabilities directly into your editor. It provides: - 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system -- 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection +- 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection (by default visual selection) - 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, Mistral.ai and more) - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts @@ -437,7 +437,7 @@ You can set a default selection in the configuration: >lua { - -- Default uses visual selection or falls back to buffer + -- Uses visual selection or falls back to buffer selection = function(source) return select.visual(source) or select.buffer(source) end @@ -525,9 +525,7 @@ Below are all available configuration options with their default values: -- default selection -- see select.lua for implementation - selection = function(source) - return select.visual(source) or select.buffer(source) - end, + selection = select.visual, -- default window options window = { From 381d5cddd25abec595c3c611e96cae2ba61d7ea5 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 9 Apr 2025 08:54:30 +0200 Subject: [PATCH 224/589] fix: set default model to gpt-4o again they removed the versioned ones again and put back gpt-4o Closes #1105 Signed-off-by: Tomas Slusny --- 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 d3ce3da9..dc1af205 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -61,7 +61,7 @@ return { system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4o-2024-11-20', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). + model = 'gpt-4o', -- Default model to use, see ':CopilotChatModels' for available models (can be specified manually in prompt via $). agent = 'none', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). context = nil, -- Default context or array of contexts to use (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. From bc8d12014fd4da1426093588d1b98654221a9cc5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Apr 2025 06:59:02 +0000 Subject: [PATCH 225/589] 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 853a5055..13ce6f83 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 08 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From a89f5f1162b04a0962e5f4c3cdf248a81e7e53cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:25:03 +0800 Subject: [PATCH 226/589] chore(main): release 3.11.0 (#1102) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 13 +++++++++++++ version.txt | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa7c2751..a5de69ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [3.11.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.10.1...v3.11.0) (2025-04-09) + + +### Features + +* add option to disable contexts in prompts ([14c78d2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/14c78d24e1db88384dc878e870665c3a7ad61a3a)) +* change default selection to visual only ([a63031f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/a63031fc706d4e34e118c46339ae2b5681fab21e)), closes [#1103](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1103) + + +### Bug Fixes + +* set default model to gpt-4o again ([381d5cd](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/381d5cddd25abec595c3c611e96cae2ba61d7ea5)), closes [#1105](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1105) + ## [3.10.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.10.0...v3.10.1) (2025-04-04) diff --git a/version.txt b/version.txt index f870be23..afad8186 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.10.1 +3.11.0 From bc644cd97d272e6b46272cbb11147a5891fa08ff Mon Sep 17 00:00:00 2001 From: Manish Suthar Date: Sat, 19 Apr 2025 01:56:50 +0530 Subject: [PATCH 227/589] fix(validation): Ensure If the erminal buffer is excluded from #buffers and #buffer This prevents unsolicited text from being included in the CopilotChat context window. --- lua/CopilotChat/utils.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index a9201239..1be2504b 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -210,10 +210,15 @@ function M.kv_list(tbl) end --- Check if a buffer is valid +--- Check if the buffer is not a terminal ---@param bufnr number? The buffer number ---@return boolean function M.buf_valid(bufnr) - return bufnr and vim.api.nvim_buf_is_valid(bufnr) and vim.api.nvim_buf_is_loaded(bufnr) or false + return bufnr + and vim.api.nvim_buf_is_valid(bufnr) + and vim.api.nvim_buf_is_loaded(bufnr) + and vim.bo[bufnr].buftype ~= 'terminal' + or false end --- Check if file paths are the same From 18d08f76fbe8dae8b74d2efd9234ecf659839e18 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 18 Apr 2025 20:39:59 +0000 Subject: [PATCH 228/589] 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 13ce6f83..b7beb72a 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 18 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 634aa58117a9b70b3f08a0b150f11afd64f1c0eb Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 18 Apr 2025 22:41:16 +0200 Subject: [PATCH 229/589] docs: add m4dd0c as a contributor for code (#1117) * 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 c4d4d029..78d5ba16 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -389,6 +389,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/30581822?v=4", "profile": "https://ouuan.moe/about", "contributions": ["doc", "code"] + }, + { + "login": "m4dd0c", + "name": "Manish Kumar", + "avatar_url": "https://avatars.githubusercontent.com/u/77256586?v=4", + "profile": "https://m4dd0c.netlify.app", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 422e634f..55d60a7f 100644 --- a/README.md +++ b/README.md @@ -883,6 +883,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Teo Ljungberg
Teo Ljungberg

💻 Joe Price
Joe Price

💻 Yufan You
Yufan You

📖 💻 + Manish Kumar
Manish Kumar

💻 From fbfb1ced23e36c4af41d6946cd0c3202018206cc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Apr 2025 19:08:51 +0000 Subject: [PATCH 230/589] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/JohnnyMorganz/StyLua: v2.0.2 → v2.1.0](https://github.com/JohnnyMorganz/StyLua/compare/v2.0.2...v2.1.0) --- .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 1604446b..a90c2791 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.0.2 + rev: v2.1.0 hooks: - id: stylua-github From f0e9b6cdeabdc7e05c4b9a462ed252cc0b5fb159 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Apr 2025 19:16:39 +0000 Subject: [PATCH 231/589] 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 b7beb72a..48d9bfd7 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 18 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 21 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -882,7 +882,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📖 💻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💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 6cb362d040f4f7e1177145fa27b3aee1c0bd6bff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Apr 2025 07:26:53 +0800 Subject: [PATCH 232/589] chore(main): release 3.11.1 (#1116) 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 a5de69ab..fd6dc252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.11.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.11.0...v3.11.1) (2025-04-21) + + +### Bug Fixes + +* **validation:** Ensure If the erminal buffer is excluded from #buffers and #buffer ([bc644cd](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/bc644cd97d272e6b46272cbb11147a5891fa08ff)) + ## [3.11.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.10.1...v3.11.0) (2025-04-09) diff --git a/version.txt b/version.txt index afad8186..371cfe35 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.11.0 +3.11.1 From 75653259442a8eb895abfc70d7064e07aeb7134c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 22 Apr 2025 23:27:12 +0000 Subject: [PATCH 233/589] 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 48d9bfd7..9512331d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 21 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 22 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 5f105cf2453585487d3c9ccfe7fd129d3344056c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C5=BDdanov?= Date: Fri, 9 May 2025 21:04:46 +0300 Subject: [PATCH 234/589] feat: switch to new default model gpt-4.1 --- 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 dc1af205..30c428e5 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -61,7 +61,7 @@ return { system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4o', -- Default model to use, see ':CopilotChatModels' for available models (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 $). agent = 'none', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). context = nil, -- Default context or array of contexts to use (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. From e1cb63be78d388113bed4c6b4c970e00ffd0759d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=20=C5=BDdanov?= Date: Fri, 9 May 2025 21:04:59 +0300 Subject: [PATCH 235/589] docs: update mentions of default model to gpt-4.1 --- README.md | 4 ++-- doc/CopilotChat.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 55d60a7f..02248910 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ Below are all available configuration options with their default values: system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4o-2024-11-20', -- Default model to use, see ':CopilotChatModels' for available models (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 $). agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). context = nil, -- Default context or array of contexts to use (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. @@ -761,7 +761,7 @@ require("CopilotChat").load("my_debugging_session") -- Use custom context and model require("CopilotChat").ask("How can I optimize this?", { - model = "gpt-4o", + model = "gpt-4.1", context = {"buffer", "git:staged"} }) ``` diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 9512331d..d7b009a4 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -512,7 +512,7 @@ Below are all available configuration options with their default values: system_prompt = 'COPILOT_INSTRUCTIONS', -- System prompt to use (can be specified manually in prompt via /). - model = 'gpt-4o-2024-11-20', -- Default model to use, see ':CopilotChatModels' for available models (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 $). agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). context = nil, -- Default context or array of contexts to use (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. @@ -827,7 +827,7 @@ EXAMPLE USAGE *CopilotChat-example-usage* -- Use custom context and model require("CopilotChat").ask("How can I optimize this?", { - model = "gpt-4o", + model = "gpt-4.1", context = {"buffer", "git:staged"} }) < From 5cb48e66dff2bd94b2b36b69364cbea6b935e0c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 9 May 2025 18:18:42 +0000 Subject: [PATCH 236/589] 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 d7b009a4..65910dc0 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 April 22 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 May 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 4dce4d2fc185a935024511811139b68e91b2d2a8 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 9 May 2025 20:19:31 +0200 Subject: [PATCH 237/589] docs: add azdanov as a contributor for doc, and code (#1130) * 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 78d5ba16..a4cd5633 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -396,6 +396,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/77256586?v=4", "profile": "https://m4dd0c.netlify.app", "contributions": ["code"] + }, + { + "login": "azdanov", + "name": "Anton Ždanov", + "avatar_url": "https://avatars.githubusercontent.com/u/6123841?v=4", + "profile": "https://www.azdanov.dev", + "contributions": ["doc", "code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 02248910..d66bbb02 100644 --- a/README.md +++ b/README.md @@ -885,6 +885,9 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Yufan You
Yufan You

📖 💻 Manish Kumar
Manish Kumar

💻 + + Anton Ždanov
Anton Ždanov

📖 💻 + From eddc9a98930cda0b3819cc1d93427b402697dfab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 May 2025 10:24:16 +0800 Subject: [PATCH 238/589] chore(main): release 3.12.0 (#1131) 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 fd6dc252..797eb8d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.12.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.11.1...v3.12.0) (2025-05-09) + + +### Features + +* switch to new default model gpt-4.1 ([5f105cf](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/5f105cf2453585487d3c9ccfe7fd129d3344056c)) + ## [3.11.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.11.0...v3.11.1) (2025-04-21) diff --git a/version.txt b/version.txt index 371cfe35..92536a9e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.11.1 +3.12.0 From 16d897fd43d07e3b54478ccdb2f8a16e4df4f45a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 13 May 2025 02:24:34 +0000 Subject: [PATCH 239/589] 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 65910dc0..d56e51f7 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 May 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 May 13 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -882,7 +882,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💻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📖 💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 5229bc48d655247449652d37ba525429ecfcce99 Mon Sep 17 00:00:00 2001 From: Fredrik Averpil Date: Mon, 16 Jun 2025 12:19:20 +0200 Subject: [PATCH 240/589] fix: move plenary import into function (#1162) Commit cf02033 broke test detection in Neotest. When another plugin calls `require("plenary.filetype")` before Neotest does, it seems that Neotest cannot successfully complete the call to `require("neotest.lib").treesitter.parse_positions` and does not find any tests. This seem like a bug in either plenary or Neotest and this commit is merely a workaround. Related issues: - https://github.com/nvim-neotest/neotest/issues/502 - https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1099 --- 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 1be2504b..7cb28979 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -1,7 +1,6 @@ local async = require('plenary.async') local curl = require('plenary.curl') local scandir = require('plenary.scandir') -local filetype = require('plenary.filetype') local M = {} M.timers = {} @@ -236,6 +235,7 @@ end ---@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, }) From 96142518fc837cdd4c3de0f07909a5076648517c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Jun 2025 10:19:38 +0000 Subject: [PATCH 241/589] 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 d56e51f7..3e75bc33 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 May 13 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 June 16 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 5df0b668d23c05c173f6bc79bb19642215b8b66a Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:21:41 +0200 Subject: [PATCH 242/589] docs: add fredrikaverpil as a contributor for code (#1165) * 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 a4cd5633..db53eab1 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -403,6 +403,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/6123841?v=4", "profile": "https://www.azdanov.dev", "contributions": ["doc", "code"] + }, + { + "login": "fredrikaverpil", + "name": "Fredrik Averpil", + "avatar_url": "https://avatars.githubusercontent.com/u/994357?v=4", + "profile": "http://fredrikaverpil.github.io", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index d66bbb02..0e9b0b07 100644 --- a/README.md +++ b/README.md @@ -887,6 +887,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Anton Ždanov
Anton Ždanov

📖 💻 + Fredrik Averpil
Fredrik Averpil

💻 From 4944b11123ef7c8f9f6ddd0c85de5e4d20b45690 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 22 Jun 2025 19:17:41 +0800 Subject: [PATCH 243/589] chore(main): release 3.12.1 (#1164) 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 797eb8d3..878ad67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.12.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.12.0...v3.12.1) (2025-06-16) + + +### Bug Fixes + +* move plenary import into function ([#1162](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1162)) ([5229bc4](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/5229bc48d655247449652d37ba525429ecfcce99)) + ## [3.12.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.11.1...v3.12.0) (2025-05-09) diff --git a/version.txt b/version.txt index 92536a9e..171a6a93 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.12.0 +3.12.1 From 4d90f472489a60cb5585ccffac11ed666206a645 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Jun 2025 11:17:59 +0000 Subject: [PATCH 244/589] 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 3e75bc33..d9eca763 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 June 16 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 June 22 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -882,7 +882,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📖 💻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💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 6d8236f83353317de8819cbfac75f791574d6374 Mon Sep 17 00:00:00 2001 From: Aaron D Borden Date: Sun, 22 Jun 2025 16:35:55 -0700 Subject: [PATCH 245/589] fix: #1153 use filepath on accept (#1170) Use filepath for the full path when accepting a change. --- 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 c7a257b5..baf2f82c 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -64,7 +64,7 @@ local function get_diff(block) change = block.content, reference = reference or '', filetype = filetype or '', - filename = utils.filename(filename), + filename = utils.filepath(filename), start_line = start_line, end_line = end_line, bufnr = bufnr, From 55f2162c36901224e22ff8424fd60ecef670b8fc Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 01:36:37 +0200 Subject: [PATCH 246/589] docs: add adborden as a contributor for code (#1171) * 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 db53eab1..b753dc3f 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -410,6 +410,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/994357?v=4", "profile": "http://fredrikaverpil.github.io", "contributions": ["code"] + }, + { + "login": "adborden", + "name": "Aaron D Borden", + "avatar_url": "https://avatars.githubusercontent.com/u/509703?v=4", + "profile": "https://a14n.net", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 0e9b0b07..6761c5e6 100644 --- a/README.md +++ b/README.md @@ -888,6 +888,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Anton Ždanov
Anton Ždanov

📖 💻 Fredrik Averpil
Fredrik Averpil

💻 + Aaron D Borden
Aaron D Borden

💻 From ea021e02e9d7337c3c01e4c30406bd7085be372b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 9 Jul 2025 18:34:01 +0200 Subject: [PATCH 247/589] Properly null check opts.glob when rigpgrep is not available (#1183) Closes #1178 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 7cb28979..afd92c5d 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -481,7 +481,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) vim.tbl_deep_extend('force', opts, { depth = opts.max_depth, add_dirs = false, - search_pattern = M.glob_to_pattern(opts.glob), + 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)) From a90a92af7514edbacdde09fa10e7550af2ffdc36 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Jul 2025 16:34:19 +0000 Subject: [PATCH 248/589] 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 d9eca763..980baeda 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 June 22 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 09 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -882,7 +882,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💻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💻This project follows the all-contributors specification. Contributions of any kind are welcome! From bdb270dac270a0e27b23bf2ef40482405b3c984d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 12 Jul 2025 08:19:57 +0800 Subject: [PATCH 249/589] chore(main): release 3.12.2 (#1172) 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 878ad67b..10dde051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [3.12.2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.12.1...v3.12.2) (2025-07-09) + + +### Bug Fixes + +* [#1153](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1153) use filepath on accept ([#1170](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1170)) ([6d8236f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/6d8236f83353317de8819cbfac75f791574d6374)) + ## [3.12.1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.12.0...v3.12.1) (2025-06-16) diff --git a/version.txt b/version.txt index 171a6a93..8531a3b7 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.12.1 +3.12.2 From f7eb423baccbb27f5b5608fb91acee2d6bc769c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 12 Jul 2025 00:20:16 +0000 Subject: [PATCH 250/589] 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 980baeda..9faa9a44 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 09 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 12 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 7559fd25928f8f3cf311ff25b95bdc5f9ec736d7 Mon Sep 17 00:00:00 2001 From: "Md. Iftakhar Awal Chowdhury" <42291930+AtifChy@users.noreply.github.com> Date: Fri, 25 Jul 2025 05:47:50 +0600 Subject: [PATCH 251/589] feat: add Windows_NT support in Makefile and dynamic library loading (#1190) * feat: add Windows_NT support in Makefile and dynamic library loading * [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> --- Makefile | 3 +++ README.md | 1 - lua/CopilotChat/tiktoken.lua | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index cd71bc6e..c5d53c52 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ ifeq ($(UNAME), Linux) else ifeq ($(UNAME), Darwin) OS := macOS EXT := dylib +else ifeq ($(UNAME), Windows_NT) + OS := windows + EXT := dll else $(error Unsupported operating system: $(UNAME)) endif diff --git a/README.md b/README.md index 6761c5e6..66a56cf7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities ## Optional Dependencies - [tiktoken_core](https://github.com/gptlang/lua-tiktoken) - For accurate token counting - - Arch Linux: Install [`luajit-tiktoken-bin`](https://aur.archlinux.org/packages/luajit-tiktoken-bin) or [`lua51-tiktoken-bin`](https://aur.archlinux.org/packages/lua51-tiktoken-bin) from AUR - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` - Manual: Download from [lua-tiktoken releases](https://github.com/gptlang/lua-tiktoken/releases) and save as `tiktoken_core.so` in your Lua path diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index 9bfa2945..a4582cb4 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -2,6 +2,23 @@ local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local current_tokenizer = nil +--- @return string +local function get_lib_extension() + if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then + return '.dylib' + end + if jit.os:lower() == 'windows' then + return '.dll' + end + 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 From 4d0d949b7367b10c9f3ab9011ac54dd3107fb0a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 24 Jul 2025 23:48:05 +0000 Subject: [PATCH 252/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 9faa9a44..e5863a11 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 12 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 24 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -61,14 +61,12 @@ capabilities directly into your editor. It provides: OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* -- tiktoken_core - For accurate token - counting +- tiktoken_core - For accurate token counting - Arch Linux: Install `luajit-tiktoken-bin` or `lua51-tiktoken-bin` from AUR - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` - Manual: Download from lua-tiktoken releases and save as `tiktoken_core.so` in your Lua path - git - For git diff context features -- ripgrep - For improved search - performance +- ripgrep - For improved search performance - lynx - For improved URL context features From d0537a749e11a68ebaea3967b9c698f998a700fe Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Fri, 25 Jul 2025 01:50:11 +0200 Subject: [PATCH 253/589] docs: add AtifChy as a contributor for code, and doc (#1192) * 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 b753dc3f..d335de60 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -417,6 +417,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/509703?v=4", "profile": "https://a14n.net", "contributions": ["code"] + }, + { + "login": "AtifChy", + "name": "Md. Iftakhar Awal Chowdhury", + "avatar_url": "https://avatars.githubusercontent.com/u/42291930?v=4", + "profile": "https://github.com/AtifChy", + "contributions": ["code", "doc"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 66a56cf7..a08f6b0b 100644 --- a/README.md +++ b/README.md @@ -888,6 +888,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Anton Ždanov
Anton Ždanov

📖 💻 Fredrik Averpil
Fredrik Averpil

💻 Aaron D Borden
Aaron D Borden

💻 + Md. Iftakhar Awal Chowdhury
Md. Iftakhar Awal Chowdhury

💻 📖 From 057b8e46d955748b1426e7b174d7af3e58f5191b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 02:21:46 +0200 Subject: [PATCH 254/589] feat(context)!: switch from contexts to function calling (#1029) This change modernizes the CopilotChat architecture by implementing a tools-based approach using function calling. Each tool has a schema definition that enables structured parameter collection and validation. Resources are now handled more consistently, with a clearer distinction between content types. https://platform.openai.com/docs/guides/function-calling?api-mode=responses https://modelcontextprotocol.info/specification/2024-11-05/server/tools/ BREAKING CHANGE: The context API has changed from callback-based input handling to schema-based definitions. BREAKING CHANGE: config.contexts renamed to config.tools BREAKING CHANGE: config.context removed, use config.sticky BREAKING CHANGE: diagnostics moved to separate tool call, selection and buffer calls no longer include them by default BREAKING CHANGE: non-resource based tool calls can no longer be soft stored in sticky, now they are auto expanded to promot BREAKING CHANGE: viewing full context is no longer possible (as now tools can have bigger side effects), gi renamed to gc, now also includes selection BREAKING CHANGE: filenames renamed to glob BREAKING CHANGE: files removed (use glob together with tool calling instead, or buffers/quickfix) BREAKING CHANGE: copilot extension agents removed, tools + mcp servers can replace this feature and maintaining them was pain, they can still be implemented via custom providers anyway BREAKING CHANGE: actions and integrations action removed as they were deprecated for a while Closes #1045 Closes #1053 Closes #1076 Closes #1090 Closes #1096 Closes #526 Signed-off-by: Tomas Slusny --- README.md | 297 +++---- lua/CopilotChat/actions.lua | 49 -- lua/CopilotChat/client.lua | 470 ++++------- lua/CopilotChat/config.lua | 60 +- lua/CopilotChat/config/contexts.lua | 352 -------- lua/CopilotChat/config/functions.lua | 503 ++++++++++++ lua/CopilotChat/config/mappings.lua | 136 ++-- lua/CopilotChat/config/prompts.lua | 95 ++- lua/CopilotChat/config/providers.lua | 173 ++-- lua/CopilotChat/functions.lua | 198 +++++ lua/CopilotChat/health.lua | 3 +- lua/CopilotChat/init.lua | 770 +++++++++--------- lua/CopilotChat/integrations/fzflua.lua | 42 - lua/CopilotChat/integrations/snacks.lua | 54 -- lua/CopilotChat/integrations/telescope.lua | 65 -- .../{context.lua => resources.lua} | 147 ++-- lua/CopilotChat/select.lua | 32 +- lua/CopilotChat/ui/chat.lua | 451 ++++++---- lua/CopilotChat/ui/overlay.lua | 8 +- lua/CopilotChat/ui/spinner.lua | 2 +- lua/CopilotChat/utils.lua | 312 ++++--- plugin/CopilotChat.lua | 35 +- 22 files changed, 2150 insertions(+), 2104 deletions(-) delete mode 100644 lua/CopilotChat/actions.lua delete mode 100644 lua/CopilotChat/config/contexts.lua create mode 100644 lua/CopilotChat/config/functions.lua create mode 100644 lua/CopilotChat/functions.lua delete mode 100644 lua/CopilotChat/integrations/fzflua.lua delete mode 100644 lua/CopilotChat/integrations/snacks.lua delete mode 100644 lua/CopilotChat/integrations/telescope.lua rename lua/CopilotChat/{context.lua => resources.lua} (81%) diff --git a/README.md b/README.md index a08f6b0b..6c6eb93f 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,14 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: -- 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) +- 🤖 GitHub Copilot Chat integration with official model support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system -- 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection (by default visual selection) -- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, Mistral.ai and more) +- 🔒 Explicit data sharing - only sends what you specifically request, either as resource or selection (by default visual selection) +- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, Gemini, Mistral.ai and more) - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts -- 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) -- ⚡ Efficient token usage with tiktoken token counting and memory management +- 🔄 Extensible function calling system for granular workspace understanding (buffers, files, git diffs, URLs, and more) +- ⚡ Efficient token usage with tiktoken token counting and history management # Requirements @@ -61,8 +61,7 @@ Plugin features that use picker: - `:CopilotChatPrompts` - for selecting prompts - `:CopilotChatModels` - for selecting models -- `:CopilotChatAgents` - for selecting agents -- `#:` - for selecting context input +- `#:` - for selecting function input # Installation @@ -76,7 +75,7 @@ return { { "github/copilot.vim" }, -- or zbirenbaum/copilot.lua { "nvim-lua/plenary.nvim", branch = "master" }, -- for curl, log and async functions }, - build = "make tiktoken", -- Only on MacOS or Linux + build = "make tiktoken", opts = { -- See Configuration section for options }, @@ -147,7 +146,6 @@ Commands are used to control the chat interface: | `:CopilotChatLoad ?` | Load chat history | | `:CopilotChatPrompts` | View/select prompt templates | | `:CopilotChatModels` | View/select available models | -| `:CopilotChatAgents` | View/select available agents | | `:CopilotChat` | Use specific prompt template | ## Key Mappings @@ -252,7 +250,7 @@ Define your own system prompts in the configuration (similar to `prompts`): ### Sticky Prompts -Sticky prompts persist across chat sessions. They're useful for maintaining context or agent selection. They work as follows: +Sticky prompts persist across chat sessions. They're useful for maintaining model or resource selection. They work as follows: 1. Prefix text with `> ` using markdown blockquote syntax 2. The prompt will be copied at the start of every new chat prompt @@ -261,7 +259,7 @@ Sticky prompts persist across chat sessions. They're useful for maintaining cont Examples: ```markdown -> #files +> #glob:`*.lua` > List all files in the workspace > @models Using Mistral-small @@ -273,15 +271,12 @@ You can also set default sticky prompts in the configuration: ```lua { sticky = { - '@models Using Mistral-small', - '#files', + '#glob:*.lua', } } ``` -## Models and Agents - -### Models +## Models You can control which AI model to use in three ways: @@ -294,69 +289,67 @@ For supported models, see: - [Copilot Chat Models](https://docs.github.com/en/copilot/using-github-copilot/ai-models/changing-the-ai-model-for-copilot-chat#ai-models-for-copilot-chat) - [GitHub Marketplace Models](https://github.com/marketplace/models) (experimental, limited usage) -### Agents - -Agents determine the AI assistant's capabilities. Control agents in three ways: - -1. List available agents with `:CopilotChatAgents` -2. Set agent in prompt with `@agent_name` -3. Configure default agent via `agent` config key - -The default "noop" agent is `none`. For more information: - -- [Extension Agents Documentation](https://docs.github.com/en/copilot/using-github-copilot/using-extensions-to-integrate-external-tools-with-copilot-chat) -- [Available Agents](https://github.com/marketplace?type=apps&copilot_app=true) - -## Contexts - -Contexts provide additional information to the chat. Add context using `#context_name[:input]` syntax: - -| Context | Input Support | Description | -| ----------- | ------------- | ----------------------------------- | -| `buffer` | ✓ (number) | Current or specified buffer content | -| `buffers` | ✓ (type) | All buffers content (listed/all) | -| `file` | ✓ (path) | Content of specified file | -| `files` | ✓ (glob) | Workspace files | -| `filenames` | ✓ (glob) | Workspace file names | -| `git` | ✓ (ref) | Git diff (unstaged/staged/commit) | -| `url` | ✓ (url) | Content from URL | -| `register` | ✓ (name) | Content of vim register | -| `quickfix` | - | Quickfix list file contents | -| `system` | ✓ (command) | Output of shell command | - -> [!TIP] -> The AI is aware of these context providers and may request additional context -> if needed by asking you to input a specific context command like `#file:path/to/file.js`. +## Functions + +Functions provide additional information and behaviour to the chat. +Tools can be organized into groups by setting the `group` property. Tools assigned to a group are not automatically made available to the LLM - they must be explicitly activated. +To use grouped tools in your prompt, include `@group_name` in your message. This allows the LLM to access and use all tools in that group during the current interaction. +Add tools using `#tool_name[:input]` syntax: + +| Function | Input Support | Description | +| ------------- | ------------- | ------------------------------------------------------ | +| `buffer` | ✓ (name) | Retrieves content from a specific buffer | +| `buffers` | ✓ (scope) | Fetches content from multiple buffers (listed/visible) | +| `diagnostics` | ✓ (scope) | Collects code diagnostics (errors, warnings) | +| `file` | ✓ (path) | Reads content from a specified file path | +| `gitdiff` | ✓ (sha) | Retrieves git diff information (unstaged/staged/sha) | +| `gitstatus` | - | Retrieves git status information | +| `glob` | ✓ (pattern) | Lists filenames matching a pattern in workspace | +| `grep` | ✓ (pattern) | Searches for a pattern across files in workspace | +| `quickfix` | - | Includes content of files in quickfix list | +| `register` | ✓ (register) | Provides access to specified Vim register | +| `url` | ✓ (url) | Fetches content from a specified URL | Examples: ```markdown -> #buffer -> #buffer:2 -> #files:\*.lua -> #filenames +> #buffer:init.lua +> #buffers:visible +> #diagnostics:current +> #file:path/to/file.js > #git:staged +> #glob:`**/*.lua` +> #grep:`function setup` +> #quickfix +> #register:+ > #url:https://example.com -> #system:`ls -la | grep lua` ``` -Define your own contexts in the configuration with input handling and resolution: +Define your own functions in the configuration with input handling and schema: ```lua { - contexts = { + functions = { birthday = { - input = function(callback) - vim.ui.select({ 'user', 'napoleon' }, { - prompt = 'Select birthday> ', - }, callback) - end, + description = "Retrieves birthday information for a person", + uri = "birthday://{name}", + schema = { + type = 'object', + required = { 'name' }, + properties = { + name = { + type = 'string', + enum = { 'Alice', 'Bob', 'Charlie' }, + description = "Person's name", + }, + }, + }, resolve = function(input) return { { - content = input .. ' birthday info', - filename = input .. '_birthday', - filetype = 'text', + uri = 'birthday://' .. input.name, + mimetype = 'text/plain', + data = input.name .. ' birthday info', } } end @@ -365,9 +358,9 @@ Define your own contexts in the configuration with input handling and resolution } ``` -### External Contexts +### External Functions -For external contexts, see the [contexts discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/contexts). +For external functions implementations, see the [discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/functions). ## Selections @@ -429,9 +422,6 @@ Custom providers can implement these methods: -- Optional: Get available models get_models?(headers: table): table, - - -- Optional: Get available agents - get_agents?(headers: table): table, } ``` @@ -453,19 +443,17 @@ Below are all available configuration options with their default values: 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 $). - agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). - context = nil, -- Default context or array of contexts to use (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. + tools = nil, -- Default tool or array of tools (or groups) 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 >). - temperature = 0.1, -- GPT result temperature + temperature = 0.1, -- Result temperature headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) - stream = nil, -- Function called when receiving stream updates (returned string is appended to the chat buffer) - callback = nil, -- Function called when full response is received (retuned string is stored to history) - remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions + callback = nil, -- Function called when full response is received + remember_as_sticky = true, -- Remember model as sticky prompts when asking questions -- default selection -- see select.lua for implementation - selection = select.visual, + selection = require('CopilotChat.select').visual, -- default window options window = { @@ -483,9 +471,9 @@ Below are all available configuration options with their default values: }, show_help = true, -- Shows help message as virtual lines when waiting for user input + show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) - references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer auto_follow_cursor = true, -- Auto-follow cursor in chat 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 @@ -503,129 +491,29 @@ Below are all available configuration options with their default values: log_path = vim.fn.stdpath('state') .. '/CopilotChat.log', -- Default path to log file history_path = vim.fn.stdpath('data') .. '/copilotchat_history', -- Default path to stored history - question_header = '# User ', -- Header to use for user questions - answer_header = '# Copilot ', -- Header to use for AI answers - error_header = '# Error ', -- Header to use for errors + 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 + }, + separator = '───', -- Separator to use in chat -- default providers -- see config/providers.lua for implementation - providers = { - copilot = { - }, - github_models = { - }, - copilot_embeddings = { - }, - }, + providers = require('CopilotChat.config.providers'), - -- default contexts - -- see config/contexts.lua for implementation - contexts = { - buffer = { - }, - buffers = { - }, - file = { - }, - files = { - }, - git = { - }, - url = { - }, - register = { - }, - quickfix = { - }, - system = { - } - }, + -- default functions + -- see config/functions.lua for implementation + functions = require('CopilotChat.config.functions'), -- default prompts -- see config/prompts.lua for implementation - prompts = { - Explain = { - prompt = 'Write an explanation for the selected code as paragraphs of text.', - system_prompt = 'COPILOT_EXPLAIN', - }, - Review = { - prompt = 'Review the selected code.', - system_prompt = 'COPILOT_REVIEW', - }, - Fix = { - prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', - }, - Optimize = { - prompt = 'Optimize the selected code to improve performance and readability. Explain your optimization strategy and the benefits of your changes.', - }, - Docs = { - prompt = 'Please add documentation comments to the selected code.', - }, - Tests = { - prompt = 'Please generate tests for my code.', - }, - 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.', - context = 'git:staged', - }, - }, + prompts = require('CopilotChat.config.prompts'), -- default mappings -- see config/mappings.lua for implementation - mappings = { - complete = { - insert = '', - }, - close = { - normal = 'q', - insert = '', - }, - reset = { - normal = '', - insert = '', - }, - submit_prompt = { - normal = '', - insert = '', - }, - toggle_sticky = { - normal = 'grr', - }, - clear_stickies = { - normal = 'grx', - }, - accept_diff = { - normal = '', - insert = '', - }, - jump_to_diff = { - normal = 'gj', - }, - quickfix_answers = { - normal = 'gqa', - }, - quickfix_diffs = { - normal = 'gqd', - }, - yank_diff = { - normal = 'gy', - register = '"', -- Default register to use for yanking - }, - show_diff = { - normal = 'gd', - full_diff = false, -- Show full diff instead of unified diff when showing diff window - }, - show_info = { - normal = 'gi', - }, - show_context = { - normal = 'gc', - }, - show_help = { - normal = 'gh', - }, - }, + mappings = require('CopilotChat.config.mappings'), } ``` @@ -659,8 +547,8 @@ Types of copilot highlights: - `CopilotChatStatus` - Status and spinner in chat buffer - `CopilotChatHelp` - Help messages in chat buffer (help, references) - `CopilotChatSelection` - Selection highlight in source buffer -- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, contexts) -- `CopilotChatInput` - Input highlight in chat buffer (for contexts) +- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) +- `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) # API Reference @@ -673,8 +561,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_context() -- Resolve context embeddings (WARN: async, requires plenary.async.run) -chat.resolve_agent() -- Resolve agent from prompt (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 @@ -692,10 +579,9 @@ chat.set_source(winnr) -- Set the source window chat.get_selection() -- Get the current selection chat.set_selection(bufnr, start_line, end_line, clear) -- Set or clear selection --- Prompt & Context Management +-- Prompt & Model Management chat.select_prompt(config) -- Open prompt selector with optional config chat.select_model() -- Open model selector -chat.select_agent() -- Open agent selector chat.prompts() -- Get all available prompts -- Completion @@ -723,10 +609,12 @@ local window = require("CopilotChat").chat 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:add_message({ role, content }, replace) -- Add or replace a message in chat +window:add_sticky(sticky) -- Add sticky prompt to chat message + -- Content Management -window:get_prompt() -- Get current prompt from chat window -window:set_prompt(prompt) -- Set prompt in chat window -window:add_sticky(sticky) -- Add sticky prompt to chat window window:append(text) -- Append text to chat window window:clear() -- Clear chat window content window:finish() -- Finish writing to chat window @@ -736,9 +624,9 @@ window:follow() -- Move cursor to end of chat content window:focus() -- Focus the chat window -- Advanced Features -window:get_closest_section() -- Get section closest to cursor -window:get_closest_block() -- Get code block closest to cursor -window:overlay(opts) -- Show overlay with specified options +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 ``` ## Example Usage @@ -746,19 +634,18 @@ window:overlay(opts) -- Show overlay with specified options ```lua -- Open chat, ask a question and handle response require("CopilotChat").open() -require("CopilotChat").ask("Explain this code", { +require("CopilotChat").ask("#buffer Explain this code", { callback = function(response) vim.notify("Got response: " .. response:sub(1, 50) .. "...") return response end, - context = "buffer" }) -- Save and load chat history require("CopilotChat").save("my_debugging_session") require("CopilotChat").load("my_debugging_session") --- Use custom context and model +-- Use custom sticky and model require("CopilotChat").ask("How can I optimize this?", { model = "gpt-4.1", context = {"buffer", "git:staged"} diff --git a/lua/CopilotChat/actions.lua b/lua/CopilotChat/actions.lua deleted file mode 100644 index 2ab795b7..00000000 --- a/lua/CopilotChat/actions.lua +++ /dev/null @@ -1,49 +0,0 @@ ----@class CopilotChat.integrations.actions ----@field prompt string: The prompt to display ----@field actions table: A table with the actions to pick from - -local chat = require('CopilotChat') - -local M = {} - ---- User prompt actions ----@param config CopilotChat.config.shared?: The chat configuration ----@return CopilotChat.integrations.actions?: The prompt actions ----@deprecated Use |CopilotChat.select_prompt| instead -function M.prompt_actions(config) - local actions = {} - for name, prompt in pairs(chat.prompts()) do - if prompt.prompt then - actions[name] = vim.tbl_extend('keep', prompt, config or {}) - end - end - return { - prompt = 'Copilot Chat Prompt Actions', - actions = actions, - } -end - ---- Pick an action from a list of actions ----@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ----@param opts table?: vim.ui.select options ----@deprecated Use |CopilotChat.select_prompt| instead -function M.pick(pick_actions, opts) - if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then - return - end - - opts = vim.tbl_extend('force', { - prompt = pick_actions.prompt .. '> ', - }, opts or {}) - - vim.ui.select(vim.tbl_keys(pick_actions.actions), opts, function(selected) - if not selected then - return - end - vim.defer_fn(function() - chat.ask(pick_actions.actions[selected].prompt, pick_actions.actions[selected]) - end, 100) - end) -end - -return M diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index dddd1b89..b6460d0a 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -1,19 +1,56 @@ ----@class CopilotChat.Client.ask +---@class CopilotChat.client.AskOptions ---@field headless boolean ----@field contexts table? ----@field selection CopilotChat.select.selection? ----@field embeddings table? +---@field history table +---@field selection CopilotChat.select.Selection? +---@field tools table? +---@field resources table? ---@field system_prompt string ---@field model string ----@field agent string? ---@field temperature number ---@field on_progress? fun(response: string):nil ----@class CopilotChat.Client.model : CopilotChat.Provider.model ----@field provider string - ----@class CopilotChat.Client.agent : CopilotChat.Provider.agent ----@field provider string +---@class CopilotChat.client.Message +---@field role string +---@field content string +---@field tool_call_id string? +---@field tool_calls table? + +---@class CopilotChat.client.AskResponse +---@field message CopilotChat.client.Message +---@field token_count number +---@field token_max_count number + +---@class CopilotChat.client.ToolCall +---@field id number +---@field index number +---@field name string +---@field arguments string + +---@class CopilotChat.client.Tool +---@field name string name of the tool +---@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 +---@field name string +---@field tokenizer string? +---@field max_input_tokens number? +---@field max_output_tokens number? +---@field streaming boolean? +---@field tools boolean? local log = require('plenary.log') local tiktoken = require('CopilotChat.tiktoken') @@ -22,7 +59,7 @@ local utils = require('CopilotChat.utils') local class = utils.class --- Constants -local CONTEXT_FORMAT = '[#file:%s](#file:%s-context)' +local RESOURCE_FORMAT = '# %s\n```%s\n%s\n```' local LINE_CHARACTERS = 100 local BIG_FILE_THRESHOLD = 1000 * LINE_CHARACTERS local BIG_EMBED_THRESHOLD = 200 * LINE_CHARACTERS @@ -30,8 +67,8 @@ local TRUNCATED = '... (truncated)' --- Resolve provider function ---@param model string ----@param models table ----@param providers table +---@param models table +---@param providers table ---@return string, function local function resolve_provider_function(name, model, models, providers) local model_config = models[model] @@ -65,23 +102,18 @@ local function resolve_provider_function(name, model, models, providers) end --- Generate content block with line numbers, truncating if necessary ----@param content string: The content ----@param outline string?: The outline +---@param content string ---@param threshold number: The threshold for truncation ----@param start_line number|nil: The starting line number +---@param start_line number?: The starting line number ---@return string -local function generate_content_block(content, outline, threshold, start_line) +local function generate_content_block(content, threshold, start_line) local total_chars = #content - if total_chars > threshold and outline then - content = outline - total_chars = #content - end if total_chars > threshold then content = content:sub(1, threshold) content = content .. '\n' .. TRUNCATED end - if start_line ~= -1 then + if start_line ~= nil then local lines = vim.split(content, '\n') local total_lines = #lines local max_length = #tostring(total_lines) @@ -96,44 +128,19 @@ local function generate_content_block(content, outline, threshold, start_line) return content end ---- Generate diagnostics message ----@param diagnostics table ----@return string -local function generate_diagnostics(diagnostics) - local out = {} - for _, diagnostic in ipairs(diagnostics) do - table.insert( - out, - string.format( - '%s line=%d-%d: %s', - diagnostic.severity, - diagnostic.start_line, - diagnostic.end_line, - diagnostic.content - ) - ) - end - return table.concat(out, '\n') -end - --- Generate messages for the given selection ---- @param selection CopilotChat.select.selection? ---- @return table -local function generate_selection_messages(selection) - if not selection then - return {} - end - +--- @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 {} + return nil end - local out = string.format('# FILE:%s CONTEXT\n', filename:upper()) - out = out .. "User's active selection:\n" + 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 @@ -141,103 +148,45 @@ local function generate_selection_messages(selection) .. string.format( '```%s\n%s\n```', filetype, - generate_content_block(content, nil, BIG_FILE_THRESHOLD, selection.start_line) + generate_content_block(content, BIG_FILE_THRESHOLD, selection.start_line) ) - if selection.diagnostics then - out = out - .. string.format("\nDiagnostics in user's active selection:\n%s", generate_diagnostics(selection.diagnostics)) - end - return { - { - name = filename, - context = string.format(CONTEXT_FORMAT, filename, filename), - content = out, - role = 'user', - }, + content = out, + role = 'user', } end ---- Generate messages for the given embeddings ---- @param embeddings table? ---- @return table -local function generate_embeddings_messages(embeddings) - if not embeddings then - return {} - end - - return vim.tbl_map(function(embedding) - local out = string.format( - '# FILE:%s CONTEXT\n```%s\n%s\n```', - embedding.filename:upper(), - embedding.filetype or 'text', - generate_content_block(embedding.content, embedding.outline, BIG_FILE_THRESHOLD) - ) - - if embedding.diagnostics then - out = out - .. string.format( - '\nFILE:%s DIAGNOSTICS:\n%s', - embedding.filename:upper(), - generate_diagnostics(embedding.diagnostics) - ) - end - - return { - name = embedding.filename, - context = string.format(CONTEXT_FORMAT, embedding.filename, embedding.filename), - content = out, - role = 'user', - } - end, embeddings) +--- Generate messages for the given resources +--- @param resources CopilotChat.client.Resource[] +--- @return table +local function generate_resource_messages(resources) + return vim + .iter(resources or {}) + :filter(function(resource) + return resource.data and resource.data ~= '' + end) + :map(function(resource) + local content = generate_content_block(resource.data, BIG_FILE_THRESHOLD, 1) + + return { + content = string.format(RESOURCE_FORMAT, resource.name, resource.type, content), + role = 'user', + } + end) + :totable() end --- Generate ask request ---- @param history table ---- @param contexts table? --- @param prompt string --- @param system_prompt string ---- @param generated_messages table -local function generate_ask_request(history, contexts, prompt, system_prompt, generated_messages) +--- @param history table +--- @param generated_messages table +local function generate_ask_request(prompt, system_prompt, history, generated_messages) local messages = {} system_prompt = vim.trim(system_prompt) - -- Include context help - if contexts and not vim.tbl_isempty(contexts) then - local help_text = [[When you need additional context, request it using this format: - -> #:`` - -Examples: -> #file:`path/to/file.js` (loads specific file) -> #buffers:`visible` (loads all visible buffers) -> #git:`staged` (loads git staged changes) -> #system:`uname -a` (loads system information) - -Guidelines: -- Always request context when needed rather than guessing about files or code -- Use the > format on a new line when requesting context -- Output context commands directly - never ask if the user wants to provide information -- Assume the user will provide requested context in their next response - -Available context providers and their usage:]] - - local context_names = vim.tbl_keys(contexts) - table.sort(context_names) - for _, name in ipairs(context_names) do - local description = contexts[name] - description = description:gsub('\n', '\n ') - help_text = help_text .. '\n\n - #' .. name .. ': ' .. description - end - - if system_prompt ~= '' then - system_prompt = system_prompt .. '\n\n' - end - system_prompt = system_prompt .. help_text - end - -- Include system prompt if not utils.empty(system_prompt) then table.insert(messages, { @@ -246,74 +195,48 @@ Available context providers and their usage:]] }) end - local context_references = {} - - -- Include embeddings and history + -- Include generated messages and history for _, message in ipairs(generated_messages) do table.insert(messages, { content = message.content, role = message.role, }) - - if message.context then - context_references[message.context] = true - end end for _, message in ipairs(history) do table.insert(messages, message) end - - -- Include context references - prompt = vim.trim(prompt) - if not vim.tbl_isempty(context_references) then - if prompt ~= '' then - prompt = '\n\n' .. prompt - end - prompt = table.concat(vim.tbl_keys(context_references), '\n') .. prompt - end - - -- Include user prompt - if not utils.empty(prompt) then + 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', }) end - log.debug('System prompt:\n', system_prompt) - log.debug('Prompt:\n', prompt) return messages end --- Generate embedding request ---- @param inputs table +--- @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.outline or embedding.content, nil, threshold, -1) - if embedding.filetype == 'raw' then - return content - else - return string.format('File: `%s`\n```%s\n%s\n```', embedding.filename, embedding.filetype, content) - end + local content = generate_content_block(embedding.data, threshold) + return string.format(RESOURCE_FORMAT, embedding.name, embedding.type, content) end, inputs) end ----@class CopilotChat.Client : Class ----@field history table ----@field providers table ----@field provider_cache table ----@field models table? ----@field agents table? ----@field current_job string? ----@field headers table? +---@class CopilotChat.client.Client : Class +---@field private providers table +---@field private provider_cache table +---@field private models table? +---@field private current_job string? +---@field private headers table? local Client = class(function(self) - self.history = {} self.providers = {} self.provider_cache = {} self.models = nil - self.agents = nil self.current_job = nil self.headers = nil end) @@ -336,7 +259,7 @@ function Client:authenticate(provider_name) end --- Fetch models from the Copilot API ----@return table +---@return table function Client:fetch_models() if self.models then return self.models @@ -372,67 +295,23 @@ function Client:fetch_models() end end - log.debug('Fetched models:', vim.inspect(models)) + log.debug('Fetched models:', #vim.tbl_keys(models)) self.models = models return self.models end ---- Fetch agents from the Copilot API ----@return table -function Client:fetch_agents() - if self.agents then - return self.agents - end - - local agents = {} - local provider_order = vim.tbl_keys(self.providers) - table.sort(provider_order) - for _, provider_name in ipairs(provider_order) do - local provider = self.providers[provider_name] - if not provider.disabled and provider.get_agents then - notify.publish(notify.STATUS, 'Fetching agents 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_agents = pcall(provider.get_agents, headers) - if not ok then - log.warn('Failed to fetch agents from ' .. provider_name .. ': ' .. provider_agents) - goto continue - end - - for _, agent in ipairs(provider_agents) do - agent.provider = provider_name - if agents[agent.id] then - agent.id = agent.id .. ':' .. provider_name - end - agents[agent.id] = agent - end - - ::continue:: - end - end - - self.agents = agents - return self.agents -end - --- Ask a question to Copilot ---@param prompt string: The prompt to send to Copilot ----@param opts CopilotChat.Client.ask: Options for the request ----@return string?, table?, number?, number? +---@param opts CopilotChat.client.AskOptions: Options for the request +---@return CopilotChat.client.AskResponse? function Client:ask(prompt, opts) opts = opts or {} - - if opts.agent == 'none' or opts.agent == 'copilot' then - opts.agent = nil - end - local job_id = utils.uuid() log.debug('Model:', opts.model) - log.debug('Agent:', opts.agent) + log.debug('Tools:', #opts.tools) + log.debug('Resources:', #opts.resources) + log.debug('History:', #opts.history) local models = self:fetch_models() local model_config = models[opts.model] @@ -440,12 +319,6 @@ function Client:ask(prompt, opts) error('Model not found: ' .. opts.model) end - local agents = self:fetch_agents() - local agent_config = opts.agent and agents[opts.agent] - if opts.agent and not agent_config then - error('Agent not found: ' .. opts.agent) - end - local provider_name = model_config.provider if not provider_name then error('Provider not found for model: ' .. opts.model) @@ -459,10 +332,8 @@ function Client:ask(prompt, opts) model = vim.tbl_extend('force', model_config, { id = opts.model:gsub(':' .. provider_name .. '$', ''), }), - agent = agent_config and vim.tbl_extend('force', agent_config, { - id = opts.agent and opts.agent:gsub(':' .. provider_name .. '$', ''), - }), temperature = opts.temperature, + tools = opts.tools, } local max_tokens = model_config.max_input_tokens @@ -477,37 +348,26 @@ function Client:ask(prompt, opts) notify.publish(notify.STATUS, 'Generating request') end - local history = not opts.headless and vim.list_slice(self.history) or {} - local references = utils.ordered_map() + local history = not opts.headless and vim.deepcopy(opts.history) or {} + local tool_calls = utils.ordered_map() local generated_messages = {} - local selection_messages = generate_selection_messages(opts.selection) - local embeddings_messages = generate_embeddings_messages(opts.embeddings) - - for _, message in ipairs(selection_messages) do - table.insert(generated_messages, message) - references:set(message.name, { - name = utils.filename(message.name), - url = message.name, - }) + 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 tokens from selection messages - local selection_tokens = 0 - for _, message in ipairs(selection_messages) do - selection_tokens = selection_tokens + tiktoken.count(message.content) - end - -- 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 required_tokens = prompt_tokens + system_tokens + selection_tokens - - -- Reserve space for first embedding - local reserved_tokens = #embeddings_messages > 0 and tiktoken.count(embeddings_messages[1].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 + selection_tokens + resource_tokens -- Calculate how many tokens we can use for history - local history_limit = max_tokens - required_tokens - reserved_tokens + 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) @@ -521,35 +381,25 @@ function Client:ask(prompt, opts) -- Now add as many files as possible with remaining token budget local remaining_tokens = max_tokens - required_tokens - history_tokens - for _, message in ipairs(embeddings_messages) do + for _, message in ipairs(resource_messages) do local tokens = tiktoken.count(message.content) if remaining_tokens - tokens >= 0 then remaining_tokens = remaining_tokens - tokens table.insert(generated_messages, message) - references:set(message.name, { - name = utils.filename(message.name), - url = message.name, - }) else break end end else -- Add all embedding messages as we cant limit them - for _, message in ipairs(embeddings_messages) do + for _, message in ipairs(resource_messages) do table.insert(generated_messages, message) - references:set(message.name, { - name = utils.filename(message.name), - url = message.name, - }) end end - log.debug('References:', #generated_messages) - - local last_message = nil local errored = false local finished = false + local token_count = 0 local response_buffer = utils.string_buffer() local function finish_stream(err, job) @@ -571,7 +421,6 @@ function Client:ask(prompt, opts) return end - log.debug('Response line:', line) if not opts.headless then notify.publish(notify.STATUS, '') end @@ -589,11 +438,19 @@ function Client:ask(prompt, opts) end local out = provider.prepare_output(content, options) - last_message = out - if out.references then - for _, reference in ipairs(out.references) do - references:set(reference.name, reference) + if out.total_tokens then + token_count = out.total_tokens + end + + if out.tool_calls then + for _, tool_call in ipairs(out.tool_calls) do + local val = tool_calls:get(tool_call.index) + if not val then + tool_calls:set(tool_call.index, tool_call) + else + val.arguments = val.arguments .. tool_call.arguments + end end end @@ -606,7 +463,7 @@ function Client:ask(prompt, opts) if out.finish_reason then local reason = out.finish_reason - if reason == 'stop' then + if reason == 'stop' or reason == 'tool_calls' then reason = nil else reason = 'Early stop: ' .. reason @@ -656,10 +513,8 @@ function Client:ask(prompt, opts) end local headers = self:authenticate(provider_name) - local request = provider.prepare_input( - generate_ask_request(history, opts.contexts, prompt, opts.system_prompt, generated_messages), - options - ) + local request = + provider.prepare_input(generate_ask_request(prompt, opts.system_prompt, history, generated_messages), options) local is_stream = request.stream local args = { @@ -681,12 +536,6 @@ function Client:ask(prompt, opts) self.current_job = nil end - if response then - log.debug('Response status:', response.status) - log.debug('Response body:\n', response.body) - log.debug('Response headers:\n', response.headers) - end - if err then local error_msg = 'Failed to get response: ' .. err @@ -716,7 +565,7 @@ function Client:ask(prompt, opts) if response then if is_stream then - if utils.empty(response_text) then + if utils.empty(response_text) and not finished then for _, line in ipairs(vim.split(response.body, '\n')) do parse_stream_line(line) end @@ -727,12 +576,15 @@ function Client:ask(prompt, opts) response_text = response_buffer:tostring() end - if utils.empty(response_text) then - error('Failed to get response: empty response') - return - end - - return response_text, references:values(), last_message and last_message.total_tokens or 0, max_tokens + return { + message = { + role = 'assistant', + content = response_text, + tool_calls = #tool_calls:values() > 0 and tool_calls:values() or nil, + }, + token_count = token_count, + token_max_count = max_tokens, + } end --- List available models @@ -755,40 +607,20 @@ function Client:list_models() end, result) end ---- List available agents ----@return table -function Client:list_agents() - local agents = self:fetch_agents() - local result = vim.tbl_keys(agents) - - table.sort(result, function(a, b) - a = agents[a] - b = agents[b] - if a.provider ~= b.provider then - return a.provider < b.provider - end - return a.id < b.id - end) - - local out = vim.tbl_map(function(id) - return agents[id] - end, result) - table.insert(out, 1, { id = 'none', name = 'None', description = 'No agent', provider = 'none' }) - return out -end - --- Generate embeddings for the given inputs ----@param inputs table: The inputs to embed +---@param inputs table: The inputs to embed ---@param model string ----@return table +---@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:fetch_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 @@ -867,14 +699,6 @@ function Client:stop() return false end ---- Reset the history and stop any running job ----@return boolean -function Client:reset() - local stopped = self:stop() - self.history = {} - return stopped -end - --- Check if there is a running job ---@return boolean function Client:running() @@ -889,5 +713,5 @@ function Client:load_providers(providers) end end ---- @type CopilotChat.Client +--- @type CopilotChat.client.Client return Client() diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 30c428e5..5e900219 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -1,8 +1,6 @@ -local select = require('CopilotChat.select') - ---@alias CopilotChat.config.Layout 'vertical'|'horizontal'|'float'|'replace' ----@class CopilotChat.config.window +---@class CopilotChat.config.Window ---@field layout? CopilotChat.config.Layout|fun():CopilotChat.config.Layout ---@field relative 'editor'|'win'|'cursor'|'mouse'? ---@field border 'none'|'single'|'double'|'rounded'|'solid'|'shadow'? @@ -14,32 +12,28 @@ local select = require('CopilotChat.select') ---@field footer string? ---@field zindex number? ----@class CopilotChat.config.shared +---@class CopilotChat.config.Shared ---@field system_prompt string? ---@field model string? ----@field agent string? ----@field context string|table|nil +---@field tools string|table|nil ---@field sticky string|table|nil ---@field temperature number? ---@field headless boolean? ----@field stream nil|fun(chunk: string, source: CopilotChat.source):string ----@field callback nil|fun(response: string, source: CopilotChat.source):string +---@field callback nil|fun(response: string, source: CopilotChat.source) ---@field remember_as_sticky boolean? ----@field include_contexts_in_prompt boolean? ----@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.selection? ----@field window CopilotChat.config.window? +---@field selection false|nil|fun(source: CopilotChat.source):CopilotChat.select.Selection? +---@field window CopilotChat.config.Window? ---@field show_help boolean? ---@field show_folds boolean? ---@field highlight_selection boolean? ---@field highlight_headers boolean? ----@field references_display 'virtual'|'write'? ---@field auto_follow_cursor boolean? ---@field auto_insert_mode boolean? ---@field insert_at_end boolean? ---@field clear_chat_on_new_prompt boolean? --- CopilotChat default configuration ----@class CopilotChat.config : CopilotChat.config.shared +---@class CopilotChat.config.Config : CopilotChat.config.Shared ---@field debug boolean? ---@field log_level 'trace'|'debug'|'info'|'warn'|'error'|'fatal'? ---@field proxy string? @@ -47,13 +41,11 @@ local select = require('CopilotChat.select') ---@field chat_autocomplete boolean? ---@field log_path string? ---@field history_path string? ----@field question_header string? ----@field answer_header string? ----@field error_header string? +---@field headers table? ---@field separator string? ----@field providers table? ----@field contexts table? ----@field prompts table? +---@field providers table? +---@field functions table? +---@field prompts table? ---@field mappings CopilotChat.config.mappings? return { @@ -62,20 +54,16 @@ return { 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 $). - agent = 'none', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). - context = nil, -- Default context or array of contexts to use (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. + tools = nil, -- Default tool or array of tools (or groups) 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 >). - temperature = 0.1, -- GPT result temperature + temperature = 0.1, -- Result temperature headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) - stream = nil, -- Function called when receiving stream updates (returned string is appended to the chat buffer) - callback = nil, -- Function called when full response is received (retuned string is stored to history) - remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions - - include_contexts_in_prompt = true, -- Include contexts in prompt + callback = nil, -- Function called when full response is received + remember_as_sticky = true, -- Remember model as sticky prompts when asking questions -- default selection - selection = select.visual, + selection = require('CopilotChat.select').visual, -- default window options window = { @@ -96,7 +84,6 @@ return { show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) - references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer auto_follow_cursor = true, -- Auto-follow cursor in chat 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 @@ -114,16 +101,19 @@ return { log_path = vim.fn.stdpath('state') .. '/CopilotChat.log', -- Default path to log file history_path = vim.fn.stdpath('data') .. '/copilotchat_history', -- Default path to stored history - question_header = '## User ', -- Header to use for user questions - answer_header = '## Copilot ', -- Header to use for AI answers - error_header = '## Error ', -- Header to use for errors + 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 + }, + separator = '───', -- Separator to use in chat -- default providers providers = require('CopilotChat.config.providers'), - -- default contexts - contexts = require('CopilotChat.config.contexts'), + -- default functions + functions = require('CopilotChat.config.functions'), -- default prompts prompts = require('CopilotChat.config.prompts'), diff --git a/lua/CopilotChat/config/contexts.lua b/lua/CopilotChat/config/contexts.lua deleted file mode 100644 index 64758f76..00000000 --- a/lua/CopilotChat/config/contexts.lua +++ /dev/null @@ -1,352 +0,0 @@ -local context = require('CopilotChat.context') -local utils = require('CopilotChat.utils') - ----@class CopilotChat.config.context ----@field description string? ----@field input fun(callback: fun(input: string?), source: CopilotChat.source)? ----@field resolve fun(input: string?, source: CopilotChat.source, prompt: string):table - ----@type table -return { - buffer = { - description = 'Includes specified buffer in chat context. Supports input (default current).', - input = function(callback) - vim.ui.select( - vim.tbl_map( - function(buf) - return { id = buf, name = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ':p:.') } - end, - vim.tbl_filter(function(buf) - return utils.buf_valid(buf) and vim.fn.buflisted(buf) == 1 - end, vim.api.nvim_list_bufs()) - ), - { - prompt = 'Select a buffer> ', - format_item = function(item) - return item.name - end, - }, - function(choice) - callback(choice and choice.id) - end - ) - end, - resolve = function(input, source) - input = input and tonumber(input) or source.bufnr - - utils.schedule_main() - return { - context.get_buffer(input), - } - end, - }, - - buffers = { - description = 'Includes all buffers in chat context. Supports input (default listed).', - input = function(callback) - vim.ui.select({ 'listed', 'visible' }, { - prompt = 'Select buffer scope> ', - }, callback) - end, - resolve = function(input) - input = input or 'listed' - - utils.schedule_main() - return vim.tbl_map( - context.get_buffer, - vim.tbl_filter(function(b) - return utils.buf_valid(b) and vim.fn.buflisted(b) == 1 and (input == 'listed' or #vim.fn.win_findbuf(b) > 0) - end, vim.api.nvim_list_bufs()) - ) - end, - }, - - file = { - description = 'Includes content of provided file in chat context. Supports input.', - input = function(callback, source) - local files = utils.scan_dir(source.cwd(), { - max_count = 0, - }) - - utils.schedule_main() - vim.ui.select(files, { - prompt = 'Select a file> ', - }, callback) - end, - resolve = function(input) - if not input or input == '' then - return {} - end - - utils.schedule_main() - return { - context.get_file(utils.filepath(input), utils.filetype(input)), - } - end, - }, - - files = { - description = 'Includes all non-hidden files in the current workspace in chat context. Supports input (glob pattern).', - input = function(callback) - vim.ui.input({ - prompt = 'Enter glob> ', - }, callback) - end, - resolve = function(input, source) - local files = utils.scan_dir(source.cwd(), { - glob = input, - }) - - utils.schedule_main() - files = vim.tbl_filter( - function(file) - return file.ft ~= nil - end, - vim.tbl_map(function(file) - return { - name = utils.filepath(file), - ft = utils.filetype(file), - } - end, files) - ) - - return vim - .iter(files) - :map(function(file) - return context.get_file(file.name, file.ft) - end) - :filter(function(file_data) - return file_data ~= nil - end) - :totable() - end, - }, - - filenames = { - description = 'Includes names of all non-hidden files in the current workspace in chat context. Supports input (glob pattern).', - input = function(callback) - vim.ui.input({ - prompt = 'Enter glob> ', - }, callback) - end, - resolve = function(input, source) - local out = {} - local files = utils.scan_dir(source.cwd(), { - glob = input, - }) - - local chunk_size = 100 - for i = 1, #files, chunk_size do - local chunk = {} - for j = i, math.min(i + chunk_size - 1, #files) do - table.insert(chunk, files[j]) - end - - local chunk_number = math.floor(i / chunk_size) - local chunk_name = chunk_number == 0 and 'file_map' or 'file_map' .. tostring(chunk_number) - - table.insert(out, { - content = table.concat(chunk, '\n'), - filename = chunk_name, - filetype = 'text', - score = 0.1, - }) - end - - return out - end, - }, - - git = { - description = 'Requires `git`. Includes current git diff in chat context. Supports input (default unstaged, also accepts commit number).', - input = function(callback) - vim.ui.select({ 'unstaged', 'staged' }, { - prompt = 'Select diff type> ', - }, callback) - end, - resolve = function(input, source) - input = input or 'unstaged' - local cmd = { - 'git', - '-C', - source.cwd(), - 'diff', - '--no-color', - '--no-ext-diff', - } - - if input == 'staged' then - table.insert(cmd, '--staged') - elseif input == 'unstaged' then - table.insert(cmd, '--') - else - table.insert(cmd, input) - end - - local out = utils.system(cmd) - - return { - { - content = out.stdout, - filename = 'git_diff_' .. input, - filetype = 'diff', - }, - } - end, - }, - - url = { - description = 'Includes content of provided URL in chat context. Supports input.', - input = function(callback) - vim.ui.input({ - prompt = 'Enter URL> ', - default = 'https://', - }, callback) - end, - resolve = function(input) - return { - context.get_url(input), - } - end, - }, - - register = { - description = 'Includes contents of register in chat context. Supports input (default +, e.g clipboard).', - input = function(callback) - local choices = utils.kv_list({ - ['+'] = 'synchronized with the system clipboard', - ['*'] = 'synchronized with the selection clipboard', - ['"'] = 'last deleted, changed, or yanked content', - ['0'] = 'last yank', - ['-'] = 'deleted or changed content smaller than one line', - ['.'] = 'last inserted text', - ['%'] = 'name of the current file', - [':'] = 'most recent executed command', - ['#'] = 'alternate buffer', - ['='] = 'result of an expression', - ['/'] = 'last search pattern', - }) - - vim.ui.select(choices, { - prompt = 'Select a register> ', - format_item = function(choice) - return choice.key .. ' - ' .. choice.value - end, - }, function(choice) - callback(choice and choice.key) - end) - end, - resolve = function(input) - input = input or '+' - - utils.schedule_main() - local lines = vim.fn.getreg(input) - if not lines or lines == '' then - return {} - end - - return { - { - content = lines, - filename = 'vim_register_' .. input, - filetype = '', - }, - } - end, - }, - - quickfix = { - description = 'Includes quickfix list file contents in chat context.', - resolve = function() - utils.schedule_main() - - local items = vim.fn.getqflist() - if not items or #items == 0 then - return {} - end - - local unique_files = {} - for _, item in ipairs(items) do - local filename = item.filename or vim.api.nvim_buf_get_name(item.bufnr) - if filename then - unique_files[filename] = true - end - end - - local files = vim.tbl_filter( - function(file) - return file.ft ~= nil - end, - vim.tbl_map(function(file) - return { - name = utils.filepath(file), - ft = utils.filetype(file), - } - end, vim.tbl_keys(unique_files)) - ) - - return vim - .iter(files) - :map(function(file) - return context.get_file(file.name, file.ft) - end) - :filter(function(file_data) - return file_data ~= nil - end) - :totable() - end, - }, - - system = { - description = [[Includes output of provided system shell command in chat context. Supports input. - -Important: -- Only use system commands as last resort, they are run every time the context is requested. -- For example instead of curl use the url context, instead of finding and grepping try to check if there is any context that can query the data you need instead. -- If you absolutely need to run a system command, try to use read-only commands and avoid commands that modify the system state. -]], - input = function(callback) - vim.ui.input({ - prompt = 'Enter command> ', - }, callback) - end, - resolve = function(input) - if not input or input == '' then - return {} - end - - utils.schedule_main() - - local shell, shell_flag - if vim.fn.has('win32') == 1 then - shell, shell_flag = 'cmd.exe', '/c' - else - shell, shell_flag = 'sh', '-c' - end - - local out = utils.system({ shell, shell_flag, input }) - if not out then - return {} - end - - local out_type = 'command_output' - local out_text = out.stdout - if out.code ~= 0 then - out_type = 'command_error' - if out.stderr and out.stderr ~= '' then - out_text = out.stderr - elseif not out_text or out_text == '' then - out_text = 'Command failed with exit code ' .. out.code - end - end - - return { - { - content = out_text, - filename = out_type .. '_' .. input:gsub('[^%w]', '_'):sub(1, 20), - filetype = 'text', - }, - } - end, - }, -} diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua new file mode 100644 index 00000000..07b11573 --- /dev/null +++ b/lua/CopilotChat/config/functions.lua @@ -0,0 +1,503 @@ +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 + +---@type table +return { + file = { + group = 'copilot', + uri = 'file://{path}', + description = 'Reads content from a specified file path, even if the file is not currently loaded as a buffer.', + + schema = { + type = 'object', + required = { 'path' }, + properties = { + path = { + type = 'string', + description = 'Path to file to include in chat context.', + enum = function(source) + return utils.glob(source.cwd(), { + max_count = 0, + }) + end, + }, + }, + }, + + resolve = function(input) + local data, mimetype = resources.get_file(input.path) + if not data then + error('File not found: ' .. input.path) + end + + return { + { + uri = 'file://' .. input.path, + mimetype = mimetype, + data = data, + }, + } + end, + }, + + glob = { + 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.', + + schema = { + type = 'object', + required = { 'pattern' }, + properties = { + pattern = { + type = 'string', + description = 'Glob pattern to match files.', + default = '**/*', + }, + }, + }, + + resolve = function(input, source) + local files = utils.glob(source.cwd(), { + pattern = input.pattern, + }) + + return { + { + uri = 'files://glob/' .. input.pattern, + mimetype = 'text/plain', + data = table.concat(files, '\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, source) + local files = utils.grep(source.cwd(), { + pattern = input.pattern, + }) + + return { + { + uri = 'files://grep/' .. input.pattern, + mimetype = 'text/plain', + data = table.concat(files, '\n'), + }, + } + end, + }, + + buffer = { + group = 'copilot', + uri = 'neovim://buffer/{name}', + description = 'Retrieves content from a specific buffer. Useful for discussing or analyzing code from a particular file that is currently loaded.', + + schema = { + type = 'object', + required = { 'name' }, + properties = { + name = { + type = 'string', + description = 'Buffer filename to include in chat context.', + 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() + end, + }, + }, + }, + + 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 + 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 = 'neovim://buffer/' .. name, + mimetype = mimetype, + data = data, + }, + } + end, + }, + + buffers = { + group = 'copilot', + uri = 'neovim://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', + }, + }, + }, + + 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 + end + return { + uri = 'neovim://buffer/' .. name, + mimetype = mimetype, + data = data, + } + end) + :filter(function(file_data) + return file_data ~= nil + end) + :totable() + end, + }, + + quickfix = { + 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.', + + resolve = function() + utils.schedule_main() + + local items = vim.fn.getqflist() + if not items or #items == 0 then + return {} + end + + local unique_files = {} + for _, item in ipairs(items) do + local filename = item.filename or vim.api.nvim_buf_get_name(item.bufnr) + if filename then + unique_files[filename] = true + end + end + + return vim + .iter(vim.tbl_keys(unique_files)) + :map(function(file) + local data, mimetype = resources.get_file(file) + if not data then + return nil + end + return { + uri = 'file://' .. file, + mimetype = mimetype, + data = data, + } + end) + :filter(function(file_data) + return file_data ~= nil + end) + :totable() + end, + }, + + diagnostics = { + group = 'copilot', + uri = 'neovim://diagnostics/{scope}', + description = 'Collects code diagnostics (errors, warnings, etc.) from specified buffers. Helpful for troubleshooting and fixing code issues.', + + schema = { + type = 'object', + required = { 'scope' }, + properties = { + scope = { + type = 'string', + description = 'Scope of buffers to use for retrieving diagnostics.', + enum = { 'current', 'listed', 'visible' }, + default = 'current', + }, + severity = { + type = 'string', + description = 'Minimum severity level of diagnostics to include.', + enum = { 'error', 'warn', 'info', 'hint' }, + default = 'warn', + }, + }, + }, + + 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' 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 + + -- 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 + 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 + + table.insert(out, { + uri = 'neovim://diagnostics/' .. name, + mimetype = 'text/plain', + data = table.concat(diag_lines, '\n'), + }) + end + end + + return out + end, + }, + + register = { + 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.', + + schema = { + type = 'object', + required = { 'register' }, + properties = { + register = { + type = 'string', + description = 'Register to include in chat context.', + enum = { + '+', + '*', + '"', + '0', + '-', + '.', + '%', + ':', + '#', + '=', + '/', + }, + default = '+', + }, + }, + }, + + resolve = function(input) + utils.schedule_main() + local lines = vim.fn.getreg(input.register) + if not lines or lines == '' then + return {} + end + + return { + { + uri = 'neovim://register/' .. input.register, + mimetype = 'text/plain', + data = lines, + }, + } + end, + }, + + gitdiff = { + group = 'copilot', + uri = 'git://diff/{target}', + description = 'Retrieves git diff information. Requires git to be installed. Useful for discussing code changes or explaining the purpose of modifications.', + + schema = { + type = 'object', + required = { 'target' }, + properties = { + target = { + type = 'string', + description = 'Target to diff against.', + enum = { 'unstaged', 'staged', '' }, + default = 'unstaged', + }, + }, + }, + + resolve = function(input, source) + local cmd = { + 'git', + '-C', + source.cwd(), + 'diff', + '--no-color', + '--no-ext-diff', + } + + if input.target == 'staged' then + table.insert(cmd, '--staged') + elseif input.target == 'unstaged' then + table.insert(cmd, '--') + else + table.insert(cmd, input.target) + end + + local out = utils.system(cmd) + + return { + { + uri = 'git://diff/' .. input.target, + mimetype = 'text/plain', + data = out.stdout, + }, + } + end, + }, + + gitstatus = { + group = 'copilot', + uri = 'git://status', + description = 'Retrieves the status of the current git repository. Useful for discussing changes, commits, and other git-related tasks.', + + resolve = function(_, source) + local cmd = { + 'git', + '-C', + source.cwd(), + 'status', + } + + local out = utils.system(cmd) + + return { + { + uri = 'git://status', + mimetype = 'text/plain', + data = out.stdout, + }, + } + end, + }, + + url = { + group = 'copilot', + uri = 'https://{url}', + description = 'Fetches content from a specified URL. Useful for referencing documentation, examples, or other online resources.', + + schema = { + type = 'object', + required = { 'url' }, + properties = { + url = { + type = 'string', + description = 'URL to include in chat context.', + }, + }, + }, + + resolve = function(input) + if not input.url:match('^https?://') then + input.url = 'https://' .. input.url + end + + local data, mimetype = resources.get_url(input.url) + if not data then + error('URL not found: ' .. input.url) + end + + return { + { + uri = input.url, + mimetype = mimetype, + data = data, + }, + } + end, + }, +} diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index baf2f82c..a527f0c3 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -1,9 +1,8 @@ local async = require('plenary.async') local copilot = require('CopilotChat') -local client = require('CopilotChat.client') local utils = require('CopilotChat.utils') ----@class CopilotChat.config.mappings.diff +---@class CopilotChat.config.mappings.Diff ---@field change string ---@field reference string ---@field filename string @@ -13,8 +12,8 @@ local utils = require('CopilotChat.utils') ---@field bufnr number? --- Get diff data from a block ----@param block CopilotChat.ui.Chat.Section.Block? ----@return CopilotChat.config.mappings.diff? +---@param block CopilotChat.ui.chat.Block? +---@return CopilotChat.config.mappings.Diff? local function get_diff(block) -- If no block found, return nil if not block then @@ -44,7 +43,7 @@ local function get_diff(block) end filename = header.filename - filetype = header.filetype or vim.filetype.match({ filename = filename }) + filetype = header.filetype or utils.filetype(filename) start_line = header.start_line end_line = header.end_line @@ -64,7 +63,7 @@ local function get_diff(block) change = block.content, reference = reference or '', filetype = filetype or '', - filename = utils.filepath(filename), + filename = filename, start_line = start_line, end_line = end_line, bufnr = bufnr, @@ -72,9 +71,9 @@ local function get_diff(block) end --- Prepare a buffer for applying a diff ----@param diff CopilotChat.config.mappings.diff? +---@param diff CopilotChat.config.mappings.Diff? ---@param source CopilotChat.source? ----@return CopilotChat.config.mappings.diff? +---@return CopilotChat.config.mappings.Diff? local function prepare_diff_buffer(diff, source) if not diff then return diff @@ -163,19 +162,20 @@ return { normal = '', insert = '', callback = function() - local section = copilot.chat:get_closest_section('question') - if not section or section.answer then + local message = copilot.chat:get_closest_message('user') + if not message then return end - copilot.ask(section.content) + copilot.ask(message.content) end, }, toggle_sticky = { normal = 'grr', callback = function() - local section = copilot.chat:get_prompt() + local message = copilot.chat:get_message('user') + local section = message and message.section if not section then return end @@ -205,12 +205,13 @@ return { clear_stickies = { normal = 'grx', callback = function() - local section = copilot.chat:get_prompt() + local message = copilot.chat:get_message('user') + local section = message and message.section if not section then return end - local lines = vim.split(section.content, '\n') + local lines = vim.split(message.content, '\n') local new_lines = {} local changed = false @@ -223,7 +224,8 @@ return { end if changed then - copilot.chat:set_prompt(vim.trim(table.concat(new_lines, '\n'))) + message.content = table.concat(new_lines, '\n') + copilot.chat:add_message(message, true) end end, }, @@ -262,7 +264,7 @@ return { callback = function() local items = {} for i, section in ipairs(copilot.chat.sections) do - if section.answer then + if section.role == 'assistant' then local prev_section = copilot.chat.sections[i - 1] local text = '' if prev_section then @@ -352,7 +354,8 @@ return { -- Apply all diffs from same file if #modified > 0 then -- Find all diffs from the same file in this section - local section = copilot.chat:get_closest_section('answer') + local message = copilot.chat:get_closest_message('assistant') + local section = message and message.section local same_file_diffs = {} if section then for _, block in ipairs(section.blocks) do @@ -422,20 +425,23 @@ return { }, show_info = { - normal = 'gi', + normal = 'gc', callback = function(source) - local section = copilot.chat:get_closest_section('question') - if not section or section.answer then + local message = copilot.chat:get_closest_message('user') + if not message then return end local lines = {} - local config, prompt = copilot.resolve_prompt(section.content) + local config, prompt = copilot.resolve_prompt(message.content) local system_prompt = config.system_prompt async.run(function() - local selected_agent = copilot.resolve_agent(prompt, config) local selected_model = copilot.resolve_model(prompt, config) + local selected_tools, resolved_resources = copilot.resolve_functions(prompt, config) + selected_tools = vim.tbl_map(function(tool) + return tool.name + end, selected_tools) utils.schedule_main() table.insert(lines, '**Logs**: `' .. copilot.config.log_path .. '`') @@ -454,82 +460,55 @@ return { table.insert(lines, '') end - if selected_agent then - table.insert(lines, '**Agent**: `' .. selected_agent .. '`') + if not utils.empty(selected_tools) then + table.insert(lines, '**Tools**') + table.insert(lines, '```') + table.insert(lines, table.concat(selected_tools, ', ')) + table.insert(lines, '```') table.insert(lines, '') end if system_prompt then table.insert(lines, '**System Prompt**') - table.insert(lines, '```') + table.insert(lines, '````') for _, line in ipairs(vim.split(vim.trim(system_prompt), '\n')) do table.insert(lines, line) end - table.insert(lines, '```') + table.insert(lines, '````') table.insert(lines, '') end - if client.memory then - table.insert(lines, '**Memory**') - table.insert(lines, '```markdown') - for _, line in ipairs(vim.split(client.memory.content, '\n')) do + local selection = copilot.get_selection() + if selection then + table.insert(lines, '**Selection**') + table.insert(lines, '') + table.insert( + lines, + string.format('**%s** (%s-%s)', selection.filename, selection.start_line, selection.end_line) + ) + table.insert(lines, string.format('````%s', selection.filetype)) + for _, line in ipairs(vim.split(selection.content, '\n')) do table.insert(lines, line) end - table.insert(lines, '```') + table.insert(lines, '````') table.insert(lines, '') end - if not utils.empty(client.history) then - table.insert(lines, ('**History** (#%s, truncated)'):format(#client.history)) + if not utils.empty(resolved_resources) then + table.insert(lines, '**Resources**') table.insert(lines, '') - - for _, message in ipairs(client.history) do - table.insert(lines, '**' .. message.role .. '**') - table.insert(lines, '`' .. vim.split(message.content, '\n')[1] .. '`') - end end - copilot.chat:overlay({ - text = vim.trim(table.concat(lines, '\n')) .. '\n', - }) - end) - end, - }, - - show_context = { - normal = 'gc', - callback = function() - local section = copilot.chat:get_closest_section('question') - if not section or section.answer then - return - end - - local lines = {} - - local selection = copilot.get_selection() - if selection then - table.insert(lines, '**Selection**') - table.insert(lines, '```' .. selection.filetype) - for _, line in ipairs(vim.split(selection.content, '\n')) do - table.insert(lines, line) - end - table.insert(lines, '```') - table.insert(lines, '') - end - - async.run(function() - local embeddings = copilot.resolve_context(section.content) - - for _, embedding in ipairs(embeddings) do - local embed_lines = vim.split(embedding.content, '\n') - local preview = vim.list_slice(embed_lines, 1, math.min(10, #embed_lines)) - local header = string.format('**%s** (%s lines)', embedding.filename, #embed_lines) - if #embed_lines > 10 then + 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) + if #resource_lines > 10 then header = header .. ' (truncated)' end table.insert(lines, header) - table.insert(lines, '```' .. embedding.filetype) + table.insert(lines, '```' .. resource.type) for _, line in ipairs(preview) do table.insert(lines, line) end @@ -537,7 +516,6 @@ return { table.insert(lines, '') end - utils.schedule_main() copilot.chat:overlay({ text = vim.trim(table.concat(lines, '\n')) .. '\n', }) @@ -549,9 +527,9 @@ return { normal = 'gh', callback = function() local chat_help = '**`Special tokens`**\n' - chat_help = chat_help .. '`@` to select an agent\n' - chat_help = chat_help .. '`#` to select a context\n' - chat_help = chat_help .. '`#:` to select input for context\n' + chat_help = chat_help .. '`@` to share function\n' + chat_help = chat_help .. '`#` to add resource\n' + chat_help = chat_help .. '`#:` to add resource with input\n' chat_help = chat_help .. '`/` to select a prompt\n' chat_help = chat_help .. '`$` to select a model\n' chat_help = chat_help .. '`> ` to make a sticky prompt (copied to next prompt)\n' diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 85552adf..9fca7d8e 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -1,35 +1,77 @@ -local COPILOT_BASE = string.format( - [[ +local COPILOT_BASE = [[ When asked for your name, you must respond with "GitHub Copilot". Follow the user's requirements carefully & to the letter. -Follow Microsoft content policies. -Avoid content that violates copyrights. -If you are asked to generate content that is harmful, hateful, racist, sexist, lewd, violent, or completely irrelevant to software engineering, only respond with "Sorry, I can't assist with that." Keep your answers short and impersonal. -The user works in an IDE called Neovim which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal. -The user is working on a %s machine. Please respond with system specific commands if applicable. + +The user works in editor called Neovim which has these core concepts: +- Buffer: An in-memory text content that may be associated with a file +- Window: A viewport that displays a buffer +- Tab: A collection of windows +- Quickfix/Location lists: Lists of positions in files, often used for errors or search results +- Registers: Named storage for text and commands (like clipboard) +- 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 +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 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. + + +If tools are explicitly defined in your system context: +- 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. +- 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. Content shared via "#:" references or headers + 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. + + 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. When presenting code changes: - -1. For each change, first provide a header outside code blocks with format: - [file:]() line:- - -2. Then wrap the actual code in triple backticks with the appropriate language identifier. - -3. Keep changes minimal and focused to produce short diffs. - -4. Include complete replacement code for the specified line range with: +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: - Proper indentation matching the source - All necessary lines (no eliding with comments) - No line number prefixes in the code - -5. Address any diagnostics issues when fixing code. - -6. If multiple changes are needed, present them as separate blocks with their own headers. -]], - vim.uv.os_uname().sysname -) +4. Address any diagnostics issues when fixing code. +5. If multiple changes are needed, present them as separate code blocks. + +]] local COPILOT_INSTRUCTIONS = [[ You are a code-focused AI programming assistant that specializes in practical software engineering solutions. @@ -76,12 +118,12 @@ 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.prompt : CopilotChat.config.shared +---@class CopilotChat.config.prompts.Prompt : CopilotChat.config.Shared ---@field prompt string? ---@field description string? ---@field mapping string? ----@type table +---@type table return { COPILOT_BASE = { system_prompt = COPILOT_BASE, @@ -141,7 +183,6 @@ return { end end vim.diagnostic.set(vim.api.nvim_create_namespace('copilot-chat-diagnostics'), source.bufnr, diagnostics) - return response end, }, @@ -163,6 +204,6 @@ return { 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.', - context = 'git:staged', + sticky = '#git:staged', }, } diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index c34a398b..65619117 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -67,52 +67,27 @@ local function get_github_token() error('Failed to find GitHub token') end ----@class CopilotChat.Provider.model ----@field id string ----@field name string ----@field tokenizer string? ----@field max_input_tokens number? ----@field max_output_tokens number? - ----@class CopilotChat.Provider.agent ----@field id string ----@field name string ----@field description string? - ----@class CopilotChat.Provider.embed ----@field index number ----@field embedding table - ----@class CopilotChat.Provider.options ----@field model CopilotChat.Provider.model ----@field agent CopilotChat.Provider.agent? +---@class CopilotChat.config.providers.Options +---@field model CopilotChat.client.Model ---@field temperature number? +---@field tools table? ----@class CopilotChat.Provider.input ----@field role string ----@field content string - ----@class CopilotChat.Provider.reference ----@field name string ----@field url string - ----@class CopilotChat.Provider.output +---@class CopilotChat.config.providers.Output ---@field content string ---@field finish_reason string? ---@field total_tokens number? ----@field references table? +---@field tool_calls table ----@class CopilotChat.Provider +---@class CopilotChat.config.providers.Provider ---@field disabled nil|boolean ---@field get_headers nil|fun():table,number? ----@field get_agents nil|fun(headers:table):table ----@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.Provider.options):table ----@field prepare_output nil|fun(output:table, opts:CopilotChat.Provider.options):CopilotChat.Provider.output ----@field get_url nil|fun(opts:CopilotChat.Provider.options):string - ----@type table +---@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 + +---@type table local M = {} M.copilot = { @@ -139,25 +114,6 @@ M.copilot = { response.body.expires_at end, - get_agents = function(headers) - local response, err = utils.curl_get('https://api.githubcopilot.com/agents', { - json_response = true, - headers = headers, - }) - - if err then - error(err) - end - - return vim.tbl_map(function(agent) - return { - id = agent.slug, - name = agent.name, - description = agent.description, - } - end, response.body.agents) - end, - get_models = function(headers) local response, err = utils.curl_get('https://api.githubcopilot.com/models', { json_response = true, @@ -171,7 +127,7 @@ M.copilot = { local models = vim .iter(response.body.data) :filter(function(model) - return model.capabilities.type == 'chat' and not vim.endswith(model.id, 'paygo') + return model.capabilities.type == 'chat' and model.model_picker_enabled end) :map(function(model) return { @@ -180,6 +136,8 @@ M.copilot = { tokenizer = model.capabilities.tokenizer, max_input_tokens = model.capabilities.limits.max_prompt_tokens, max_output_tokens = model.capabilities.limits.max_output_tokens, + streaming = model.capabilities.supports.streaming, + tools = model.capabilities.supports.tool_calls, policy = not model['policy'] or model['policy']['state'] == 'enabled', version = model.version, } @@ -212,24 +170,59 @@ M.copilot = { local is_o1 = vim.startswith(opts.model.id, 'o1') inputs = vim.tbl_map(function(input) + local output = { + role = input.role, + content = input.content, + } + if is_o1 then if input.role == 'system' then - input.role = 'user' + output.role = 'user' end end - return input + 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.stream = true out.temperature = opts.temperature end @@ -241,46 +234,51 @@ M.copilot = { end, prepare_output = function(output) - local references = {} - - if output.copilot_references then - for _, reference in ipairs(output.copilot_references) do - local metadata = reference.metadata - if metadata and metadata.display_name and metadata.display_url then - table.insert(references, { - name = metadata.display_name, - url = metadata.display_url, - }) + 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 - end - local message - if output.choices and #output.choices > 0 then - message = output.choices[1] + choice = output.choices[1] else - message = output + choice = output end - local content = message.message and message.message.content or message.delta and message.delta.content - - local usage = message.usage and message.usage.total_tokens or output.usage and output.usage.total_tokens - - local finish_reason = message.finish_reason or message.done_reason or output.finish_reason or output.done_reason + local message = choice.message or choice.delta + local content = message and message.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, finish_reason = finish_reason, total_tokens = usage, - references = references, + tool_calls = tool_calls, } end, - get_url = function(opts) - if opts.agent then - return 'https://api.githubcopilot.com/agents/' .. opts.agent.id .. '?chat' - end - + get_url = function() return 'https://api.githubcopilot.com/chat/completions' end, } @@ -336,6 +334,7 @@ M.github_models = { tokenizer = 'o200k_base', max_input_tokens = max_input_tokens, max_output_tokens = max_output_tokens, + streaming = true, } end) :totable() diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua new file mode 100644 index 00000000..8a28ca81 --- /dev/null +++ b/lua/CopilotChat/functions.lua @@ -0,0 +1,198 @@ +local utils = require('CopilotChat.utils') + +local M = {} + +local INPUT_SEPARATOR = ';;' + +local function sorted_propnames(schema) + local prop_names = vim.tbl_keys(schema.properties) + local required_set = {} + if schema.required then + for _, name in ipairs(schema.required) do + required_set[name] = true + end + end + + -- Sort properties with priority: required without default > required with default > optional + table.sort(prop_names, function(a, b) + local a_required = required_set[a] or false + local b_required = required_set[b] or false + local a_has_default = schema.properties[a].default ~= nil + local b_has_default = schema.properties[b].default ~= nil + + -- First priority: required properties without default + if a_required and not a_has_default and (not b_required or b_has_default) then + return true + end + if b_required and not b_has_default and (not a_required or a_has_default) then + return false + end + + -- Second priority: required properties with default + if a_required and not b_required then + return true + end + if b_required and not a_required then + return false + end + + -- Finally sort alphabetically + return a < b + end) + + return prop_names +end + +local function filter_schema(tbl) + if type(tbl) ~= 'table' then + return tbl + end + + local result = {} + for k, v in pairs(tbl) do + if type(v) ~= 'function' and k ~= 'examples' then + result[k] = type(v) == 'table' and filter_schema(v) or v + end + end + return result +end + +---@param uri string The URI to parse +---@param pattern string The pattern to match against (e.g., 'file://{path}') +---@return table|nil inputs Extracted parameters or nil if no match +function M.match_uri(uri, pattern) + -- Convert the pattern into a Lua pattern by escaping special characters + -- and replacing {name} placeholders with capture groups + local lua_pattern = pattern:gsub('([%(%)%.%%%+%-%*%?%[%]%^%$])', '%%%1') + + -- Extract parameter names from the pattern + local param_names = {} + for param in pattern:gmatch('{([^}:*]+)[^}]*}') do + table.insert(param_names, param) + -- Replace {param} with a capture group in our Lua pattern + -- Use non-greedy capture to handle multiple params properly + lua_pattern = lua_pattern:gsub('{' .. param .. '[^}]*}', '(.-)') + end + + -- If no parameters, just do a direct comparison + if #param_names == 0 then + return uri == pattern and {} or nil + end + + -- Match the URI against our constructed pattern + local matches = { uri:match('^' .. lua_pattern .. '$') } + + -- If match failed, return nil + if #matches == 0 or matches[1] == nil then + return nil + end + + -- Build the result table mapping parameter names to their values + local result = {} + for i, param_name in ipairs(param_names) do + result[param_name] = matches[i] + end + + return result +end + +--- Prepare the schema for use +---@param tools table +---@return table +function M.parse_tools(tools) + local tool_names = vim.tbl_keys(tools) + table.sort(tool_names) + return vim.tbl_map(function(name) + local tool = tools[name] + local schema = tool.schema + + if schema then + schema = filter_schema(schema) + end + + return { + name = name, + description = tool.description, + schema = schema, + } + end, tool_names) +end + +--- Parse context input string into a table based on the schema +---@param input string|table|nil +---@param schema table? +---@return table +function M.parse_input(input, schema) + if not schema or not schema.properties then + return {} + end + + if type(input) == 'table' then + return input + end + + local parts = vim.split(input or '', INPUT_SEPARATOR) + local result = {} + local prop_names = sorted_propnames(schema) + + -- Map input parts to schema properties in sorted order + local i = 1 + for _, prop_name in ipairs(prop_names) do + local prop_schema = schema.properties[prop_name] + local value = not utils.empty(parts[i]) and parts[i] or nil + if value == nil and prop_schema.default ~= nil then + value = prop_schema.default + end + + result[prop_name] = value + i = i + 1 + if i > #parts then + break + end + end + + return result +end + +--- Get input from the user based on the schema +---@param schema table? +---@param source CopilotChat.source +---@return string? +function M.enter_input(schema, source) + if not schema or not schema.properties then + return nil + end + + local prop_names = sorted_propnames(schema) + local out = {} + + for _, prop_name in ipairs(prop_names) do + local cfg = schema.properties[prop_name] + if not schema.required or vim.tbl_contains(schema.required, prop_name) then + if cfg.enum then + local choices = type(cfg.enum) == 'table' and cfg.enum or cfg.enum(source) + local choice = utils.select(choices, { + prompt = string.format('Select %s> ', prop_name), + }) + + table.insert(out, choice or '') + elseif cfg.type == 'boolean' then + table.insert(out, utils.select({ 'true', 'false' }, { + prompt = string.format('Select %s> ', prop_name), + }) or '') + else + table.insert(out, utils.input({ + prompt = string.format('Enter %s> ', prop_name), + }) or '') + end + end + end + + local out = vim.trim(table.concat(out, INPUT_SEPARATOR)) + if out:match('%s+') then + out = string.format('`%s`', out) + end + return out +end + +return M diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index 5d02df19..cf1e568a 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -4,7 +4,6 @@ local start = vim.health.start or vim.health.report_start local error = vim.health.error or vim.health.report_error local warn = vim.health.warn or vim.health.report_warn local ok = vim.health.ok or vim.health.report_ok -local info = vim.health.info or vim.health.report_info --- Run a command and handle potential errors ---@param executable string @@ -42,7 +41,7 @@ end function M.check() start('CopilotChat.nvim [core]') - local vim_version = vim.trim(vim.api.nvim_command_output('version')) + local vim_version = vim.trim(vim.api.nvim_exec2('version', { output = true }).output) if vim.fn.has('nvim-0.10.0') == 1 then ok('nvim: ' .. vim_version) else diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 1062e1a4..1f592349 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1,17 +1,21 @@ local async = require('plenary.async') local log = require('plenary.log') -local context = require('CopilotChat.context') +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') local PLUGIN_NAME = 'CopilotChat' -local WORD = '([^%s]+)' -local WORD_INPUT = '([^%s:]+:`[^`]+`)' +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 ----@field config CopilotChat.config ----@field chat CopilotChat.ui.Chat +---@field config CopilotChat.config.Config +---@field chat CopilotChat.ui.chat.Chat local M = {} --- @class CopilotChat.source @@ -21,23 +25,19 @@ local M = {} --- @class CopilotChat.state --- @field source CopilotChat.source? ---- @field last_prompt string? ---- @field last_response string? ---- @field highlights_loaded boolean +--- @field sticky string[]? local state = { -- Current state tracking source = nil, -- Last state tracking - last_prompt = nil, - last_response = nil, - highlights_loaded = false, + sticky = nil, } --- Insert sticky values from config into prompt ---@param prompt string ----@param config CopilotChat.config.shared -local function insert_sticky(prompt, config, override_sticky) +---@param config CopilotChat.config.Shared +local function insert_sticky(prompt, config) local lines = vim.split(prompt or '', '\n') local stickies = utils.ordered_map() @@ -58,8 +58,10 @@ local function insert_sticky(prompt, config, override_sticky) stickies:set('$' .. config.model, true) end - if config.remember_as_sticky and config.agent and config.agent ~= M.config.agent then - stickies:set('@' .. config.agent, true) + if config.remember_as_sticky and config.tools and not vim.deep_equal(config.tools, M.config.tools) then + for _, tool in ipairs(utils.to_table(config.tools)) do + stickies:set('@' .. tool, true) + end end if @@ -71,32 +73,18 @@ local function insert_sticky(prompt, config, override_sticky) stickies:set('/' .. config.system_prompt, true) end - if config.remember_as_sticky and config.context and not vim.deep_equal(config.context, M.config.context) then - if type(config.context) == 'table' then - ---@diagnostic disable-next-line: param-type-mismatch - for _, context in ipairs(config.context) do - stickies:set('#' .. context, true) - end - else - stickies:set('#' .. config.context, true) - end - end - - if config.sticky and (override_sticky or not vim.deep_equal(config.sticky, M.config.sticky)) then - if type(config.sticky) == 'table' then - ---@diagnostic disable-next-line: param-type-mismatch - for _, sticky in ipairs(config.sticky) do - stickies:set(sticky, true) - end - else - stickies:set(config.sticky, true) + if config.sticky and not vim.deep_equal(config.sticky, M.config.sticky) then + for _, sticky in ipairs(utils.to_table(config.sticky)) do + stickies:set(sticky, true) end end -- Insert stickies at start of prompt local prompt_lines = {} for _, sticky in ipairs(stickies:keys()) do - table.insert(prompt_lines, '> ' .. sticky) + if sticky ~= '' then + table.insert(prompt_lines, '> ' .. sticky) + end end if #prompt_lines > 0 then table.insert(prompt_lines, '') @@ -130,68 +118,44 @@ local function update_highlights() strict = false, }) end - - if state.highlights_loaded then - return - end - - async.run(function() - local items = M.complete_items() - utils.schedule_main() - - for _, item in ipairs(items) do - local pattern = vim.fn.escape(item.word, '.-$^*[]') - if vim.startswith(item.word, '#') then - vim.cmd('syntax match CopilotChatKeyword "' .. pattern .. '\\(:.\\+\\)\\?" containedin=ALL') - else - vim.cmd('syntax match CopilotChatKeyword "' .. pattern .. '" containedin=ALL') - end - end - - vim.cmd('syntax match CopilotChatInput ":\\(.\\+\\)" contained containedin=CopilotChatKeyword') - state.highlights_loaded = true - end) end --- Finish writing to chat buffer. ---@param start_of_chat boolean? local function finish(start_of_chat) - if not start_of_chat then - M.chat:append('\n\n') - end - - M.chat:append(M.config.question_header .. M.config.separator .. '\n\n') - - -- Insert sticky values from config into prompt if start_of_chat then - state.last_prompt = insert_sticky(state.last_prompt, M.config, true) + local sticky = {} + if M.config.sticky then + for _, sticky_line in ipairs(utils.to_table(M.config.sticky)) do + table.insert(sticky, sticky_line) + end + end + state.sticky = sticky end - -- Reinsert sticky prompts from last prompt and last response - local lines = {} - if state.last_prompt then - lines = vim.split(state.last_prompt, '\n') - end - if state.last_response then - for _, line in ipairs(vim.split(state.last_response, '\n')) do - table.insert(lines, line) + local prompt_content = '' + local last_message = M.chat.messages[#M.chat.messages] + local tool_calls = last_message and last_message.tool_calls or {} + + if not utils.empty(state.sticky) then + for _, sticky in ipairs(state.sticky) do + prompt_content = prompt_content .. '> ' .. sticky .. '\n' end + prompt_content = prompt_content .. '\n' end - local has_sticky = false - local in_code_block = false - for _, line in ipairs(lines) do - if line:match('^```') then - in_code_block = not in_code_block - end - if vim.startswith(line, '> ') and not in_code_block then - M.chat:append(line .. '\n') - has_sticky = true + + if not utils.empty(tool_calls) then + for _, tool_call in ipairs(tool_calls) do + prompt_content = prompt_content .. string.format('#%s:%s\n', tool_call.name, tool_call.id) end - end - if has_sticky then - M.chat:append('\n') + prompt_content = prompt_content .. '\n' end + M.chat:add_message({ + role = 'user', + content = prompt_content, + }) + M.chat:finish() end @@ -199,20 +163,13 @@ end ---@param err string|table|nil local function show_error(err) err = err or 'Unknown error' + err = utils.make_string(err) - if type(err) == 'string' then - while true do - local new_err = err:gsub('^[^:]+:%d+: ', '') - if new_err == err then - break - end - err = new_err - end - else - err = utils.make_string(err) - end + M.chat:add_message({ + role = 'assistant', + content = '\n' .. string.format(BLOCK_OUTPUT_FORMAT, 'error', err) .. '\n', + }) - M.chat:append('\n' .. M.config.error_header .. '\n```error\n' .. err .. '\n```') finish() end @@ -261,15 +218,171 @@ 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 +--- 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 enabled_tools = {} + local resolved_resources = {} + local resolved_tools = {} + local matches = utils.to_table(config.tools) + local tool_calls = {} + for _, message in ipairs(M.chat.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 + + -- 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(matches, match) + return '' + end + end + return '@' .. match + end) + 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] = tool + end + end + end + + local matches = utils.ordered_map() + + -- 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, { + 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) + matches:set(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) + matches:set(pattern, { + word = word, + }) + end + + -- Resolve each tool reference + local function expand_tool(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) and enabled_tools[name] 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 = enabled_tools[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 and not tool_id then + tool = M.config.functions[name] + end + if not tool then + -- If tool is not found, return the original pattern + return nil + end + if not tool_id and not tool.uri then + -- If this is a tool that is not resource and was not called by LLM, reject it + return nil + end + + local result = '' + local ok, output = pcall(tool.resolve, functions.parse_input(input, tool.schema), state.source or {}, prompt) + if not ok then + result = string.format(BLOCK_OUTPUT_FORMAT, 'error', utils.make_string(output)) + else + for _, content in ipairs(output) do + if content then + local content_out = nil + if content.uri then + content_out = '##' .. content.uri + table.insert(resolved_resources, resources.to_resource(content)) + if tool_id then + table.insert(state.sticky, content_out) + end + else + content_out = string.format(BLOCK_OUTPUT_FORMAT, utils.mimetype_to_filetype(content.mimetype), content.data) + end + + 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 nil + end + + return result + end + + -- Resolve and process all tools + for _, pattern in ipairs(matches:keys()) do + local match = matches:get(pattern) + local out = expand_tool(match.word, match.input) or pattern + prompt = prompt:gsub(vim.pesc(pattern), out, 1) + end + + return functions.parse_tools(enabled_tools), resolved_resources, resolved_tools, prompt +end + --- Resolve the final prompt and config from prompt template. ---@param prompt string? ----@param config CopilotChat.config.shared? ----@return CopilotChat.config.prompt, string +---@param config CopilotChat.config.Shared? +---@return CopilotChat.config.prompts.Prompt, string function M.resolve_prompt(prompt, config) if not prompt then - local section = M.chat:get_prompt() - if section then - prompt = section.content + local message = M.chat:get_message('user') + if message then + prompt = message.content end end @@ -303,107 +416,20 @@ function M.resolve_prompt(prompt, config) if prompts_to_use[config.system_prompt] then config.system_prompt = prompts_to_use[config.system_prompt].system_prompt end - return config, prompt -end - ---- Resolve the context embeddings from the prompt. ----@param prompt string? ----@param config CopilotChat.config.shared? ----@return table, string ----@async -function M.resolve_context(prompt, config) - config, prompt = M.resolve_prompt(prompt, config) - - local contexts = {} - local function parse_context(prompt_context) - local split = vim.split(prompt_context, ':') - local context_name = table.remove(split, 1) - local context_input = vim.trim(table.concat(split, ':')) - if vim.startswith(context_input, '`') and vim.endswith(context_input, '`') then - context_input = context_input:sub(2, -2) - end - - if M.config.contexts[context_name] then - table.insert(contexts, { - name = context_name, - input = (context_input ~= '' and context_input or nil), - }) - - return true - end - - return false - end - prompt = prompt:gsub('#' .. WORD_INPUT, function(match) - return parse_context(match) and '' or '#' .. match - end) - - prompt = prompt:gsub('#' .. WORD, function(match) - return parse_context(match) and '' or '#' .. match - end) - - if config.context then - if type(config.context) == 'table' then - ---@diagnostic disable-next-line: param-type-mismatch - for _, config_context in ipairs(config.context) do - parse_context(config_context) - end - else - parse_context(config.context) - end - end - - local embeddings = utils.ordered_map() - for _, context_data in ipairs(contexts) do - local context_value = M.config.contexts[context_data.name] - notify.publish( - notify.STATUS, - 'Resolving context: ' .. context_data.name .. (context_data.input and ' with input: ' .. context_data.input or '') - ) - - local ok, resolved_embeddings = pcall(context_value.resolve, context_data.input, state.source or {}, prompt) - if ok then - for _, embedding in ipairs(resolved_embeddings) do - if embedding then - embeddings:set(embedding.filename, embedding) - end - end - else - log.error('Failed to resolve context: ' .. context_data.name, resolved_embeddings) + if config.system_prompt then + config.system_prompt = config.system_prompt:gsub('{OS_NAME}', jit.os) + if state.source then + config.system_prompt = config.system_prompt:gsub('{DIR}', state.source.cwd()) end end - return embeddings:values(), prompt -end - ---- Resolve the agent from the prompt. ----@param prompt string? ----@param config CopilotChat.config.shared? ----@return string, string ----@async -function M.resolve_agent(prompt, config) - config, prompt = M.resolve_prompt(prompt, config) - - local agents = vim.tbl_map(function(agent) - return agent.id - end, client:list_agents()) - - local selected_agent = config.agent or '' - prompt = prompt:gsub('@' .. WORD, function(match) - if vim.tbl_contains(agents, match) then - selected_agent = match - return '' - end - return '@' .. match - end) - - return selected_agent, prompt + return config, prompt end --- Resolve the model from the prompt. ---@param prompt string? ----@param config CopilotChat.config.shared? +---@param config CopilotChat.config.Shared? ---@return string, string ---@async function M.resolve_model(prompt, config) @@ -457,7 +483,7 @@ function M.set_source(source_winnr) end --- Get the selection from the source buffer. ----@return CopilotChat.select.selection? +---@return CopilotChat.select.Selection? function M.get_selection() local config = vim.tbl_deep_extend('force', M.config, M.chat.config) local selection = config.selection @@ -506,8 +532,8 @@ function M.set_selection(bufnr, start_line, end_line, clear) end --- Trigger the completion for the chat window. ----@param without_context boolean? -function M.trigger_complete(without_context) +---@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() @@ -523,23 +549,18 @@ function M.trigger_complete(without_context) return end - if not without_context and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then - local found_context = M.config.contexts[prefix:sub(2, -2)] - if found_context and found_context.input then + if not without_input and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then + local found_tool = M.config.functions[prefix:sub(2, -2)] + if found_tool and found_tool.schema then async.run(function() - found_context.input(function(value) - if not value then - return - end - - local value_str = vim.trim(tostring(value)) - if value_str:find('%s') then - value_str = '`' .. value_str .. '`' - end + local value = functions.enter_input(found_tool.schema, state.source) + if not value then + return + end - vim.api.nvim_buf_set_text(bufnr, row - 1, col, row - 1, col, { value_str }) - vim.api.nvim_win_set_cursor(0, { row, col + #value_str }) - end, state.source or {}) + 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 @@ -577,7 +598,6 @@ end ---@async function M.complete_items() local models = client:list_models() - local agents = client:list_agents() local prompts_to_use = M.prompts() local items = {} @@ -616,32 +636,59 @@ function M.complete_items() } end - for _, agent in pairs(agents) do + 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 = '@' .. agent.id, - abbr = agent.id, - kind = agent.provider, - info = agent.description, - menu = agent.name, + 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, value in pairs(M.config.contexts) do + for name, tool in pairs(M.config.functions) do items[#items + 1] = { - word = '#' .. name, + word = '@' .. name, abbr = name, - kind = 'context', - info = value.description or '', - menu = value.input and string.format('#%s:', name) or string.format('#%s', 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 = '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 @@ -653,7 +700,7 @@ function M.complete_items() end --- Get the prompts to use. ----@return table +---@return table function M.prompts() local prompts_to_use = {} @@ -676,18 +723,22 @@ function M.prompts() end --- Open the chat window. ----@param config CopilotChat.config.shared? +---@param config CopilotChat.config.Shared? function M.open(config) config = vim.tbl_deep_extend('force', M.config, config or {}) utils.return_to_normal_mode() M.chat:open(config) - local section = M.chat:get_prompt() - if section then - local prompt = insert_sticky(section.content, config) + local message = M.chat:get_message('user') + if message then + local prompt = insert_sticky(message.content, config) if prompt then - M.chat:set_prompt(prompt) + M.chat:add_message({ + role = 'user', + content = '\n' .. prompt, + }, true) + M.chat:finish() end end @@ -701,7 +752,7 @@ function M.close() end --- Toggle the chat window. ----@param config CopilotChat.config.shared? +---@param config CopilotChat.config.Shared? function M.toggle(config) if M.chat:visible() then M.close() @@ -710,12 +761,6 @@ function M.toggle(config) end end ---- Get the last response. ---- @returns string -function M.response() - return state.last_response -end - --- Select default Copilot GPT model. function M.select_model() async.run(function() @@ -725,6 +770,8 @@ function M.select_model() id = model.id, name = model.name, provider = model.provider, + streaming = model.streaming, + tools = model.tools, selected = model.id == M.config.model, } end, models) @@ -733,53 +780,39 @@ function M.select_model() vim.ui.select(choices, { prompt = 'Select a model> ', format_item = function(item) - local out = string.format('%s (%s:%s)', item.name, item.provider, item.id) + local indicators = {} + local out = item.name + if item.selected then out = '* ' .. out end - return out - end, - }, function(choice) - if choice then - M.config.model = choice.id - end - end) - end) -end ---- Select default Copilot agent. -function M.select_agent() - async.run(function() - local agents = client:list_agents() - local choices = vim.tbl_map(function(agent) - return { - id = agent.id, - name = agent.name, - provider = agent.provider, - selected = agent.id == M.config.agent, - } - end, agents) + if item.provider then + table.insert(indicators, item.provider) + end + if item.streaming then + table.insert(indicators, 'streaming') + end + if item.tools then + table.insert(indicators, 'tools') + end - utils.schedule_main() - vim.ui.select(choices, { - prompt = 'Select an agent> ', - format_item = function(item) - local out = string.format('%s (%s:%s)', item.name, item.provider, item.id) - if item.selected then - out = '* ' .. out + if #indicators > 0 then + out = out .. ' [' .. table.concat(indicators, ', ') .. ']' end + return out end, }, function(choice) if choice then - M.config.agent = choice.id + M.config.model = choice.id end end) end) end --- Select a prompt template to use. ----@param config CopilotChat.config.shared? +---@param config CopilotChat.config.Shared? function M.select_prompt(config) local prompts = M.prompts() local keys = vim.tbl_keys(prompts) @@ -813,7 +846,7 @@ end --- Ask a question to the Copilot model. ---@param prompt string? ----@param config CopilotChat.config.shared? +---@param config CopilotChat.config.Shared? function M.ask(prompt, config) prompt = prompt or '' if prompt == '' then @@ -836,10 +869,18 @@ function M.ask(prompt, config) M.open(config) end - state.last_prompt = prompt - M.chat:set_prompt(prompt) - M.chat:append('\n\n' .. M.config.answer_header .. M.config.separator .. '\n\n') - M.chat:follow() + 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 else update_source() end @@ -848,16 +889,6 @@ function M.ask(prompt, config) config, prompt = M.resolve_prompt(prompt, config) local system_prompt = config.system_prompt or '' - -- Resolve context name and description - local contexts = {} - if config.include_contexts_in_prompt then - for name, context in pairs(M.config.contexts) do - if context.description then - contexts[name] = context.description - end - end - end - -- Remove sticky prefix prompt = table.concat( vim.tbl_map(function(l) @@ -870,39 +901,55 @@ function M.ask(prompt, config) local selection = M.get_selection() local ok, err = pcall(async.run, function() - local selected_agent, prompt = M.resolve_agent(prompt, config) + local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) - local embeddings, prompt = M.resolve_context(prompt, config) + 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 - local query_ok, filtered_embeddings = - pcall(context.filter_embeddings, prompt, selected_model, config.headless, embeddings) + prompt = vim.trim(prompt) - if not query_ok then + if not config.headless then utils.schedule_main() - log.error(filtered_embeddings) - if not config.headless then - show_error(filtered_embeddings) + + if not utils.empty(resolved_tools) then + M.chat:remove_message('user') + else + M.chat:add_message({ + role = 'user', + content = '\n' .. prompt .. '\n', + }, true) end - return + + for _, tool in ipairs(resolved_tools) do + M.chat:add_message({ + role = 'tool', + tool_call_id = tool.id, + content = tool.result .. '\n', + }) + end + + M.chat:follow() end - local ask_ok, response, references, token_count, token_max_count = pcall(client.ask, client, prompt, { + local ask_ok, ask_response = pcall(client.ask, client, prompt, { headless = config.headless, - contexts = contexts, + history = M.chat.messages, selection = selection, - embeddings = filtered_embeddings, + resources = resolved_resources, + tools = selected_tools, system_prompt = system_prompt, model = selected_model, - agent = selected_agent, temperature = config.temperature, on_progress = vim.schedule_wrap(function(token) - local out = config.stream and config.stream(token, state.source) or nil - if out == nil then - out = token - end - local to_print = not config.headless and out - if to_print and to_print ~= '' then - M.chat:append(token) + if not config.headless then + M.chat:add_message({ + content = token, + role = 'assistant', + }) end end), }) @@ -910,48 +957,44 @@ function M.ask(prompt, config) utils.schedule_main() if not ask_ok then - log.error(response) + log.error(ask_response) if not config.headless then - show_error(response) + show_error(ask_response) end return end -- If there was no error and no response, it means job was cancelled - if response == nil then + if ask_response == nil then return end - -- Call the callback function and store to history - local out = config.callback and config.callback(response, state.source) or nil - if out == nil then - out = response - end - local to_store = not config.headless and out - if to_store and to_store ~= '' then - table.insert(client.history, { - content = prompt, - role = 'user', - }) - table.insert(client.history, { - content = to_store, - role = 'assistant', - }) + local response = ask_response.message + local token_count = ask_response.token_count + local token_max_count = ask_response.token_max_count + + -- 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 end if not config.headless then - state.last_response = response - M.chat.references = references + response.content = vim.trim(response.content) + if utils.empty(response.content) then + response.content = '' + else + response.content = '\n' .. response.content .. '\n' + end + M.chat:add_message(response, true) M.chat.token_count = token_count M.chat.token_max_count = token_max_count - - if not utils.empty(references) and config.references_display == 'write' then - M.chat:append('\n\n**`References`**:') - for _, ref in ipairs(references) do - M.chat:append(string.format('\n[%s](%s)', ref.name, ref.url)) - end - end - finish() end end) @@ -967,26 +1010,19 @@ end --- Stop current copilot output and optionally reset the chat ten show the help message. ---@param reset boolean? function M.stop(reset) - local stopped = false + local stopped = client:stop() if reset then - client:reset() M.chat:clear() vim.diagnostic.reset(vim.api.nvim_create_namespace('copilot-chat-diagnostics')) - state.last_prompt = nil - state.last_response = nil -- Clear the selection if state.source then M.set_selection(state.source.bufnr, 0, 0, true) end - - stopped = true - else - stopped = client:stop() end - if stopped then + if stopped or reset then finish(reset) end end @@ -1009,15 +1045,10 @@ function M.save(name, history_path) return end - local prompt = M.chat:get_prompt() - local history = vim.list_slice(client.history) - if prompt then - table.insert(history, { - content = prompt.content, - role = 'user', - }) + local history = vim.deepcopy(M.chat.messages) + for _, message in ipairs(history) do + message.section = nil end - history_path = vim.fs.normalize(history_path) vim.fn.mkdir(history_path, 'p') history_path = history_path .. '/' .. name .. '.json' @@ -1059,32 +1090,11 @@ function M.load(name, history_path) }, }) - client:reset() - M.chat:clear() - - client.history = history - for i, message in ipairs(history) do - if message.role == 'user' then - if i > 1 then - M.chat:append('\n\n') - end - M.chat:append(M.config.question_header .. M.config.separator .. '\n\n') - M.chat:append(message.content) - elseif message.role == 'assistant' then - M.chat:append('\n\n' .. M.config.answer_header .. M.config.separator .. '\n\n') - M.chat:append(message.content) - end - end - log.info('Loaded history from ' .. history_path) - if #history > 0 then - local last = history[#history] - if last and last.role == 'user' then - M.chat:append('\n\n') - M.chat:finish() - return - end + M.stop(true) + for _, message in ipairs(history) do + M.chat:add_message(message) end finish(#history == 0) @@ -1100,11 +1110,20 @@ function M.log_level(level) plugin = 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) end --- Set up the plugin ----@param config CopilotChat.config? +---@param config CopilotChat.config.Config? function M.setup(config) M.config = vim.tbl_deep_extend('force', require('CopilotChat.config'), config or {}) state.highlights_loaded = false @@ -1130,8 +1149,7 @@ function M.setup(config) M.chat:delete() end M.chat = require('CopilotChat.ui.chat')( - M.config.question_header, - M.config.answer_header, + M.config.headers, M.config.separator, utils.key_to_info('show_help', M.config.mappings.show_help), function(bufnr) diff --git a/lua/CopilotChat/integrations/fzflua.lua b/lua/CopilotChat/integrations/fzflua.lua deleted file mode 100644 index 174d0139..00000000 --- a/lua/CopilotChat/integrations/fzflua.lua +++ /dev/null @@ -1,42 +0,0 @@ -local fzflua = require('fzf-lua') -local chat = require('CopilotChat') -local utils = require('CopilotChat.utils') - -local M = {} - ---- Pick an action from a list of actions ----@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ----@param opts table?: fzf-lua options ----@deprecated Use |CopilotChat.select_prompt| instead -function M.pick(pick_actions, opts) - if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then - return - end - - utils.return_to_normal_mode() - opts = vim.tbl_extend('force', { - prompt = pick_actions.prompt .. '> ', - preview = function(items) - return pick_actions.actions[items[1]].prompt - end, - winopts = { - preview = { - wrap = 'wrap', - }, - }, - actions = { - ['default'] = function(selected) - if not selected or vim.tbl_isempty(selected) then - return - end - vim.defer_fn(function() - chat.ask(pick_actions.actions[selected[1]].prompt, pick_actions.actions[selected[1]]) - end, 100) - end, - }, - }, opts or {}) - - fzflua.fzf_exec(vim.tbl_keys(pick_actions.actions), opts) -end - -return M diff --git a/lua/CopilotChat/integrations/snacks.lua b/lua/CopilotChat/integrations/snacks.lua deleted file mode 100644 index 14a2daaa..00000000 --- a/lua/CopilotChat/integrations/snacks.lua +++ /dev/null @@ -1,54 +0,0 @@ -local snacks = require('snacks') -local chat = require('CopilotChat') -local utils = require('CopilotChat.utils') - -local M = {} - ---- Pick an action from a list of actions ----@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ----@param opts table?: snacks options ----@deprecated Use |CopilotChat.select_prompt| instead -function M.pick(pick_actions, opts) - if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then - return - end - - utils.return_to_normal_mode() - opts = vim.tbl_extend('force', { - items = vim.tbl_map(function(name) - return { - id = name, - text = name, - file = name, - preview = { - text = pick_actions.actions[name].prompt, - ft = 'text', - }, - } - end, vim.tbl_keys(pick_actions.actions)), - preview = 'preview', - win = { - preview = { - wo = { - wrap = true, - linebreak = true, - }, - }, - }, - title = pick_actions.prompt, - confirm = function(picker) - local selected = picker:current() - if selected then - local action = pick_actions.actions[selected.id] - vim.defer_fn(function() - chat.ask(action.prompt, action) - end, 100) - end - picker:close() - end, - }, opts or {}) - - snacks.picker(opts) -end - -return M diff --git a/lua/CopilotChat/integrations/telescope.lua b/lua/CopilotChat/integrations/telescope.lua deleted file mode 100644 index 5e14d913..00000000 --- a/lua/CopilotChat/integrations/telescope.lua +++ /dev/null @@ -1,65 +0,0 @@ -local actions = require('telescope.actions') -local action_state = require('telescope.actions.state') -local pickers = require('telescope.pickers') -local finders = require('telescope.finders') -local themes = require('telescope.themes') -local conf = require('telescope.config').values -local previewers = require('telescope.previewers') -local chat = require('CopilotChat') -local utils = require('CopilotChat.utils') - -local M = {} - ---- Pick an action from a list of actions ----@param pick_actions CopilotChat.integrations.actions?: A table with the actions to pick from ----@param opts table?: Telescope options ----@deprecated Use |CopilotChat.select_prompt| instead -function M.pick(pick_actions, opts) - if not pick_actions or not pick_actions.actions or vim.tbl_isempty(pick_actions.actions) then - return - end - - utils.return_to_normal_mode() - - if not (opts and opts.theme) then - opts = themes.get_dropdown(opts or {}) - end - - pickers - .new(opts, { - prompt_title = pick_actions.prompt, - finder = finders.new_table({ - results = vim.tbl_keys(pick_actions.actions), - }), - previewer = previewers.new_buffer_previewer({ - define_preview = function(self, entry) - vim.api.nvim_win_set_option(self.state.winid, 'wrap', true) - vim.api.nvim_buf_set_lines( - self.state.bufnr, - 0, - -1, - false, - vim.split(pick_actions.actions[entry[1]].prompt or '', '\n') - ) - end, - }), - sorter = conf.generic_sorter(opts), - attach_mappings = function(prompt_bufnr) - actions.select_default:replace(function() - actions.close(prompt_bufnr) - local selected = action_state.get_selected_entry() - if not selected or vim.tbl_isempty(selected) then - return - end - - vim.defer_fn(function() - chat.ask(pick_actions.actions[selected[1]].prompt, pick_actions.actions[selected[1]]) - end, 100) - end) - return true - end, - }) - :find() -end - -return M diff --git a/lua/CopilotChat/context.lua b/lua/CopilotChat/resources.lua similarity index 81% rename from lua/CopilotChat/context.lua rename to lua/CopilotChat/resources.lua index 3d50ca3d..da79d6ec 100644 --- a/lua/CopilotChat/context.lua +++ b/lua/CopilotChat/resources.lua @@ -1,4 +1,4 @@ ----@class CopilotChat.context.symbol +---@class CopilotChat.resources.Symbol ---@field name string? ---@field signature string ---@field type string @@ -7,16 +7,6 @@ ---@field end_row number ---@field end_col number ----@class CopilotChat.context.embed ----@field content string ----@field filename string ----@field filetype string ----@field outline string? ----@field diagnostics table? ----@field symbols table? ----@field embedding table? ----@field score number? - local async = require('plenary.async') local log = require('plenary.log') local client = require('CopilotChat.client') @@ -94,9 +84,9 @@ local function spatial_distance_cosine(a, b) end --- Rank data by relatedness to the query ----@param query CopilotChat.context.embed ----@param data table ----@return table +---@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) @@ -189,8 +179,8 @@ end --- Rank data by symbols and filenames ---@param query string ----@param data table ----@return table +---@param data table +---@return table local function data_ranked_by_symbols(query, data) -- Get query trigrams including compound versions local query_trigrams = {} @@ -211,7 +201,7 @@ local function data_ranked_by_symbols(query, data) local max_score = 0 for _, entry in ipairs(data) do - local basename = utils.filename(entry.filename):gsub('%..*$', '') + local basename = utils.filename(entry.name):gsub('%..*$', '') -- Get trigrams for basename and compound version local file_trigrams = get_trigrams(basename) @@ -327,9 +317,9 @@ end --- Build an outline and symbols from a string ---@param content string ---@param ft string ----@return string?, table? +---@return string?, table? local function get_outline(content, ft) - if not ft or ft == '' or ft == 'text' or ft == 'raw' then + if not ft or ft == '' then return nil end @@ -399,47 +389,36 @@ end --- Get data for a file ---@param filename string ----@param filetype string? ----@return CopilotChat.context.embed? -function M.get_file(filename, filetype) +---@return string?, string? +function M.get_file(filename) + local filetype = utils.filetype(filename) if not filetype then return nil end - local modified = utils.file_mtime(filename) if not modified then return nil end - local cached = file_cache[filename] - if cached and cached._modified >= modified then - return { - content = cached.content, - _modified = cached._modified, - filename = filename, - filetype = filetype, + local data = file_cache[filename] + if not data or data._modified < modified then + local content = utils.read_file(filename) + if not content or content == '' then + return nil + end + data = { + content = content, + _modified = modified, } + file_cache[filename] = data end - local content = utils.read_file(filename) - if not content or content == '' then - return nil - end - - local out = { - content = content, - filename = filename, - filetype = filetype, - _modified = modified, - } - - file_cache[filename] = out - return out + return data.content, utils.filetype_to_mimetype(filetype) end --- Get data for a buffer ---@param bufnr number ----@return CopilotChat.context.embed? +---@return string?, string? function M.get_buffer(bufnr) if not utils.buf_valid(bufnr) then return nil @@ -450,23 +429,18 @@ function M.get_buffer(bufnr) return nil end - return { - content = table.concat(content, '\n'), - filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), - filetype = vim.bo[bufnr].filetype, - score = 0.1, - diagnostics = utils.diagnostics(bufnr), - } + return table.concat(content, '\n'), utils.filetype_to_mimetype(vim.bo[bufnr].filetype) end --- Get the content of an URL ---@param url string ----@return CopilotChat.context.embed? +---@return string?, string? function M.get_url(url) if not url or url == '' then return nil end + local ft = utils.filetype(url) local content = url_cache[url] if not content then local ok, out = async.util.apcall(utils.system, { 'lynx', '-dump', url }) @@ -504,37 +478,41 @@ function M.get_url(url) url_cache[url] = content end + 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 { - content = content, - filename = url, - filetype = 'text', + name = utils.uri_to_filename(resource.uri), + type = utils.mimetype_to_filetype(resource.mimetype), + data = resource.data, } end ---- Filter embeddings based on the query +--- Process resources based on the query ---@param prompt string ---@param model string ----@param headless boolean ----@param embeddings table ----@return table -function M.filter_embeddings(prompt, model, headless, embeddings) +---@param resources table +---@return table +function M.process_resources(prompt, model, resources) -- If we dont need to embed anything, just return directly - if #embeddings < MULTI_FILE_THRESHOLD then - return embeddings + if #resources < MULTI_FILE_THRESHOLD then + return resources end notify.publish(notify.STATUS, 'Preparing embedding outline') - for _, input in ipairs(embeddings) do - -- Precalculate hash and attributes for caching - local hash = input.filename .. utils.quick_hash(input.content) + -- Get the outlines for each resource + for _, input in ipairs(resources) do + local hash = input.name .. utils.quick_hash(input.data) input._hash = hash - input.filename = input.filename or 'unknown' - input.filetype = input.filetype or 'text' local outline = outline_cache[hash] if not outline then - local outline_text, symbols = get_outline(input.content, input.filetype) + local outline_text, symbols = get_outline(input.data, input.type) if outline_text then outline = { outline = outline_text, @@ -555,32 +533,18 @@ function M.filter_embeddings(prompt, model, headless, embeddings) -- Build query from history and prompt local query = prompt - if not headless then - query = table.concat( - vim - .iter(client.history) - :filter(function(m) - return m.role == 'user' - end) - :map(function(m) - return vim.trim(m.content) - end) - :totable(), - '\n' - ) .. '\n' .. prompt - end -- Rank embeddings by symbols - embeddings = data_ranked_by_symbols(query, embeddings) - log.debug('Ranked data:', #embeddings) - for i, item in ipairs(embeddings) do - log.debug(string.format('%s: %s - %s', i, item.score, item.filename)) + 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(embeddings) do + for _, input in ipairs(resources) do local hash = input._hash local embed = embedding_cache[hash] if embed then @@ -591,14 +555,13 @@ function M.filter_embeddings(prompt, model, headless, embeddings) end end table.insert(to_process, { - content = query, - filename = 'query', - filetype = 'raw', + type = 'text', + data = query, }) -- Embed the data and process the results for _, input in ipairs(client:embed(to_process, model)) do - if input.filetype ~= 'raw' then + if input._hash then embedding_cache[input._hash] = input.embedding end table.insert(results, input) diff --git a/lua/CopilotChat/select.lua b/lua/CopilotChat/select.lua index 2254c913..8bef366c 100644 --- a/lua/CopilotChat/select.lua +++ b/lua/CopilotChat/select.lua @@ -1,18 +1,16 @@ ----@class CopilotChat.select.selection +---@class CopilotChat.select.Selection ---@field content string ---@field start_line number ---@field end_line number ---@field filename string ---@field filetype string ---@field bufnr number ----@field diagnostics table? -local utils = require('CopilotChat.utils') local M = {} --- Select and process current visual selection --- @param source CopilotChat.source ---- @return CopilotChat.select.selection|nil +--- @return CopilotChat.select.Selection|nil function M.visual(source) local bufnr = source.bufnr local start_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '<')) @@ -35,18 +33,17 @@ function M.visual(source) return { content = lines_content, - filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), + filename = vim.api.nvim_buf_get_name(bufnr), filetype = vim.bo[bufnr].filetype, start_line = start_line, end_line = finish_line, bufnr = bufnr, - diagnostics = utils.diagnostics(bufnr, start_line, finish_line), } end --- Select and process whole buffer --- @param source CopilotChat.source ---- @return CopilotChat.select.selection|nil +--- @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) @@ -54,22 +51,19 @@ function M.buffer(source) return nil end - local out = { + return { content = table.concat(lines, '\n'), - filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), + filename = vim.api.nvim_buf_get_name(bufnr), filetype = vim.bo[bufnr].filetype, start_line = 1, end_line = #lines, bufnr = bufnr, } - - out.diagnostics = utils.diagnostics(bufnr, out.start_line, out.end_line) - return out end --- Select and process current line --- @param source CopilotChat.source ---- @return CopilotChat.select.selection|nil +--- @return CopilotChat.select.Selection|nil function M.line(source) local bufnr = source.bufnr local winnr = source.winnr @@ -79,22 +73,19 @@ function M.line(source) return nil end - local out = { + return { content = line, - filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), + filename = vim.api.nvim_buf_get_name(bufnr), filetype = vim.bo[bufnr].filetype, start_line = cursor[1], end_line = cursor[1], bufnr = bufnr, } - - out.diagnostics = utils.diagnostics(bufnr, out.start_line, out.end_line) - return out 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 +--- @return CopilotChat.select.Selection|nil function M.unnamed(source) local bufnr = source.bufnr local start_line = unpack(vim.api.nvim_buf_get_mark(bufnr, '[')) @@ -117,12 +108,11 @@ function M.unnamed(source) return { content = lines_content, - filename = utils.filepath(vim.api.nvim_buf_get_name(bufnr)), + filename = vim.api.nvim_buf_get_name(bufnr), filetype = vim.bo[bufnr].filetype, start_line = start_line, end_line = finish_line, bufnr = bufnr, - diagnostics = utils.diagnostics(bufnr, start_line, finish_line), } end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 82af9789..f54e9435 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -14,71 +14,71 @@ function CopilotChatFoldExpr(lnum, separator) end local HEADER_PATTERNS = { - '%[file:.+%]%((.+)%) line:(%d+)-?(%d*)', - '%[file:(.+)%] line:(%d+)-?(%d*)', + '^```?(%w+)%s+path=(%S+)%s+start_line=(%d+)%s+end_line=(%d+)$', + '^```(%w+)$', } ---@param header? string ----@return string?, number?, number? +---@return string?, string?, number?, number? local function match_header(header) if not header then return end for _, pattern in ipairs(HEADER_PATTERNS) do - local filename, start_line, end_line = header:match(pattern) - if filename then - return utils.filepath(filename), tonumber(start_line) or 1, tonumber(end_line) or tonumber(start_line) or 1 + 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 + elseif type then + return type, 'block' end end end ----@class CopilotChat.ui.Chat.Section.Block.Header +---@class CopilotChat.ui.chat.Header ---@field filename string ---@field start_line number ---@field end_line number ---@field filetype string ----@class CopilotChat.ui.Chat.Section.Block ----@field header CopilotChat.ui.Chat.Section.Block.Header +---@class CopilotChat.ui.chat.Block +---@field header CopilotChat.ui.chat.Header ---@field start_line number ---@field end_line number ---@field content string? ----@class CopilotChat.ui.Chat.Section ----@field answer boolean +---@class CopilotChat.ui.chat.Section ---@field start_line number ---@field end_line number ----@field blocks table ----@field content string? +---@field blocks table + +---@class CopilotChat.ui.chat.Message : CopilotChat.client.Message +---@field id string +---@field section CopilotChat.ui.chat.Section? ----@class CopilotChat.ui.Chat : CopilotChat.ui.Overlay +---@class CopilotChat.ui.chat.Chat : CopilotChat.ui.overlay.Overlay ---@field winnr number? ----@field config CopilotChat.config.shared ----@field layout CopilotChat.config.Layout? ----@field sections table ----@field references table +---@field config CopilotChat.config.Shared ---@field token_count number? ---@field token_max_count number? ----@field private question_header string ----@field private answer_header string +---@field messages table +---@field private layout CopilotChat.config.Layout? +---@field private headers table ---@field private separator string ---@field private header_ns number ----@field private spinner CopilotChat.ui.Spinner ----@field private chat_overlay CopilotChat.ui.Overlay -local Chat = class(function(self, question_header, answer_header, separator, help, on_buf_create) +---@field private spinner CopilotChat.ui.spinner.Spinner +---@field private chat_overlay CopilotChat.ui.overlay.Overlay +local Chat = class(function(self, headers, separator, help, on_buf_create) Overlay.init(self, 'copilot-chat', help, on_buf_create) self.winnr = nil - self.sections = {} self.config = {} - self.layout = nil - self.references = {} self.token_count = nil self.token_max_count = nil + self.messages = {} - self.question_header = question_header - self.answer_header = answer_header + self.layout = nil + self.headers = headers or {} self.separator = separator self.header_ns = vim.api.nvim_create_namespace('copilot-chat-headers') @@ -110,10 +110,10 @@ function Chat:focused() return self:visible() and vim.api.nvim_get_current_win() == self.winnr end ---- Get the closest section to the cursor. ----@param type? "answer"|"question" If specified, only considers sections of the given type ----@return CopilotChat.ui.Chat.Section? -function Chat:get_closest_section(type) +--- 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 @@ -121,25 +121,23 @@ function Chat:get_closest_section(type) self:render() local cursor_pos = vim.api.nvim_win_get_cursor(self.winnr) local cursor_line = cursor_pos[1] - local closest_section = nil + local closest_message = nil local max_line_below_cursor = -1 - for _, section in ipairs(self.sections) do - local matches_type = not type - or (type == 'answer' and section.answer) - or (type == 'question' and not section.answer) - - if matches_type and section.start_line <= cursor_line and section.start_line > max_line_below_cursor then + 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_section = section + closest_message = message end end - return closest_section + return closest_message end --- Get the closest code block to the cursor. ----@return CopilotChat.ui.Chat.Section.Block? +---@return CopilotChat.ui.chat.Block? function Chat:get_closest_block() if not self:visible() then return nil @@ -151,7 +149,8 @@ function Chat:get_closest_block() local closest_block = nil local max_line_below_cursor = -1 - for _, section in pairs(self.sections) do + 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 @@ -163,39 +162,20 @@ function Chat:get_closest_block() return closest_block end ---- Get the prompt in the chat window. ----@return CopilotChat.ui.Chat.Section? -function Chat:get_prompt() +--- Get last message by role in the chat window. +---@return CopilotChat.ui.chat.Message? +function Chat:get_message(role) if not self:visible() then return end - self:render() - local section = self.sections[#self.sections] - if not section or section.answer then - return - end - - return section -end - ---- Set the prompt in the chat window. ----@param prompt string? -function Chat:set_prompt(prompt) - if not self:visible() then - return - end - - local section = self:get_prompt() - if not section then - return + for i = #self.messages, 1, -1 do + local message = self.messages[i] + local matches_role = not role or message.role == role + if matches_role then + return message + end end - - local modifiable = vim.bo[self.bufnr].modifiable - vim.bo[self.bufnr].modifiable = true - local lines = prompt and vim.split('\n' .. prompt, '\n') or {} - vim.api.nvim_buf_set_lines(self.bufnr, section.start_line - 1, section.end_line, false, lines) - vim.bo[self.bufnr].modifiable = modifiable end --- Add a sticky line to the prompt in the chat window. @@ -205,8 +185,8 @@ function Chat:add_sticky(sticky) return end - local prompt = self:get_prompt() - if not prompt then + local prompt = self:get_message('user') + if not prompt or not prompt.section then return end @@ -239,7 +219,7 @@ function Chat:add_sticky(sticky) return end - insert_line = prompt.start_line + insert_line - 1 + 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 @@ -265,7 +245,7 @@ function Chat:overlay(opts) end --- Open the chat window. ----@param config CopilotChat.config.shared +---@param config CopilotChat.config.Shared function Chat:open(config) self:validate() @@ -415,6 +395,88 @@ function Chat:finish() end end +function Chat:add_message(message, replace) + local current_message = self.messages[#self.messages] + local needs_header = false + + -- Check if we need to add a header (role change or first message) + if not current_message or current_message.role ~= message.role then + needs_header = true + end + + -- Add appropriate header based on role + if needs_header then + message.id = message.id or utils.uuid() + local header = self.headers[message.role] + if current_message then + header = '\n' .. header + end + self:append(header .. '(' .. message.id .. ')' .. self.separator .. '\n\n') + elseif replace and current_message then + self:append('') + self:render() + + current_message.content = message.content + local section = current_message.section + + if section then + vim.bo[self.bufnr].modifiable = true + vim.api.nvim_buf_set_lines( + self.bufnr, + section.start_line - 1, + section.end_line, + false, + vim.split(message.content, '\n') + ) + vim.bo[self.bufnr].modifiable = false + end + + self:append('') + return + end + + -- Handle message content combining or creation + if current_message and current_message.role == message.role then + current_message.content = current_message.content .. message.content + self:append(message.content) + else + table.insert(self.messages, message) + self:append(message.content) + end +end + +function Chat:remove_message(role) + if not self:visible() then + return + end + + self:render() + local message = self:get_closest_message(role) + if not message then + return + end + + local section = message.section + if not section then + return + end + + -- Remove the section from the buffer + vim.bo[self.bufnr].modifiable = true + vim.api.nvim_buf_set_lines(self.bufnr, section.start_line - 2, section.end_line + 1, false, {}) + vim.bo[self.bufnr].modifiable = false + + -- 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:render() +end + --- Append text to the chat window. ---@param str string function Chat:append(str) @@ -451,9 +513,9 @@ end --- Clear the chat window. function Chat:clear() self:validate() - self.references = {} self.token_count = nil self.token_max_count = nil + self.messages = {} vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, {}) vim.bo[self.bufnr].modifiable = false @@ -493,81 +555,79 @@ end function Chat:render() vim.api.nvim_buf_clear_namespace(self.bufnr, self.header_ns, 0, -1) local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) - local line_count = #lines - local sections = {} - local current_section = nil + local new_messages = {} + local current_message = nil local current_block = nil + local function parse_header(header, line) + return line:match('^' .. vim.pesc(header) .. '%(([%w%-]+)%)' .. vim.pesc(self.separator) .. '$') + end + for l, line in ipairs(lines) do - local separator_found = false - - if line == self.answer_header .. self.separator then - separator_found = true - if current_section then - current_section.end_line = l - 1 - current_section.content = - vim.trim(table.concat(vim.list_slice(lines, current_section.start_line, current_section.end_line), '\n')) - table.insert(sections, current_section) - end - current_section = { - answer = true, - start_line = l + 1, - blocks = {}, - } - elseif line == self.question_header .. self.separator then - separator_found = true - if current_section then - current_section.end_line = l - 1 - current_section.content = - vim.trim(table.concat(vim.list_slice(lines, current_section.start_line, current_section.end_line), '\n')) - table.insert(sections, current_section) - end - current_section = { - answer = false, - start_line = l + 1, - blocks = {}, - } - elseif l == line_count then - if current_section then - current_section.end_line = l - current_section.content = - vim.trim(table.concat(vim.list_slice(lines, current_section.start_line, current_section.end_line), '\n')) - table.insert(sections, current_section) - end - end + -- 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 sep_col = vim.fn.strwidth(header_value) + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, sep_col, { + virt_text = { + { string.rep(self.separator, vim.go.columns), 'CopilotChatSeparator' }, + }, + virt_text_win_col = sep_col, + priority = 200, + strict = false, + }) + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, 0, { + end_col = sep_col, + hl_group = 'CopilotChatHeader', + priority = 100, + strict = false, + }) + end - -- Highlight separators - if self.config.highlight_headers and separator_found then - local sep = vim.fn.strwidth(line) - vim.fn.strwidth(self.separator) - -- separator line - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, sep, { - virt_text_win_col = sep, - virt_text = { - { string.rep(self.separator, vim.go.columns), 'CopilotChatSeparator' }, - }, - priority = 100, - strict = false, - }) - -- header hl group - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, 0, { - end_col = sep + 1, - hl_group = 'CopilotChatHeader', - priority = 100, - 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' + ) + ) + end - -- Parse code blocks - if current_section and current_section.answer then - local filetype = line:match('^```(%w+)$') - if filetype and not current_block then - local filename, start_line, end_line = match_header(lines[l - 1]) - if not filename then - filename, start_line, end_line = match_header(lines[l - 2]) + -- 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 - filename = filename or 'code-block' + -- 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 + end + end + + -- Code blocks + if current_message and current_message.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, @@ -577,18 +637,96 @@ function Chat:render() }, start_line = l + 1, } + 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) + end + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l, 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_section.blocks, current_block) + 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, self.header_ns, 'CopilotChatAnnotationHeader', l - 1, 0, #line) + if not utils.empty(tool_call.arguments) then + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_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 + end + end + end + + -- 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 + if message.tool_calls and #message.tool_calls > 0 then + local section = message.section + if section and section.end_line then + local virt_lines = { { { 'Tool calls:', 'CopilotChatAnnotationHeader' } } } + for _, tc in ipairs(message.tool_calls) do + table.insert(virt_lines, { { string.format(' %s:%s', tc.name, tostring(tc.id)), 'CopilotChatAnnotation' } }) + for _, json_line in ipairs(vim.split(vim.inspect(utils.json_decode(tc.arguments)), '\n')) do + table.insert(virt_lines, { { ' ' .. json_line, 'CopilotChatAnnotation' } }) + end + end + vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, section.end_line - 1, 0, { + virt_lines = virt_lines, + virt_lines_above = true, + priority = 100, + strict = false, + }) + end + end + + 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, self.header_ns, section.start_line, 0, { + virt_lines = virt_lines, + virt_lines_above = true, + priority = 100, + strict = false, + }) + end + end end - local last_section = sections[#sections] - if last_section and not last_section.answer then + -- Show help as before, using last user message + local last_message = self.messages[#self.messages] + if last_message and last_message.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 @@ -596,29 +734,10 @@ function Chat:render() end msg = msg .. self.token_count .. '/' .. self.token_max_count .. ' tokens used' end - - self:show_help(msg, last_section.start_line - last_section.end_line - 1) - - if not utils.empty(self.references) and self.config.references_display == 'virtual' then - msg = 'References:\n' - for _, ref in ipairs(self.references) do - msg = msg .. ' ' .. ref.name .. '\n' - end - - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, last_section.start_line - 2, 0, { - hl_mode = 'combine', - priority = 100, - virt_lines_above = true, - virt_lines = vim.tbl_map(function(t) - return { { t, 'CopilotChatHelp' } } - end, vim.split(msg, '\n')), - }) - end + self:show_help(msg, last_message.section.start_line - last_message.section.end_line - 1) else self:show_help() end - - self.sections = sections 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 9b70cb5e..a23c022e 100644 --- a/lua/CopilotChat/ui/overlay.lua +++ b/lua/CopilotChat/ui/overlay.lua @@ -1,7 +1,7 @@ local utils = require('CopilotChat.utils') local class = utils.class ----@class CopilotChat.ui.Overlay : Class +---@class CopilotChat.ui.overlay.Overlay : Class ---@field bufnr number? ---@field protected name string ---@field protected help string @@ -19,10 +19,6 @@ local Overlay = class(function(self, name, help, on_buf_create) self.on_hide = nil self.help_ns = vim.api.nvim_create_namespace('copilot-chat-help') - self.hl_ns = vim.api.nvim_create_namespace('copilot-chat-highlights') - vim.api.nvim_set_hl(self.hl_ns, '@diff.plus', { bg = utils.blend_color('DiffAdd', 20) }) - vim.api.nvim_set_hl(self.hl_ns, '@diff.minus', { bg = utils.blend_color('DiffDelete', 20) }) - vim.api.nvim_set_hl(self.hl_ns, '@diff.delta', { bg = utils.blend_color('DiffChange', 20) }) end) --- Show the overlay buffer @@ -38,7 +34,6 @@ function Overlay:show(text, winnr, filetype, syntax, on_show, on_hide) end self:validate() - vim.api.nvim_win_set_hl_ns(winnr, self.hl_ns) text = text .. '\n' self.cursor = vim.api.nvim_win_get_cursor(winnr) @@ -122,7 +117,6 @@ function Overlay:restore(winnr, bufnr) end vim.api.nvim_win_set_buf(winnr, bufnr) - vim.api.nvim_win_set_hl_ns(winnr, 0) if self.cursor then vim.api.nvim_win_set_cursor(winnr, self.cursor) diff --git a/lua/CopilotChat/ui/spinner.lua b/lua/CopilotChat/ui/spinner.lua index 4b69ae44..0f582032 100644 --- a/lua/CopilotChat/ui/spinner.lua +++ b/lua/CopilotChat/ui/spinner.lua @@ -14,7 +14,7 @@ local spinner_frames = { '⠏', } ----@class CopilotChat.ui.Spinner : Class +---@class CopilotChat.ui.spinner.Spinner : Class ---@field bufnr number ---@field status string? ---@field private index number diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index afd92c5d..1cf1a151 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -1,6 +1,7 @@ local async = require('plenary.async') local curl = require('plenary.curl') local scandir = require('plenary.scandir') +local log = require('plenary.log') local M = {} M.timers = {} @@ -102,6 +103,24 @@ function M.ordered_map() } end +--- Convert arguments to a table +---@param ... any The arguments +---@return table +function M.to_table(...) + local result = {} + for i = 1, select('#', ...) do + local x = select(i, ...) + if type(x) == 'table' then + for _, v in ipairs(x) do + table.insert(result, v) + end + elseif x ~= nil then + table.insert(result, x) + end + end + return result +end + ---@class StringBuffer ---@field add fun(self:StringBuffer, s:string) ---@field set fun(self:StringBuffer, s:string) @@ -149,26 +168,6 @@ function M.temp_file(text) return temp_file end ---- Blend a color with the neovim background ----@param color_name string The color name ----@param blend number The blend percentage ----@return string? -function M.blend_color(color_name, blend) - local color_int = vim.api.nvim_get_hl(0, { name = color_name }).fg - local bg_int = vim.api.nvim_get_hl(0, { name = 'Normal' }).bg - - if not color_int or not bg_int then - return - end - - local color = { (color_int / 65536) % 256, (color_int / 256) % 256, color_int % 256 } - local bg = { (bg_int / 65536) % 256, (bg_int / 256) % 256, bg_int % 256 } - local r = math.floor((color[1] * blend + bg[1] * (100 - blend)) / 100) - local g = math.floor((color[2] * blend + bg[2] * (100 - blend)) / 100) - local b = math.floor((color[3] * blend + bg[3] * (100 - blend)) / 100) - return string.format('#%02x%02x%02x', r, g, b) -end - --- Return to normal mode function M.return_to_normal_mode() local mode = vim.fn.mode():lower() @@ -179,11 +178,6 @@ function M.return_to_normal_mode() end end ---- Mark a function as deprecated -function M.deprecate(old, new) - vim.deprecate(old, new, '3.0.X', 'CopilotChat.nvim', false) -end - --- Debounce a function function M.debounce(id, fn, delay) if M.timers[id] then @@ -193,21 +187,6 @@ function M.debounce(id, fn, delay) M.timers[id] = vim.defer_fn(fn, delay) end ---- Create key-value list from table ----@param tbl table The table ----@return table -function M.kv_list(tbl) - local result = {} - for k, v in pairs(tbl) do - table.insert(result, { - key = k, - value = v, - }) - end - - return result -end - --- Check if a buffer is valid --- Check if the buffer is not a terminal ---@param bufnr number? The buffer number @@ -246,6 +225,53 @@ function M.filetype(filename) 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 + 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 @@ -253,13 +279,6 @@ function M.filename(filepath) return vim.fs.basename(filepath) end ---- Get the file path ----@param filename string The file name ----@return string -function M.filepath(filename) - return vim.fn.fnamemodify(filename, ':p:.') -end - --- Generate a UUID ---@return string function M.uuid() @@ -291,6 +310,13 @@ function M.make_string(...) x = vim.inspect(x) else x = tostring(x) + while true do + local new_x = x:gsub('^[^:]+:%d+: ', '') + if new_x == x then + break + end + x = new_x + end end t[#t + 1] = x @@ -329,8 +355,10 @@ end ---@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, } @@ -339,6 +367,7 @@ M.curl_get = async.wrap(function(url, opts, callback) 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 @@ -366,9 +395,10 @@ end, 3) ---@param opts table? The options ---@async M.curl_post = async.wrap(function(url, opts, callback) + log.debug('POST request:', url, opts) local args = { - callback = callback, on_error = function(err) + log.debug('POST error:', err) callback(nil, err and err.stderr or err) end, } @@ -376,13 +406,8 @@ M.curl_post = async.wrap(function(url, opts, callback) args = vim.tbl_deep_extend('force', M.curl_args, args) args = vim.tbl_deep_extend('force', args, opts or {}) - if args.json_response then - args.headers = vim.tbl_deep_extend('force', args.headers or {}, { - Accept = 'application/json', - }) - end - args.callback = function(response) + log.debug('POST response:', url, response) if response and not vim.startswith(tostring(response.status), '20') then callback(response, response.body) return @@ -402,6 +427,12 @@ M.curl_post = async.wrap(function(url, opts, callback) 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', @@ -413,7 +444,18 @@ M.curl_post = async.wrap(function(url, opts, callback) curl.post(url, args) end, 3) ----@class CopilotChat.utils.scan_dir_opts +local function filter_files(files, max_count) + files = vim.tbl_filter(function(file) + return file ~= '' and M.filetype(file) ~= nil + 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 glob? string The glob pattern to match files @@ -421,30 +463,19 @@ end, 3) ---@field no_ignore? boolean Whether to respect or ignore .gitignore --- Scan a directory ----@param path string The directory path ----@param opts CopilotChat.utils.scan_dir_opts? The options +---@param path string +---@param opts CopilotChat.utils.ScanOpts? ---@async -M.scan_dir = async.wrap(function(path, opts, callback) +M.glob = async.wrap(function(path, opts, callback) opts = vim.tbl_deep_extend('force', M.scan_args, opts or {}) - local function filter_files(files) - files = vim.tbl_filter(function(file) - return file ~= '' and M.filetype(file) ~= nil - end, files) - if opts.max_count and opts.max_count > 0 then - files = vim.list_slice(files, 1, opts.max_count) - end - - return files - end - -- Use ripgrep if available if vim.fn.executable('rg') == 1 then local cmd = { 'rg' } - if opts.glob then + if opts.pattern then table.insert(cmd, '-g') - table.insert(cmd, opts.glob) + table.insert(cmd, opts.pattern) end if opts.max_depth then @@ -466,7 +497,7 @@ M.scan_dir = async.wrap(function(path, opts, callback) 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')) + files = filter_files(vim.split(result.stdout, '\n'), opts.max_count) end callback(files) @@ -484,12 +515,71 @@ M.scan_dir = async.wrap(function(path, opts, callback) 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)) + callback(filter_files(files, opts.max_count)) end, }) ) 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? @@ -587,6 +677,46 @@ M.ts_parse = async.wrap(function(parser, callback) end end, 2) +--- Wait for a user input +M.input = async.wrap(function(opts, callback) + local fn = function() + vim.ui.input(opts, function(input) + if input == nil or input == '' then + callback(nil) + return + end + + callback(input) + end) + end + + if vim.in_fast_event() then + vim.schedule(fn) + else + fn() + end +end, 2) + +--- Select an item from a list +M.select = async.wrap(function(choices, opts, callback) + local fn = function() + vim.ui.select(choices, opts, function(item) + if item == nil or item == '' then + callback(nil) + return + end + + callback(item) + end) + end + + if vim.in_fast_event() then + vim.schedule(fn) + else + fn() + end +end, 3) + --- Get the info for a key. ---@param name string ---@param key table @@ -770,40 +900,4 @@ function M.glob_to_pattern(g) return p end ----@class CopilotChat.Diagnostic ----@field content string ----@field start_line number ----@field end_line number ----@field severity string - ---- Get diagnostics in a given range ---- @param bufnr number ---- @param start_line number? ---- @param end_line number? ---- @return table|nil -function M.diagnostics(bufnr, start_line, end_line) - local diagnostics = vim.diagnostic.get(bufnr) - local range_diagnostics = {} - local severity = { - [1] = 'ERROR', - [2] = 'WARNING', - [3] = 'INFORMATION', - [4] = 'HINT', - } - - for _, diagnostic in ipairs(diagnostics) do - local lnum = diagnostic.lnum + 1 - if (not start_line or lnum >= start_line) and (not end_line or lnum <= end_line) then - table.insert(range_diagnostics, { - severity = severity[diagnostic.severity], - content = diagnostic.message, - start_line = lnum, - end_line = diagnostic.end_lnum and diagnostic.end_lnum + 1 or lnum, - }) - end - end - - return #range_diagnostics > 0 and range_diagnostics or nil -end - return M diff --git a/plugin/CopilotChat.lua b/plugin/CopilotChat.lua index 5674d6bc..de5e0158 100644 --- a/plugin/CopilotChat.lua +++ b/plugin/CopilotChat.lua @@ -8,14 +8,29 @@ if vim.fn.has('nvim-' .. min_version) ~= 1 then return end +local group = vim.api.nvim_create_augroup('CopilotChat', {}) + -- Setup highlights -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, 'CopilotChatKeyword', { link = 'Keyword', default = true }) -vim.api.nvim_set_hl(0, 'CopilotChatInput', { link = 'Special', default = true }) -vim.api.nvim_set_hl(0, 'CopilotChatSelection', { link = 'Visual', default = true }) -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 }) +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, 'CopilotChatStatus', { link = 'DiagnosticHint', default = true }) + vim.api.nvim_set_hl(0, 'CopilotChatHelp', { link = 'DiagnosticInfo', default = true }) + vim.api.nvim_set_hl(0, 'CopilotChatKeyword', { link = 'Keyword', 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 + local bg = vim.api.nvim_get_hl(0, { name = 'CopilotChatAnnotation', link = false }).bg + vim.api.nvim_set_hl(0, 'CopilotChatAnnotationHeader', { fg = fg, bg = bg }) +end +vim.api.nvim_create_autocmd('ColorScheme', { + group = group, + callback = function() + setup_highlights() + end, +}) +setup_highlights() -- Setup commands vim.api.nvim_create_user_command('CopilotChat', function(args) @@ -39,10 +54,6 @@ vim.api.nvim_create_user_command('CopilotChatModels', function() local chat = require('CopilotChat') chat.select_model() end, { force = true }) -vim.api.nvim_create_user_command('CopilotChatAgents', function() - local chat = require('CopilotChat') - chat.select_agent() -end, { force = true }) vim.api.nvim_create_user_command('CopilotChatOpen', function() local chat = require('CopilotChat') chat.open() @@ -90,7 +101,7 @@ end, { nargs = '*', force = true, complete = complete_load }) -- with "rooter" plugins, LSP and stuff as vim.fn.getcwd() when -- i pass window number inside doesnt work vim.api.nvim_create_autocmd({ 'VimEnter', 'WinEnter', 'DirChanged' }, { - group = vim.api.nvim_create_augroup('CopilotChat', {}), + group = group, callback = function() vim.w.cchat_cwd = vim.fn.getcwd() end, From 32de1bbfacfb7905afb521c7ea22deaa92442d1e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 28 Jul 2025 00:22:08 +0000 Subject: [PATCH 255/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 312 +++++++++++++++----------------------------- 1 file changed, 106 insertions(+), 206 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index e5863a11..f8e9ea04 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 24 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 28 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -14,8 +14,8 @@ Table of Contents *CopilotChat-table-of-contents* - Commands |CopilotChat-commands| - Key Mappings |CopilotChat-key-mappings| - Prompts |CopilotChat-prompts| - - Models and Agents |CopilotChat-models-and-agents| - - Contexts |CopilotChat-contexts| + - Models |CopilotChat-models| + - Functions |CopilotChat-functions| - Selections |CopilotChat-selections| - Providers |CopilotChat-providers| 4. Configuration |CopilotChat-configuration| @@ -36,14 +36,14 @@ Table of Contents *CopilotChat-table-of-contents* CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: -- 🤖 GitHub Copilot Chat integration with official model and agent support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) +- 🤖 GitHub Copilot Chat integration with official model support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) - 💻 Rich workspace context powered by smart embeddings system -- 🔒 Explicit context sharing - only sends what you specifically request, either as context or selection (by default visual selection) -- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, LM Studio, Mistral.ai and more) +- 🔒 Explicit data sharing - only sends what you specifically request, either as resource or selection (by default visual selection) +- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, Gemini, Mistral.ai and more) - 📝 Interactive chat UI with completion, diffs and quickfix integration - 🎯 Powerful prompt system with composable templates and sticky prompts -- 🔄 Extensible context providers for granular workspace understanding (buffers, files, git diffs, URLs, and more) -- ⚡ Efficient token usage with tiktoken token counting and memory management +- 🔄 Extensible function calling system for granular workspace understanding (buffers, files, git diffs, URLs, and more) +- ⚡ Efficient token usage with tiktoken token counting and history management ============================================================================== @@ -85,8 +85,7 @@ Plugin features that use picker: - `:CopilotChatPrompts` - for selecting prompts - `:CopilotChatModels` - for selecting models -- `:CopilotChatAgents` - for selecting agents -- `#:` - for selecting context input +- `#:` - for selecting function input ============================================================================== @@ -103,7 +102,7 @@ LAZY.NVIM *CopilotChat-lazy.nvim* { "github/copilot.vim" }, -- or zbirenbaum/copilot.lua { "nvim-lua/plenary.nvim", branch = "master" }, -- for curl, log and async functions }, - build = "make tiktoken", -- Only on MacOS or Linux + build = "make tiktoken", opts = { -- See Configuration section for options }, @@ -181,7 +180,6 @@ Commands are used to control the chat interface: :CopilotChatLoad ? Load chat history :CopilotChatPrompts View/select prompt templates :CopilotChatModels View/select available models - :CopilotChatAgents View/select available agents :CopilotChat Use specific prompt template KEY MAPPINGS *CopilotChat-key-mappings* @@ -292,7 +290,7 @@ Define your own system prompts in the configuration (similar to `prompts`): STICKY PROMPTS ~ Sticky prompts persist across chat sessions. They’re useful for maintaining -context or agent selection. They work as follows: +model or resource selection. They work as follows: 1. Prefix text with `>` using markdown blockquote syntax 2. The prompt will be copied at the start of every new chat prompt @@ -301,7 +299,7 @@ context or agent selection. They work as follows: Examples: >markdown - > #files + > #glob:`*.lua` > List all files in the workspace > @models Using Mistral-small @@ -313,17 +311,13 @@ You can also set default sticky prompts in the configuration: >lua { sticky = { - '@models Using Mistral-small', - '#files', + '#glob:*.lua', } } < -MODELS AND AGENTS *CopilotChat-models-and-agents* - - -MODELS ~ +MODELS *CopilotChat-models* You can control which AI model to use in three ways: @@ -337,72 +331,84 @@ For supported models, see: - GitHub Marketplace Models (experimental, limited usage) -AGENTS ~ +FUNCTIONS *CopilotChat-functions* + +Functions provide additional information and behaviour to the chat. Tools can +be organized into groups by setting the `group` property. Tools assigned to a +group are not automatically made available to the LLM - they must be explicitly +activated. To use grouped tools in your prompt, include `@group_name` in your +message. This allows the LLM to access and use all tools in that group during +the current interaction. Add tools using `#tool_name[:input]` syntax: -Agents determine the AI assistant’s capabilities. Control agents in three -ways: + -------------------------------------------------------------------------- + Function Input Description + Support + ------------- ------------ ----------------------------------------------- + buffer ✓ (name) Retrieves content from a specific buffer -1. List available agents with `:CopilotChatAgents` -2. Set agent in prompt with `@agent_name` -3. Configure default agent via `agent` config key + buffers ✓ (scope) Fetches content from multiple buffers + (listed/visible) -The default "noop" agent is `none`. For more information: + diagnostics ✓ (scope) Collects code diagnostics (errors, warnings) -- Extension Agents Documentation -- Available Agents + file ✓ (path) Reads content from a specified file path + gitdiff ✓ (sha) Retrieves git diff information + (unstaged/staged/sha) -CONTEXTS *CopilotChat-contexts* + gitstatus - Retrieves git status information -Contexts provide additional information to the chat. Add context using -`#context_name[:input]` syntax: + glob ✓ (pattern) Lists filenames matching a pattern in workspace - Context Input Support Description - ----------- --------------- ------------------------------------- - buffer ✓ (number) Current or specified buffer content - buffers ✓ (type) All buffers content (listed/all) - file ✓ (path) Content of specified file - files ✓ (glob) Workspace files - filenames ✓ (glob) Workspace file names - git ✓ (ref) Git diff (unstaged/staged/commit) - url ✓ (url) Content from URL - register ✓ (name) Content of vim register - quickfix - Quickfix list file contents - system ✓ (command) Output of shell command + grep ✓ (pattern) Searches for a pattern across files in + workspace - [!TIP] The AI is aware of these context providers and may request additional - context if needed by asking you to input a specific context command like - `#file:path/to/file.js`. + quickfix - Includes content of files in quickfix list + + register ✓ (register) Provides access to specified Vim register + + url ✓ (url) Fetches content from a specified URL + -------------------------------------------------------------------------- Examples: >markdown - > #buffer - > #buffer:2 - > #files:\*.lua - > #filenames + > #buffer:init.lua + > #buffers:visible + > #diagnostics:current + > #file:path/to/file.js > #git:staged + > #glob:`**/*.lua` + > #grep:`function setup` + > #quickfix + > #register:+ > #url:https://example.com - > #system:`ls -la | grep lua` < -Define your own contexts in the configuration with input handling and -resolution: +Define your own functions in the configuration with input handling and schema: >lua { - contexts = { + functions = { birthday = { - input = function(callback) - vim.ui.select({ 'user', 'napoleon' }, { - prompt = 'Select birthday> ', - }, callback) - end, + description = "Retrieves birthday information for a person", + uri = "birthday://{name}", + schema = { + type = 'object', + required = { 'name' }, + properties = { + name = { + type = 'string', + enum = { 'Alice', 'Bob', 'Charlie' }, + description = "Person's name", + }, + }, + }, resolve = function(input) return { { - content = input .. ' birthday info', - filename = input .. '_birthday', - filetype = 'text', + uri = 'birthday://' .. input.name, + mimetype = 'text/plain', + data = input.name .. ' birthday info', } } end @@ -412,10 +418,10 @@ resolution: < -EXTERNAL CONTEXTS ~ +EXTERNAL FUNCTIONS ~ -For external contexts, see the contexts discussion page -. +For external functions implementations, see the discussion page +. SELECTIONS *CopilotChat-selections* @@ -481,9 +487,6 @@ Custom providers can implement these methods: -- Optional: Get available models get_models?(headers: table): table, - - -- Optional: Get available agents - get_agents?(headers: table): table, } < @@ -511,19 +514,17 @@ Below are all available configuration options with their default values: 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 $). - agent = 'copilot', -- Default agent to use, see ':CopilotChatAgents' for available agents (can be specified manually in prompt via @). - context = nil, -- Default context or array of contexts to use (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. + tools = nil, -- Default tool or array of tools (or groups) 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 >). - temperature = 0.1, -- GPT result temperature + temperature = 0.1, -- Result temperature headless = false, -- Do not write to chat buffer and use history (useful for using custom processing) - stream = nil, -- Function called when receiving stream updates (returned string is appended to the chat buffer) - callback = nil, -- Function called when full response is received (retuned string is stored to history) - remember_as_sticky = true, -- Remember model/agent/context as sticky prompts when asking questions + callback = nil, -- Function called when full response is received + remember_as_sticky = true, -- Remember model as sticky prompts when asking questions -- default selection -- see select.lua for implementation - selection = select.visual, + selection = require('CopilotChat.select').visual, -- default window options window = { @@ -541,9 +542,9 @@ Below are all available configuration options with their default values: }, show_help = true, -- Shows help message as virtual lines when waiting for user input + show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) - references_display = 'virtual', -- 'virtual', 'write', Display references in chat as virtual text or write to buffer auto_follow_cursor = true, -- Auto-follow cursor in chat 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 @@ -561,129 +562,29 @@ Below are all available configuration options with their default values: log_path = vim.fn.stdpath('state') .. '/CopilotChat.log', -- Default path to log file history_path = vim.fn.stdpath('data') .. '/copilotchat_history', -- Default path to stored history - question_header = '# User ', -- Header to use for user questions - answer_header = '# Copilot ', -- Header to use for AI answers - error_header = '# Error ', -- Header to use for errors + 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 + }, + separator = '───', -- Separator to use in chat -- default providers -- see config/providers.lua for implementation - providers = { - copilot = { - }, - github_models = { - }, - copilot_embeddings = { - }, - }, + providers = require('CopilotChat.config.providers'), - -- default contexts - -- see config/contexts.lua for implementation - contexts = { - buffer = { - }, - buffers = { - }, - file = { - }, - files = { - }, - git = { - }, - url = { - }, - register = { - }, - quickfix = { - }, - system = { - } - }, + -- default functions + -- see config/functions.lua for implementation + functions = require('CopilotChat.config.functions'), -- default prompts -- see config/prompts.lua for implementation - prompts = { - Explain = { - prompt = 'Write an explanation for the selected code as paragraphs of text.', - system_prompt = 'COPILOT_EXPLAIN', - }, - Review = { - prompt = 'Review the selected code.', - system_prompt = 'COPILOT_REVIEW', - }, - Fix = { - prompt = 'There is a problem in this code. Identify the issues and rewrite the code with fixes. Explain what was wrong and how your changes address the problems.', - }, - Optimize = { - prompt = 'Optimize the selected code to improve performance and readability. Explain your optimization strategy and the benefits of your changes.', - }, - Docs = { - prompt = 'Please add documentation comments to the selected code.', - }, - Tests = { - prompt = 'Please generate tests for my code.', - }, - 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.', - context = 'git:staged', - }, - }, + prompts = require('CopilotChat.config.prompts'), -- default mappings -- see config/mappings.lua for implementation - mappings = { - complete = { - insert = '', - }, - close = { - normal = 'q', - insert = '', - }, - reset = { - normal = '', - insert = '', - }, - submit_prompt = { - normal = '', - insert = '', - }, - toggle_sticky = { - normal = 'grr', - }, - clear_stickies = { - normal = 'grx', - }, - accept_diff = { - normal = '', - insert = '', - }, - jump_to_diff = { - normal = 'gj', - }, - quickfix_answers = { - normal = 'gqa', - }, - quickfix_diffs = { - normal = 'gqd', - }, - yank_diff = { - normal = 'gy', - register = '"', -- Default register to use for yanking - }, - show_diff = { - normal = 'gd', - full_diff = false, -- Show full diff instead of unified diff when showing diff window - }, - show_info = { - normal = 'gi', - }, - show_context = { - normal = 'gc', - }, - show_help = { - normal = 'gh', - }, - }, + mappings = require('CopilotChat.config.mappings'), } < @@ -719,8 +620,8 @@ Types of copilot highlights: - `CopilotChatStatus` - Status and spinner in chat buffer - `CopilotChatHelp` - Help messages in chat buffer (help, references) - `CopilotChatSelection` - Selection highlight in source buffer -- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, contexts) -- `CopilotChatInput` - Input highlight in chat buffer (for contexts) +- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) +- `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) ============================================================================== @@ -736,8 +637,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_context() -- Resolve context embeddings (WARN: async, requires plenary.async.run) - chat.resolve_agent() -- Resolve agent from prompt (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 @@ -755,10 +655,9 @@ CORE *CopilotChat-core* chat.get_selection() -- Get the current selection chat.set_selection(bufnr, start_line, end_line, clear) -- Set or clear selection - -- Prompt & Context Management + -- Prompt & Model Management chat.select_prompt(config) -- Open prompt selector with optional config chat.select_model() -- Open model selector - chat.select_agent() -- Open agent selector chat.prompts() -- Get all available prompts -- Completion @@ -787,10 +686,12 @@ You can also access the chat window UI methods through the `chat.chat` object: 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:add_message({ role, content }, replace) -- Add or replace a message in chat + window:add_sticky(sticky) -- Add sticky prompt to chat message + -- Content Management - window:get_prompt() -- Get current prompt from chat window - window:set_prompt(prompt) -- Set prompt in chat window - window:add_sticky(sticky) -- Add sticky prompt to chat window window:append(text) -- Append text to chat window window:clear() -- Clear chat window content window:finish() -- Finish writing to chat window @@ -800,9 +701,9 @@ 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_section() -- Get section closest to cursor - window:get_closest_block() -- Get code block closest to cursor - window:overlay(opts) -- Show overlay with specified options + 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 < @@ -811,19 +712,18 @@ EXAMPLE USAGE *CopilotChat-example-usage* >lua -- Open chat, ask a question and handle response require("CopilotChat").open() - require("CopilotChat").ask("Explain this code", { + require("CopilotChat").ask("#buffer Explain this code", { callback = function(response) vim.notify("Got response: " .. response:sub(1, 50) .. "...") return response end, - context = "buffer" }) -- Save and load chat history require("CopilotChat").save("my_debugging_session") require("CopilotChat").load("my_debugging_session") - -- Use custom context and model + -- Use custom sticky and model require("CopilotChat").ask("How can I optimize this?", { model = "gpt-4.1", context = {"buffer", "git:staged"} @@ -880,7 +780,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💻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💻 📖This project follows the all-contributors specification. Contributions of any kind are welcome! From b4b7f9c2bb34d43b18dbbe0a889881630e217bc3 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 04:30:14 +0200 Subject: [PATCH 256/589] fix: update to latest lua actions and update README (#1196) Signed-off-by: Tomas Slusny --- .github/workflows/ci.yml | 8 +++++--- README.md | 5 ++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5831a56..0a96b7f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,12 +53,14 @@ jobs: version: nightly - name: luajit - uses: leafo/gh-actions-lua@v10 + uses: leafo/gh-actions-lua@v11 with: - luaVersion: "luajit-openresty" + luaVersion: "luajit-2.1" - name: luarocks - uses: leafo/gh-actions-luarocks@v4 + uses: leafo/gh-actions-luarocks@v5 + with: + luarocksVersion: "3.12.2" - name: run test shell: bash diff --git a/README.md b/README.md index 6c6eb93f..0535af19 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,7 @@ Default mappings in the chat interface: | - | `gqd` | Add all diffs from chat to quickfix list | | - | `gy` | Yank nearest diff to register | | - | `gd` | Show diff between source and nearest diff | -| - | `gi` | Show info about current chat | -| - | `gc` | Show current chat context | +| - | `gc` | Show info about current chat | | - | `gh` | Show help message | The mappings can be customized by setting the `mappings` table in your configuration. Each mapping can have: @@ -648,7 +647,7 @@ require("CopilotChat").load("my_debugging_session") -- Use custom sticky and model require("CopilotChat").ask("How can I optimize this?", { model = "gpt-4.1", - context = {"buffer", "git:staged"} + sticky = {"#buffer", "#gitdiff:staged"} }) ``` From 84a5728b91970b162a116ad000c7084eeef4315c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 28 Jul 2025 02:30:31 +0000 Subject: [PATCH 257/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index f8e9ea04..d2567481 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -200,8 +200,7 @@ Default mappings in the chat interface: - gqd Add all diffs from chat to quickfix list - gy Yank nearest diff to register - gd Show diff between source and nearest diff - - gi Show info about current chat - - gc Show current chat context + - gc Show info about current chat - gh Show help message The mappings can be customized by setting the `mappings` table in your configuration. Each mapping can have: @@ -726,7 +725,7 @@ EXAMPLE USAGE *CopilotChat-example-usage* -- Use custom sticky and model require("CopilotChat").ask("How can I optimize this?", { model = "gpt-4.1", - context = {"buffer", "git:staged"} + sticky = {"#buffer", "#gitdiff:staged"} }) < From a9e2e657f5b5b8ec212283466038688d98ea4038 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 04:39:05 +0200 Subject: [PATCH 258/589] chore: allow manual dispatch on actions (#1197) Signed-off-by: Tomas Slusny --- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 4 ++-- .github/workflows/todo.yml | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a96b7f3..e722714f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: lint: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a809e59e..cc8398cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ name: Release on: push: - branches: - - main + branches: [main] + workflow_dispatch: permissions: contents: write diff --git a/.github/workflows/todo.yml b/.github/workflows/todo.yml index d70ff362..ebf3e56e 100644 --- a/.github/workflows/todo.yml +++ b/.github/workflows/todo.yml @@ -1,6 +1,7 @@ name: "Convert TODO to Issue" on: push: + branches: [main] workflow_dispatch: inputs: MANUAL_COMMIT_REF: From dd0616661505a3c4892ddcdb9517b720a74e59b8 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 05:28:13 +0200 Subject: [PATCH 259/589] fix(functions): properly handle multiple tool calls at once (#1198) Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 3 ++- lua/CopilotChat/ui/chat.lua | 29 ++++++++++++----------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 1f592349..c4da582e 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -926,9 +926,10 @@ function M.ask(prompt, config) for _, tool in ipairs(resolved_tools) do M.chat:add_message({ + id = tool.id, role = 'tool', tool_call_id = tool.id, - content = tool.result .. '\n', + content = '\n' .. tool.result .. '\n', }) end diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index f54e9435..0b1fdc32 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -397,22 +397,23 @@ end function Chat:add_message(message, replace) local current_message = self.messages[#self.messages] - local needs_header = false + local is_new = not current_message + or current_message.role ~= message.role + or (message.id and current_message.id ~= message.id) - -- Check if we need to add a header (role change or first message) - if not current_message or current_message.role ~= message.role then - needs_header = true - end - - -- Add appropriate header based on role - if needs_header then + 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() local header = self.headers[message.role] if current_message then header = '\n' .. header end + + table.insert(self.messages, message) 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:append('') self:render() @@ -432,15 +433,9 @@ function Chat:add_message(message, replace) end self:append('') - return - end - - -- Handle message content combining or creation - if current_message and current_message.role == message.role then - current_message.content = current_message.content .. message.content - self:append(message.content) else - table.insert(self.messages, message) + -- Append to the current message + current_message.content = current_message.content .. message.content self:append(message.content) end end @@ -561,7 +556,7 @@ function Chat:render() local current_block = nil local function parse_header(header, line) - return line:match('^' .. vim.pesc(header) .. '%(([%w%-]+)%)' .. vim.pesc(self.separator) .. '$') + return line:match('^' .. vim.pesc(header) .. '%(([^)]+)%)' .. vim.pesc(self.separator) .. '$') end for l, line in ipairs(lines) do From e0df6d1242af29b6262b0eb3e4248568c57c4b3e Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 05:34:36 +0200 Subject: [PATCH 260/589] fix(quickfix): use new chat messages instead of old chat sections for populating qf (#1199) Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 58 +++++++++++++++-------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index a527f0c3..bdcb93ce 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -263,18 +263,18 @@ return { normal = 'gqa', callback = function() local items = {} - for i, section in ipairs(copilot.chat.sections) do - if section.role == 'assistant' then - local prev_section = copilot.chat.sections[i - 1] + for i, message in ipairs(copilot.chat.messages) do + if message.section and message.role == 'assistant' then + local prev_message = copilot.chat.messages[i - 1] local text = '' - if prev_section then - text = prev_section.content + if prev_message then + text = prev_message.content end table.insert(items, { bufnr = copilot.chat.bufnr, - lnum = section.start_line, - end_lnum = section.end_line, + lnum = message.section.start_line, + end_lnum = message.section.end_line, text = text, }) end @@ -291,32 +291,34 @@ return { local selection = copilot.get_selection() local items = {} - for _, section in ipairs(copilot.chat.sections) do - for _, block in ipairs(section.blocks) do - local header = block.header + 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 + 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 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) - 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) + end - table.insert(items, { - bufnr = copilot.chat.bufnr, - lnum = block.start_line, - end_lnum = block.end_line, - text = text, - }) + 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') + vim.fn.setqflist(items) + vim.cmd('copen') + end end, }, From 946069a03946ce35619cbacc3a6757819d096ac5 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 16:45:55 +0200 Subject: [PATCH 261/589] fix(functions): properly resolve defaults for diagnostics (#1201) Closes #1200 Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/functions.lua | 4 ++-- lua/CopilotChat/functions.lua | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 07b11573..1644a03f 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -258,12 +258,12 @@ return { diagnostics = { group = 'copilot', - uri = 'neovim://diagnostics/{scope}', + uri = 'neovim://diagnostics/{scope}/{severity}', description = 'Collects code diagnostics (errors, warnings, etc.) from specified buffers. Helpful for troubleshooting and fixing code issues.', schema = { type = 'object', - required = { 'scope' }, + required = { 'scope', 'severity' }, properties = { scope = { type = 'string', diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index 8a28ca81..6cc287bb 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -136,8 +136,7 @@ function M.parse_input(input, schema) local prop_names = sorted_propnames(schema) -- Map input parts to schema properties in sorted order - local i = 1 - for _, prop_name in ipairs(prop_names) do + for i, prop_name in ipairs(prop_names) do local prop_schema = schema.properties[prop_name] local value = not utils.empty(parts[i]) and parts[i] or nil if value == nil and prop_schema.default ~= nil then @@ -145,10 +144,6 @@ function M.parse_input(input, schema) end result[prop_name] = value - i = i + 1 - if i > #parts then - break - end end return result From 6ac77aaa68a0ce7fe3c8c41622ab1986f8f6d2c7 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 17:09:55 +0200 Subject: [PATCH 262/589] feat(resources)!: add option to enable resource processing (#1202) This adds option to enable resource processing, disabled by default. BREAKING CHANGE: intelligent resource processing is now disabled by default, use config.resource_processing: true to reenable Signed-off-by: Tomas Slusny --- README.md | 4 +++- lua/CopilotChat/client.lua | 20 +++----------------- lua/CopilotChat/config.lua | 5 ++++- lua/CopilotChat/init.lua | 14 +++++++++----- 4 files changed, 19 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 0535af19..931ee8ab 100644 --- a/README.md +++ b/README.md @@ -445,10 +445,12 @@ Below are all available configuration options with their default values: tools = nil, -- Default tool or array of tools (or groups) 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 >). + 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 - remember_as_sticky = true, -- Remember model as sticky prompts when asking questions + remember_as_sticky = true, -- Remember config as sticky prompts when asking questions -- default selection -- see select.lua for implementation diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index b6460d0a..93143754 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -61,9 +61,7 @@ local class = utils.class --- Constants local RESOURCE_FORMAT = '# %s\n```%s\n%s\n```' local LINE_CHARACTERS = 100 -local BIG_FILE_THRESHOLD = 1000 * LINE_CHARACTERS local BIG_EMBED_THRESHOLD = 200 * LINE_CHARACTERS -local TRUNCATED = '... (truncated)' --- Resolve provider function ---@param model string @@ -103,16 +101,9 @@ end --- Generate content block with line numbers, truncating if necessary ---@param content string ----@param threshold number: The threshold for truncation ---@param start_line number?: The starting line number ---@return string -local function generate_content_block(content, threshold, start_line) - local total_chars = #content - if total_chars > threshold then - content = content:sub(1, threshold) - content = content .. '\n' .. TRUNCATED - end - +local function generate_content_block(content, start_line) if start_line ~= nil then local lines = vim.split(content, '\n') local total_lines = #lines @@ -144,12 +135,7 @@ local function generate_selection_message(selection) 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, BIG_FILE_THRESHOLD, selection.start_line) - ) + out = out .. string.format('```%s\n%s\n```', filetype, generate_content_block(content, selection.start_line)) return { content = out, @@ -167,7 +153,7 @@ local function generate_resource_messages(resources) return resource.data and resource.data ~= '' end) :map(function(resource) - local content = generate_content_block(resource.data, BIG_FILE_THRESHOLD, 1) + local content = generate_content_block(resource.data, 1) return { content = string.format(RESOURCE_FORMAT, resource.name, resource.type, content), diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 5e900219..c456a5b1 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -17,6 +17,7 @@ ---@field model string? ---@field tools string|table|nil ---@field sticky string|table|nil +---@field resource_processing boolean? ---@field temperature number? ---@field headless boolean? ---@field callback nil|fun(response: string, source: CopilotChat.source) @@ -57,10 +58,12 @@ return { tools = nil, -- Default tool or array of tools (or groups) 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 >). + 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 - remember_as_sticky = true, -- Remember model as sticky prompts when asking questions + remember_as_sticky = true, -- Remember config as sticky prompts when asking questions -- default selection selection = require('CopilotChat.select').visual, diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index c4da582e..426ccad3 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -903,11 +903,15 @@ function M.ask(prompt, config) local ok, err = pcall(async.run, function() local selected_tools, resolved_resources, resolved_tools, prompt = M.resolve_functions(prompt, config) local selected_model, prompt = M.resolve_model(prompt, config) - 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) + + 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) From 885801c69f812dbe89c719760f0bca2e51ab060c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 28 Jul 2025 15:10:11 +0000 Subject: [PATCH 263/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index d2567481..5e852bdf 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -516,10 +516,12 @@ Below are all available configuration options with their default values: tools = nil, -- Default tool or array of tools (or groups) 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 >). + 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 - remember_as_sticky = true, -- Remember model as sticky prompts when asking questions + remember_as_sticky = true, -- Remember config as sticky prompts when asking questions -- default selection -- see select.lua for implementation From 9d9b2809e1240f9525752ae145799b88d22cd7af Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Mon, 28 Jul 2025 20:58:37 +0200 Subject: [PATCH 264/589] feat(ui): improve chat responsiveness by starting spinner early (#1205) Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 15 ++------------- lua/CopilotChat/ui/chat.lua | 35 ++++++++++++++++++++--------------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 426ccad3..4e00344b 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -729,19 +729,6 @@ function M.open(config) utils.return_to_normal_mode() M.chat:open(config) - - local message = M.chat:get_message('user') - if message then - local prompt = insert_sticky(message.content, config) - if prompt then - M.chat:add_message({ - role = 'user', - content = '\n' .. prompt, - }, true) - M.chat:finish() - end - end - M.chat:follow() M.chat:focus() end @@ -869,6 +856,8 @@ function M.ask(prompt, config) M.open(config) end + M.chat:start() + local sticky = {} local in_code_block = false for _, line in ipairs(vim.split(prompt, '\n')) do diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 0b1fdc32..669dfa6c 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -382,6 +382,21 @@ function Chat:follow() vim.api.nvim_win_set_cursor(self.winnr, { last_line + 1, last_column }) end +--- Prepare the chat window for writing. +function Chat:start() + self:validate() + + if self:focused() then + utils.return_to_normal_mode() + end + + if self.spinner then + self.spinner:start() + end + + vim.bo[self.bufnr].modifiable = false +end + --- Finish writing to the chat window. function Chat:finish() if not self.spinner then @@ -414,9 +429,7 @@ function Chat:add_message(message, replace) self:append(message.content) elseif replace and current_message then -- Replace the content of the current message - self:append('') self:render() - current_message.content = message.content local section = current_message.section @@ -430,9 +443,8 @@ function Chat:add_message(message, replace) vim.split(message.content, '\n') ) vim.bo[self.bufnr].modifiable = false + self:append('') end - - self:append('') else -- Append to the current message current_message.content = current_message.content .. message.content @@ -476,15 +488,6 @@ end ---@param str string function Chat:append(str) self:validate() - vim.bo[self.bufnr].modifiable = true - - if self:focused() then - utils.return_to_normal_mode() - end - - if self.spinner then - self.spinner:start() - end -- Decide if we should follow cursor after appending text. local should_follow_cursor = self.config.auto_follow_cursor @@ -496,13 +499,14 @@ function Chat:append(str) end local last_line, last_column, _ = self:last() + + vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_text(self.bufnr, last_line, last_column, last_line, last_column, vim.split(str, '\n')) + vim.bo[self.bufnr].modifiable = false if should_follow_cursor then self:follow() end - - vim.bo[self.bufnr].modifiable = false end --- Clear the chat window. @@ -548,6 +552,7 @@ end --- Render the chat window. ---@protected function Chat:render() + self:validate() vim.api.nvim_buf_clear_namespace(self.bufnr, self.header_ns, 0, -1) local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) From dab50896c7e1e80142dd297e6fc75590735b3e9c Mon Sep 17 00:00:00 2001 From: Danilo Horta Date: Mon, 28 Jul 2025 21:58:46 +0100 Subject: [PATCH 265/589] fix: update sticky reference for commit messages (#1207) Changes the commit prompt's sticky reference from '#git:staged' to '#gitdiff:staged' to properly match the expected format for retrieving staged changes when generating commit messages. --- 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 9fca7d8e..2b56bdda 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -204,6 +204,6 @@ return { 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 = '#git:staged', + sticky = '#gitdiff:staged', }, } From cbb846f9c3979bc8fe5dc7589cdab542adcd224b Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 23:00:00 +0200 Subject: [PATCH 266/589] docs: add danilohorta as a contributor for code (#1208) * 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 d335de60..3e77a246 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -424,6 +424,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/42291930?v=4", "profile": "https://github.com/AtifChy", "contributions": ["code", "doc"] + }, + { + "login": "danilohorta", + "name": "Danilo Horta", + "avatar_url": "https://avatars.githubusercontent.com/u/214497460?v=4", + "profile": "https://github.com/danilohorta", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 931ee8ab..c093a813 100644 --- a/README.md +++ b/README.md @@ -777,6 +777,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Fredrik Averpil
Fredrik Averpil

💻 Aaron D Borden
Aaron D Borden

💻 Md. Iftakhar Awal Chowdhury
Md. Iftakhar Awal Chowdhury

💻 📖 + Danilo Horta
Danilo Horta

💻 From e632470171cd82a95c2675360120833c159e7ae0 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 29 Jul 2025 00:48:01 +0200 Subject: [PATCH 267/589] fix(functions): if enum returns only 1 choice auto accept it (#1209) Signed-off-by: Tomas Slusny --- lua/CopilotChat/functions.lua | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index 6cc287bb..c23957e5 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -166,9 +166,14 @@ function M.enter_input(schema, source) if not schema.required or vim.tbl_contains(schema.required, prop_name) then if cfg.enum then local choices = type(cfg.enum) == 'table' and cfg.enum or cfg.enum(source) - local choice = utils.select(choices, { - prompt = string.format('Select %s> ', prop_name), - }) + local choice + if #choices == 1 then + choice = choices[1] + else + choice = utils.select(choices, { + prompt = string.format('Select %s> ', prop_name), + }) + end table.insert(out, choice or '') elseif cfg.type == 'boolean' then From 4c80c0bbb7d3018908ee99165f3a43cc582aeec4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 28 Jul 2025 22:48:23 +0000 Subject: [PATCH 268/589] 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 5e852bdf..aaaa5e72 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -781,7 +781,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💻 📖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💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 1d6911fef13952c9b56347485f090baeff77a7e4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 29 Jul 2025 09:53:21 +0200 Subject: [PATCH 269/589] fix: add back sticky loading on opening window (#1210) Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 4e00344b..6e2de658 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -729,6 +729,19 @@ function M.open(config) utils.return_to_normal_mode() M.chat:open(config) + + local message = M.chat:get_message('user') + if message then + local prompt = insert_sticky(message.content, config) + if prompt then + M.chat:add_message({ + role = 'user', + content = '\n' .. prompt, + }, true) + M.chat:finish() + end + end + M.chat:follow() M.chat:focus() end From bfd1e22216146546ca3b9c2eb8f7afd220998601 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 29 Jul 2025 07:53:38 +0000 Subject: [PATCH 270/589] 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 aaaa5e72..799f0f62 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 28 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 29 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 8a5cda1d90c4d4756dda39cfd748e52cbcde5a99 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 29 Jul 2025 11:27:37 +0200 Subject: [PATCH 271/589] fix(functions): if schema.properties is empty, do not send schema (#1211) This breaks stuff especially with github mcp server Signed-off-by: Tomas Slusny --- lua/CopilotChat/functions.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index c23957e5..c9d8488b 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -48,10 +48,16 @@ local function filter_schema(tbl) return tbl end + if utils.empty(tbl.properties) then + return nil + end + local result = {} for k, v in pairs(tbl) do - if type(v) ~= 'function' and k ~= 'examples' then - result[k] = type(v) == 'table' and filter_schema(v) or v + 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 + end end end return result From d905917a025e4c056db28b3082dd474475bad8cd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 29 Jul 2025 12:35:07 +0200 Subject: [PATCH 272/589] fix(functions): properly escape percent signs in uri inputs (#1212) Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 6e2de658..9900e62f 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -366,9 +366,12 @@ function M.resolve_functions(prompt, config) -- Resolve and process all tools for _, pattern in ipairs(matches:keys()) do - local match = matches:get(pattern) - local out = expand_tool(match.word, match.input) or pattern - prompt = prompt:gsub(vim.pesc(pattern), out, 1) + if not utils.empty(pattern) then + local match = matches:get(pattern) + local out = expand_tool(match.word, match.input) or pattern + out = out:gsub('%%', '%%%%') -- Escape percent signs for gsub + prompt = prompt:gsub(vim.pesc(pattern), out, 1) + end end return functions.parse_tools(enabled_tools), resolved_resources, resolved_tools, prompt From c87f6af0a15cfdaf09826d6fdf5f15ef6a0f782f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 29 Jul 2025 17:21:39 +0200 Subject: [PATCH 273/589] chore: unify client:models function and improve variable naming (#1213) Signed-off-by: Tomas Slusny --- lua/CopilotChat/client.lua | 40 +++++++++----------------------------- lua/CopilotChat/init.lua | 32 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 93143754..b0a48b85 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -216,15 +216,13 @@ end ---@class CopilotChat.client.Client : Class ---@field private providers table ---@field private provider_cache table ----@field private models table? +---@field private model_cache table? ---@field private current_job string? ----@field private headers table? local Client = class(function(self) self.providers = {} self.provider_cache = {} - self.models = nil + self.model_cache = nil self.current_job = nil - self.headers = nil end) --- Authenticate with GitHub and get the required headers @@ -246,9 +244,9 @@ end --- Fetch models from the Copilot API ---@return table -function Client:fetch_models() - if self.models then - return self.models +function Client:models() + if self.model_cache then + return self.model_cache end local models = {} @@ -282,8 +280,8 @@ function Client:fetch_models() end log.debug('Fetched models:', #vim.tbl_keys(models)) - self.models = models - return self.models + self.model_cache = models + return self.model_cache end --- Ask a question to Copilot @@ -299,7 +297,7 @@ function Client:ask(prompt, opts) log.debug('Resources:', #opts.resources) log.debug('History:', #opts.history) - local models = self:fetch_models() + local models = self:models() local model_config = models[opts.model] if not model_config then error('Model not found: ' .. opts.model) @@ -573,26 +571,6 @@ function Client:ask(prompt, opts) } end ---- List available models ----@return table -function Client:list_models() - local models = self:fetch_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 - --- Generate embeddings for the given inputs ---@param inputs table: The inputs to embed ---@param model string @@ -603,7 +581,7 @@ function Client:embed(inputs, model) return inputs end - local models = self:fetch_models() + 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 diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 9900e62f..9ef35e85 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -120,6 +120,26 @@ local function update_highlights() end 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 + --- Finish writing to chat buffer. ---@param start_of_chat boolean? local function finish(start_of_chat) @@ -284,8 +304,8 @@ function M.resolve_functions(prompt, config) }) end - -- Resolve each tool reference - local function expand_tool(name, input) + -- Resolve each function reference + local function expand_function(name, input) notify.publish(notify.STATUS, 'Running function: ' .. name) local tool_id = nil @@ -368,7 +388,7 @@ function M.resolve_functions(prompt, config) for _, pattern in ipairs(matches:keys()) do if not utils.empty(pattern) then local match = matches:get(pattern) - local out = expand_tool(match.word, match.input) or pattern + local out = expand_function(match.word, match.input) or pattern out = out:gsub('%%', '%%%%') -- Escape percent signs for gsub prompt = prompt:gsub(vim.pesc(pattern), out, 1) end @@ -440,7 +460,7 @@ function M.resolve_model(prompt, config) local models = vim.tbl_map(function(model) return model.id - end, client:list_models()) + end, list_models()) local selected_model = config.model or '' prompt = prompt:gsub('%$' .. WORD, function(match) @@ -600,7 +620,7 @@ end ---@return table ---@async function M.complete_items() - local models = client:list_models() + local models = list_models() local prompts_to_use = M.prompts() local items = {} @@ -767,7 +787,7 @@ end --- Select default Copilot GPT model. function M.select_model() async.run(function() - local models = client:list_models() + local models = list_models() local choices = vim.tbl_map(function(model) return { id = model.id, From b738fb40de3a4bcbb835b8ff6ab2d171acc5d2dd Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 29 Jul 2025 18:05:29 +0200 Subject: [PATCH 274/589] fix: check for explicit uri input properly (#1214) Previous check for empty schema broke this, the check should be done only after input type is checked Signed-off-by: Tomas Slusny --- lua/CopilotChat/functions.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index c9d8488b..dbcf5bcd 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -129,14 +129,14 @@ end ---@param schema table? ---@return table function M.parse_input(input, schema) - if not schema or not schema.properties then - return {} - end - if type(input) == 'table' then return input end + if not schema or not schema.properties then + return {} + end + local parts = vim.split(input or '', INPUT_SEPARATOR) local result = {} local prop_names = sorted_propnames(schema) From 450fcecf2f71d0469e9c98f5967252092714ed03 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Tue, 29 Jul 2025 21:04:45 +0200 Subject: [PATCH 275/589] feat: display group as kind when listing resources (#1215) Signed-off-by: Tomas Slusny --- 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 9ef35e85..72155fbd 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -702,7 +702,7 @@ function M.complete_items() items[#items + 1] = { word = '#' .. tool.name, abbr = tool.name, - kind = 'resource', + kind = M.config.functions[tool.name].group or 'resource', info = info, menu = uri, icase = 1, From 9c4501e7ae92020f2d9b828086016ee70e7fa52c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 10:27:02 +0200 Subject: [PATCH 276/589] feat(providers)!: new github models api, in-built authorization without copilot.vim dep (#1218) - Switch to new github models api. This brings device code as prerequisite so add support for retrieving device code as well - With device code flow, add support for it for regular copilot auth as well - Disable github_models provider by default as now it requires device code flow BREAKING CHANGE: github_models provider is now disabled by default, enable with `providers.github_models.disabled = false` Closes #1140 Signed-off-by: Tomas Slusny --- README.md | 11 +- lua/CopilotChat/config/providers.lua | 212 ++++++++++++++++++--------- lua/CopilotChat/init.lua | 1 + lua/CopilotChat/notify.lua | 1 + lua/CopilotChat/ui/chat.lua | 6 + lua/CopilotChat/utils.lua | 23 +++ 6 files changed, 178 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index c093a813..5daa432f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - [Neovim 0.10.0+](https://neovim.io/) - Older versions are not officially supported - [curl](https://curl.se/) - Version 8.0.0+ recommended for best compatibility - [Copilot chat in the IDE](https://github.com/settings/copilot) enabled in GitHub settings +- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) - Plugin dependency > [!WARNING] > For Neovim < 0.11.0, add `noinsert` or `noselect` to your `completeopt` otherwise chat autocompletion will not work. @@ -39,6 +40,8 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities ## Optional Dependencies +- [copilot.vim](https://github.com/github/copilot.vim) - For `:Copilot setup` authorization, otherwise in-built method i used + - [tiktoken_core](https://github.com/gptlang/lua-tiktoken) - For accurate token counting - Arch Linux: Install [`luajit-tiktoken-bin`](https://aur.archlinux.org/packages/luajit-tiktoken-bin) or [`lua51-tiktoken-bin`](https://aur.archlinux.org/packages/lua51-tiktoken-bin) from AUR - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` @@ -72,7 +75,6 @@ return { { "CopilotC-Nvim/CopilotChat.nvim", dependencies = { - { "github/copilot.vim" }, -- or zbirenbaum/copilot.lua { "nvim-lua/plenary.nvim", branch = "master" }, -- for curl, log and async functions }, build = "make tiktoken", @@ -92,7 +94,6 @@ Similar to the lazy setup, you can use the following configuration: ```vim call plug#begin() -Plug 'github/copilot.vim' Plug 'nvim-lua/plenary.nvim' Plug 'CopilotC-Nvim/CopilotChat.nvim' call plug#end() @@ -112,9 +113,7 @@ EOF mkdir -p ~/.config/nvim/pack/copilotchat/start cd ~/.config/nvim/pack/copilotchat/start -git clone https://github.com/github/copilot.vim git clone https://github.com/nvim-lua/plenary.nvim - git clone https://github.com/CopilotC-Nvim/CopilotChat.nvim ``` @@ -392,8 +391,8 @@ Providers are modules that implement integration with different AI providers. ### Built-in Providers - `copilot` - Default GitHub Copilot provider used for chat -- `github_models` - Provider for GitHub Marketplace models -- `copilot_embeddings` - Provider for Copilot embeddings, not standalone +- `github_models` - Provider for GitHub Marketplace models (disabled by default, enable it via `providers.github_models.disabled = false`) +- `copilot_embeddings` - Provider for Copilot embeddings, not standalone, used by `copilot` and `github_models` providers ### Provider Interface diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 65619117..0e406bd6 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -1,70 +1,160 @@ +local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') +local plenary_utils = require('plenary.async.util') local EDITOR_VERSION = 'Neovim/' .. vim.version().major .. '.' .. vim.version().minor .. '.' .. vim.version().patch -local cached_github_token = nil +local token_cache = nil +local unsaved_token_cache = {} +local function load_tokens() + if token_cache then + return token_cache + end + + 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) + if file then + token_cache = vim.json.decode(file) + else + token_cache = {} + end + + return token_cache +end + +local function get_token(tag) + if unsaved_token_cache[tag] then + return unsaved_token_cache[tag] + end + + local tokens = load_tokens() + return tokens[tag] +end -local function config_path() - local config = vim.fs.normalize('$XDG_CONFIG_HOME') - if config and vim.uv.fs_stat(config) then - return config +local function set_token(tag, token, save) + if not save then + unsaved_token_cache[tag] = token + return token end - if vim.fn.has('win32') > 0 then - config = vim.fs.normalize('$LOCALAPPDATA') - if not config or not vim.uv.fs_stat(config) then - config = vim.fs.normalize('$HOME/AppData/Local') + + 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)) + return token +end + +--- Get the github token using device flow +---@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', { + body = { + client_id = client_id, + scope = scope, + }, + headers = { ['Accept'] = 'application/json' }, + }) + + local data = vim.json.decode(res.body) + return data + end + + local function poll_for_token(device_code, interval) + while true do + plenary_utils.sleep(interval * 1000) + + local res = utils.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 end - else - config = vim.fs.normalize('$HOME/.config') end - if config and vim.uv.fs_stat(config) then - return config + + local token = get_token(tag) + if token then + return token end + + local code_data = request_device_code() + notify.publish( + notify.MESSAGE, + '[' .. tag .. '] Visit ' .. code_data.verification_uri .. ' and enter code: ' .. code_data.user_code + ) + notify.publish(notify.STATUS, '[' .. tag .. '] Waiting for GitHub models authorization...') + token = poll_for_token(code_data.device_code, code_data.interval) + return set_token(tag, token, true) end --- Get the github copilot oauth cached token (gu_ token) ---@return string -local function get_github_token() - if cached_github_token then - return cached_github_token +local function get_github_token(tag) + local function config_path() + local config = vim.fs.normalize('$XDG_CONFIG_HOME') + if config and vim.uv.fs_stat(config) then + return config + end + if vim.fn.has('win32') > 0 then + config = vim.fs.normalize('$LOCALAPPDATA') + if not config or not vim.uv.fs_stat(config) then + config = vim.fs.normalize('$HOME/AppData/Local') + end + else + config = vim.fs.normalize('$HOME/.config') + end + if config and vim.uv.fs_stat(config) then + return config + end + end + + local token = get_token(tag) + if token then + return token end -- loading token from the environment only in GitHub Codespaces - local token = os.getenv('GITHUB_TOKEN') local codespaces = os.getenv('CODESPACES') + token = os.getenv('GITHUB_TOKEN') if token and codespaces then - cached_github_token = token - return token + return set_token(tag, token, false) end -- loading token from the file local config_path = config_path() - if not config_path then - error('Failed to find config path for GitHub token') - end + if config_path then + -- token can be sometimes in apps.json sometimes in hosts.json + local file_paths = { + config_path .. '/github-copilot/hosts.json', + config_path .. '/github-copilot/apps.json', + } - -- token can be sometimes in apps.json sometimes in hosts.json - local file_paths = { - config_path .. '/github-copilot/hosts.json', - config_path .. '/github-copilot/apps.json', - } - - for _, file_path in ipairs(file_paths) do - local file_data = utils.read_file(file_path) - if file_data then - local parsed_data = utils.json_decode(file_data) - if parsed_data then - for key, value in pairs(parsed_data) do - if string.find(key, 'github.com') then - cached_github_token = value.oauth_token - return value.oauth_token + for _, file_path in ipairs(file_paths) do + local file_data = utils.read_file(file_path) + if file_data then + local parsed_data = utils.json_decode(file_data) + if parsed_data then + for key, value in pairs(parsed_data) do + if string.find(key, 'github.com') and value and value.oauth_token then + return set_token(tag, value.oauth_token, true) + end end end end end end - error('Failed to find GitHub token') + return github_device_flow(tag, 'Iv1.b507a08c87ecfe98', '') end ---@class CopilotChat.config.providers.Options @@ -97,7 +187,7 @@ M.copilot = { local response, err = utils.curl_get('https://api.github.com/copilot_internal/v2/token', { json_response = true, headers = { - ['Authorization'] = 'Token ' .. get_github_token(), + ['Authorization'] = 'Token ' .. get_github_token('copilot'), }, }) @@ -284,30 +374,19 @@ M.copilot = { } M.github_models = { + disabled = true, embed = 'copilot_embeddings', get_headers = function() return { - ['Authorization'] = 'Bearer ' .. get_github_token(), - ['x-ms-useragent'] = EDITOR_VERSION, - ['x-ms-user-agent'] = EDITOR_VERSION, + ['Authorization'] = 'Bearer ' .. github_device_flow('github_models', 'Ov23liqtJusaUH38tIoK', 'read:user copilot'), } end, get_models = function(headers) - local response, err = utils.curl_post('https://api.catalog.azureml.ms/asset-gallery/v1.0/models', { - headers = headers, - json_request = true, + local response, err = utils.curl_get('https://models.github.ai/catalog/models', { json_response = true, - body = { - filters = { - { field = 'freePlayground', values = { 'true' }, operator = 'eq' }, - { field = 'labels', values = { 'latest' }, operator = 'eq' }, - }, - order = { - { field = 'displayName', direction = 'asc' }, - }, - }, + headers = headers, }) if err then @@ -315,26 +394,19 @@ M.github_models = { end return vim - .iter(response.body.summaries) - :filter(function(model) - return vim.tbl_contains(model.inferenceTasks, 'chat-completion') - end) + .iter(response.body) :map(function(model) - local context_window = model.modelLimits.textLimits.inputContextWindow - local max_output_tokens = model.modelLimits.textLimits.maxOutputTokens - local max_input_tokens = context_window - max_output_tokens - if max_input_tokens <= 0 then - max_output_tokens = 4096 - max_input_tokens = context_window - max_output_tokens - end - + local max_output_tokens = model.limits.max_output_tokens + local max_input_tokens = model.limits.max_input_tokens return { - id = model.name, - name = model.displayName, + id = model.id, + name = model.name, tokenizer = 'o200k_base', max_input_tokens = max_input_tokens, max_output_tokens = max_output_tokens, - streaming = true, + streaming = vim.tbl_contains(model.capabilities, 'streaming'), + tools = vim.tbl_contains(model.capabilities, 'tool-calling'), + version = model.version, } end) :totable() @@ -344,7 +416,7 @@ M.github_models = { prepare_output = M.copilot.prepare_output, get_url = function() - return 'https://models.inference.ai.azure.com/chat/completions' + return 'https://models.github.ai/inference/chat/completions' end, } diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 72155fbd..71e80f3f 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -893,6 +893,7 @@ function M.ask(prompt, config) end M.chat:start() + M.chat:append('\n') local sticky = {} local in_code_block = false diff --git a/lua/CopilotChat/notify.lua b/lua/CopilotChat/notify.lua index db1af837..99aa499a 100644 --- a/lua/CopilotChat/notify.lua +++ b/lua/CopilotChat/notify.lua @@ -3,6 +3,7 @@ local log = require('plenary.log') local M = {} M.STATUS = 'status' +M.MESSAGE = 'message' M.listeners = {} diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 669dfa6c..bcd513fd 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 notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local class = utils.class @@ -95,6 +96,11 @@ local Chat = class(function(self, headers, separator, help, on_buf_create) end, }) end) + + notify.listen(notify.MESSAGE, function(msg) + utils.schedule_main() + self:append('\n' .. msg .. '\n') + end) end, Overlay) --- Returns whether the chat window is visible. diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 1cf1a151..b1400837 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -615,6 +615,29 @@ function M.read_file(path) 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 From f45a7ee8da7ef15fd88f8a83ea7b76b50c8d6eb2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Jul 2025 08:27:25 +0000 Subject: [PATCH 277/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 799f0f62..979b45fc 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 29 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 30 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -52,6 +52,7 @@ capabilities directly into your editor. It provides: - Neovim 0.10.0+ - Older versions are not officially supported - curl - Version 8.0.0+ recommended for best compatibility - Copilot chat in the IDE enabled in GitHub settings +- plenary.nvim - Plugin dependency [!WARNING] For Neovim < 0.11.0, add `noinsert` or `noselect` to your @@ -61,12 +62,16 @@ capabilities directly into your editor. It provides: OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* -- tiktoken_core - For accurate token counting +- copilot.vim - For `:Copilot setup` + authorization, otherwise in-built method i used +- tiktoken_core - For accurate token + counting - Arch Linux: Install `luajit-tiktoken-bin` or `lua51-tiktoken-bin` from AUR - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` - Manual: Download from lua-tiktoken releases and save as `tiktoken_core.so` in your Lua path - git - For git diff context features -- ripgrep - For improved search performance +- ripgrep - For improved search + performance - lynx - For improved URL context features @@ -99,7 +104,6 @@ LAZY.NVIM *CopilotChat-lazy.nvim* { "CopilotC-Nvim/CopilotChat.nvim", dependencies = { - { "github/copilot.vim" }, -- or zbirenbaum/copilot.lua { "nvim-lua/plenary.nvim", branch = "master" }, -- for curl, log and async functions }, build = "make tiktoken", @@ -121,7 +125,6 @@ Similar to the lazy setup, you can use the following configuration: >vim call plug#begin() - Plug 'github/copilot.vim' Plug 'nvim-lua/plenary.nvim' Plug 'CopilotC-Nvim/CopilotChat.nvim' call plug#end() @@ -142,9 +145,7 @@ MANUAL *CopilotChat-manual* mkdir -p ~/.config/nvim/pack/copilotchat/start cd ~/.config/nvim/pack/copilotchat/start - git clone https://github.com/github/copilot.vim git clone https://github.com/nvim-lua/plenary.nvim - git clone https://github.com/CopilotC-Nvim/CopilotChat.nvim < @@ -456,8 +457,8 @@ Providers are modules that implement integration with different AI providers. BUILT-IN PROVIDERS ~ - `copilot` - Default GitHub Copilot provider used for chat -- `github_models` - Provider for GitHub Marketplace models -- `copilot_embeddings` - Provider for Copilot embeddings, not standalone +- `github_models` - Provider for GitHub Marketplace models (disabled by default, enable it via `providers.github_models.disabled = false`) +- `copilot_embeddings` - Provider for Copilot embeddings, not standalone, used by `copilot` and `github_models` providers PROVIDER INTERFACE ~ From d9f4e29c3b46b827443b1832209d22d05c1a69af Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 10:34:23 +0200 Subject: [PATCH 278/589] fix(healthcheck): chance copilot.vim dependency to optional (#1219) Signed-off-by: Tomas Slusny --- lua/CopilotChat/health.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index cf1e568a..a75416ee 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -98,8 +98,8 @@ function M.check() if has_copilot or copilot_loaded then ok('copilot: ' .. (has_copilot and 'copilot.lua' or 'copilot.vim')) else - error( - 'copilot: missing, required for 2 factor authentication. Install "github/copilot.vim" or "zbirenbaum/copilot.lua" plugins.' + warn( + 'copilot: missing, optional for improved auth implementation. Install "github/copilot.vim" or "zbirenbaum/copilot.lua" plugins.' ) end From 950fdb6ab56754929d4db91c73139b33e645deec Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 11:16:16 +0200 Subject: [PATCH 279/589] feat(functions): automatically parse schema from url templates (#1220) Signed-off-by: Tomas Slusny --- lua/CopilotChat/functions.lua | 52 ++++++++++++++++++++++++++++++----- lua/CopilotChat/init.lua | 34 ++++++++++++++--------- lua/CopilotChat/tiktoken.lua | 10 +++++-- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index dbcf5bcd..04c5b103 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -3,6 +3,7 @@ local utils = require('CopilotChat.utils') local M = {} local INPUT_SEPARATOR = ';;' +local URI_PARAM_PATTERN = '{([^}:*]+)[^}]*}' local function sorted_propnames(schema) local prop_names = vim.tbl_keys(schema.properties) @@ -63,6 +64,17 @@ local function filter_schema(tbl) return result end +--- Convert a URI template to a URL by replacing parameters with values from input +---@param uri_template string The URI template containing parameters in the form {param} +---@param input table A table containing parameter values, e.g., { path = '/my/file.txt' } +---@return string The resulting URL with parameters replaced +function M.uri_to_url(uri_template, input) + -- Replace {param} in the template with input[param] or empty string + return (uri_template:gsub(URI_PARAM_PATTERN, function(param) + return input[param] or '' + end)) +end + ---@param uri string The URI to parse ---@param pattern string The pattern to match against (e.g., 'file://{path}') ---@return table|nil inputs Extracted parameters or nil if no match @@ -73,7 +85,7 @@ function M.match_uri(uri, pattern) -- Extract parameter names from the pattern local param_names = {} - for param in pattern:gmatch('{([^}:*]+)[^}]*}') do + for param in pattern:gmatch(URI_PARAM_PATTERN) do table.insert(param_names, param) -- Replace {param} with a capture group in our Lua pattern -- Use non-greedy capture to handle multiple params properly @@ -102,6 +114,37 @@ function M.match_uri(uri, pattern) return result end +---@param tool CopilotChat.config.functions.Function +function M.parse_schema(tool) + local schema = tool.schema + + -- If schema is missing but uri is present, generate a default schema from uri + if not schema and tool.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 + table.insert(param_names, param) + end + if #param_names > 0 then + schema = { + type = 'object', + properties = {}, + required = {}, + } + for _, param in ipairs(param_names) do + schema.properties[param] = { type = 'string' } + table.insert(schema.required, param) + end + end + end + + if schema then + schema = filter_schema(schema) + end + + return schema +end + --- Prepare the schema for use ---@param tools table ---@return table @@ -110,16 +153,11 @@ function M.parse_tools(tools) table.sort(tool_names) return vim.tbl_map(function(name) local tool = tools[name] - local schema = tool.schema - - if schema then - schema = filter_schema(schema) - end return { name = name, description = tool.description, - schema = schema, + schema = M.parse_schema(tool), } end, tool_names) end diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 71e80f3f..91aaf143 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -245,6 +245,12 @@ end ---@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 + local enabled_tools = {} local resolved_resources = {} local resolved_tools = {} @@ -271,7 +277,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] = tool + enabled_tools[name] = true end end end @@ -311,7 +317,7 @@ function M.resolve_functions(prompt, config) 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) and enabled_tools[name] then + 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 @@ -319,7 +325,7 @@ function M.resolve_functions(prompt, config) end end - local tool = enabled_tools[name] + 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 @@ -334,20 +340,16 @@ function M.resolve_functions(prompt, config) end end end - if not tool and not tool_id then - tool = M.config.functions[name] - end if not tool then - -- If tool is not found, return the original pattern return nil end - if not tool_id and not tool.uri then - -- If this is a tool that is not resource and was not called by LLM, reject it + if tool_id and not enabled_tools[name] and not tool.uri then return nil end + local schema = tools[name] and tools[name].schema or nil local result = '' - local ok, output = pcall(tool.resolve, functions.parse_input(input, tool.schema), state.source or {}, prompt) + local ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source or {}, input) if not ok then result = string.format(BLOCK_OUTPUT_FORMAT, 'error', utils.make_string(output)) else @@ -394,7 +396,12 @@ function M.resolve_functions(prompt, config) end end - return functions.parse_tools(enabled_tools), resolved_resources, resolved_tools, prompt + return vim.tbl_map(function(name) + return tools[name] + end, vim.tbl_keys(enabled_tools)), + resolved_resources, + resolved_tools, + prompt end --- Resolve the final prompt and config from prompt template. @@ -574,9 +581,10 @@ function M.trigger_complete(without_input) if not without_input and vim.startswith(prefix, '#') and vim.endswith(prefix, ':') then local found_tool = M.config.functions[prefix:sub(2, -2)] - if found_tool and found_tool.schema then + 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_tool.schema, state.source) + local value = functions.enter_input(found_schema, state.source) if not value then return end diff --git a/lua/CopilotChat/tiktoken.lua b/lua/CopilotChat/tiktoken.lua index a4582cb4..dde3d2b5 100644 --- a/lua/CopilotChat/tiktoken.lua +++ b/lua/CopilotChat/tiktoken.lua @@ -92,7 +92,13 @@ function M.encode(prompt) if type(prompt) ~= 'string' then error('Prompt must be a string') end - return tiktoken_core.encode(prompt) + + local ok, result = pcall(tiktoken_core.encode, prompt) + if not ok then + return nil + end + + return result end --- Count the tokens in a prompt @@ -105,7 +111,7 @@ function M.count(prompt) local tokens = M.encode(prompt) if not tokens then - return 0 + return math.ceil(#prompt * 0.5) -- Fallback to 1/2 character count end return #tokens end From c03bd1df78b276aa5be2f173c2a31ad273164f15 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 11:20:25 +0200 Subject: [PATCH 280/589] fix(functions): properly send prompt as 3rd function resolve param (#1221) Signed-off-by: Tomas Slusny --- 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 91aaf143..39e40959 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -349,7 +349,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 {}, input) + local ok, output = pcall(tool.resolve, functions.parse_input(input, schema), state.source or {}, prompt) if not ok then result = string.format(BLOCK_OUTPUT_FORMAT, 'error', utils.make_string(output)) else From 1f96d53c3f10f176ca25065a23e610d7b4a72b99 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 12:02:37 +0200 Subject: [PATCH 281/589] fix(ui): fix check for auto follow cursor (#1222) Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 2 -- lua/CopilotChat/ui/chat.lua | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 39e40959..5baa0fa8 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -970,8 +970,6 @@ function M.ask(prompt, config) content = '\n' .. tool.result .. '\n', }) end - - M.chat:follow() end local ask_ok, ask_response = pcall(client.ask, client, prompt, { diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index bcd513fd..55964682 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -501,7 +501,7 @@ function Chat:append(str) local current_pos = vim.api.nvim_win_get_cursor(self.winnr) local line_count = vim.api.nvim_buf_line_count(self.bufnr) -- Follow only if the cursor is currently at the last line. - should_follow_cursor = current_pos[1] == line_count + should_follow_cursor = current_pos[1] >= line_count - 1 end local last_line, last_column, _ = self:last() From 294bcb620ff66183e142cd8a43a7c77d5bc77a16 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 13:22:38 +0200 Subject: [PATCH 282/589] fix(providers): do not save copilot.vim token (#1223) Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/providers.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 0e406bd6..eee655a8 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -92,7 +92,7 @@ local function github_device_flow(tag, client_id, scope) notify.MESSAGE, '[' .. tag .. '] Visit ' .. code_data.verification_uri .. ' and enter code: ' .. code_data.user_code ) - notify.publish(notify.STATUS, '[' .. tag .. '] Waiting for GitHub models authorization...') + notify.publish(notify.STATUS, '[' .. tag .. '] Waiting for authorization...') token = poll_for_token(code_data.device_code, code_data.interval) return set_token(tag, token, true) end @@ -146,7 +146,7 @@ local function get_github_token(tag) if parsed_data then for key, value in pairs(parsed_data) do if string.find(key, 'github.com') and value and value.oauth_token then - return set_token(tag, value.oauth_token, true) + return set_token(tag, value.oauth_token, false) end end end @@ -187,7 +187,7 @@ M.copilot = { local response, err = utils.curl_get('https://api.github.com/copilot_internal/v2/token', { json_response = true, headers = { - ['Authorization'] = 'Token ' .. get_github_token('copilot'), + ['Authorization'] = 'Token ' .. get_github_token('github_copilot'), }, }) From 67ed258c6ccc0a9bfbb6dfcbe3d5e19e22888e73 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 17:24:51 +0200 Subject: [PATCH 283/589] fix(ui): do not allow empty separator (#1224) This even working before was a miracle, the plugin is fairly reliant on separator not being empty string Signed-off-by: Tomas Slusny --- README.md | 2 +- lua/CopilotChat/config.lua | 2 +- lua/CopilotChat/init.lua | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5daa432f..e10f3d61 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,7 @@ Below are all available configuration options with their default values: show_help = true, -- Shows help message as virtual lines when waiting for user input show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection - highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) + 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 insert_at_end = false, -- Move cursor to end of buffer when inserting text diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index c456a5b1..f2f2c708 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -86,7 +86,7 @@ return { show_help = true, -- Shows help message as virtual lines when waiting for user input show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection - highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) + 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 insert_at_end = false, -- Move cursor to end of buffer when inserting text diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 5baa0fa8..fa2e4308 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1162,7 +1162,8 @@ end --- Set up the plugin ---@param config CopilotChat.config.Config? function M.setup(config) - M.config = vim.tbl_deep_extend('force', require('CopilotChat.config'), config or {}) + 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 @@ -1181,6 +1182,13 @@ function M.setup(config) M.log_level(M.config.log_level) 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 = default_config.separator + end + if M.chat then M.chat:close(state.source and state.source.bufnr or nil) M.chat:delete() From 1b5cb07d0ef679d14f0896b724de47c640c69a6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Jul 2025 15:25:16 +0000 Subject: [PATCH 284/589] 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 979b45fc..5bbef619 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -546,7 +546,7 @@ Below are all available configuration options with their default values: show_help = true, -- Shows help message as virtual lines when waiting for user input show_folds = true, -- Shows folds for sections in chat highlight_selection = true, -- Highlight selection - highlight_headers = true, -- Highlight headers in chat, disable if using markdown renderers (like render-markdown.nvim) + 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 insert_at_end = false, -- Move cursor to end of buffer when inserting text From 8071a6979b5569ce03f7f4d7192814da4c2d4e0b Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 19:19:44 +0200 Subject: [PATCH 285/589] feat(ui): highlight copilotchat keywords (#1225) Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 55964682..0f7663a5 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -66,7 +66,6 @@ end ---@field private layout CopilotChat.config.Layout? ---@field private headers table ---@field private separator string ----@field private header_ns number ---@field private spinner CopilotChat.ui.spinner.Spinner ---@field private chat_overlay CopilotChat.ui.overlay.Overlay local Chat = class(function(self, headers, separator, help, on_buf_create) @@ -81,7 +80,6 @@ local Chat = class(function(self, headers, separator, help, on_buf_create) self.layout = nil self.headers = headers or {} self.separator = separator - self.header_ns = vim.api.nvim_create_namespace('copilot-chat-headers') self.spinner = Spinner() self.chat_overlay = Overlay('copilot-overlay', 'q to close', function(bufnr) @@ -559,7 +557,10 @@ end ---@protected function Chat:render() self:validate() - vim.api.nvim_buf_clear_namespace(self.bufnr, self.header_ns, 0, -1) + + local highlight_ns = vim.api.nvim_create_namespace('copilot-chat-headers') + vim.api.nvim_buf_clear_namespace(self.bufnr, highlight_ns, 0, -1) + local lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) local new_messages = {} @@ -578,7 +579,7 @@ function Chat:render() -- 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, self.header_ns, l - 1, sep_col, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, sep_col, { virt_text = { { string.rep(self.separator, vim.go.columns), 'CopilotChatSeparator' }, }, @@ -586,7 +587,7 @@ function Chat:render() priority = 200, strict = false, }) - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l - 1, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l - 1, 0, { end_col = sep_col, hl_group = 'CopilotChatHeader', priority = 100, @@ -647,7 +648,7 @@ function Chat:render() if start_line and end_line then text = text .. string.format(' lines %d-%d', start_line, end_line) end - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, l, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, l, 0, { virt_lines_above = true, virt_lines = { { { text, 'CopilotChatAnnotationHeader' } } }, priority = 100, @@ -674,9 +675,9 @@ function Chat:render() 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, self.header_ns, 'CopilotChatAnnotationHeader', l - 1, 0, #line) + 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, self.header_ns, l - 1, 0, { + 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')), @@ -688,6 +689,20 @@ function Chat:render() end end end + + -- Highlight keywords + -- FIXME: This is not optimal, but i cant figure out how to do it better as treesitter keeps overriding it + local patterns = { + '()#?#[^ ]+()', + '()@[^ ]+()', + '()%$[^ ]+()', + '()/[^ ]+()', + } + for _, pattern in ipairs(patterns) do + for s, e in line:gmatch(pattern) do + vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatKeyword', l - 1, s - 1, e - 1) + end + end end -- Replace self.messages with new_messages (preserving tool_calls, etc.) @@ -705,7 +720,7 @@ function Chat:render() table.insert(virt_lines, { { ' ' .. json_line, 'CopilotChatAnnotation' } }) end end - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, section.end_line - 1, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, section.end_line - 1, 0, { virt_lines = virt_lines, virt_lines_above = true, priority = 100, @@ -720,7 +735,7 @@ function Chat:render() local virt_lines = { { { 'Tool: ' .. message.tool_call_id, 'CopilotChatAnnotationHeader' } }, } - vim.api.nvim_buf_set_extmark(self.bufnr, self.header_ns, section.start_line, 0, { + vim.api.nvim_buf_set_extmark(self.bufnr, highlight_ns, section.start_line, 0, { virt_lines = virt_lines, virt_lines_above = true, priority = 100, From b124b94264140a5d352512b38b7a46d85ee59b24 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 22:54:27 +0200 Subject: [PATCH 286/589] fix(functions): use vim.filetype.match for non bulk file reads (#1226) To improve accuracy, use vim.filetype.match instead of plenary filetype when not filtering lists of files. Closes #1181 Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/functions.lua | 1 + lua/CopilotChat/utils.lua | 26 ++++++++++++++++---------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 1644a03f..65363811 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -37,6 +37,7 @@ return { }, resolve = function(input) + utils.schedule_main() local data, mimetype = resources.get_file(input.path) if not data then error('File not found: ' .. input.path) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index b1400837..ae3e6313 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -214,15 +214,7 @@ end ---@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 == '' then - return nil - end - return ft + return vim.filetype.match({ filename = filename }) end --- Get the mimetype from filetype @@ -445,8 +437,22 @@ M.curl_post = async.wrap(function(url, opts, callback) end, 3) local function filter_files(files, max_count) + local filetype = require('plenary.filetype') + files = vim.tbl_filter(function(file) - return file ~= '' and M.filetype(file) ~= nil + 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) From a01bbd6779f4bee23c29ebcfe0d2f5fa5664b5bf Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Wed, 30 Jul 2025 23:03:15 +0200 Subject: [PATCH 287/589] feat(ui): add window.blend option for controllin float transparency (#1227) Closes #1126 Signed-off-by: Tomas Slusny --- README.md | 1 + lua/CopilotChat/config.lua | 2 ++ lua/CopilotChat/ui/chat.lua | 1 + 3 files changed, 4 insertions(+) diff --git a/README.md b/README.md index e10f3d61..7958837a 100644 --- a/README.md +++ b/README.md @@ -468,6 +468,7 @@ Below are all available configuration options with their default values: title = 'Copilot Chat', -- title of chat window footer = nil, -- footer of chat window zindex = 1, -- determines if window is on top or below other floating windows + blend = 0, -- window blend (transparency), 0-100, 0 is opaque, 100 is fully transparent }, show_help = true, -- Shows help message as virtual lines when waiting for user input diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index f2f2c708..8809fdc6 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -11,6 +11,7 @@ ---@field title string? ---@field footer string? ---@field zindex number? +---@field blend number? ---@class CopilotChat.config.Shared ---@field system_prompt string? @@ -81,6 +82,7 @@ return { title = 'Copilot Chat', -- title of chat window footer = nil, -- footer of chat window zindex = 1, -- determines if window is on top or below other floating windows + blend = 0, -- window blend (transparency), 0-100, 0 is opaque, 100 is fully transparent }, show_help = true, -- Shows help message as virtual lines when waiting for user input diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 0f7663a5..e2cf7503 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -289,6 +289,7 @@ function Chat:open(config) } self.winnr = vim.api.nvim_open_win(self.bufnr, false, win_opts) + vim.wo[self.winnr].winblend = window.blend or 0 elseif layout == 'vertical' then local orig = vim.api.nvim_get_current_win() local cmd = 'vsplit' From 72ac912877a55ea6c61d803dee38704c9e7c255c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Jul 2025 21:03:35 +0000 Subject: [PATCH 288/589] 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 5bbef619..02932d3c 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -541,6 +541,7 @@ Below are all available configuration options with their default values: title = 'Copilot Chat', -- title of chat window footer = nil, -- footer of chat window zindex = 1, -- determines if window is on top or below other floating windows + blend = 0, -- window blend (transparency), 0-100, 0 is opaque, 100 is fully transparent }, show_help = true, -- Shows help message as virtual lines when waiting for user input From 1713ce6c8ec700a7833236a8dadfae8a0742b14d Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 31 Jul 2025 16:11:18 +0200 Subject: [PATCH 289/589] feat(providers): add info output to panel for copilot with stats (#1229) Signed-off-by: Tomas Slusny --- README.md | 3 ++ lua/CopilotChat/client.lua | 31 +++++++++++++++++ lua/CopilotChat/config/mappings.lua | 10 ++++++ lua/CopilotChat/config/providers.lua | 51 ++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) diff --git a/README.md b/README.md index 7958837a..19e790c6 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,9 @@ Custom providers can implement these methods: -- Optional: Embeddings provider name or function embed?: string|function, + -- Optional: Extra info about the provider displayed in info panel + get_info?(): string[] + -- Optional: Get extra request headers with optional expiration time get_headers?(): table, number?, diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index b0a48b85..11c353b6 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -284,6 +284,37 @@ function Client:models() return self.model_cache end +--- Get information about all providers +---@return table +function Client:info() + local infos = {} + local now = math.floor(os.time()) + local CACHE_TTL = 300 -- 5 minutes + + for provider_name, provider in pairs(self.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 + end + end + end + + log.debug('Fetched provider infos:', #vim.tbl_keys(infos)) + return infos +end + --- Ask a question to Copilot ---@param prompt string: The prompt to send to Copilot ---@param opts CopilotChat.client.AskOptions: Options for the request diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index bdcb93ce..73cb25a0 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -1,5 +1,6 @@ local async = require('plenary.async') local copilot = require('CopilotChat') +local client = require('CopilotChat.client') local utils = require('CopilotChat.utils') ---@class CopilotChat.config.mappings.Diff @@ -439,6 +440,7 @@ return { local system_prompt = config.system_prompt async.run(function() + local infos = client:info() local selected_model = copilot.resolve_model(prompt, config) local selected_tools, resolved_resources = copilot.resolve_functions(prompt, config) selected_tools = vim.tbl_map(function(tool) @@ -451,6 +453,14 @@ return { table.insert(lines, '**Temp Files**: `' .. vim.fn.fnamemodify(os.tmpname(), ':h') .. '`') table.insert(lines, '') + for provider, infolines in pairs(infos) do + table.insert(lines, '**Provider**: `' .. provider .. '`') + for _, line in ipairs(infolines) do + table.insert(lines, line) + end + table.insert(lines, '') + end + if source and utils.buf_valid(source.bufnr) then local source_name = vim.api.nvim_buf_get_name(source.bufnr) table.insert(lines, '**Source**: `' .. source_name .. '`') diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index eee655a8..5b35a2b4 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -171,6 +171,7 @@ end ---@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 embed nil|string|fun(inputs:table, headers:table):table ---@field prepare_input nil|fun(inputs:table, opts:CopilotChat.config.providers.Options):table @@ -204,6 +205,56 @@ M.copilot = { response.body.expires_at end, + get_info = function(headers) + local response, err = utils.curl_get('https://api.github.com/copilot_internal/user', { + json_response = true, + headers = { + ['Authorization'] = 'Token ' .. get_github_token('github_copilot'), + }, + }) + + if err then + error(err) + end + + local stats = response.body + local lines = {} + + if not stats or not stats.quota_snapshots then + return { 'No Copilot stats available.' } + end + + local function usage_line(name, snap) + if not snap then + return + end + + table.insert(lines, string.format(' **%s**', name)) + + if snap.unlimited then + table.insert(lines, ' Usage: Unlimited') + else + local used = snap.entitlement - snap.remaining + local percent = snap.entitlement > 0 and (used / snap.entitlement * 100) or 0 + table.insert(lines, string.format(' Usage: %d / %d (%.1f%%)', used, snap.entitlement, percent)) + table.insert(lines, string.format(' Remaining: %d', snap.remaining)) + if snap.overage_permitted ~= nil then + table.insert(lines, ' Overage: ' .. (snap.overage_permitted and 'Permitted' or 'Not Permitted')) + end + end + end + + usage_line('Premium requests', stats.quota_snapshots.premium_interactions) + usage_line('Chat', stats.quota_snapshots.chat) + usage_line('Completions', stats.quota_snapshots.completions) + + if stats.quota_reset_date then + table.insert(lines, string.format(' **Quota** resets on: %s', stats.quota_reset_date)) + end + + return lines + end, + get_models = function(headers) local response, err = utils.curl_get('https://api.githubcopilot.com/models', { json_response = true, From 776d4d4d8f693c0ded0d235d9195f6ddef20a8a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 31 Jul 2025 14:11:41 +0000 Subject: [PATCH 290/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 02932d3c..ff6f1ec8 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 30 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 31 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -473,6 +473,9 @@ Custom providers can implement these methods: -- Optional: Embeddings provider name or function embed?: string|function, + -- Optional: Extra info about the provider displayed in info panel + get_info?(): string[] + -- Optional: Get extra request headers with optional expiration time get_headers?(): table, number?, From f53069c595a3b12bbe8b9b711917f9ef33c22a0a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Thu, 31 Jul 2025 17:38:14 +0200 Subject: [PATCH 291/589] fix: properly validate source window when retrieving cwd (#1231) Closes #1230 Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index fa2e4308..d6dd3061 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -498,6 +498,9 @@ function M.set_source(source_winnr) bufnr = source_bufnr, winnr = source_winnr, cwd = function() + if not vim.api.nvim_win_is_valid(source_winnr) then + return '.' + end local dir = vim.w[source_winnr].cchat_cwd if not dir or dir == '' then return '.' From 82be513c07a27f55860d55144c54040d1c93cf2a Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 01:20:29 +0200 Subject: [PATCH 292/589] fix(chat): improve how sticky prompts are stored and parsed (#1233) Preserve sticky from current user message as well when calling .ask directly. Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 66 +++++++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index d6dd3061..0fda4bad 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -38,14 +38,24 @@ 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 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 sticky_indices = {} + local in_code_block = false + for _, line in ipairs(vim.split(combined_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 + stickies:set(vim.trim(line:sub(3)), true) + end + end for i, line in ipairs(lines) do if vim.startswith(line, '> ') then table.insert(sticky_indices, i) - stickies:set(vim.trim(line:sub(3)), true) end end for i = #sticky_indices, 1, -1 do @@ -99,6 +109,20 @@ local function insert_sticky(prompt, config) 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 +end + --- Update the highlights for chat buffer local function update_highlights() local selection_ns = vim.api.nvim_create_namespace('copilot-chat-selection') @@ -498,11 +522,10 @@ function M.set_source(source_winnr) bufnr = source_bufnr, winnr = source_winnr, cwd = function() - if not vim.api.nvim_win_is_valid(source_winnr) then - return '.' - end - local dir = vim.w[source_winnr].cchat_cwd - if not dir or dir == '' then + 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 @@ -764,6 +787,7 @@ 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') if message then local prompt = insert_sticky(message.content, config) @@ -772,7 +796,6 @@ function M.open(config) role = 'user', content = '\n' .. prompt, }, true) - M.chat:finish() end end @@ -889,37 +912,30 @@ 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 {}) - prompt = insert_sticky(prompt, config) - prompt = vim.trim(prompt) + -- Stop previous conversation and open window if not config.headless then if config.clear_chat_on_new_prompt then M.stop(true) elseif client:stop() then finish() end - if not M.chat:focused() then M.open(config) end + else + update_source() + end + + -- Resolve prompt after window is opened + 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') - - 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 - else - update_source() end -- Resolve prompt references From fc93d1c535bf9538a0a036f118b1034930ee5eb9 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 01:24:08 +0200 Subject: [PATCH 293/589] fix(chat): properly reset modifiable after modifying it (#1234) Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index e2cf7503..049973f8 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -439,6 +439,7 @@ function Chat:add_message(message, replace) local section = current_message.section if section then + local modifiable = vim.bo[self.bufnr].modifiable vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_lines( self.bufnr, @@ -447,7 +448,7 @@ function Chat:add_message(message, replace) false, vim.split(message.content, '\n') ) - vim.bo[self.bufnr].modifiable = false + vim.bo[self.bufnr].modifiable = modifiable self:append('') end else @@ -474,9 +475,10 @@ function Chat:remove_message(role) end -- Remove the section from the buffer + local modifiable = vim.bo[self.bufnr].modifiable vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_lines(self.bufnr, section.start_line - 2, section.end_line + 1, false, {}) - vim.bo[self.bufnr].modifiable = false + vim.bo[self.bufnr].modifiable = modifiable -- Remove the message from the messages list for i, msg in ipairs(self.messages) do @@ -505,9 +507,10 @@ function Chat:append(str) local last_line, last_column, _ = self:last() + local modifiable = vim.bo[self.bufnr].modifiable vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_text(self.bufnr, last_line, last_column, last_line, last_column, vim.split(str, '\n')) - vim.bo[self.bufnr].modifiable = false + vim.bo[self.bufnr].modifiable = modifiable if should_follow_cursor then self:follow() @@ -520,9 +523,11 @@ function Chat:clear() self.token_count = nil self.token_max_count = nil self.messages = {} + + local modifiable = vim.bo[self.bufnr].modifiable vim.bo[self.bufnr].modifiable = true vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, {}) - vim.bo[self.bufnr].modifiable = false + vim.bo[self.bufnr].modifiable = modifiable end --- Create the chat window buffer. From dec3127e4f373875d7fd50854e221ed8dc0e061f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 01:27:05 +0200 Subject: [PATCH 294/589] fix(utils): remove temp file after curl request is done (#1235) Closes #1194 Signed-off-by: Tomas Slusny --- lua/CopilotChat/utils.lua | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index ae3e6313..0b42fffb 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -398,8 +398,16 @@ M.curl_post = async.wrap(function(url, opts, callback) 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 @@ -430,7 +438,8 @@ M.curl_post = async.wrap(function(url, opts, callback) ['Content-Type'] = 'application/json', }) - args.body = M.temp_file(vim.json.encode(args.body)) + temp_file_path = M.temp_file(vim.json.encode(args.body)) + args.body = temp_file_path end curl.post(url, args) From 425ff0c48906a94ca522f6d2e98e4b39057e4fd4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 01:30:59 +0200 Subject: [PATCH 295/589] fix(chat): highlight keywords only in user messages (#1236) Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 049973f8..06b35d64 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -669,6 +669,22 @@ function Chat:render() end end + -- Keywords + if current_message and current_message.role == 'user' then + -- FIXME: This is not optimal, but i cant figure out how to do it better as treesitter keeps overriding it + local patterns = { + '()#?#[^ ]+()', + '()@[^ ]+()', + '()%$[^ ]+()', + '()/[^ ]+()', + } + for _, pattern in ipairs(patterns) do + for s, e in line:gmatch(pattern) do + vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatKeyword', l - 1, s - 1, e - 1) + end + end + end + -- If last line, finish last message if l == #lines and current_message then current_message.section.end_line = l @@ -695,20 +711,6 @@ function Chat:render() end end end - - -- Highlight keywords - -- FIXME: This is not optimal, but i cant figure out how to do it better as treesitter keeps overriding it - local patterns = { - '()#?#[^ ]+()', - '()@[^ ]+()', - '()%$[^ ]+()', - '()/[^ ]+()', - } - for _, pattern in ipairs(patterns) do - for s, e in line:gmatch(pattern) do - vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatKeyword', l - 1, s - 1, e - 1) - end - end end -- Replace self.messages with new_messages (preserving tool_calls, etc.) From 1a17534c17e6ae9f5417df08b8c0eec434c47875 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 01:45:30 +0200 Subject: [PATCH 296/589] fix(chat): show messages in overlay (#1237) message can be triggered from other places other than chat so just show chat overlay instead Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 3 +-- lua/CopilotChat/ui/chat.lua | 15 ++++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 0fda4bad..dd9fb70c 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -1213,8 +1213,7 @@ function M.setup(config) M.chat:delete() end M.chat = require('CopilotChat.ui.chat')( - M.config.headers, - M.config.separator, + M.config, utils.key_to_info('show_help', M.config.mappings.show_help), function(bufnr) for name, _ in pairs(M.config.mappings) do diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 06b35d64..5605ee16 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -68,18 +68,18 @@ 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, headers, separator, help, on_buf_create) +local Chat = class(function(self, config, help, on_buf_create) Overlay.init(self, 'copilot-chat', help, on_buf_create) self.winnr = nil - self.config = {} + self.config = config self.token_count = nil self.token_max_count = nil self.messages = {} self.layout = nil - self.headers = headers or {} - self.separator = separator + self.headers = config.headers + self.separator = config.separator self.spinner = Spinner() self.chat_overlay = Overlay('copilot-overlay', 'q to close', function(bufnr) @@ -97,7 +97,12 @@ local Chat = class(function(self, headers, separator, help, on_buf_create) notify.listen(notify.MESSAGE, function(msg) utils.schedule_main() - self:append('\n' .. msg .. '\n') + + if not self:visible() then + self:open(self.config) + end + + self:overlay({ text = msg }) end) end, Overlay) From 7c82936f2126b106af1b1bf0f9ae4d42dd45fcad Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 02:00:10 +0200 Subject: [PATCH 297/589] fix(prompt): be more specific when definining what is resource (#1238) 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 2b56bdda..8bb5efd9 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -29,7 +29,7 @@ If tools are explicitly defined in your system context: - 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. Content shared via "#:" references or headers + 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. From 718f48b120f98081a556df0caadd916511490ecb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Aug 2025 00:00:28 +0000 Subject: [PATCH 298/589] 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 ff6f1ec8..bde68614 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 July 31 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 01 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 02cf9e52634b3e3d45beb2c4e5bbc17da28aef64 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 02:24:01 +0200 Subject: [PATCH 299/589] feat(health): add temp dir writable check (#1239) Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/mappings.lua | 1 - lua/CopilotChat/health.lua | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/mappings.lua b/lua/CopilotChat/config/mappings.lua index 73cb25a0..3bd339cc 100644 --- a/lua/CopilotChat/config/mappings.lua +++ b/lua/CopilotChat/config/mappings.lua @@ -450,7 +450,6 @@ return { utils.schedule_main() table.insert(lines, '**Logs**: `' .. copilot.config.log_path .. '`') table.insert(lines, '**History**: `' .. copilot.config.history_path .. '`') - table.insert(lines, '**Temp Files**: `' .. vim.fn.fnamemodify(os.tmpname(), ':h') .. '`') table.insert(lines, '') for provider, infolines in pairs(infos) do diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index a75416ee..acfc5bd2 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -55,6 +55,25 @@ function M.check() error('setup: not called, required for plugin to work. See `:h CopilotChat-installation`.') end + start('CopilotChat.nvim [filesystem]') + + local testfile = os.tmpname() + local f = io.open(testfile, 'w') + local writable = false + if f then + f:write('test') + f:close() + writable = true + end + if writable then + ok('temp dir: writable (' .. testfile .. ')') + os.remove(testfile) + else + local stat = vim.loop.fs_stat(vim.fn.fnamemodify(testfile, ':h')) + local perms = stat and string.format('%o', stat.mode % 512) or 'unknown' + error('temp dir: not writable. Permissions: ' .. perms .. ' (dir: ' .. vim.fn.fnamemodify(testfile, ':h') .. ')') + end + start('CopilotChat.nvim [commands]') local curl_version = run_command('curl', '--version') From 01d38b27ea2183302c743dac09b27611d09d7591 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 02:41:03 +0200 Subject: [PATCH 300/589] feat(providers): prioritize gh clie auth if available for github models (#1240) Also add healthchecks Signed-off-by: Tomas Slusny --- README.md | 2 -- lua/CopilotChat/config/providers.lua | 35 ++++++++++++++++++++++++---- lua/CopilotChat/health.lua | 11 ++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 19e790c6..333b8495 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,6 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities ## Optional Dependencies -- [copilot.vim](https://github.com/github/copilot.vim) - For `:Copilot setup` authorization, otherwise in-built method i used - - [tiktoken_core](https://github.com/gptlang/lua-tiktoken) - For accurate token counting - Arch Linux: Install [`luajit-tiktoken-bin`](https://aur.archlinux.org/packages/luajit-tiktoken-bin) or [`lua51-tiktoken-bin`](https://aur.archlinux.org/packages/lua51-tiktoken-bin) from AUR - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 5b35a2b4..d2ac7976 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -99,7 +99,7 @@ end --- Get the github copilot oauth cached token (gu_ token) ---@return string -local function get_github_token(tag) +local function get_github_copilot_token(tag) local function config_path() local config = vim.fs.normalize('$XDG_CONFIG_HOME') if config and vim.uv.fs_stat(config) then @@ -157,6 +157,33 @@ local function get_github_token(tag) return github_device_flow(tag, 'Iv1.b507a08c87ecfe98', '') end +local function get_github_models_token(tag) + local token = get_token(tag) + if token then + return token + end + + -- loading token from the environment only in GitHub Codespaces + local codespaces = os.getenv('CODESPACES') + token = os.getenv('GITHUB_TOKEN') + if token and codespaces then + return set_token(tag, token, false) + end + + -- loading token from gh cli if available + if vim.fn.executable('gh') == 0 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) + if gh_token ~= '' and not gh_token:find('no oauth token') then + return set_token(tag, gh_token, false) + end + end + end + + return github_device_flow(tag, '178c6fc778ccc68e1d6a', 'read:user copilot') +end + ---@class CopilotChat.config.providers.Options ---@field model CopilotChat.client.Model ---@field temperature number? @@ -188,7 +215,7 @@ M.copilot = { local response, err = utils.curl_get('https://api.github.com/copilot_internal/v2/token', { json_response = true, headers = { - ['Authorization'] = 'Token ' .. get_github_token('github_copilot'), + ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), }, }) @@ -209,7 +236,7 @@ M.copilot = { local response, err = utils.curl_get('https://api.github.com/copilot_internal/user', { json_response = true, headers = { - ['Authorization'] = 'Token ' .. get_github_token('github_copilot'), + ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), }, }) @@ -430,7 +457,7 @@ M.github_models = { get_headers = function() return { - ['Authorization'] = 'Bearer ' .. github_device_flow('github_models', 'Ov23liqtJusaUH38tIoK', 'read:user copilot'), + ['Authorization'] = 'Bearer ' .. get_github_models_token('github_models'), } end, diff --git a/lua/CopilotChat/health.lua b/lua/CopilotChat/health.lua index acfc5bd2..1c8bc3b4 100644 --- a/lua/CopilotChat/health.lua +++ b/lua/CopilotChat/health.lua @@ -55,8 +55,6 @@ function M.check() error('setup: not called, required for plugin to work. See `:h CopilotChat-installation`.') end - start('CopilotChat.nvim [filesystem]') - local testfile = os.tmpname() local f = io.open(testfile, 'w') local writable = false @@ -104,6 +102,13 @@ function M.check() ok('lynx: ' .. lynx_version) end + local gh_version = run_command('gh', '--version') + if gh_version == false then + warn('gh: missing, optional for improved GitHub authorization. See "https://cli.github.com/".') + else + ok('gh: ' .. gh_version) + end + start('CopilotChat.nvim [dependencies]') if lualib_installed('plenary') then @@ -118,7 +123,7 @@ function M.check() ok('copilot: ' .. (has_copilot and 'copilot.lua' or 'copilot.vim')) else warn( - 'copilot: missing, optional for improved auth implementation. Install "github/copilot.vim" or "zbirenbaum/copilot.lua" plugins.' + 'copilot: missing, optional for improved Copilot authorization. Install "github/copilot.vim" or "zbirenbaum/copilot.lua" plugins.' ) end From 3264dd25ef9d82862c88fe57c1ed2fcacab37c1c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Aug 2025 00:41:24 +0000 Subject: [PATCH 301/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index bde68614..4400881d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -62,16 +62,12 @@ capabilities directly into your editor. It provides: OPTIONAL DEPENDENCIES *CopilotChat-optional-dependencies* -- copilot.vim - For `:Copilot setup` - authorization, otherwise in-built method i used -- tiktoken_core - For accurate token - counting +- tiktoken_core - For accurate token counting - Arch Linux: Install `luajit-tiktoken-bin` or `lua51-tiktoken-bin` from AUR - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` - Manual: Download from lua-tiktoken releases and save as `tiktoken_core.so` in your Lua path - git - For git diff context features -- ripgrep - For improved search - performance +- ripgrep - For improved search performance - lynx - For improved URL context features From ea4168476a0fdbd5bf40a4a769d6c1dc998929eb Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 03:05:39 +0200 Subject: [PATCH 302/589] feat(mappings): use C-Space as default completion trigger instead of (#1241) To prevent conflicts with copilot.vim and copilot.lua by default, confusing people 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 3bd339cc..aaaa0ea6 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 653111bbbbc8fa6f8aa8a9bf5f820c6f926c9b89 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 03:08:30 +0200 Subject: [PATCH 303/589] chore: update readme and do not show empty choices for completion (#1242) Signed-off-by: Tomas Slusny --- README.md | 32 ++++++++++++++++---------------- lua/CopilotChat/functions.lua | 4 +++- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 333b8495..d9d5d433 100644 --- a/README.md +++ b/README.md @@ -149,22 +149,22 @@ Commands are used to control the chat interface: Default mappings in the chat interface: -| 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 | The mappings can be customized by setting the `mappings` table in your configuration. Each mapping can have: diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index 04c5b103..782510ea 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -211,7 +211,9 @@ function M.enter_input(schema, source) if cfg.enum then local choices = type(cfg.enum) == 'table' and cfg.enum or cfg.enum(source) local choice - if #choices == 1 then + if #choices == 0 then + choice = nil + elseif #choices == 1 then choice = choices[1] else choice = utils.select(choices, { From cbea42c9cf3bab433b02674b696cd7d540eb1f52 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Aug 2025 01:08:46 +0000 Subject: [PATCH 304/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 4400881d..716fbd92 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -183,22 +183,22 @@ KEY MAPPINGS *CopilotChat-key-mappings* Default mappings in the chat interface: - 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 The mappings can be customized by setting the `mappings` table in your configuration. Each mapping can have: From f7a3228f155d0533197ac79b0e08582e504d0399 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 10:36:25 +0200 Subject: [PATCH 305/589] fix(functions): properly filter tool schema from functions (#1243) Signed-off-by: Tomas Slusny --- lua/CopilotChat/functions.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/CopilotChat/functions.lua b/lua/CopilotChat/functions.lua index 782510ea..6e936a3d 100644 --- a/lua/CopilotChat/functions.lua +++ b/lua/CopilotChat/functions.lua @@ -44,12 +44,12 @@ local function sorted_propnames(schema) return prop_names end -local function filter_schema(tbl) +local function filter_schema(tbl, root) if type(tbl) ~= 'table' then return tbl end - if utils.empty(tbl.properties) then + if root and utils.empty(tbl.properties) then return nil end @@ -139,7 +139,7 @@ function M.parse_schema(tool) end if schema then - schema = filter_schema(schema) + schema = filter_schema(schema, true) end return schema From d1d155e50193e28a3ec00f8e21d6f11445f96ea1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 11:41:43 +0200 Subject: [PATCH 306/589] fix(chat): properly replace all message data when replacing message (#1244) Signed-off-by: Tomas Slusny --- lua/CopilotChat/ui/chat.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index 5605ee16..fe70feff 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -440,7 +440,11 @@ function Chat:add_message(message, replace) elseif replace and current_message then -- Replace the content of the current message self:render() - current_message.content = message.content + + for k, v in pairs(message) do + current_message[k] = v + end + local section = current_message.section if section then From c3d00484c42065a883db0fb859c686e277012d6c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 11:54:00 +0200 Subject: [PATCH 307/589] fix(chat): do not allow sending empty prompt (#1245) Closes #1189 Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index dd9fb70c..1c25eda5 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -991,6 +991,14 @@ function M.ask(prompt, config) end end + if utils.empty(prompt) and utils.empty(resolved_tools) then + if not config.headless then + M.chat:remove_message('user') + finish() + end + return + end + local ask_ok, ask_response = pcall(client.ask, client, prompt, { headless = config.headless, history = M.chat.messages, From ced388c97b313ea235809824ed501970b155e59f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 12:46:36 +0200 Subject: [PATCH 308/589] feat(prompts): add configurable response language (#1246) Closes #1086 Signed-off-by: Tomas Slusny --- README.md | 8 ++++---- lua/CopilotChat/config.lua | 2 ++ lua/CopilotChat/config/prompts.lua | 1 + lua/CopilotChat/init.lua | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index d9d5d433..3355f1b7 100644 --- a/README.md +++ b/README.md @@ -171,14 +171,13 @@ The mappings can be customized by setting the `mappings` table in your configura - `normal`: Key for normal mode - `insert`: Key for insert mode -For example, to change the submit prompt mapping or show_diff full diff option: +For example, to change the complete mapping to Tab or show_diff full diff option: ```lua { mappings = { - submit_prompt = { - normal = 's', - insert = '' + complete = { + insert = '' } show_diff = { full_diff = true @@ -444,6 +443,7 @@ Below are all available configuration options with their default values: 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 @). 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) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 8809fdc6..30ad2bd8 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -18,6 +18,7 @@ ---@field model string? ---@field tools string|table|nil ---@field sticky string|table|nil +---@field language string? ---@field resource_processing boolean? ---@field temperature number? ---@field headless boolean? @@ -58,6 +59,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 @). 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) diff --git a/lua/CopilotChat/config/prompts.lua b/lua/CopilotChat/config/prompts.lua index 8bb5efd9..8764f914 100644 --- a/lua/CopilotChat/config/prompts.lua +++ b/lua/CopilotChat/config/prompts.lua @@ -2,6 +2,7 @@ local COPILOT_BASE = [[ 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. +Always answer in {LANGUAGE} unless explicitly asked otherwise. The user works in editor called Neovim which has these core concepts: - Buffer: An in-memory text content that may be associated with a file diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 1c25eda5..67848dd8 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -473,6 +473,7 @@ function M.resolve_prompt(prompt, config) if config.system_prompt then 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 From aa5e50ee15cfdb0e8cb66ecab9f928ed0fc148ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Aug 2025 10:46:52 +0000 Subject: [PATCH 309/589] 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 716fbd92..8ecf90b9 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -205,14 +205,14 @@ configuration. Each mapping can have: - `normal`: Key for normal mode - `insert`: Key for insert mode -For example, to change the submit prompt mapping or show_diff full diff option: +For example, to change the complete mapping to Tab or show_diff full diff +option: >lua { mappings = { - submit_prompt = { - normal = 's', - insert = '' + complete = { + insert = '' } show_diff = { full_diff = true @@ -515,6 +515,7 @@ Below are all available configuration options with their default values: 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 @). 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) From ed28296abb59fe083855d6f70371fbccbc939a92 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 18:29:20 +0200 Subject: [PATCH 310/589] docs: properly use functions instead of tools in README (#1248) Signed-off-by: Tomas Slusny --- README.md | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3355f1b7..9917c928 100644 --- a/README.md +++ b/README.md @@ -286,24 +286,24 @@ For supported models, see: ## Functions -Functions provide additional information and behaviour to the chat. -Tools can be organized into groups by setting the `group` property. Tools assigned to a group are not automatically made available to the LLM - they must be explicitly activated. -To use grouped tools in your prompt, include `@group_name` in your message. This allows the LLM to access and use all tools in that group during the current interaction. -Add tools using `#tool_name[:input]` syntax: - -| Function | Input Support | Description | -| ------------- | ------------- | ------------------------------------------------------ | -| `buffer` | ✓ (name) | Retrieves content from a specific buffer | -| `buffers` | ✓ (scope) | Fetches content from multiple buffers (listed/visible) | -| `diagnostics` | ✓ (scope) | Collects code diagnostics (errors, warnings) | -| `file` | ✓ (path) | Reads content from a specified file path | -| `gitdiff` | ✓ (sha) | Retrieves git diff information (unstaged/staged/sha) | -| `gitstatus` | - | Retrieves git status information | -| `glob` | ✓ (pattern) | Lists filenames matching a pattern in workspace | -| `grep` | ✓ (pattern) | Searches for a pattern across files in workspace | -| `quickfix` | - | Includes content of files in quickfix list | -| `register` | ✓ (register) | Provides access to specified Vim register | -| `url` | ✓ (url) | Fetches content from a specified URL | +Functions provide additional information and behaviour to the chat. +Functions can be organized into groups by setting the `group` property. +Functions can be made available to the LLM with `@group_name` or `@function_name` syntax. LLM will then be able to use them in responses as tool calls. +If function has URI, they can also be used directly in prompt with `#function_name[:input]` syntax for providing context as resources. + +| Function | Input Support | URI | Description | +| ------------- | ------------- | --- | ------------------------------------------------------ | +| `buffer` | ✓ (name) | ✓ | Retrieves content from a specific buffer | +| `buffers` | ✓ (scope) | ✓ | Fetches content from multiple buffers (listed/visible) | +| `diagnostics` | ✓ (scope) | ✓ | Collects code diagnostics (errors, warnings) | +| `file` | ✓ (path) | ✓ | Reads content from a specified file path | +| `gitdiff` | ✓ (sha) | ✓ | Retrieves git diff information (unstaged/staged/sha) | +| `gitstatus` | - | ✓ | Retrieves git status information | +| `glob` | ✓ (pattern) | ✓ | Lists filenames matching a pattern in workspace | +| `grep` | ✓ (pattern) | ✓ | Searches for a pattern across files in workspace | +| `quickfix` | - | ✓ | Includes content of files in quickfix list | +| `register` | ✓ (register) | ✓ | Provides access to specified Vim register | +| `url` | ✓ (url) | ✓ | Fetches content from a specified URL | Examples: @@ -318,6 +318,9 @@ Examples: > #quickfix > #register:+ > #url:https://example.com +> @glob +> @grep +> @file ``` Define your own functions in the configuration with input handling and schema: @@ -563,7 +566,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_tools() -- Resolve tools that are available for automatic use by LLM +chat.resolve_functions() -- Resolve functions that are available for automatic use by LLM (WARN: async, requires plenary.async.run) chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management @@ -619,6 +622,7 @@ window:add_sticky(sticky) -- Add sticky prompt to chat mess -- Content Management window:append(text) -- Append text to chat window window:clear() -- Clear chat window content +window:start() -- Start writing to chat window window:finish() -- Finish writing to chat window -- Navigation From 40a88e13dac6d15427eeac951b7d2274613971ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 1 Aug 2025 16:29:43 +0000 Subject: [PATCH 311/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 57 ++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 8ecf90b9..1e5a908f 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -329,42 +329,43 @@ For supported models, see: FUNCTIONS *CopilotChat-functions* -Functions provide additional information and behaviour to the chat. Tools can -be organized into groups by setting the `group` property. Tools assigned to a -group are not automatically made available to the LLM - they must be explicitly -activated. To use grouped tools in your prompt, include `@group_name` in your -message. This allows the LLM to access and use all tools in that group during -the current interaction. Add tools using `#tool_name[:input]` syntax: +Functions provide additional information and behaviour to the chat. Functions +can be organized into groups by setting the `group` property. Functions can be +made available to the LLM with `@group_name` or `@function_name` syntax. LLM +will then be able to use them in responses as tool calls. If function has URI, +they can also be used directly in prompt with `#function_name[:input]` syntax +for providing context as resources. - -------------------------------------------------------------------------- - Function Input Description - Support - ------------- ------------ ----------------------------------------------- - buffer ✓ (name) Retrieves content from a specific buffer + ------------------------------------------------------------------------------- + Function Input URI Description + Support + ------------- ------------ ----- ---------------------------------------------- + buffer ✓ (name) ✓ Retrieves content from a specific buffer - buffers ✓ (scope) Fetches content from multiple buffers - (listed/visible) + buffers ✓ (scope) ✓ Fetches content from multiple buffers + (listed/visible) - diagnostics ✓ (scope) Collects code diagnostics (errors, warnings) + diagnostics ✓ (scope) ✓ Collects code diagnostics (errors, warnings) - file ✓ (path) Reads content from a specified file path + file ✓ (path) ✓ Reads content from a specified file path - gitdiff ✓ (sha) Retrieves git diff information - (unstaged/staged/sha) + gitdiff ✓ (sha) ✓ Retrieves git diff information + (unstaged/staged/sha) - gitstatus - Retrieves git status information + gitstatus - ✓ Retrieves git status information - glob ✓ (pattern) Lists filenames matching a pattern in workspace + glob ✓ (pattern) ✓ Lists filenames matching a pattern in + workspace - grep ✓ (pattern) Searches for a pattern across files in - workspace + grep ✓ (pattern) ✓ Searches for a pattern across files in + workspace - quickfix - Includes content of files in quickfix list + quickfix - ✓ Includes content of files in quickfix list - register ✓ (register) Provides access to specified Vim register + register ✓ (register) ✓ Provides access to specified Vim register - url ✓ (url) Fetches content from a specified URL - -------------------------------------------------------------------------- + url ✓ (url) ✓ Fetches content from a specified URL + ------------------------------------------------------------------------------- Examples: >markdown @@ -378,6 +379,9 @@ Examples: > #quickfix > #register:+ > #url:https://example.com + > @glob + > @grep + > @file < Define your own functions in the configuration with input handling and schema: @@ -640,7 +644,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_tools() -- Resolve tools that are available for automatic use by LLM + chat.resolve_functions() -- Resolve functions that are available for automatic use by LLM (WARN: async, requires plenary.async.run) chat.resolve_model() -- Resolve model from prompt (WARN: async, requires plenary.async.run) -- Window Management @@ -697,6 +701,7 @@ You can also access the chat window UI methods through the `chat.chat` object: -- Content Management window:append(text) -- Append text to chat window window:clear() -- Clear chat window content + window:start() -- Start writing to chat window window:finish() -- Finish writing to chat window -- Navigation From 9fd068f5d6a0ca00fc739a98f29125cb577b2dfa Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Fri, 1 Aug 2025 23:19:27 +0200 Subject: [PATCH 312/589] fix(files): use also plenary filetype on top of vim.filetype.match (#1250) I thought its fine to go back to vim.filetype.match but its as unreliable as ever. Closes #1249 Signed-off-by: Tomas Slusny --- lua/CopilotChat/utils.lua | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index 0b42fffb..251238ca 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -214,7 +214,17 @@ end ---@param filename string The file name ---@return string|nil function M.filetype(filename) - return vim.filetype.match({ filename = filename }) + local filetype = require('plenary.filetype') + + local ft = filetype.detect(filename, { + fs_access = false, + }) + + if ft == '' or not ft then + return vim.filetype.match({ filename = filename }) + end + + return ft end --- Get the mimetype from filetype From 3509cf0971c59ba79fbcd618d82910f8567a7929 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 2 Aug 2025 14:16:56 +0200 Subject: [PATCH 313/589] fix(functions): change neovim://buffer to just buffer:// to avoid conflicts (#1252) also make quickfix function properly support buffer resources Signed-off-by: Tomas Slusny --- lua/CopilotChat/config/functions.lua | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/config/functions.lua b/lua/CopilotChat/config/functions.lua index 65363811..25c3f6c5 100644 --- a/lua/CopilotChat/config/functions.lua +++ b/lua/CopilotChat/config/functions.lua @@ -118,7 +118,7 @@ return { buffer = { group = 'copilot', - uri = 'neovim://buffer/{name}', + uri = 'buffer://{name}', description = 'Retrieves content from a specific buffer. Useful for discussing or analyzing code from a particular file that is currently loaded.', schema = { @@ -162,7 +162,7 @@ return { end return { { - uri = 'neovim://buffer/' .. name, + uri = 'buffer://' .. name, mimetype = mimetype, data = data, }, @@ -172,7 +172,7 @@ return { buffers = { group = 'copilot', - uri = 'neovim://buffers/{scope}', + uri = 'buffers://{scope}', description = 'Fetches content from multiple buffers. Helps with discussing or analyzing code across multiple files simultaneously.', schema = { @@ -204,7 +204,7 @@ return { return nil end return { - uri = 'neovim://buffer/' .. name, + uri = 'buffer://' .. name, mimetype = mimetype, data = data, } @@ -229,23 +229,35 @@ return { return {} end - local unique_files = {} + 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 - unique_files[filename] = true + 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(unique_files)) + .iter(vim.tbl_keys(file_to_bufnr)) :map(function(file) - local data, mimetype = resources.get_file(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 = 'file://' .. file, + uri = uri, mimetype = mimetype, data = data, } From eec50b885165da1e404b9effcc0f4d52e5f66f3b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 Aug 2025 12:17:17 +0000 Subject: [PATCH 314/589] 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 1e5a908f..9888219d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 01 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 02 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 4cd53a48f04fe45331e4845fabd32a81e46f4d2f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 2 Aug 2025 15:36:02 +0200 Subject: [PATCH 315/589] docs(README): improve readme (#1253) * docs(README): improve readme Signed-off-by: Tomas Slusny * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update readme Signed-off-by: Tomas Slusny * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update again Signed-off-by: Tomas Slusny * more readme updates Signed-off-by: Tomas Slusny * more updates Signed-off-by: Tomas Slusny --------- Signed-off-by: Tomas Slusny Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- README.md | 488 ++++++++++++++++++------------------------------------ 1 file changed, 162 insertions(+), 326 deletions(-) diff --git a/README.md b/README.md index 9917c928..b0ea7e5e 100644 --- a/README.md +++ b/README.md @@ -16,23 +16,24 @@ https://github.com/user-attachments/assets/8cad5643-63b2-4641-a5c4-68bc313f20e6 -CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities directly into your editor. It provides: +CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim with a focus on transparency and user control. -- 🤖 GitHub Copilot Chat integration with official model support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) -- 💻 Rich workspace context powered by smart embeddings system -- 🔒 Explicit data sharing - only sends what you specifically request, either as resource or selection (by default visual selection) -- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, Gemini, Mistral.ai and more) -- 📝 Interactive chat UI with completion, diffs and quickfix integration -- 🎯 Powerful prompt system with composable templates and sticky prompts -- 🔄 Extensible function calling system for granular workspace understanding (buffers, files, git diffs, URLs, and more) -- ⚡ Efficient token usage with tiktoken token counting and history management +- 🤖 **Multiple AI Models** - GitHub Copilot (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash) + custom providers (Ollama, Mistral.ai) +- 🔧 **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 +- 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context +- ⚡ **Efficient** - Smart token usage with tiktoken counting and history management +- 🔌 **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) -# Requirements +# Installation + +## Requirements -- [Neovim 0.10.0+](https://neovim.io/) - Older versions are not officially supported -- [curl](https://curl.se/) - Version 8.0.0+ recommended for best compatibility +- [Neovim 0.10.0+](https://neovim.io/) +- [curl 8.0.0+](https://curl.se/) - [Copilot chat in the IDE](https://github.com/settings/copilot) enabled in GitHub settings -- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) - Plugin dependency +- [plenary.nvim](https://github.com/nvim-lua/plenary.nvim) > [!WARNING] > For Neovim < 0.11.0, add `noinsert` or `noselect` to your `completeopt` otherwise chat autocompletion will not work. @@ -44,7 +45,6 @@ CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat capabilities - Arch Linux: Install [`luajit-tiktoken-bin`](https://aur.archlinux.org/packages/luajit-tiktoken-bin) or [`lua51-tiktoken-bin`](https://aur.archlinux.org/packages/lua51-tiktoken-bin) from AUR - Via luarocks: `sudo luarocks install --lua-version 5.1 tiktoken_core` - Manual: Download from [lua-tiktoken releases](https://github.com/gptlang/lua-tiktoken/releases) and save as `tiktoken_core.so` in your Lua path - - [git](https://git-scm.com/) - For git diff context features - [ripgrep](https://github.com/BurntSushi/ripgrep) - For improved search performance - [lynx](https://lynx.invisible-island.net/) - For improved URL context features @@ -58,14 +58,6 @@ For various plugin pickers to work correctly, you need to replace `vim.ui.select - [snacks.picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md#%EF%B8%8F-config) - enable `ui_select` config - [mini.pick](https://github.com/echasnovski/mini.pick/blob/main/lua/mini/pick.lua#L1229) - set `vim.ui.select = require('mini.pick').ui_select` -Plugin features that use picker: - -- `:CopilotChatPrompts` - for selecting prompts -- `:CopilotChatModels` - for selecting models -- `#:` - for selecting function input - -# Installation - ## [lazy.nvim](https://github.com/folke/lazy.nvim) ```lua @@ -73,23 +65,18 @@ return { { "CopilotC-Nvim/CopilotChat.nvim", dependencies = { - { "nvim-lua/plenary.nvim", branch = "master" }, -- for curl, log and async functions + { "nvim-lua/plenary.nvim", branch = "master" }, }, build = "make tiktoken", opts = { -- See Configuration section for options }, - -- See Commands section for default commands if you want to lazy load on them }, } ``` -See [@jellydn](https://github.com/jellydn) for [configuration](https://github.com/jellydn/lazy-nvim-ide/blob/main/lua/plugins/extras/copilot-chat-v2.lua) - ## [vim-plug](https://github.com/junegunn/vim-plug) -Similar to the lazy setup, you can use the following configuration: - ```vim call plug#begin() Plug 'nvim-lua/plenary.nvim' @@ -97,40 +84,41 @@ Plug 'CopilotC-Nvim/CopilotChat.nvim' call plug#end() lua << EOF -require("CopilotChat").setup { - -- See Configuration section for options -} +require("CopilotChat").setup() EOF ``` -## Manual +# Core Concepts -1. Put the files in the right place +- **Resources** (`#`) - Add specific content (files, git diffs, URLs) to your prompt +- **Tools** (`@`) - Give LLM access to functions it can call with your approval +- **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 -``` -mkdir -p ~/.config/nvim/pack/copilotchat/start -cd ~/.config/nvim/pack/copilotchat/start +## Examples -git clone https://github.com/nvim-lua/plenary.nvim -git clone https://github.com/CopilotC-Nvim/CopilotChat.nvim -``` +```markdown +# Add specific file to context -2. Add to your configuration (e.g. `~/.config/nvim/init.lua`) +#file:src/main.lua -```lua -require("CopilotChat").setup { - -- See Configuration section for options -} +# Give LLM access to workspace tools + +@copilot What files are in this project? + +# Sticky prompt that persists + +> #buffer:current +> You are a helpful coding assistant ``` -See [@deathbeam](https://github.com/deathbeam) for [configuration](https://github.com/deathbeam/dotfiles/blob/master/nvim/.config/nvim/lua/config/copilot.lua) +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. -# Features +# Usage ## Commands -Commands are used to control the chat interface: - | Command | Description | | -------------------------- | ----------------------------- | | `:CopilotChat ?` | Open chat with optional input | @@ -147,8 +135,6 @@ Commands are used to control the chat interface: ## Key Mappings -Default mappings in the chat interface: - | Insert | Normal | Action | | ----------- | ------- | ------------------------------------------ | | `` | - | Trigger/accept completion menu for tokens | @@ -166,73 +152,125 @@ Default mappings in the chat interface: | - | `gc` | Show info about current chat | | - | `gh` | Show help message | -The mappings can be customized by setting the `mappings` table in your configuration. Each mapping can have: +## Built-in Functions + +| 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:+` | +| `url` | Fetches content from a specified URL | `#url:https://...` | + +## Predefined Prompts + +Use with `:CopilotChat` or reference with `/PromptName`: + +- `/Explain` - Explain selected code +- `/Review` - Code review +- `/Fix` - Fix bugs +- `/Optimize` - Performance improvements +- `/Docs` - Add documentation +- `/Tests` - Generate tests +- `/Commit` - Commit message + +# Configuration -- `normal`: Key for normal mode -- `insert`: Key for insert mode +For all available configuration options, see [`lua/CopilotChat/config.lua`](lua/CopilotChat/config.lua). -For example, to change the complete mapping to Tab or show_diff full diff option: +## Quick Setup + +Most users only need to configure a few options: ```lua { - mappings = { - complete = { - insert = '' - } - show_diff = { - full_diff = true - } - } + model = 'gpt-4.1', -- AI model to use + temperature = 0.1, -- Lower = focused, higher = creative + window = { + layout = 'vertical', -- 'vertical', 'horizontal', 'float' + width = 0.5, -- 50% of screen width + }, + auto_insert_mode = true, -- Enter insert mode when opening } ``` -## Prompts +## Window & Appearance -### Predefined Prompts +```lua +{ + window = { + layout = 'float', + width = 80, -- Fixed width in columns + height = 20, -- Fixed height in rows + border = 'rounded', -- 'single', 'double', 'rounded', 'solid' + title = '🤖 AI Assistant', + zindex = 100, -- Ensure window stays on top + }, -Predefined prompt templates for common tasks. Reference them with `/PromptName` in chat, use `:CopilotChat` or `:CopilotChatPrompts` to select them: + headers = { + user = '👤 You: ', + assistant = '🤖 Copilot: ', + tool = '🔧 Tool: ', + }, + separator = '━━', + show_folds = false, -- Disable folding for cleaner look +} +``` -| Prompt | Description | -| ---------- | ------------------------------------------------ | -| `Explain` | Write an explanation for the selected code | -| `Review` | Review the selected code | -| `Fix` | Rewrite the code with bug fixes | -| `Optimize` | Optimize code for performance and readability | -| `Docs` | Add documentation comments to the code | -| `Tests` | Generate tests for the code | -| `Commit` | Write commit message using commitizen convention | +## Buffer Behavior -Define your own prompts in the configuration: +```lua +-- Auto-command to customize chat buffer behavior +vim.api.nvim_create_autocmd('BufEnter', { + pattern = 'copilot-*', + callback = function() + vim.opt_local.relativenumber = false + vim.opt_local.number = false + vim.opt_local.conceallevel = 0 + end, +}) +``` + +## Highlights + +You can customize colors by setting highlight groups in your config: ```lua -{ - prompts = { - MyCustomPrompt = { - prompt = 'Explain how it works.', - system_prompt = 'You are very good at explaining stuff', - mapping = 'ccmc', - description = 'My custom prompt description', - } - } -} +-- 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 }) ``` -### System Prompts +Types of copilot highlights: -System prompts define the AI model's behavior. Reference them with `/PROMPT_NAME` in chat: +- `CopilotChatHeader` - Header highlight in chat buffer +- `CopilotChatSeparator` - Separator highlight in chat buffer +- `CopilotChatStatus` - Status and spinner in chat buffer +- `CopilotChatHelp` - Help messages in chat buffer (help, references) +- `CopilotChatSelection` - Selection highlight in source buffer +- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) +- `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) -| Prompt | Description | -| ---------------------- | ------------------------------------------ | -| `COPILOT_BASE` | All prompts should be built on top of this | -| `COPILOT_INSTRUCTIONS` | Base instructions | -| `COPILOT_EXPLAIN` | Adds coding tutor behavior | -| `COPILOT_REVIEW` | Adds code review behavior with diagnostics | +## Prompts -Define your own system prompts in the configuration (similar to `prompts`): +Define your own prompts in the configuration: ```lua { prompts = { + MyCustomPrompt = { + prompt = 'Explain how it works.', + system_prompt = 'You are very good at explaining stuff', + mapping = 'ccmc', + description = 'My custom prompt description', + }, Yarrr = { system_prompt = 'You are fascinated by pirates, so please respond in pirate speak.', }, @@ -243,86 +281,8 @@ Define your own system prompts in the configuration (similar to `prompts`): } ``` -### Sticky Prompts - -Sticky prompts persist across chat sessions. They're useful for maintaining model or resource selection. They work as follows: - -1. Prefix text with `> ` using markdown blockquote syntax -2. The prompt will be copied at the start of every new chat prompt -3. Edit sticky prompts freely while maintaining the `> ` prefix - -Examples: - -```markdown -> #glob:`*.lua` -> List all files in the workspace - -> @models Using Mistral-small -> What is 1 + 11 -``` - -You can also set default sticky prompts in the configuration: - -```lua -{ - sticky = { - '#glob:*.lua', - } -} -``` - -## Models - -You can control which AI model to use in three ways: - -1. List available models with `:CopilotChatModels` -2. Set model in prompt with `$model_name` -3. Configure default model via `model` config key - -For supported models, see: - -- [Copilot Chat Models](https://docs.github.com/en/copilot/using-github-copilot/ai-models/changing-the-ai-model-for-copilot-chat#ai-models-for-copilot-chat) -- [GitHub Marketplace Models](https://github.com/marketplace/models) (experimental, limited usage) - ## Functions -Functions provide additional information and behaviour to the chat. -Functions can be organized into groups by setting the `group` property. -Functions can be made available to the LLM with `@group_name` or `@function_name` syntax. LLM will then be able to use them in responses as tool calls. -If function has URI, they can also be used directly in prompt with `#function_name[:input]` syntax for providing context as resources. - -| Function | Input Support | URI | Description | -| ------------- | ------------- | --- | ------------------------------------------------------ | -| `buffer` | ✓ (name) | ✓ | Retrieves content from a specific buffer | -| `buffers` | ✓ (scope) | ✓ | Fetches content from multiple buffers (listed/visible) | -| `diagnostics` | ✓ (scope) | ✓ | Collects code diagnostics (errors, warnings) | -| `file` | ✓ (path) | ✓ | Reads content from a specified file path | -| `gitdiff` | ✓ (sha) | ✓ | Retrieves git diff information (unstaged/staged/sha) | -| `gitstatus` | - | ✓ | Retrieves git status information | -| `glob` | ✓ (pattern) | ✓ | Lists filenames matching a pattern in workspace | -| `grep` | ✓ (pattern) | ✓ | Searches for a pattern across files in workspace | -| `quickfix` | - | ✓ | Includes content of files in quickfix list | -| `register` | ✓ (register) | ✓ | Provides access to specified Vim register | -| `url` | ✓ (url) | ✓ | Fetches content from a specified URL | - -Examples: - -```markdown -> #buffer:init.lua -> #buffers:visible -> #diagnostics:current -> #file:path/to/file.js -> #git:staged -> #glob:`**/*.lua` -> #grep:`function setup` -> #quickfix -> #register:+ -> #url:https://example.com -> @glob -> @grep -> @file -``` - Define your own functions in the configuration with input handling and schema: ```lua @@ -356,47 +316,46 @@ Define your own functions in the configuration with input handling and schema: } ``` -### External Functions - -For external functions implementations, see the [discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/functions). - ## Selections -Selections determine the source content for chat interactions. - -Available selections are located in `local select = require("CopilotChat.select")`: - -| Selection | Description | -| --------- | ------------------------------------------------------ | -| `visual` | Current visual selection | -| `buffer` | Current buffer content | -| `line` | Current line content | -| `unnamed` | Unnamed register (last deleted/changed/yanked content) | - -You can set a default selection in the configuration: +Control what content is automatically included: ```lua { - -- Uses visual selection or falls back to buffer + -- Use visual selection, fallback to current line selection = function(source) - return select.visual(source) or select.buffer(source) - end + return require('CopilotChat.select').visual(source) or + require('CopilotChat.select').line(source) + end, } ``` -## Providers +**Available selections:** -Providers are modules that implement integration with different AI providers. +- `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) -### Built-in Providers +## Providers -- `copilot` - Default GitHub Copilot provider used for chat -- `github_models` - Provider for GitHub Marketplace models (disabled by default, enable it via `providers.github_models.disabled = false`) -- `copilot_embeddings` - Provider for Copilot embeddings, not standalone, used by `copilot` and `github_models` providers +Add custom AI providers: -### Provider Interface +```lua +{ + 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, + prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, + prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, + } + } +} +``` -Custom providers can implement these methods: +**Provider Interface:** ```lua { @@ -426,134 +385,11 @@ Custom providers can implement these methods: } ``` -### External Providers - -For external providers (Ollama, LM Studio, Mistral.ai), see the [providers discussion page](https://github.com/CopilotC-Nvim/CopilotChat.nvim/discussions/categories/providers). - -# Configuration - -## Default Configuration - -Below are all available configuration options with their default values: - -```lua -{ - - -- 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 /). - - 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 @). - 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) +**Built-in providers:** - 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 - remember_as_sticky = true, -- Remember config as sticky prompts when asking questions - - -- default selection - -- see select.lua for implementation - selection = require('CopilotChat.select').visual, - - -- default window options - window = { - layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace', or a function that returns the layout - width = 0.5, -- fractional width of parent, or absolute width in columns when > 1 - height = 0.5, -- fractional height of parent, or absolute height in rows when > 1 - -- Options below only apply to floating windows - relative = 'editor', -- 'editor', 'win', 'cursor', 'mouse' - border = 'single', -- 'none', single', 'double', 'rounded', 'solid', 'shadow' - row = nil, -- row position of the window, default is centered - col = nil, -- column position of the window, default is centered - title = 'Copilot Chat', -- title of chat window - footer = nil, -- footer of chat window - zindex = 1, -- determines if window is on top or below other floating windows - blend = 0, -- window blend (transparency), 0-100, 0 is opaque, 100 is fully transparent - }, - - show_help = true, -- Shows help message as virtual lines when waiting for user input - show_folds = true, -- Shows folds for sections in chat - 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 - 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 - - -- Static config starts here (can be configured only via setup function) - - debug = false, -- Enable debug logging (same as 'log_level = 'debug') - log_level = 'info', -- Log level to use, 'trace', 'debug', 'info', 'warn', 'error', 'fatal' - proxy = nil, -- [protocol://]host[:port] Use this proxy - allow_insecure = false, -- Allow insecure server connections - - 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 - 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 - }, - - separator = '───', -- Separator to use in chat - - -- default providers - -- see config/providers.lua for implementation - providers = require('CopilotChat.config.providers'), - - -- default functions - -- see config/functions.lua for implementation - functions = require('CopilotChat.config.functions'), - - -- default prompts - -- see config/prompts.lua for implementation - prompts = require('CopilotChat.config.prompts'), - - -- default mappings - -- see config/mappings.lua for implementation - mappings = require('CopilotChat.config.mappings'), -} -``` - -## Customizing Buffers - -Types of copilot buffers: - -- `copilot-chat` - Main chat buffer -- `copilot-overlay` - Overlay buffers (e.g. help, info, diff) - -You can set local options for plugin buffers like this: - -```lua -vim.api.nvim_create_autocmd('BufEnter', { - pattern = 'copilot-*', - callback = function() - -- Set buffer-local options - vim.opt_local.relativenumber = false - vim.opt_local.number = false - vim.opt_local.conceallevel = 0 - end -}) -``` - -## Customizing Highlights - -Types of copilot highlights: - -- `CopilotChatHeader` - Header highlight in chat buffer -- `CopilotChatSeparator` - Separator highlight in chat buffer -- `CopilotChatStatus` - Status and spinner in chat buffer -- `CopilotChatHelp` - Help messages in chat buffer (help, references) -- `CopilotChatSelection` - Selection highlight in source buffer -- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) -- `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) +- `copilot` - GitHub Copilot (default) +- `github_models` - GitHub Marketplace models (disabled by default) +- `copilot_embeddings` - Copilot embeddings provider # API Reference From 5ee40855485b5e1e661b74ecd57e37801594ae4a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 Aug 2025 13:36:23 +0000 Subject: [PATCH 316/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 571 +++++++++++++++----------------------------- 1 file changed, 198 insertions(+), 373 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 9888219d..743276dd 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -3,25 +3,28 @@ ============================================================================== Table of Contents *CopilotChat-table-of-contents* -1. Requirements |CopilotChat-requirements| +1. Installation |CopilotChat-installation| + - Requirements |CopilotChat-requirements| - Optional Dependencies |CopilotChat-optional-dependencies| - Integration with pickers |CopilotChat-integration-with-pickers| -2. Installation |CopilotChat-installation| - lazy.nvim |CopilotChat-lazy.nvim| - vim-plug |CopilotChat-vim-plug| - - Manual |CopilotChat-manual| -3. Features |CopilotChat-features| +2. Core Concepts |CopilotChat-core-concepts| + - Examples |CopilotChat-examples| +3. Usage |CopilotChat-usage| - Commands |CopilotChat-commands| - Key Mappings |CopilotChat-key-mappings| + - Built-in Functions |CopilotChat-built-in-functions| + - Predefined Prompts |CopilotChat-predefined-prompts| +4. Configuration |CopilotChat-configuration| + - Quick Setup |CopilotChat-quick-setup| + - Window & Appearance |CopilotChat-window-&-appearance| + - Buffer Behavior |CopilotChat-buffer-behavior| + - Highlights |CopilotChat-highlights| - Prompts |CopilotChat-prompts| - - Models |CopilotChat-models| - Functions |CopilotChat-functions| - Selections |CopilotChat-selections| - Providers |CopilotChat-providers| -4. Configuration |CopilotChat-configuration| - - Default Configuration |CopilotChat-default-configuration| - - Customizing Buffers |CopilotChat-customizing-buffers| - - Customizing Highlights |CopilotChat-customizing-highlights| 5. API Reference |CopilotChat-api-reference| - Core |CopilotChat-core| - Chat Window |CopilotChat-chat-window| @@ -33,26 +36,28 @@ Table of Contents *CopilotChat-table-of-contents* 8. Stargazers |CopilotChat-stargazers| 9. Links |CopilotChat-links| -CopilotChat.nvim is a Neovim plugin that brings GitHub Copilot Chat -capabilities directly into your editor. It provides: +CopilotChat.nvim brings GitHub Copilot Chat capabilities directly into Neovim +with a focus on transparency and user control. -- 🤖 GitHub Copilot Chat integration with official model support (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash, and more) -- 💻 Rich workspace context powered by smart embeddings system -- 🔒 Explicit data sharing - only sends what you specifically request, either as resource or selection (by default visual selection) -- 🔌 Modular provider architecture supporting both official and custom LLM backends (Ollama, Gemini, Mistral.ai and more) -- 📝 Interactive chat UI with completion, diffs and quickfix integration -- 🎯 Powerful prompt system with composable templates and sticky prompts -- 🔄 Extensible function calling system for granular workspace understanding (buffers, files, git diffs, URLs, and more) -- ⚡ Efficient token usage with tiktoken token counting and history management +- 🤖 **Multiple AI Models** - GitHub Copilot (GPT-4o, Claude 3.7 Sonnet, Gemini 2.0 Flash) + custom providers (Ollama, Mistral.ai) +- 🔧 **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 +- 🎯 **Smart Prompts** - Composable templates and sticky prompts for consistent context +- ⚡ **Efficient** - Smart token usage with tiktoken counting and history management +- 🔌 **Extensible** - Custom functions and providers , plus integrations like mcphub.nvim ============================================================================== -1. Requirements *CopilotChat-requirements* +1. Installation *CopilotChat-installation* + -- Neovim 0.10.0+ - Older versions are not officially supported -- curl - Version 8.0.0+ recommended for best compatibility +REQUIREMENTS *CopilotChat-requirements* + +- Neovim 0.10.0+ +- curl 8.0.0+ - Copilot chat in the IDE enabled in GitHub settings -- plenary.nvim - Plugin dependency +- plenary.nvim [!WARNING] For Neovim < 0.11.0, add `noinsert` or `noselect` to your @@ -82,16 +87,6 @@ very basic). Here are some examples: - snacks.picker - enable `ui_select` config - mini.pick - set `vim.ui.select = require('mini.pick').ui_select` -Plugin features that use picker: - -- `:CopilotChatPrompts` - for selecting prompts -- `:CopilotChatModels` - for selecting models -- `#:` - for selecting function input - - -============================================================================== -2. Installation *CopilotChat-installation* - LAZY.NVIM *CopilotChat-lazy.nvim* @@ -100,25 +95,19 @@ LAZY.NVIM *CopilotChat-lazy.nvim* { "CopilotC-Nvim/CopilotChat.nvim", dependencies = { - { "nvim-lua/plenary.nvim", branch = "master" }, -- for curl, log and async functions + { "nvim-lua/plenary.nvim", branch = "master" }, }, build = "make tiktoken", opts = { -- See Configuration section for options }, - -- See Commands section for default commands if you want to lazy load on them }, } < -See @jellydn for configuration - - VIM-PLUG *CopilotChat-vim-plug* -Similar to the lazy setup, you can use the following configuration: - >vim call plug#begin() Plug 'nvim-lua/plenary.nvim' @@ -126,45 +115,49 @@ Similar to the lazy setup, you can use the following configuration: call plug#end() lua << EOF - require("CopilotChat").setup { - -- See Configuration section for options - } + require("CopilotChat").setup() EOF < -MANUAL *CopilotChat-manual* +============================================================================== +2. Core Concepts *CopilotChat-core-concepts* -1. Put the files in the right place +- **Resources** (`#`) - Add specific content (files, git diffs, URLs) to your prompt +- **Tools** (`@`) - Give LLM access to functions it can call with your approval +- **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 -> - mkdir -p ~/.config/nvim/pack/copilotchat/start - cd ~/.config/nvim/pack/copilotchat/start - - git clone https://github.com/nvim-lua/plenary.nvim - git clone https://github.com/CopilotC-Nvim/CopilotChat.nvim -< -1. Add to your configuration (e.g. `~/.config/nvim/init.lua`) +EXAMPLES *CopilotChat-examples* ->lua - require("CopilotChat").setup { - -- See Configuration section for options - } +>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:current + > You are a helpful coding assistant < -See @deathbeam for configuration - +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. ============================================================================== -3. Features *CopilotChat-features* +3. Usage *CopilotChat-usage* COMMANDS *CopilotChat-commands* -Commands are used to control the chat interface: - Command Description -------------------------- ------------------------------- :CopilotChat ? Open chat with optional input @@ -181,8 +174,6 @@ Commands are used to control the chat interface: KEY MAPPINGS *CopilotChat-key-mappings* -Default mappings in the chat interface: - Insert Normal Action ----------- -------- -------------------------------------------- - Trigger/accept completion menu for tokens @@ -199,190 +190,162 @@ Default mappings in the chat interface: - gd Show diff between source and nearest diff - gc Show info about current chat - gh Show help message -The mappings can be customized by setting the `mappings` table in your -configuration. Each mapping can have: -- `normal`: Key for normal mode -- `insert`: Key for insert mode +BUILT-IN FUNCTIONS *CopilotChat-built-in-functions* -For example, to change the complete mapping to Tab or show_diff full diff -option: + ------------------------------------------------------------------------------ + Function Description Example Usage + ------------- ----------------------------------------- ---------------------- + buffer Retrieves content from a specific buffer #buffer ->lua - { - mappings = { - complete = { - insert = '' - } - show_diff = { - full_diff = true - } - } - } -< + buffers Fetches content from multiple buffers #buffers:visible + diagnostics Collects code diagnostics (errors, #diagnostics:current + warnings) -PROMPTS *CopilotChat-prompts* + file Reads content from a specified file path #file:path/to/file + gitdiff Retrieves git diff information #gitdiff:staged -PREDEFINED PROMPTS ~ + gitstatus Retrieves git status information #gitstatus -Predefined prompt templates for common tasks. Reference them with `/PromptName` -in chat, use `:CopilotChat` or `:CopilotChatPrompts` to select -them: + glob Lists filenames matching a pattern in #glob:**/*.lua + workspace - Prompt Description - ---------- -------------------------------------------------- - Explain Write an explanation for the selected code - Review Review the selected code - Fix Rewrite the code with bug fixes - Optimize Optimize code for performance and readability - Docs Add documentation comments to the code - Tests Generate tests for the code - Commit Write commit message using commitizen convention -Define your own prompts in the configuration: + grep Searches for a pattern across files in #grep:TODO + workspace ->lua - { - prompts = { - MyCustomPrompt = { - prompt = 'Explain how it works.', - system_prompt = 'You are very good at explaining stuff', - mapping = 'ccmc', - description = 'My custom prompt description', - } - } - } -< + quickfix Includes content of files in quickfix #quickfix + list + register Provides access to specified Vim register #register:+ -SYSTEM PROMPTS ~ + url Fetches content from a specified URL #url:https://... + ------------------------------------------------------------------------------ -System prompts define the AI model’s behavior. Reference them with -`/PROMPT_NAME` in chat: +PREDEFINED PROMPTS *CopilotChat-predefined-prompts* - Prompt Description - ---------------------- -------------------------------------------- - COPILOT_BASE All prompts should be built on top of this - COPILOT_INSTRUCTIONS Base instructions - COPILOT_EXPLAIN Adds coding tutor behavior - COPILOT_REVIEW Adds code review behavior with diagnostics -Define your own system prompts in the configuration (similar to `prompts`): +Use with `:CopilotChat` or reference with `/PromptName`: ->lua - { - prompts = { - Yarrr = { - 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, - } - } - } -< +- `/Explain` - Explain selected code +- `/Review` - Code review +- `/Fix` - Fix bugs +- `/Optimize` - Performance improvements +- `/Docs` - Add documentation +- `/Tests` - Generate tests +- `/Commit` - Commit message -STICKY PROMPTS ~ - -Sticky prompts persist across chat sessions. They’re useful for maintaining -model or resource selection. They work as follows: +============================================================================== +4. Configuration *CopilotChat-configuration* -1. Prefix text with `>` using markdown blockquote syntax -2. The prompt will be copied at the start of every new chat prompt -3. Edit sticky prompts freely while maintaining the `>` prefix +For all available configuration options, see `lua/CopilotChat/config.lua` +. -Examples: ->markdown - > #glob:`*.lua` - > List all files in the workspace - - > @models Using Mistral-small - > What is 1 + 11 -< +QUICK SETUP *CopilotChat-quick-setup* -You can also set default sticky prompts in the configuration: +Most users only need to configure a few options: >lua { - sticky = { - '#glob:*.lua', - } + model = 'gpt-4.1', -- AI model to use + temperature = 0.1, -- Lower = focused, higher = creative + window = { + layout = 'vertical', -- 'vertical', 'horizontal', 'float' + width = 0.5, -- 50% of screen width + }, + auto_insert_mode = true, -- Enter insert mode when opening } < -MODELS *CopilotChat-models* +WINDOW & APPEARANCE *CopilotChat-window-&-appearance* -You can control which AI model to use in three ways: - -1. List available models with `:CopilotChatModels` -2. Set model in prompt with `$model_name` -3. Configure default model via `model` config key - -For supported models, see: - -- Copilot Chat Models -- GitHub Marketplace Models (experimental, limited usage) +>lua + { + window = { + layout = 'float', + width = 80, -- Fixed width in columns + height = 20, -- Fixed height in rows + border = 'rounded', -- 'single', 'double', 'rounded', 'solid' + title = '🤖 AI Assistant', + zindex = 100, -- Ensure window stays on top + }, + + headers = { + user = '👤 You: ', + assistant = '🤖 Copilot: ', + tool = '🔧 Tool: ', + }, + separator = '━━', + show_folds = false, -- Disable folding for cleaner look + } +< -FUNCTIONS *CopilotChat-functions* +BUFFER BEHAVIOR *CopilotChat-buffer-behavior* -Functions provide additional information and behaviour to the chat. Functions -can be organized into groups by setting the `group` property. Functions can be -made available to the LLM with `@group_name` or `@function_name` syntax. LLM -will then be able to use them in responses as tool calls. If function has URI, -they can also be used directly in prompt with `#function_name[:input]` syntax -for providing context as resources. +>lua + -- Auto-command to customize chat buffer behavior + vim.api.nvim_create_autocmd('BufEnter', { + pattern = 'copilot-*', + callback = function() + vim.opt_local.relativenumber = false + vim.opt_local.number = false + vim.opt_local.conceallevel = 0 + end, + }) +< - ------------------------------------------------------------------------------- - Function Input URI Description - Support - ------------- ------------ ----- ---------------------------------------------- - buffer ✓ (name) ✓ Retrieves content from a specific buffer - buffers ✓ (scope) ✓ Fetches content from multiple buffers - (listed/visible) +HIGHLIGHTS *CopilotChat-highlights* - diagnostics ✓ (scope) ✓ Collects code diagnostics (errors, warnings) +You can customize colors by setting highlight groups in your config: - file ✓ (path) ✓ Reads content from a specified file path +>lua + -- 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 }) +< - gitdiff ✓ (sha) ✓ Retrieves git diff information - (unstaged/staged/sha) +Types of copilot highlights: - gitstatus - ✓ Retrieves git status information +- `CopilotChatHeader` - Header highlight in chat buffer +- `CopilotChatSeparator` - Separator highlight in chat buffer +- `CopilotChatStatus` - Status and spinner in chat buffer +- `CopilotChatHelp` - Help messages in chat buffer (help, references) +- `CopilotChatSelection` - Selection highlight in source buffer +- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) +- `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) - glob ✓ (pattern) ✓ Lists filenames matching a pattern in - workspace - grep ✓ (pattern) ✓ Searches for a pattern across files in - workspace +PROMPTS *CopilotChat-prompts* - quickfix - ✓ Includes content of files in quickfix list +Define your own prompts in the configuration: - register ✓ (register) ✓ Provides access to specified Vim register +>lua + { + prompts = { + MyCustomPrompt = { + prompt = 'Explain how it works.', + system_prompt = 'You are very good at explaining stuff', + mapping = 'ccmc', + description = 'My custom prompt description', + }, + Yarrr = { + 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, + } + } + } +< - url ✓ (url) ✓ Fetches content from a specified URL - ------------------------------------------------------------------------------- -Examples: ->markdown - > #buffer:init.lua - > #buffers:visible - > #diagnostics:current - > #file:path/to/file.js - > #git:staged - > #glob:`**/*.lua` - > #grep:`function setup` - > #quickfix - > #register:+ - > #url:https://example.com - > @glob - > @grep - > @file -< +FUNCTIONS *CopilotChat-functions* Define your own functions in the configuration with input handling and schema: @@ -418,52 +381,47 @@ Define your own functions in the configuration with input handling and schema: < -EXTERNAL FUNCTIONS ~ - -For external functions implementations, see the discussion page -. - - SELECTIONS *CopilotChat-selections* -Selections determine the source content for chat interactions. - -Available selections are located in `local select = -require("CopilotChat.select")`: - - Selection Description - ----------- -------------------------------------------------------- - visual Current visual selection - buffer Current buffer content - line Current line content - unnamed Unnamed register (last deleted/changed/yanked content) -You can set a default selection in the configuration: +Control what content is automatically included: >lua { - -- Uses visual selection or falls back to buffer + -- Use visual selection, fallback to current line selection = function(source) - return select.visual(source) or select.buffer(source) - end + return require('CopilotChat.select').visual(source) or + require('CopilotChat.select').line(source) + end, } < +**Available selections:** -PROVIDERS *CopilotChat-providers* - -Providers are modules that implement integration with different AI providers. - +- `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) -BUILT-IN PROVIDERS ~ -- `copilot` - Default GitHub Copilot provider used for chat -- `github_models` - Provider for GitHub Marketplace models (disabled by default, enable it via `providers.github_models.disabled = false`) -- `copilot_embeddings` - Provider for Copilot embeddings, not standalone, used by `copilot` and `github_models` providers +PROVIDERS *CopilotChat-providers* +Add custom AI providers: -PROVIDER INTERFACE ~ +>lua + { + 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, + prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, + prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, + } + } + } +< -Custom providers can implement these methods: +**Provider Interface:** >lua { @@ -493,142 +451,11 @@ Custom providers can implement these methods: } < +**Built-in providers:** -EXTERNAL PROVIDERS ~ - -For external providers (Ollama, LM Studio, Mistral.ai), see the providers -discussion page -. - - -============================================================================== -4. Configuration *CopilotChat-configuration* - - -DEFAULT CONFIGURATION *CopilotChat-default-configuration* - -Below are all available configuration options with their default values: - ->lua - { - - -- 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 /). - - 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 @). - 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 - remember_as_sticky = true, -- Remember config as sticky prompts when asking questions - - -- default selection - -- see select.lua for implementation - selection = require('CopilotChat.select').visual, - - -- default window options - window = { - layout = 'vertical', -- 'vertical', 'horizontal', 'float', 'replace', or a function that returns the layout - width = 0.5, -- fractional width of parent, or absolute width in columns when > 1 - height = 0.5, -- fractional height of parent, or absolute height in rows when > 1 - -- Options below only apply to floating windows - relative = 'editor', -- 'editor', 'win', 'cursor', 'mouse' - border = 'single', -- 'none', single', 'double', 'rounded', 'solid', 'shadow' - row = nil, -- row position of the window, default is centered - col = nil, -- column position of the window, default is centered - title = 'Copilot Chat', -- title of chat window - footer = nil, -- footer of chat window - zindex = 1, -- determines if window is on top or below other floating windows - blend = 0, -- window blend (transparency), 0-100, 0 is opaque, 100 is fully transparent - }, - - show_help = true, -- Shows help message as virtual lines when waiting for user input - show_folds = true, -- Shows folds for sections in chat - 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 - 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 - - -- Static config starts here (can be configured only via setup function) - - debug = false, -- Enable debug logging (same as 'log_level = 'debug') - log_level = 'info', -- Log level to use, 'trace', 'debug', 'info', 'warn', 'error', 'fatal' - proxy = nil, -- [protocol://]host[:port] Use this proxy - allow_insecure = false, -- Allow insecure server connections - - 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 - 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 - }, - - separator = '───', -- Separator to use in chat - - -- default providers - -- see config/providers.lua for implementation - providers = require('CopilotChat.config.providers'), - - -- default functions - -- see config/functions.lua for implementation - functions = require('CopilotChat.config.functions'), - - -- default prompts - -- see config/prompts.lua for implementation - prompts = require('CopilotChat.config.prompts'), - - -- default mappings - -- see config/mappings.lua for implementation - mappings = require('CopilotChat.config.mappings'), - } -< - - -CUSTOMIZING BUFFERS *CopilotChat-customizing-buffers* - -Types of copilot buffers: - -- `copilot-chat` - Main chat buffer -- `copilot-overlay` - Overlay buffers (e.g. help, info, diff) - -You can set local options for plugin buffers like this: - ->lua - vim.api.nvim_create_autocmd('BufEnter', { - pattern = 'copilot-*', - callback = function() - -- Set buffer-local options - vim.opt_local.relativenumber = false - vim.opt_local.number = false - vim.opt_local.conceallevel = 0 - end - }) -< - - -CUSTOMIZING HIGHLIGHTS *CopilotChat-customizing-highlights* - -Types of copilot highlights: - -- `CopilotChatHeader` - Header highlight in chat buffer -- `CopilotChatSeparator` - Separator highlight in chat buffer -- `CopilotChatStatus` - Status and spinner in chat buffer -- `CopilotChatHelp` - Help messages in chat buffer (help, references) -- `CopilotChatSelection` - Selection highlight in source buffer -- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) -- `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) +- `copilot` - GitHub Copilot (default) +- `github_models` - GitHub Marketplace models (disabled by default) +- `copilot_embeddings` - Copilot embeddings provider ============================================================================== @@ -801,9 +628,7 @@ Contributions of any kind are welcome! ============================================================================== 9. Links *CopilotChat-links* -1. *@jellydn*: -2. *@deathbeam*: -3. *Stargazers over time*: https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg?variant=adaptive +1. *Stargazers over time*: https://starchart.cc/CopilotC-Nvim/CopilotChat.nvim.svg?variant=adaptive Generated by panvimdoc From 8116b2d79bb335b8a7d3f19853b4271cd8ca6b06 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 2 Aug 2025 15:51:32 +0200 Subject: [PATCH 317/589] docs(README): use table for prompts as well (#1254) * docs(README): use table for prompts as well 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 | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b0ea7e5e..22de3b98 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif | `:CopilotChatModels` | View/select available models | | `:CopilotChat` | Use specific prompt template | -## Key Mappings +## Chat Key Mappings | Insert | Normal | Action | | ----------- | ------- | ------------------------------------------ | @@ -152,7 +152,7 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif | - | `gc` | Show info about current chat | | - | `gh` | Show help message | -## Built-in Functions +## Functions | Function | Description | Example Usage | | ------------- | ------------------------------------------------ | ---------------------- | @@ -168,17 +168,17 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif | `register` | Provides access to specified Vim register | `#register:+` | | `url` | Fetches content from a specified URL | `#url:https://...` | -## Predefined Prompts - -Use with `:CopilotChat` or reference with `/PromptName`: +## Prompts -- `/Explain` - Explain selected code -- `/Review` - Code review -- `/Fix` - Fix bugs -- `/Optimize` - Performance improvements -- `/Docs` - Add documentation -- `/Tests` - Generate tests -- `/Commit` - Commit message +| Prompt | Description | +| ---------- | ---------------------------------------------------------------------- | +| `Explain` | Write detailed explanation of selected code as paragraphs | +| `Review` | Comprehensive code review with line-specific issue reporting | +| `Fix` | Identify problems and rewrite code with fixes and explanation | +| `Optimize` | Improve performance and readability with optimization strategy | +| `Docs` | Add documentation comments to selected code | +| `Tests` | Generate tests for selected code | +| `Commit` | Generate commit message with commitizen convention from staged changes | # Configuration From 4eb9076dbaffdbabd919c3719346047175997c79 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 Aug 2025 13:51:52 +0000 Subject: [PATCH 318/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 36 ++++++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 743276dd..e5596682 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -13,9 +13,9 @@ Table of Contents *CopilotChat-table-of-contents* - Examples |CopilotChat-examples| 3. Usage |CopilotChat-usage| - Commands |CopilotChat-commands| - - Key Mappings |CopilotChat-key-mappings| - - Built-in Functions |CopilotChat-built-in-functions| - - Predefined Prompts |CopilotChat-predefined-prompts| + - Chat Key Mappings |CopilotChat-chat-key-mappings| + - Functions |CopilotChat-functions| + - Prompts |CopilotChat-prompts| 4. Configuration |CopilotChat-configuration| - Quick Setup |CopilotChat-quick-setup| - Window & Appearance |CopilotChat-window-&-appearance| @@ -172,7 +172,7 @@ COMMANDS *CopilotChat-commands* :CopilotChatModels View/select available models :CopilotChat Use specific prompt template -KEY MAPPINGS *CopilotChat-key-mappings* +CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* Insert Normal Action ----------- -------- -------------------------------------------- @@ -191,7 +191,7 @@ KEY MAPPINGS *CopilotChat-key-mappings* - gc Show info about current chat - gh Show help message -BUILT-IN FUNCTIONS *CopilotChat-built-in-functions* +FUNCTIONS *CopilotChat-functions* ------------------------------------------------------------------------------ Function Description Example Usage @@ -223,18 +223,26 @@ BUILT-IN FUNCTIONS *CopilotChat-built-in-functions* url Fetches content from a specified URL #url:https://... ------------------------------------------------------------------------------ -PREDEFINED PROMPTS *CopilotChat-predefined-prompts* +PROMPTS *CopilotChat-prompts* + + ------------------------------------------------------------------------- + Prompt Description + ---------- -------------------------------------------------------------- + Explain Write detailed explanation of selected code as paragraphs + + Review Comprehensive code review with line-specific issue reporting + + Fix Identify problems and rewrite code with fixes and explanation + + Optimize Improve performance and readability with optimization strategy -Use with `:CopilotChat` or reference with `/PromptName`: + Docs Add documentation comments to selected code -- `/Explain` - Explain selected code -- `/Review` - Code review -- `/Fix` - Fix bugs -- `/Optimize` - Performance improvements -- `/Docs` - Add documentation -- `/Tests` - Generate tests -- `/Commit` - Commit message + Tests Generate tests for selected code + Commit Generate commit message with commitizen convention from staged + changes + ------------------------------------------------------------------------- ============================================================================== 4. Configuration *CopilotChat-configuration* From e1dc20c2e8b85f2133e4629203444825e87f15ea Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 2 Aug 2025 16:11:47 +0200 Subject: [PATCH 319/589] docs(README): Fix duplicate headers (#1256) Closes #1255 Signed-off-by: Tomas Slusny --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 22de3b98..d70e31a4 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,9 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif | - | `gc` | Show info about current chat | | - | `gh` | Show help message | -## Functions +## Predefined Functions + +All predefined functions belong to the `copilot` group. | Function | Description | Example Usage | | ------------- | ------------------------------------------------ | ---------------------- | @@ -168,7 +170,7 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif | `register` | Provides access to specified Vim register | `#register:+` | | `url` | Fetches content from a specified URL | `#url:https://...` | -## Prompts +## Predefined Prompts | Prompt | Description | | ---------- | ---------------------------------------------------------------------- | From 560d61c4da98da53cf35bdb2359ff6f76b3c12b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 2 Aug 2025 14:12:05 +0000 Subject: [PATCH 320/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index e5596682..c23f2808 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -14,8 +14,8 @@ Table of Contents *CopilotChat-table-of-contents* 3. Usage |CopilotChat-usage| - Commands |CopilotChat-commands| - Chat Key Mappings |CopilotChat-chat-key-mappings| - - Functions |CopilotChat-functions| - - Prompts |CopilotChat-prompts| + - Predefined Functions |CopilotChat-predefined-functions| + - Predefined Prompts |CopilotChat-predefined-prompts| 4. Configuration |CopilotChat-configuration| - Quick Setup |CopilotChat-quick-setup| - Window & Appearance |CopilotChat-window-&-appearance| @@ -191,7 +191,9 @@ CHAT KEY MAPPINGS *CopilotChat-chat-key-mappings* - gc Show info about current chat - gh Show help message -FUNCTIONS *CopilotChat-functions* +PREDEFINED FUNCTIONS *CopilotChat-predefined-functions* + +All predefined functions belong to the `copilot` group. ------------------------------------------------------------------------------ Function Description Example Usage @@ -223,7 +225,7 @@ FUNCTIONS *CopilotChat-functions* url Fetches content from a specified URL #url:https://... ------------------------------------------------------------------------------ -PROMPTS *CopilotChat-prompts* +PREDEFINED PROMPTS *CopilotChat-predefined-prompts* ------------------------------------------------------------------------- Prompt Description From 4d2586be38a6dbb07fec5d5f3d3335e973ea0ae1 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 2 Aug 2025 18:19:02 +0200 Subject: [PATCH 321/589] fix(functions): properly allow skipping handling for tools (#1257) Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 67848dd8..163287b5 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -973,23 +973,40 @@ function M.ask(prompt, config) if not config.headless then utils.schedule_main() + -- Remove any tool calls that we did not handle + local assistant_message = M.chat:get_message('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 + + assistant_message.tool_calls = vim + .iter(assistant_message.tool_calls) + :filter(function(tool_call) + return handled_ids[tool_call.id] + end) + :totable() + end + if not utils.empty(resolved_tools) then + -- If we are handling tools, replace user message with tool results M.chat:remove_message('user') + for _, tool in ipairs(resolved_tools) do + M.chat:add_message({ + id = tool.id, + 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({ role = 'user', content = '\n' .. prompt .. '\n', }, true) end - - for _, tool in ipairs(resolved_tools) do - M.chat:add_message({ - id = tool.id, - role = 'tool', - tool_call_id = tool.id, - content = '\n' .. tool.result .. '\n', - }) - end end if utils.empty(prompt) and utils.empty(resolved_tools) then From bad83db89bb3d813be62dd1b2767406ac3c96e4c Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 2 Aug 2025 18:30:10 +0200 Subject: [PATCH 322/589] fix(chat): handle empty prompt and tools before ask (#1258) Move the check for empty prompt and resolved tools before making the ask call. This ensures that unnecessary user messages are removed and the finish callback is called early, preventing redundant processing. Also remove assistant message if all tool calls are handled. Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 163287b5..d2dbee0c 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -969,10 +969,17 @@ function M.ask(prompt, config) end prompt = vim.trim(prompt) + utils.schedule_main() - if not config.headless then - utils.schedule_main() + if utils.empty(prompt) and utils.empty(resolved_tools) then + if not config.headless then + M.chat:remove_message('user') + finish() + end + return + end + if not config.headless then -- Remove any tool calls that we did not handle local assistant_message = M.chat:get_message('assistant') if assistant_message and assistant_message.tool_calls then @@ -987,6 +994,10 @@ function M.ask(prompt, config) return handled_ids[tool_call.id] end) :totable() + + if utils.empty(assistant_message.tool_calls) then + M.chat:remove_message('assistant') + end end if not utils.empty(resolved_tools) then @@ -1009,14 +1020,6 @@ function M.ask(prompt, config) end end - if utils.empty(prompt) and utils.empty(resolved_tools) then - if not config.headless then - M.chat:remove_message('user') - finish() - end - return - end - local ask_ok, ask_response = pcall(client.ask, client, prompt, { headless = config.headless, history = M.chat.messages, From 936426a500d2f0da25f7d3f065e07450ac851c66 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sat, 2 Aug 2025 18:51:36 +0200 Subject: [PATCH 323/589] fix(chat): handle skipped tool calls with explicit error result (#1259) Previously, unhandled tool calls were simply removed from the assistant message, which could lead to confusion or lack of feedback. Now, when a tool call is skipped, an explicit error result is added to the resolved tools, indicating that the user skipped the function call. This improves transparency and makes the handling of skipped tool calls clearer. Signed-off-by: Tomas Slusny --- lua/CopilotChat/init.lua | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index d2dbee0c..4e81c934 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -971,16 +971,7 @@ function M.ask(prompt, config) prompt = vim.trim(prompt) utils.schedule_main() - if utils.empty(prompt) and utils.empty(resolved_tools) then - if not config.headless then - M.chat:remove_message('user') - finish() - end - return - end - if not config.headless then - -- Remove any tool calls that we did not handle local assistant_message = M.chat:get_message('assistant') if assistant_message and assistant_message.tool_calls then local handled_ids = {} @@ -988,15 +979,15 @@ function M.ask(prompt, config) handled_ids[tool.id] = true end - assistant_message.tool_calls = vim - .iter(assistant_message.tool_calls) - :filter(function(tool_call) - return handled_ids[tool_call.id] - end) - :totable() - - if utils.empty(assistant_message.tool_calls) then - M.chat:remove_message('assistant') + -- 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 @@ -1020,6 +1011,14 @@ function M.ask(prompt, config) end end + if utils.empty(prompt) and utils.empty(resolved_tools) then + if not config.headless then + M.chat:remove_message('user') + finish() + end + return + end + local ask_ok, ask_response = pcall(client.ask, client, prompt, { headless = config.headless, history = M.chat.messages, From 95d4d7ade6b0ae8acee77f291228109dc37602cc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 3 Aug 2025 06:34:55 +0200 Subject: [PATCH 324/589] chore(main): release 4.0.0 (#1191) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10dde051..12e31352 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## [4.0.0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.12.2...v4.0.0) (2025-08-02) + + +### ⚠ BREAKING CHANGES + +* **mappings:** use C-Space as default completion trigger instead of Tab +* **providers:** github_models provider is now disabled by default, enable with `providers.github_models.disabled = false` +* **resources:** intelligent resource processing is now disabled by default, use config.resource_processing: true to reenable +* **context:** Multiple breaking changes due to big refactor: + - The context API has changed from callback-based input handling to schema-based definitions. + - config.contexts renamed to config.tools + - config.context removed, use config.sticky + - diagnostics moved to separate tool call, selection and buffer calls no longer include them by default + - gi renamed to gc, now also includes selection + - filenames renamed to glob + - files removed (use glob together with tool calling instead, or buffers/quickfix) + - copilot extension agents removed, tools + mcp servers can replace this feature and maintaining them was pain, they can still be implemented via custom providers anyway + - actions and integrations action removed as they were deprecated for a while + - config.questionHeader, config.answerHeader moved to config.headers.user/config.headers.assistant + +### Features + +* add Windows_NT support in Makefile and dynamic library loading ([#1190](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1190)) ([7559fd2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/7559fd25928f8f3cf311ff25b95bdc5f9ec736d7)) +* **context:** switch from contexts to function calling ([057b8e4](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/057b8e46d955748b1426e7b174d7af3e58f5191b)), closes [#1045](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1045) [#1090](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1090) [#1096](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1096) [#526](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/526) +* display group as kind when listing resources ([#1215](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1215)) ([450fcec](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/450fcecf2f71d0469e9c98f5967252092714ed03)) +* **functions:** automatically parse schema from url templates ([#1220](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1220)) ([950fdb6](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/950fdb6ab56754929d4db91c73139b33e645deec)) +* **health:** add temp dir writable check ([#1239](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1239)) ([02cf9e5](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/02cf9e52634b3e3d45beb2c4e5bbc17da28aef64)) +* **mappings:** use C-Space as default completion trigger instead of Tab ([ea41684](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/ea4168476a0fdbd5bf40a4a769d6c1dc998929eb)) +* **prompts:** add configurable response language ([#1246](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1246)) ([ced388c](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/ced388c97b313ea235809824ed501970b155e59f)), closes [#1086](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1086) +* **providers:** add info output to panel for copilot with stats ([#1229](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1229)) ([1713ce6](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/1713ce6c8ec700a7833236a8dadfae8a0742b14d)) +* **providers:** new github models api, in-built authorization without copilot.vim dep ([#1218](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1218)) ([9c4501e](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/9c4501e7ae92020f2d9b828086016ee70e7fa52c)), closes [#1140](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1140) +* **providers:** prioritize gh clie auth if available for github models ([#1240](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1240)) ([01d38b2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/01d38b27ea2183302c743dac09b27611d09d7591)) +* **resources:** add option to enable resource processing ([#1202](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1202)) ([6ac77aa](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/6ac77aaa68a0ce7fe3c8c41622ab1986f8f6d2c7)) +* **ui:** add window.blend option for controllin float transparency ([#1227](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1227)) ([a01bbd6](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/a01bbd6779f4bee23c29ebcfe0d2f5fa5664b5bf)), closes [#1126](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1126) +* **ui:** highlight copilotchat keywords ([#1225](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1225)) ([8071a69](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/8071a6979b5569ce03f7f4d7192814da4c2d4e0b)) +* **ui:** improve chat responsiveness by starting spinner early ([#1205](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1205)) ([9d9b280](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/9d9b2809e1240f9525752ae145799b88d22cd7af)) + + +### Bug Fixes + +* add back sticky loading on opening window ([#1210](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1210)) ([1d6911f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/1d6911fef13952c9b56347485f090baeff77a7e4)) +* **chat:** do not allow sending empty prompt ([#1245](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1245)) ([c3d0048](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/c3d00484c42065a883db0fb859c686e277012d6c)), closes [#1189](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1189) +* **chat:** handle empty prompt and tools before ask ([#1258](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1258)) ([bad83db](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/bad83db89bb3d813be62dd1b2767406ac3c96e4c)) +* **chat:** handle skipped tool calls with explicit error result ([#1259](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1259)) ([936426a](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/936426a500d2f0da25f7d3f065e07450ac851c66)) +* **chat:** highlight keywords only in user messages ([#1236](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1236)) ([425ff0c](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/425ff0c48906a94ca522f6d2e98e4b39057e4fd4)) +* **chat:** improve how sticky prompts are stored and parsed ([#1233](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1233)) ([82be513](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/82be513c07a27f55860d55144c54040d1c93cf2a)) +* **chat:** properly replace all message data when replacing message ([#1244](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1244)) ([d1d155e](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/d1d155e50193e28a3ec00f8e21d6f11445f96ea1)) +* **chat:** properly reset modifiable after modifying it ([#1234](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1234)) ([fc93d1c](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/fc93d1c535bf9538a0a036f118b1034930ee5eb9)) +* **chat:** show messages in overlay ([#1237](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1237)) ([1a17534](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/1a17534c17e6ae9f5417df08b8c0eec434c47875)) +* check for explicit uri input properly ([#1214](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1214)) ([b738fb4](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/b738fb40de3a4bcbb835b8ff6ab2d171acc5d2dd)) +* **files:** use also plenary filetype on top of vim.filetype.match ([#1250](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1250)) ([9fd068f](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/9fd068f5d6a0ca00fc739a98f29125cb577b2dfa)), closes [#1249](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1249) +* **functions:** change neovim://buffer to just buffer:// to avoid conflicts ([#1252](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1252)) ([3509cf0](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/3509cf0971c59ba79fbcd618d82910f8567a7929)) +* **functions:** if enum returns only 1 choice auto accept it ([#1209](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1209)) ([e632470](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/e632470171cd82a95c2675360120833c159e7ae0)) +* **functions:** if schema.properties is empty, do not send schema ([#1211](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1211)) ([8a5cda1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/8a5cda1d90c4d4756dda39cfd748e52cbcde5a99)) +* **functions:** properly allow skipping handling for tools ([#1257](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1257)) ([4d2586b](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/4d2586be38a6dbb07fec5d5f3d3335e973ea0ae1)) +* **functions:** properly escape percent signs in uri inputs ([#1212](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1212)) ([d905917](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/d905917a025e4c056db28b3082dd474475bad8cd)) +* **functions:** properly filter tool schema from functions ([#1243](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1243)) ([f7a3228](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/f7a3228f155d0533197ac79b0e08582e504d0399)) +* **functions:** properly handle multiple tool calls at once ([#1198](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1198)) ([dd06166](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/dd0616661505a3c4892ddcdb9517b720a74e59b8)) +* **functions:** properly resolve defaults for diagnostics ([#1201](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1201)) ([946069a](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/946069a03946ce35619cbacc3a6757819d096ac5)), closes [#1200](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1200) +* **functions:** properly send prompt as 3rd function resolve param ([#1221](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1221)) ([c03bd1d](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/c03bd1df78b276aa5be2f173c2a31ad273164f15)) +* **functions:** use vim.filetype.match for non bulk file reads ([#1226](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1226)) ([b124b94](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/b124b94264140a5d352512b38b7a46d85ee59b24)), closes [#1181](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1181) +* **healthcheck:** chance copilot.vim dependency to optional ([#1219](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1219)) ([d9f4e29](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/d9f4e29c3b46b827443b1832209d22d05c1a69af)) +* **prompt:** be more specific when definining what is resource ([#1238](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1238)) ([7c82936](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/7c82936f2126b106af1b1bf0f9ae4d42dd45fcad)) +* properly validate source window when retrieving cwd ([#1231](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1231)) ([f53069c](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/f53069c595a3b12bbe8b9b711917f9ef33c22a0a)), closes [#1230](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1230) +* **providers:** do not save copilot.vim token ([#1223](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1223)) ([294bcb6](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/294bcb620ff66183e142cd8a43a7c77d5bc77a16)) +* **quickfix:** use new chat messages instead of old chat sections for populating qf ([#1199](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1199)) ([e0df6d1](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/e0df6d1242af29b6262b0eb3e4248568c57c4b3e)) +* **ui:** do not allow empty separator ([#1224](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1224)) ([67ed258](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/67ed258c6ccc0a9bfbb6dfcbe3d5e19e22888e73)) +* **ui:** fix check for auto follow cursor ([#1222](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1222)) ([1f96d53](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/1f96d53c3f10f176ca25065a23e610d7b4a72b99)) +* update sticky reference for commit messages ([#1207](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1207)) ([dab5089](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/dab50896c7e1e80142dd297e6fc75590735b3e9c)) +* update to latest lua actions and update README ([#1196](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1196)) ([b4b7f9c](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/b4b7f9c2bb34d43b18dbbe0a889881630e217bc3)) +* **utils:** remove temp file after curl request is done ([#1235](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1235)) ([dec3127](https://github.com/CopilotC-Nvim/CopilotChat.nvim/commit/dec3127e4f373875d7fd50854e221ed8dc0e061f)), closes [#1194](https://github.com/CopilotC-Nvim/CopilotChat.nvim/issues/1194) + ## [3.12.2](https://github.com/CopilotC-Nvim/CopilotChat.nvim/compare/v3.12.1...v3.12.2) (2025-07-09) diff --git a/version.txt b/version.txt index 8531a3b7..fcdb2e10 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -3.12.2 +4.0.0 From 4fdf89a30366da3189ae20be67b5b315773e6d45 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 Aug 2025 04:35:14 +0000 Subject: [PATCH 325/589] 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 c23f2808..d45bb55d 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 02 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 August 03 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 0d64e267a5aef3bd7d580a2c488bcc8b66d374a4 Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 3 Aug 2025 08:19:48 +0200 Subject: [PATCH 326/589] feat(ui): improve keyword highlights accuracy and performance (#1260) Signed-off-by: Tomas Slusny --- README.md | 8 ++++++-- lua/CopilotChat/ui/chat.lua | 19 +++---------------- plugin/CopilotChat.lua | 20 ++++++++++++++++++-- 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d70e31a4..c35320a4 100644 --- a/README.md +++ b/README.md @@ -255,9 +255,13 @@ Types of copilot highlights: - `CopilotChatHeader` - Header highlight in chat buffer - `CopilotChatSeparator` - Separator highlight in chat buffer - `CopilotChatStatus` - Status and spinner in chat buffer -- `CopilotChatHelp` - Help messages in chat buffer (help, references) +- `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://...`) - `CopilotChatSelection` - Selection highlight in source buffer -- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) ## Prompts diff --git a/lua/CopilotChat/ui/chat.lua b/lua/CopilotChat/ui/chat.lua index fe70feff..8b22c32e 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -340,6 +340,9 @@ function Chat:open(config) vim.wo[self.winnr].foldcolumn = '0' end + local ns = vim.api.nvim_create_namespace('copilot-chat-local-hl') + vim.api.nvim_set_hl(ns, '@markup.quote.markdown', {}) + vim.api.nvim_win_set_hl_ns(self.winnr, ns) vim.api.nvim_win_set_buf(self.winnr, self.bufnr) self:render() end @@ -678,22 +681,6 @@ function Chat:render() end end - -- Keywords - if current_message and current_message.role == 'user' then - -- FIXME: This is not optimal, but i cant figure out how to do it better as treesitter keeps overriding it - local patterns = { - '()#?#[^ ]+()', - '()@[^ ]+()', - '()%$[^ ]+()', - '()/[^ ]+()', - } - for _, pattern in ipairs(patterns) do - for s, e in line:gmatch(pattern) do - vim.api.nvim_buf_add_highlight(self.bufnr, highlight_ns, 'CopilotChatKeyword', l - 1, s - 1, e - 1) - end - end - end - -- If last line, finish last message if l == #lines and current_message then current_message.section.end_line = l diff --git a/plugin/CopilotChat.lua b/plugin/CopilotChat.lua index de5e0158..e59ee49e 100644 --- a/plugin/CopilotChat.lua +++ b/plugin/CopilotChat.lua @@ -16,10 +16,14 @@ local function setup_highlights() vim.api.nvim_set_hl(0, 'CopilotChatSeparator', { link = '@punctuation.special.markdown', 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, 'CopilotChatKeyword', { link = 'Keyword', default = true }) + vim.api.nvim_set_hl(0, 'CopilotChatResource', { link = 'Constant', default = true }) + vim.api.nvim_set_hl(0, 'CopilotChatTool', { link = 'Function', default = true }) + 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 }) + vim.api.nvim_set_hl(0, 'CopilotChatAnnotation', { link = 'ColorColumn', default = true }) local fg = vim.api.nvim_get_hl(0, { name = 'CopilotChatStatus', link = false }).fg local bg = vim.api.nvim_get_hl(0, { name = 'CopilotChatAnnotation', link = false }).bg vim.api.nvim_set_hl(0, 'CopilotChatAnnotationHeader', { fg = fg, bg = bg }) @@ -75,6 +79,18 @@ 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 831db05de8ba7a9211ae40ccffb2c8a978202c3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 Aug 2025 06:20:07 +0000 Subject: [PATCH 327/589] chore(doc): auto generate docs --- doc/CopilotChat.txt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index d45bb55d..f49db7f1 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -325,9 +325,13 @@ Types of copilot highlights: - `CopilotChatHeader` - Header highlight in chat buffer - `CopilotChatSeparator` - Separator highlight in chat buffer - `CopilotChatStatus` - Status and spinner in chat buffer -- `CopilotChatHelp` - Help messages in chat buffer (help, references) +- `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://...`) - `CopilotChatSelection` - Selection highlight in source buffer -- `CopilotChatKeyword` - Keyword highlight in chat buffer (e.g. prompts, tools) - `CopilotChatAnnotation` - Annotation highlight in chat buffer (file headers, tool call headers, tool call body) From 8510f30ff8c338482e7c8a2a7d102519cc57315f Mon Sep 17 00:00:00 2001 From: Tomas Slusny Date: Sun, 3 Aug 2025 17:34:17 +0200 Subject: [PATCH 328/589] 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 329/589] 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 330/589] 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 331/589] 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 332/589] 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 333/589] 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 334/589] 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 335/589] 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 336/589] 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 337/589] 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 338/589] 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 339/589] 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 340/589] 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 341/589] 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 342/589] 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 343/589] 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 344/589] 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 345/589] 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 346/589] 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 347/589] 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 348/589] 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 349/589] 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 350/589] 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 351/589] 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 352/589] 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 353/589] 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 354/589] 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 355/589] 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 356/589] 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 357/589] 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 358/589] 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 359/589] 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 360/589] 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 361/589] 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 362/589] 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 363/589] 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 364/589] 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 365/589] 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 366/589] 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 367/589] 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 368/589] 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 369/589] 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 370/589] 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 371/589] 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 372/589] 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 373/589] 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 374/589] 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 375/589] 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 376/589] 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 377/589] 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 378/589] 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 379/589] 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 380/589] 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 381/589] 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 382/589] 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 383/589] 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 384/589] 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 385/589] 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 386/589] 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 387/589] 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 388/589] 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 389/589] 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 390/589] 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 391/589] 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 392/589] 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 393/589] 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 394/589] 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 395/589] 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 396/589] 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 397/589] 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 398/589] 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 399/589] 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 400/589] 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 401/589] 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 402/589] 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 403/589] 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 404/589] 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 405/589] 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 406/589] 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 407/589] 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 408/589] 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 409/589] 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 410/589] 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 411/589] 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 412/589] 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 413/589] 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 414/589] 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 415/589] 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 416/589] 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 417/589] 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 418/589] 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 419/589] 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 420/589] 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 421/589] 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 422/589] 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 423/589] 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 424/589] 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 425/589] 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 426/589] 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 427/589] 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 428/589] 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 429/589] 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 430/589] 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 431/589] 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 432/589] 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 433/589] 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 434/589] 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 435/589] 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 436/589] 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 437/589] 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 438/589] 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 439/589] 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 440/589] 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 441/589] 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 442/589] 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 443/589] 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 444/589] 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 445/589] 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 446/589] 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 447/589] 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 448/589] 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 449/589] 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 450/589] 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 451/589] 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 452/589] 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 453/589] 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 454/589] 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 455/589] 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 456/589] 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 457/589] 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 458/589] 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 459/589] 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 460/589] 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 461/589] 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 462/589] 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 463/589] 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 464/589] 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 465/589] 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 466/589] 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 467/589] 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 468/589] 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 469/589] [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 470/589] 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 471/589] 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 472/589] 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 473/589] 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 474/589] 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 475/589] 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 476/589] 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 477/589] 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 478/589] 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 479/589] 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 480/589] 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 481/589] 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 482/589] 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 483/589] 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 484/589] 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 485/589] 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 486/589] 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 487/589] 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 488/589] [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 489/589] 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 490/589] 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 491/589] 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 492/589] 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 493/589] 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 494/589] 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 495/589] 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 496/589] 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 497/589] 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 498/589] 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 499/589] 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 500/589] 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 501/589] 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 502/589] 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 503/589] 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 504/589] 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 505/589] 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 506/589] 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 507/589] 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 508/589] 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 509/589] 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 510/589] 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 511/589] 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 512/589] 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 513/589] [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 514/589] 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 515/589] 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 516/589] 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 517/589] 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 518/589] 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 519/589] 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 520/589] 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 521/589] 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 522/589] 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 523/589] 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 524/589] 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 525/589] 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 526/589] 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 527/589] 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 528/589] 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 529/589] 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 530/589] 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 531/589] 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 532/589] 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 533/589] 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 534/589] 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 535/589] 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 536/589] 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 537/589] 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 538/589] 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 539/589] 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 540/589] 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 541/589] 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 542/589] 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 543/589] 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 544/589] 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 545/589] 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 546/589] 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 547/589] 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 548/589] [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 549/589] 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 550/589] 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 551/589] 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 552/589] 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 553/589] 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 554/589] 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 555/589] 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 556/589] 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 557/589] [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 558/589] 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 559/589] 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 560/589] 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 561/589] 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 562/589] 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 563/589] 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 564/589] 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 565/589] 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 566/589] 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 567/589] 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 568/589] 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 569/589] 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 570/589] 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 571/589] 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 572/589] 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 573/589] 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 574/589] 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 575/589] 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 576/589] 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 577/589] 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* From 7cdc9c1a3fb85954852008a5a5c103394e0ed502 Mon Sep 17 00:00:00 2001 From: Mihamina Rakotomandimby Date: Tue, 26 May 2026 02:04:40 +0300 Subject: [PATCH 578/589] Display copilot multipliers in model list (#1567) * debug: temporary log file to make sure how is the response * feat(providers): add model billing multiplier support Expose the 'multiplier' field from model billing configuration into the provider definitions. This change allows the plugin to track and display usage cost multipliers for specific LLM models. Changes include: - Updating providers.lua to pull 'multiplier' from billing data. - Updating init.lua to propagate the multiplier to the model selection UI. - Adding a visual indicator in the model picker to show the multiplier. Notable behavior change: The model selection menu will now display billing multipliers, such as '[x2]', next to supported models. Testing: Verify the model selector displays the multiplier correctly when a model has billing information defined: :lua print(vim.inspect(require('CopilotChat.config.providers').copilot)) Check that models without billing info remain unaffected. * feat(model): add fallback indicator for missing multipliers Update the model selection interface to explicitly display 'x not specified' when a model multiplier is undefined. Previously, missing multipliers resulted in no indicator being shown. This change ensures visual consistency in the status line or UI components that list model configurations. Notable change: - The string 'x not specified' will now appear in the indicators table when a multiplier is nil. Testing: - Open the model selection menu and verify that models without a defined multiplier now show the fallback text instead of being empty. * refactor(config): remove debug logging and clean up UI helpers Refactored the provider and UI logic for model selection. What changed: - Removed the log.info call for Copilot models in providers.lua as it added unnecessary noise to the logs. - Simplified the 'select_model' logic in init.lua by removing the conditional fallback for missing multipliers. Notable behavior changes: - Models without a multiplier will no longer show 'x not specified' in the picker list. Testing: - Check that the model picker still renders correctly. - Verify that logs are clean during model fetching. * [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> --- lua/CopilotChat/config/providers.lua | 1 + lua/CopilotChat/init.lua | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 44233f3d..ea5eea71 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -655,6 +655,7 @@ M.copilot = { tools = model.capabilities.supports.tool_calls, policy = not model['policy'] or model['policy']['state'] == 'enabled', version = model.version, + multiplier = model.billing and model.billing.multiplier or nil, 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. diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index e4dfdc3a..feba3754 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -343,6 +343,7 @@ function M.select_model() streaming = model.streaming, tools = model.tools, reasoning = model.reasoning, + multiplier = model.multiplier, selected = model.id == M.config.model, } end, models) @@ -358,6 +359,9 @@ function M.select_model() out = '* ' .. out end + if item.multiplier ~= nil then + table.insert(indicators, 'x' .. tostring(item.multiplier)) + end if item.provider then table.insert(indicators, item.provider) end From 2db7b404110f92e6d9197fee9cb9a708ae205a10 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 25 May 2026 23:05:03 +0000 Subject: [PATCH 579/589] 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 ec5ad985..3c4d451a 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,5 +1,5 @@ *CopilotChat.txt* - For NVIM v0.8.0 Last change: 2026 April 26 + For NVIM v0.8.0 Last change: 2026 May 25 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 84a3968442c00257251bad7d593d84faace95fb1 Mon Sep 17 00:00:00 2001 From: RoseSecurity <72598486+RoseSecurity@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:49:24 -0400 Subject: [PATCH 580/589] fix: use gpt-5-mini as default model (#1568) Update default model from 'gpt-4.1' to 'gpt-5-mini' across the repo. Modified README, generated docs, provider example, and the main config (luas/CopilotChat/config.lua) so examples and defaults reflect the new model. This ensures the plugin and docs use the newer model by default. Co-authored-by: Michael Rosenfeld --- README.md | 8 ++++---- doc/CopilotChat.txt | 8 ++++---- lua/CopilotChat/config.lua | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 62364cf3..1f7c884a 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Most users only need to configure a few options: ```lua { - model = 'gpt-4.1', -- AI model to use + model = 'gpt-5-mini', -- AI model to use temperature = 0.1, -- Lower = focused, higher = creative trusted_tools = nil, -- Require approval for all tool calls window = { @@ -311,7 +311,7 @@ Types of copilot highlights: - `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`) +- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-5-mini`) - `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 @@ -418,7 +418,7 @@ Add custom AI 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_models = function() return { { id = 'gpt-5-mini', name = 'GPT-5 mini model' } } end, prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, } @@ -554,7 +554,7 @@ require('CopilotChat').load('my_debugging_session') -- Use custom sticky and model require('CopilotChat').ask('How can I optimize this?', { - model = 'gpt-4.1', + model = 'gpt-5-mini', sticky = { '#buffer', '#gitdiff:staged' }, }) diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 3c4d451a..38754722 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -330,7 +330,7 @@ Most users only need to configure a few options: >lua { - model = 'gpt-4.1', -- AI model to use + model = 'gpt-5-mini', -- AI model to use temperature = 0.1, -- Lower = focused, higher = creative trusted_tools = nil, -- Require approval for all tool calls window = { @@ -404,7 +404,7 @@ Types of copilot highlights: - `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`) +- `CopilotChatModel` - Model highlight in chat buffer (e.g. `$gpt-5-mini`) - `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 @@ -516,7 +516,7 @@ Add custom AI 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_models = function() return { { id = 'gpt-5-mini', name = 'GPT-5 mini model' } } end, prepare_input = require('CopilotChat.config.providers').copilot.prepare_input, prepare_output = require('CopilotChat.config.providers').copilot.prepare_output, } @@ -658,7 +658,7 @@ EXAMPLE USAGE *CopilotChat-example-usage* -- Use custom sticky and model require('CopilotChat').ask('How can I optimize this?', { - model = 'gpt-4.1', + model = 'gpt-5-mini', sticky = { '#buffer', '#gitdiff:staged' }, }) diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index a54a66f3..96c584f3 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -61,7 +61,7 @@ return { 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 $). + model = 'gpt-5-mini', -- 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 >). From e407da3726bcaa96e7a6316e50f6c3fb2d4feb9f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 1 Jun 2026 13:49:52 +0000 Subject: [PATCH 581/589] 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 38754722..f8826145 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,5 +1,5 @@ *CopilotChat.txt* - For NVIM v0.8.0 Last change: 2026 May 25 + For NVIM v0.8.0 Last change: 2026 June 01 ============================================================================== Table of Contents *CopilotChat-table-of-contents* From 2caf36bedcbb1efb507995c55833683095be22a7 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:52:09 +0200 Subject: [PATCH 582/589] docs: add RoseSecurity as a contributor for doc, and code (#1569) * 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 e502fd86..cd8c81e2 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -522,6 +522,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/18503099?v=4", "profile": "https://github.com/kolchurinvv", "contributions": ["code"] + }, + { + "login": "RoseSecurity", + "name": "RoseSecurity", + "avatar_url": "https://avatars.githubusercontent.com/u/72598486?v=4", + "profile": "https://rosesecurity.dev", + "contributions": ["doc", "code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 1f7c884a..324b2acb 100644 --- a/README.md +++ b/README.md @@ -711,6 +711,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Calum Lynch
Calum Lynch

💻 sirjls
sirjls

💻 Vladimir Kolchurin
Vladimir Kolchurin

💻 + RoseSecurity
RoseSecurity

📖 💻 From 702c6e4091fcb8590e36d79e31248066f36557cc Mon Sep 17 00:00:00 2001 From: Abhraneel Mukherjee <121384410+abhr-0@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:46:11 +0530 Subject: [PATCH 583/589] fix(chat): render the chat ui on `InsertEnter` (#1570) --- 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 7f14475e..dba7bfbd 100644 --- a/lua/CopilotChat/ui/chat.lua +++ b/lua/CopilotChat/ui/chat.lua @@ -610,7 +610,7 @@ function Chat:create() end end) - vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertLeave' }, { + vim.api.nvim_create_autocmd({ 'TextChanged', 'InsertEnter', 'InsertLeave' }, { buffer = bufnr, callback = function() utils.debounce('chat-parse-' .. bufnr, function() From e0f8a52dca70d948a316c8e93c098f8f87707711 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Jun 2026 07:16:36 +0000 Subject: [PATCH 584/589] 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 f8826145..755adf18 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,5 +1,5 @@ *CopilotChat.txt* - For NVIM v0.8.0 Last change: 2026 June 01 + For NVIM v0.8.0 Last change: 2026 June 02 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -723,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💻Vladimir Kolchurin💻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💻RoseSecurity📖 💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 2025f92022bef434de0511c5157a19974a9377a4 Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:17:21 +0200 Subject: [PATCH 585/589] docs: add abhr-0 as a contributor for code (#1571) * 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 cd8c81e2..04915e9e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -529,6 +529,13 @@ "avatar_url": "https://avatars.githubusercontent.com/u/72598486?v=4", "profile": "https://rosesecurity.dev", "contributions": ["doc", "code"] + }, + { + "login": "abhr-0", + "name": "Abhraneel Mukherjee", + "avatar_url": "https://avatars.githubusercontent.com/u/121384410?v=4", + "profile": "https://github.com/abhr-0", + "contributions": ["code"] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 324b2acb..434cae00 100644 --- a/README.md +++ b/README.md @@ -712,6 +712,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d sirjls
sirjls

💻 Vladimir Kolchurin
Vladimir Kolchurin

💻 RoseSecurity
RoseSecurity

📖 💻 + Abhraneel Mukherjee
Abhraneel Mukherjee

💻 From 33d1bbed8659a45cafa5a9dd8df31c6e9294b87d Mon Sep 17 00:00:00 2001 From: "pullfrog[bot]" <226033991+pullfrog[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:53:06 +0000 Subject: [PATCH 586/589] Add `pullfrog.yml` workflow --- .github/workflows/pullfrog.yml | 57 ++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/pullfrog.yml diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml new file mode 100644 index 00000000..6516ae2e --- /dev/null +++ b/.github/workflows/pullfrog.yml @@ -0,0 +1,57 @@ +# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED +name: Pullfrog +run-name: ${{ inputs.name || github.workflow }} +on: + workflow_dispatch: + inputs: + prompt: + type: string + description: Agent prompt + name: + type: string + description: Run name + +permissions: + contents: read + +jobs: + pullfrog: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Run agent + uses: pullfrog/pullfrog@v0 + with: + prompt: ${{ inputs.prompt }} + env: + # add at least one provider API key + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: + ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + + # for Amazon Bedrock (https://docs.pullfrog.com/bedrock) + # AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }} + # AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + # AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + # AWS_REGION: us-east-1 + # BEDROCK_MODEL_ID: + + # for Google Vertex AI (https://docs.pullfrog.com/vertex) + # VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }} + # GOOGLE_CLOUD_PROJECT: my-project + # VERTEX_LOCATION: global + # VERTEX_MODEL_ID: From ab5cf12ea8b8c8af82feb616bf7fbac096edc0e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 15:53:36 +0000 Subject: [PATCH 587/589] 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 755adf18..e3c65b58 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,5 +1,5 @@ *CopilotChat.txt* - For NVIM v0.8.0 Last change: 2026 June 02 + For NVIM v0.8.0 Last change: 2026 June 16 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -723,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💻Vladimir Kolchurin💻RoseSecurity📖 💻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💻RoseSecurity📖 💻Abhraneel Mukherjee💻This project follows the all-contributors specification. Contributions of any kind are welcome! From 317955e3388a69cdb723cf6cd6ec80bff2614b93 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 07:12:06 +0800 Subject: [PATCH 588/589] [pre-commit.ci] pre-commit autoupdate (#1565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/JohnnyMorganz/StyLua: v2.4.1 → v2.5.2](https://github.com/JohnnyMorganz/StyLua/compare/v2.4.1...v2.5.2) 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 ae6b39ed..283f1965 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.1 + rev: v2.5.2 hooks: - id: stylua-github From f0c2a23ab79dc295f5e0eef0d8b587a39020c852 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 23:12:23 +0000 Subject: [PATCH 589/589] 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 e3c65b58..fda50bbd 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,5 +1,5 @@ *CopilotChat.txt* - For NVIM v0.8.0 Last change: 2026 June 16 + For NVIM v0.8.0 Last change: 2026 June 17 ============================================================================== Table of Contents *CopilotChat-table-of-contents*