diff --git a/.all-contributorsrc b/.all-contributorsrc index c4ad3996..04915e9e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -487,6 +487,55 @@ "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"] + }, + { + "login": "junqizhang", + "name": "junqizhang", + "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"] + }, + { + "login": "sirjls", + "name": "sirjls", + "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"] + }, + { + "login": "RoseSecurity", + "name": "RoseSecurity", + "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/.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"] + } +} 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] 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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac8e688c..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.3.1 + rev: v2.5.2 hooks: - id: stylua-github 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..393f93c9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,67 +50,77 @@ 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), + [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. diff --git a/README.md b/README.md index 209034a1..434cae00 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,29 +92,13 @@ 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 -## 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 like `bash`, `edit`, `file`, `glob`, `grep`, `gitdiff` etc. You'll see the proposed function call and can approve/reject it before execution. +> [!TIP] +> Press `` after typing `#` or `@` to see available options and auto-complete. This is the easiest way to discover what's available! # Usage @@ -136,54 +120,54 @@ 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 | - -> [!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 | 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) +| 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 @@ -197,6 +181,55 @@ All predefined functions belong to the `copilot` group. | `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). @@ -207,8 +240,9 @@ 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 = { layout = 'vertical', -- 'vertical', 'horizontal', 'float' width = 0.5, -- 50% of screen width @@ -241,12 +275,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 @@ -275,9 +311,10 @@ 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 ## Prompts @@ -304,14 +341,46 @@ 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, +} +``` + +**How tool trust works:** + +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` + +**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. + 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 +398,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 +416,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-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, } @@ -363,7 +434,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 +450,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 +496,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 +515,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 +529,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 +541,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-5-mini', + sticky = { '#buffer', '#gitdiff:staged' }, +}) + +-- Automatically trust a small read-only tool set +require('CopilotChat').setup({ + trusted_tools = { 'file', 'glob', 'grep' }, }) ``` @@ -512,6 +591,12 @@ To run tests: make test ``` +To run the same formatting check as CI: + +```bash +stylua --check . +``` + ## Contributing 1. Fork the repository @@ -619,6 +704,15 @@ 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

💻 + + + junqizhang
junqizhang

💻 + Calum Lynch
Calum Lynch

💻 + sirjls
sirjls

💻 + Vladimir Kolchurin
Vladimir Kolchurin

💻 + RoseSecurity
RoseSecurity

📖 💻 + Abhraneel Mukherjee
Abhraneel Mukherjee

💻 diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 1830fb8a..fda50bbd 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,5 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2025 December 22 +*CopilotChat.txt* + For NVIM v0.8.0 Last change: 2026 June 17 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -10,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| @@ -40,7 +42,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 @@ -125,33 +127,14 @@ 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 -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 like `bash`, `edit`, -`file`, `glob`, `grep`, `gitdiff` etc. You’ll see the proposed function call -and can approve/reject it before execution. - + [!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* @@ -175,68 +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 - - 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 + - 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 Type Description Example Usage - ----------- ---------- ----------------------------------------- -------------------- - bash Tool Executes a bash command and returns @copilot only - output + --------------------------------------------------------------------------------- + Function Manual Description Available Options + #... + ----------- -------- -------------------------- --------------------------------- + bash No Executes a bash command Tool-only (use @copilot) + and returns output - buffer Resource Retrieves content from buffer(s) with #buffer:active - diagnostics + buffer Yes Retrieves content from active, visible, listed, + buffer(s) with diagnostics quickfix, buffer number, or + filename - clipboard Resource Provides access to system clipboard #clipboard - content + clipboard Yes Provides access to system No options + clipboard content - edit Tool Applies a unified diff to a file @copilot only + edit No Applies a unified diff to Tool-only (use @copilot) + a file - file Resource Reads content from a specified file path #file:path/to/file + file Yes Reads content from a Any file path (use for + specified file path completion) - gitdiff Resource Retrieves git diff information #gitdiff:staged + gitdiff Yes Retrieves git diff unstaged (default), staged, or + information commit SHA - glob Resource Lists filenames matching a pattern in #glob:**/*.lua - workspace + glob Yes Lists filenames matching a Any glob pattern (default: **/*) + pattern in workspace - grep Resource Searches for a pattern across files in #grep:TODO - workspace + grep Yes Searches for a pattern Any search pattern + across files in workspace - selection Resource Includes the current visual selection #selection - with diagnostics + selection Yes Includes the current No options + visual selection with + diagnostics - 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) + 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* @@ -260,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* @@ -273,8 +330,9 @@ 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 = { layout = 'vertical', -- 'vertical', 'horizontal', 'float' width = 0.5, -- 50% of screen width @@ -308,13 +366,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 @@ -341,12 +401,13 @@ 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-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 PROMPTS *CopilotChat-prompts* @@ -375,14 +436,47 @@ 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, + } +< + +**How tool trust works:** + +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` + +**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. 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' }, @@ -400,14 +494,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* @@ -417,9 +514,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-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, } @@ -435,7 +532,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?, @@ -451,13 +548,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) ============================================================================== @@ -467,7 +567,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 @@ -498,7 +598,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 @@ -517,8 +617,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 @@ -532,10 +632,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) < @@ -544,22 +645,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-5-mini', + sticky = { '#buffer', '#gitdiff:staged' }, + }) + + -- Automatically trust a small read-only tool set + require('CopilotChat').setup({ + trusted_tools = { 'file', 'glob', 'grep' }, }) < @@ -594,6 +699,12 @@ To run tests: make test < +To run the same formatting check as CI: + +>bash + stylua --check . +< + CONTRIBUTING *CopilotChat-contributing* @@ -612,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💻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! diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 93e1c91d..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') @@ -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 @@ -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() @@ -434,11 +445,22 @@ 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) - if not val then - tool_calls:set(tool_call.index, tool_call) + 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 + 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 @@ -451,6 +473,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, @@ -511,7 +537,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 = { @@ -551,12 +584,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() @@ -576,12 +607,18 @@ 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, token_max_count = max_tokens, diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index b78ddf61..96c584f3 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? @@ -43,6 +44,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? @@ -59,10 +61,11 @@ 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 >). + 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 @@ -105,6 +108,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/config/functions.lua b/lua/CopilotChat/config/functions.lua index f9063047..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[] @@ -64,6 +65,7 @@ return { enum = function(source) return files.glob(source.cwd(), { max_count = 0, + hidden = true, }) end, }, @@ -247,7 +249,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..c3028f8a 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, }, @@ -294,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() @@ -361,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/config/providers.lua b/lua/CopilotChat/config/providers.lua index aac7b36c..ea5eea71 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') @@ -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) @@ -196,8 +196,24 @@ 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 table +---@param inputs CopilotChat.client.Message[] ---@param opts CopilotChat.config.providers.Options ---@return table local function prepare_responses_input(inputs, opts) @@ -257,11 +273,12 @@ 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) 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 = { @@ -308,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 @@ -353,6 +370,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 +384,7 @@ local function prepare_responses_output(output) finish_reason = 'error: ' .. tostring(error_msg), total_tokens = nil, tool_calls = {}, + model = nil, } end @@ -379,13 +398,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 table.insert(tool_calls, { - id = item.call_id or ('tooluse_' .. (#tool_calls + 1)), - index = #tool_calls + 1, - name = item.name or '', + id = item.call_id, + index = output.output_index, + name = item.name, arguments = item.arguments or '', }) end @@ -398,6 +416,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 @@ -414,9 +435,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 @@ -429,6 +450,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 +462,7 @@ local function prepare_responses_output(output) finish_reason = finish_reason, total_tokens = total_tokens, tool_calls = tool_calls, + model = model, } end @@ -455,11 +480,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 '', }) @@ -477,6 +500,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 +508,7 @@ local function prepare_chat_output(output) finish_reason = finish_reason, total_tokens = usage, tool_calls = tool_calls, + model = model, } end @@ -498,13 +523,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 @@ -524,11 +551,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, @@ -584,9 +619,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 @@ -613,7 +655,11 @@ 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. + base_url = base_url, } end) :totable() @@ -629,22 +675,64 @@ 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' }, }) end end + -- Auto model selector + table.insert(models, { + id = 'auto', + name = 'Auto (Copilot)', + description = 'Auto selects the best model for your request.', + base_url = base_url, + }) + return models end, + resolve_model = function(headers, model) + if model ~= 'auto' then + return model + end + + 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 = request_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 - 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) @@ -655,10 +743,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, } diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index 9125bb4a..feba3754 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 @@ -54,8 +86,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 @@ -112,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 @@ -124,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 @@ -304,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) @@ -319,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 @@ -349,8 +392,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 +401,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) @@ -374,7 +417,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 @@ -426,16 +469,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 @@ -542,6 +578,94 @@ 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 + + -- 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)) @@ -696,6 +820,7 @@ function M.setup(config) end) -- Initialize chat + 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 21eb6cd4..7c4e60ce 100644 --- a/lua/CopilotChat/prompts.lua +++ b/lua/CopilotChat/prompts.lua @@ -1,8 +1,9 @@ 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') local WORD = '([^%s:]+)' @@ -12,16 +13,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 @@ -60,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 @@ -76,18 +83,75 @@ 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 + +--- 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) @@ -95,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) @@ -112,7 +171,6 @@ function M.resolve_functions(prompt, config) local resolved_resources = {} local resolved_tools = {} - local resolved_stickies = {} local tool_calls = {} utils.schedule_main() @@ -192,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 @@ -259,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. @@ -314,7 +365,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) @@ -337,7 +388,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 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/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/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 45ac51a6..dba7bfbd 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') @@ -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+)$', } @@ -98,21 +99,21 @@ 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? ---@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 ----@field winnr number? +---@field winnr integer? ---@field config CopilotChat.config.Shared ---@field token_count number? ---@field token_max_count number? @@ -124,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) @@ -160,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, @@ -242,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() @@ -268,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 @@ -287,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 @@ -604,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() @@ -770,6 +776,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 = '', @@ -859,10 +868,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 547394b8..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) @@ -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 @@ -124,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/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/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 diff --git a/lua/CopilotChat/utils/diff.lua b/lua/CopilotChat/utils/diff.lua index c0cf2f85..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 @@ -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 28184585..24b7b003 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 @@ -176,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') @@ -184,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 @@ -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 diff --git a/lua/CopilotChat/notify.lua b/lua/CopilotChat/utils/notify.lua similarity index 91% rename from lua/CopilotChat/notify.lua rename to lua/CopilotChat/utils/notify.lua index 99aa499a..b15b209a 100644 --- a/lua/CopilotChat/notify.lua +++ b/lua/CopilotChat/utils/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/tests/notify_spec.lua b/tests/notify_spec.lua new file mode 100644 index 00000000..020c9391 --- /dev/null +++ b/tests/notify_spec.lua @@ -0,0 +1,126 @@ +local notify = require('CopilotChat.utils.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)