diff --git a/.all-contributorsrc b/.all-contributorsrc index d9fb93f0..59f826dd 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -44,6 +44,42 @@ "contributions": [ "code" ] + }, + { + "login": "Cassius0924", + "name": "He Zhizhou", + "avatar_url": "https://avatars.githubusercontent.com/u/62874592?v=4", + "profile": "https://github.com/Cassius0924", + "contributions": [ + "code" + ] + }, + { + "login": "rguruprakash", + "name": "Guruprakash Rajakkannu", + "avatar_url": "https://avatars.githubusercontent.com/u/9963717?v=4", + "profile": "https://www.linkedin.com/in/guruprakashrajakkannu/", + "contributions": [ + "code" + ] + }, + { + "login": "kristofka", + "name": "kristofka", + "avatar_url": "https://avatars.githubusercontent.com/u/140354?v=4", + "profile": "https://github.com/kristofka", + "contributions": [ + "code" + ] + }, + { + "login": "PostCyberPunk", + "name": "PostCyberPunk", + "avatar_url": "https://avatars.githubusercontent.com/u/134976996?v=4", + "profile": "https://github.com/PostCyberPunk", + "contributions": [ + "doc" + ] } ], "contributorsPerLine": 7, diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..cadcae03 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +ignore = E203, E501, W503 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..949fd0ff --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [gptlang, jellydn] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6f3d1425 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release +on: + push: + branches: + - release + pull_request: + branches: + - main + - release + +jobs: + release: + name: release + runs-on: ubuntu-latest + steps: + - uses: google-github-actions/release-please-action@v3 + id: release + with: + release-type: simple + package-name: CopilotChat.nvim + - uses: actions/checkout@v3 + - name: tag stable versions + if: ${{ steps.release.outputs.release_created }} + run: | + git config user.name github-actions[bot] + git config user.email github-actions[bot]@users.noreply.github.com + git remote add gh-token "https://${{ secrets.GITHUB_TOKEN }}@github.com/google-github-actions/release-please-action.git" + git tag -d stable || true + git push origin :stable || true + git tag -a stable -m "Last Stable Release" + git push origin stable diff --git a/.luarc.json b/.luarc.json index d213f255..04ea467f 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,6 +1,3 @@ { - "diagnostics.globals": [ - "describe", - "it" - ] -} \ No newline at end of file + "diagnostics.globals": ["describe", "it"] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..93e78079 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +repos: + - repo: https://github.com/psf/black + rev: "23.10.0" + hooks: + - id: black + - repo: https://github.com/PyCQA/isort + rev: "5.12.0" + hooks: + - id: isort + - repo: https://github.com/PyCQA/flake8 + rev: "6.1.0" + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/mirrors-prettier + rev: "fc260393cc4ec09f8fc0a5ba4437f481c8b55dc1" + hooks: + - id: prettier diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0ccfe4ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +## [1.2.1](https://github.com/jellydn/CopilotChat.nvim/compare/v1.2.0...v1.2.1) (2024-02-05) + + +### Reverts + +* **ci:** add release workflow back ([81a9d81](https://github.com/jellydn/CopilotChat.nvim/commit/81a9d818b1369d41108c46da477e4ea5cec0a525)) + +## [1.2.0](https://github.com/jellydn/CopilotChat.nvim/compare/v1.1.0...v1.2.0) (2024-02-04) + +### Features + +- show date time and additional information on end separator ([#53](https://github.com/jellydn/CopilotChat.nvim/issues/53)) ([b8d0a9d](https://github.com/jellydn/CopilotChat.nvim/commit/b8d0a9d0e0824ff3b643a2652202be2a51b37dbc)) + +### Bug Fixes + +- handle get remote plugin path on Windows ([0b917f6](https://github.com/jellydn/CopilotChat.nvim/commit/0b917f633eaef621d293f344965e9e0545be9a80)) + +## [1.1.0](https://github.com/jellydn/CopilotChat.nvim/compare/v1.0.0...v1.1.0) (2024-02-04) + +### Features + +- add CopilotChatDebugInfo command ([#51](https://github.com/jellydn/CopilotChat.nvim/issues/51)) ([89b6276](https://github.com/jellydn/CopilotChat.nvim/commit/89b6276e995de2e05ea391a9d1045676737c93bd)) + +## 1.0.0 (2024-02-03) + +### ⚠ BREAKING CHANGES + +- drop new buffer mode + +### Features + +- add a note for help user to continue the chat ([8a80ee7](https://github.com/jellydn/CopilotChat.nvim/commit/8a80ee7d3f9d0dcb65b315255d629c2cd8263dac)) +- add CCExplain command ([640f361](https://github.com/jellydn/CopilotChat.nvim/commit/640f361a54be51e7c479257c374d4a26d8fcd31d)) +- add CCTests command ([b34a78f](https://github.com/jellydn/CopilotChat.nvim/commit/b34a78f05ebe65ca093e4dc4b66de9120a681f4c)) +- add configuration options for wrap and filetype ([b4c6e76](https://github.com/jellydn/CopilotChat.nvim/commit/b4c6e760232ec54d4632edef3869e1a05ec61751)) +- add CopilotChatToggleLayout ([07988b9](https://github.com/jellydn/CopilotChat.nvim/commit/07988b95a412756169016e991dabcf190a930c7e)) +- add debug flag ([d0dbd4c](https://github.com/jellydn/CopilotChat.nvim/commit/d0dbd4c6fb9be75ccaa591b050198d40c097f423)) +- add health check ([974f14f](https://github.com/jellydn/CopilotChat.nvim/commit/974f14f0d0978d858cbe0126568f30fd63262cb6)) +- add new keymap to get previous user prompt ([6e7e80f](https://github.com/jellydn/CopilotChat.nvim/commit/6e7e80f118c589a009fa1703a284ad292260e3a0)) +- set filetype to markdown and text wrapping ([9b19d51](https://github.com/jellydn/CopilotChat.nvim/commit/9b19d51deacdf5c958933e99a2e75ebe4c968a9b)) +- show chat in markdown format ([9c14152](https://github.com/jellydn/CopilotChat.nvim/commit/9c141523de12e723b1d72d95760f2daddcecd1d9)) + +### Bug Fixes + +- **ci:** generate doc ([6287fd4](https://github.com/jellydn/CopilotChat.nvim/commit/6287fd452d83d43a739d4c7c7a5524537032fc5d)) +- Close spinner if the buffer does not exist ([#11](https://github.com/jellydn/CopilotChat.nvim/issues/11)) ([0ea238d](https://github.com/jellydn/CopilotChat.nvim/commit/0ea238d7be9c7872dd9932a56d3521531b2297db)) +- remove LiteralString, use Any for fixing issue on Python 3.10 ([b68c352](https://github.com/jellydn/CopilotChat.nvim/commit/b68c3522d03c8ac9a332169c56e725b69a43b07c)), closes [#45](https://github.com/jellydn/CopilotChat.nvim/issues/45) + +### Reverts + +- change back to CopilotChat command ([e304f79](https://github.com/jellydn/CopilotChat.nvim/commit/e304f792a5fbba412c2a5a1f717ec7e2ab12e5b0)) + +### Code Refactoring + +- drop new buffer mode ([0a30b7c](https://github.com/jellydn/CopilotChat.nvim/commit/0a30b7cfbd8b52bf8a9e4cd96dcade4995e6eb3a)) diff --git a/Makefile b/Makefile index aeeba1d6..86f44cfd 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,21 @@ .PHONY: help help: - @echo "install - install vusted" - @echo "test - run test" + @echo "Available commands:" + @echo " install-cli - Install Lua and Luarocks using Homebrew" + @echo " install-pre-commit - Install pre-commit using pip" + @echo " install - Install vusted using Luarocks" + @echo " test - Run tests using vusted" .PHONY: install-cli install-cli: brew install luarocks brew install lua +.PHONY: install-pre-commit +install-pre-commit: + pip install pre-commit + pre-commit install + .PHONY: install install: luarocks install vusted diff --git a/README.md b/README.md index 0db3e07e..a6445df0 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,9 @@ -# Copilot Chat for Neovim +**Project Update: My Fork Now Part of CopilotC-Nvim** - +I'm pleased to share that my fork has been archived and integrated into the CopilotC-Nvim organization. This move enhances collaboration, increases update frequency, and expands community support. -[![All Contributors](https://img.shields.io/badge/all_contributors-4-orange.svg?style=flat-square)](#contributors-) +**What's Next?** +- Check out the project at [CopilotChat.nvim](https://github.com/CopilotC-Nvim/CopilotChat.nvim). +- For updates and contributions, visit the new repository under CopilotC-Nvim. - - -> [!NOTE] -> There is a new command: `CopilotChatInPlace`, which functions similarly to ChatGPT plugin. You can find it in the [canary](https://github.com/jellydn/CopilotChat.nvim/tree/canary?tab=readme-ov-file#lazynvim) branch. - -## Authentication - -It will prompt you with instructions on your first start. If you already have `Copilot.vim` or `Copilot.lua`, it will work automatically. - -## Installation - -### Lazy.nvim - -1. `pip install python-dotenv requests pynvim==0.5.0 prompt-toolkit` -2. Put it in your lazy setup - -```lua -return { - { - "jellydn/CopilotChat.nvim", - opts = { - mode = "split", -- newbuffer or split , default: newbuffer - }, - build = function() - vim.defer_fn(function() - vim.cmd("UpdateRemotePlugins") - vim.notify("CopilotChat - Updated remote plugins. Please restart Neovim.") - end, 3000) - end, - event = "VeryLazy", - keys = { - { "cce", "CopilotChatExplain", desc = "CopilotChat - Explain code" }, - { "cct", "CopilotChatTests", desc = "CopilotChat - Generate tests" }, - }, - }, -} -``` - -3. Run `:UpdateRemotePlugins` -4. Restart `neovim` - -### Manual - -1. Put the files in the right place - -``` -$ git clone https://github.com/gptlang/CopilotChat.nvim -$ cd CopilotChat.nvim -$ cp -r --backup=nil rplugin ~/.config/nvim/ -``` - -2. Install dependencies - -``` -$ pip install -r requirements.txt -``` - -3. Open up Neovim and run `:UpdateRemotePlugins` -4. Restart Neovim - -## Usage - -### Configuration - -You have the ability to tailor this plugin to your specific needs using the configuration options outlined below: - -```lua -{ - debug = false, -- Enable or disable debug mode - prompts = { -- Set dynamic prompts for CopilotChat commands - Explain = 'Explain how it works.', - Tests = 'Briefly explain how the selected code works, then generate unit tests.', - } -} -``` - -You have the capability to expand the prompts to create more versatile commands: - -```lua -return { - "jellydn/CopilotChat.nvim", - opts = { - mode = "split", - prompts = { - Explain = "Explain how it works.", - Review = "Review the following code and provide concise suggestions.", - Tests = "Briefly explain how the selected code works, then generate unit tests.", - Refactor = "Refactor the code to improve clarity and readability.", - }, - }, - build = function() - vim.defer_fn(function() - vim.cmd("UpdateRemotePlugins") - vim.notify("CopilotChat - Updated remote plugins. Please restart Neovim.") - end, 3000) - end, - event = "VeryLazy", - keys = { - { "cce", "CopilotChatExplain", desc = "CopilotChat - Explain code" }, - { "cct", "CopilotChatTests", desc = "CopilotChat - Generate tests" }, - { "ccr", "CopilotChatReview", desc = "CopilotChat - Review code" }, - { "ccR", "CopilotChatRefactor", desc = "CopilotChat - Refactor code" }, - } -} -``` - -For further reference, you can view my [configuration](https://github.com/jellydn/lazy-nvim-ide/blob/main/lua/plugins/extras/copilot-chat.lua). - -### Chat with Github Copilot - -1. Copy some code into the unnamed register using the `y` command. -2. Run the command `:CopilotChat` followed by your question. For example, `:CopilotChat What does this code do?` - -![Chat Demo](https://i.gyazo.com/10fbd1543380d15551791c1a6dcbcd46.gif) - -### Code Explanation - -1. Copy some code into the unnamed register using the `y` command. -2. Run the command `:CopilotChatExplain`. - -![Explain Code Demo](https://i.gyazo.com/e5031f402536a1a9d6c82b2c38d469e3.gif) - -### Generate Tests - -1. Copy some code into the unnamed register using the `y` command. -2. Run the command `:CopilotChatTests`. - -[![Generate tests](https://i.gyazo.com/f285467d4b8d8f8fd36aa777305312ae.gif)](https://gyazo.com/f285467d4b8d8f8fd36aa777305312ae) - -## Roadmap - -- Translation to pure Lua -- Tokenizer -- Use vector encodings to automatically select code -- Sub commands - See [issue #5](https://github.com/gptlang/CopilotChat.nvim/issues/5) - -## Contributors ✨ - -Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): - - - - - - - - - - - - - -
gptlang
gptlang

💻 📖
Dung Duc Huynh (Kaka)
Dung Duc Huynh (Kaka)

💻 📖
Ahmed Haracic
Ahmed Haracic

💻
Trí Thiện Nguyễn
Trí Thiện Nguyễn

💻
- - - - - - -This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! +Your support and contributions are appreciated as we continue to develop Copilot Chat for Neovim. diff --git a/cspell-tool.txt b/cspell-tool.txt index 62c228e1..400bb5a2 100644 --- a/cspell-tool.txt +++ b/cspell-tool.txt @@ -1,24 +1,70 @@ -newbuffer -nargs +pynvim nvim +nargs Neovim -pynvim -jellydn +healthcheck +bufexists +vlog +vararg +tjdevries neovim +echohl +stdpath +outfile +nameupper +lineinfo +currentline +echom +tiktoken +jellydn +zbirenbaum rplugin -dotenv +jellydn's +ecosse +pcall +noremap +nowait +gptlang +Huynh +Haracic +Thiện +Nguyễn +Zhizhou +Guruprakash +Rajakkannu +vsplit +mypynvim Nvim +AUTOCMD +Autocmd getreg -getbufvar bufnr -buftype -nofile -vnew -enew -setlocal -bufhidden -noswapfile -linebreak -fileencoding +autocmd +dotenv machineid -roleplay \ No newline at end of file +winnr +Nightfly +keymaps +linebreak +diffthis +diffoff +conceallevel +concealcursor +extmark +virt +Keymapper +autocmdmapper +keymapper +termcodes +feedkeys +mynvim +autocmds +shrinked +zindex +noautocmd +roleplay +vusted +luarocks +isort +checkhealth +sysname \ No newline at end of file diff --git a/cspell.json b/cspell.json index d7404183..656c8347 100644 --- a/cspell.json +++ b/cspell.json @@ -10,13 +10,6 @@ "addWords": true } ], - "dictionaries": [ - "cspell-tool" - ], - "ignorePaths": [ - "node_modules", - "dist", - "build", - "/cspell-tool.txt" - ] -} \ No newline at end of file + "dictionaries": ["cspell-tool"], + "ignorePaths": ["node_modules", "dist", "build", "/cspell-tool.txt"] +} diff --git a/doc/CopilotChat.txt b/doc/CopilotChat.txt index 05d1afca..a4361c87 100644 --- a/doc/CopilotChat.txt +++ b/doc/CopilotChat.txt @@ -1,4 +1,4 @@ -*CopilotChat.txt* For NVIM v0.8.0 Last change: 2024 January 30 +*CopilotChat.txt* For NVIM v0.8.0 Last change: 2024 February 05 ============================================================================== Table of Contents *CopilotChat-table-of-contents* @@ -7,7 +7,9 @@ Table of Contents *CopilotChat-table-of-contents* - Authentication |CopilotChat-copilot-chat-for-neovim-authentication| - Installation |CopilotChat-copilot-chat-for-neovim-installation| - Usage |CopilotChat-copilot-chat-for-neovim-usage| + - Tips |CopilotChat-copilot-chat-for-neovim-tips| - Roadmap |CopilotChat-copilot-chat-for-neovim-roadmap| + - Development |CopilotChat-copilot-chat-for-neovim-development| - Contributors ✨ |CopilotChat-copilot-chat-for-neovim-contributors-✨| ============================================================================== @@ -16,10 +18,14 @@ Table of Contents *CopilotChat-table-of-contents* |CopilotChat-| - [!NOTE] There is a new command: `CopilotChatInPlace`, which functions similarly - to ChatGPT plugin. You can find it in the canary - - branch. + [!NOTE] You might want to take a look at this fork + which is more well maintained & + is more configurable. I personally use it now as well. + + [!NOTE] A new command, `CopilotChatInPlace` has been introduced. It functions + like the ChatGPT plugin. Please run ":UpdateRemotePlugins" command and restart + Neovim before starting a chat with Copilot. To stay updated on our roadmap, + please join our Discord community. AUTHENTICATION *CopilotChat-copilot-chat-for-neovim-authentication* @@ -33,32 +39,58 @@ INSTALLATION *CopilotChat-copilot-chat-for-neovim-installation* LAZY.NVIM ~ 1. `pip install python-dotenv requests pynvim==0.5.0 prompt-toolkit` -2. Put it in your lazy setup +2. `pip install tiktoken` (optional for displaying prompt token counts) +3. Put it in your lazy setup >lua return { { "jellydn/CopilotChat.nvim", opts = { - mode = "split", -- newbuffer or split , default: newbuffer + show_help = "yes", -- Show help text for CopilotChatInPlace, default: yes + debug = false, -- Enable or disable debug mode, the log file will be in ~/.local/state/nvim/CopilotChat.nvim.log + disable_extra_info = 'no', -- Disable extra information (e.g: system prompt) in the response. }, build = function() - vim.defer_fn(function() - vim.cmd("UpdateRemotePlugins") - vim.notify("CopilotChat - Updated remote plugins. Please restart Neovim.") - end, 3000) + vim.notify("Please update the remote plugins by running ':UpdateRemotePlugins', then restart Neovim.") end, event = "VeryLazy", keys = { { "cce", "CopilotChatExplain", desc = "CopilotChat - Explain code" }, { "cct", "CopilotChatTests", desc = "CopilotChat - Generate tests" }, + { + "ccv", + ":CopilotChatVisual", + mode = "x", + desc = "CopilotChat - Open in vertical split", + }, + { + "ccx", + ":CopilotChatInPlace", + mode = "x", + desc = "CopilotChat - Run in-place code", + }, }, }, } < -1. Run `:UpdateRemotePlugins` -2. Restart `neovim` +1. Run command `:UpdateRemotePlugins`, then inspect the file `~/.local/share/nvim/rplugin.vim` for additional details. You will notice that the commands have been registered. + +For example: + +>vim + " python3 plugins + call remote#host#RegisterPlugin('python3', '/Users/huynhdung/.local/share/nvim/lazy/CopilotChat.nvim/rplugin/python3/copilot-plugin.py', [ + \ {'sync': v:false, 'name': 'CopilotChat', 'type': 'command', 'opts': {'nargs': '1'}}, + \ {'sync': v:false, 'name': 'CopilotChatVisual', 'type': 'command', 'opts': {'nargs': '1', 'range': ''}}, + \ {'sync': v:false, 'name': 'CopilotChatInPlace', 'type': 'command', 'opts': {'nargs': '*', 'range': ''}}, + \ {'sync': v:false, 'name': 'CopilotChatAutocmd', 'type': 'command', 'opts': {'nargs': '*'}}, + \ {'sync': v:false, 'name': 'CopilotChatMapping', 'type': 'command', 'opts': {'nargs': '*'}}, + \ ]) +< + +1. Restart `neovim` MANUAL ~ @@ -66,7 +98,7 @@ MANUAL ~ 1. Put the files in the right place > - $ git clone https://github.com/gptlang/CopilotChat.nvim + $ git clone https://github.com/jellydn/CopilotChat.nvim $ cd CopilotChat.nvim $ cp -r --backup=nil rplugin ~/.config/nvim/ < @@ -92,6 +124,7 @@ configuration options outlined below: >lua { debug = false, -- Enable or disable debug mode + show_help = 'yes', -- Show help text for CopilotChatInPlace prompts = { -- Set dynamic prompts for CopilotChat commands Explain = 'Explain how it works.', Tests = 'Briefly explain how the selected code works, then generate unit tests.', @@ -106,7 +139,8 @@ commands: return { "jellydn/CopilotChat.nvim", opts = { - mode = "split", + debug = true, + show_help = "yes", prompts = { Explain = "Explain how it works.", Review = "Review the following code and provide concise suggestions.", @@ -115,10 +149,7 @@ commands: }, }, build = function() - vim.defer_fn(function() - vim.cmd("UpdateRemotePlugins") - vim.notify("CopilotChat - Updated remote plugins. Please restart Neovim.") - end, 3000) + vim.notify("Please update the remote plugins by running ':UpdateRemotePlugins', then restart Neovim.") end, event = "VeryLazy", keys = { @@ -130,7 +161,7 @@ commands: } < -For further reference, you can view my configuration +For further reference, you can view @jellydn’s configuration . @@ -154,6 +185,137 @@ GENERATE TESTS ~ +TOKEN COUNT & FOLD WITH VISUAL MODE ~ + +1. Select some code using visual mode. +2. Run the command `:CopilotChatVisual` with your question. + + + + +IN-PLACE CHAT POPUP ~ + +1. Select some code using visual mode. +2. Run the command `:CopilotChatInPlace` and type your prompt. For example, `What does this code do?` +3. Press `Enter` to send your question to Github Copilot. +4. Press `q` to quit. There is help text at the bottom of the screen. You can also press `?` to toggle the help text. + + + + +TIPS *CopilotChat-copilot-chat-for-neovim-tips* + + +DEBUGGING WITH :MESSAGES AND :COPILOTCHATDEBUGINFO ~ + +If you encounter any issues, you can run the command `:messages` to inspect the +log. You can also run the command `:CopilotChatDebugInfo` to inspect the debug +information. + + + + +HOW TO SETUP WITH WHICH-KEY.NVIM ~ + +A special thanks to @ecosse3 for the configuration of which-key +. + +>lua + { + "jellydn/CopilotChat.nvim", + event = "VeryLazy", + opts = { + prompts = { + Explain = "Explain how it works.", + Review = "Review the following code and provide concise suggestions.", + Tests = "Briefly explain how the selected code works, then generate unit tests.", + Refactor = "Refactor the code to improve clarity and readability.", + }, + }, + build = function() + vim.notify("Please update the remote plugins by running ':UpdateRemotePlugins', then restart Neovim.") + end, + config = function() + local present, wk = pcall(require, "which-key") + if not present then + return + end + + wk.register({ + c = { + c = { + name = "Copilot Chat", + } + } + }, { + mode = "n", + prefix = "", + silent = true, + noremap = true, + nowait = false, + }) + end, + keys = { + { "ccc", ":CopilotChat ", desc = "CopilotChat - Prompt" }, + { "cce", ":CopilotChatExplain ", desc = "CopilotChat - Explain code" }, + { "cct", "CopilotChatTests", desc = "CopilotChat - Generate tests" }, + { "ccr", "CopilotChatReview", desc = "CopilotChat - Review code" }, + { "ccR", "CopilotChatRefactor", desc = "CopilotChat - Refactor code" }, + } + }, +< + + +CREATE A SIMPLE INPUT FOR COPILOTCHAT ~ + +Follow the example below to create a simple input for CopilotChat. + +>lua + -- Create input for CopilotChat + { + "cci", + function() + local input = vim.fn.input("Ask Copilot: ") + if input ~= "" then + vim.cmd("CopilotChat " .. input) + end + end, + desc = "CopilotChat - Ask input", + }, +< + + +ADD SAME KEYBINDS IN BOTH VISUAL AND NORMAL MODE ~ + +>lua + { + "jellydn/CopilotChat.nvim", + keys = + function() + local keybinds={ + --add your custom keybinds here + } + -- change prompt and keybinds as per your need + local my_prompts = { + {prompt = "In Neovim.",desc = "Neovim",key = "n"}, + {prompt = "Help with this",desc = "Help",key = "h"}, + {prompt = "Simplify and imporve readablilty",desc = "Simplify",key = "s"}, + {prompt = "Optimize the code to improve perfomance and readablilty.",desc = "Optimize",key = "o"}, + {prompt = "Find possible errors and fix them for me",desc = "Fix",key = "f"}, + {prompt = "Explain in detail",desc = "Explain",key = "e"}, + {prompt = "Write a shell scirpt",desc = "Shell",key = "S"}, + } + -- you can change cc to your desired keybind prefix + for _,v in pairs(my_prompts) do + table.insert(keybinds,{ "cc"..v.key, ":CopilotChatVisual "..v.prompt.."", mode = "x", desc = "CopilotChat - "..v.desc }) + table.insert(keybinds,{ "cc"..v.key, "CopilotChat "..v.prompt.."", desc = "CopilotChat - "..v.desc }) + end + return keybinds + end, + }, +< + + ROADMAP *CopilotChat-copilot-chat-for-neovim-roadmap* - Translation to pure Lua @@ -162,22 +324,42 @@ ROADMAP *CopilotChat-copilot-chat-for-neovim-roadmap* - Sub commands - See issue #5 +DEVELOPMENT *CopilotChat-copilot-chat-for-neovim-development* + + +INSTALLING PRE-COMMIT TOOL ~ + +For development, you can use the provided Makefile command to install the +pre-commit tool: + +>bash + make install-pre-commit +< + +This will install the pre-commit tool and the pre-commit hooks. + + CONTRIBUTORS ✨ *CopilotChat-copilot-chat-for-neovim-contributors-✨* Thanks goes to these wonderful people (emoji key ): -gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻This project follows the all-contributors +gptlang💻 📖Dung Duc Huynh (Kaka)💻 📖Ahmed Haracic💻Trí Thiện Nguyễn💻He Zhizhou💻Guruprakash Rajakkannu💻kristofka💻PostCyberPunk📖This project follows the all-contributors specification. Contributions of any kind welcome! ============================================================================== 2. Links *CopilotChat-links* -1. *All Contributors*: https://img.shields.io/badge/all_contributors-4-orange.svg?style=flat-square -2. *Chat Demo*: https://i.gyazo.com/10fbd1543380d15551791c1a6dcbcd46.gif -3. *Explain Code Demo*: https://i.gyazo.com/e5031f402536a1a9d6c82b2c38d469e3.gif -4. *Generate tests*: https://i.gyazo.com/f285467d4b8d8f8fd36aa777305312ae.gif +1. *All Contributors*: https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square +2. *@jellydn*: +3. *Chat Demo*: https://i.gyazo.com/10fbd1543380d15551791c1a6dcbcd46.gif +4. *Explain Code Demo*: https://i.gyazo.com/e5031f402536a1a9d6c82b2c38d469e3.gif +5. *Generate tests*: https://i.gyazo.com/f285467d4b8d8f8fd36aa777305312ae.gif +6. *Fold Demo*: https://i.gyazo.com/766fb3b6ffeb697e650fc839882822a8.gif +7. *In-place Demo*: https://i.gyazo.com/4a5badaa109cd483c1fc23d296325cb0.gif +8. *Debug Info*: https://i.gyazo.com/bf00e700bcee1b77bcbf7b516b552521.gif +9. *@ecosse3*: Generated by panvimdoc diff --git a/lua/CopilotChat/init.lua b/lua/CopilotChat/init.lua index acb64fcb..9fe5f325 100644 --- a/lua/CopilotChat/init.lua +++ b/lua/CopilotChat/init.lua @@ -7,15 +7,23 @@ local default_prompts = { Tests = 'Briefly how selected code works then generate unit tests.', } +_COPILOT_CHAT_GLOBAL_CONFIG = {} + -- Set up the plugin ---@param options (table | nil) --- - mode: ('newbuffer' | 'split') default: newbuffer. +-- - show_help: ('yes' | 'no') default: 'yes'. +-- - disable_extra_info: ('yes' | 'no') default: 'yes'. -- - prompts: (table?) default: default_prompts. +-- - debug: (boolean?) default: false. M.setup = function(options) - vim.g.copilot_chat_view_option = options and options.mode or 'newbuffer' + vim.g.copilot_chat_show_help = options and options.show_help or 'yes' + vim.g.copilot_chat_disable_separators = options and options.disable_extra_info or 'yes' + local debug = options and options.debug or false + _COPILOT_CHAT_GLOBAL_CONFIG.debug = debug -- Merge the provided prompts with the default prompts local prompts = vim.tbl_extend('force', default_prompts, options and options.prompts or {}) + vim.g.copilot_chat_user_prompts = prompts -- Loop through merged table and generate commands based on keys. for key, value in pairs(prompts) do @@ -24,14 +32,72 @@ M.setup = function(options) end, { nargs = '*', range = true }) end - -- Toggle between newbuffer and split - utils.create_cmd('CopilotChatToggleLayout', function() - if vim.g.copilot_chat_view_option == 'newbuffer' then - vim.g.copilot_chat_view_option = 'split' - else - vim.g.copilot_chat_view_option = 'newbuffer' + -- Show debug info + utils.create_cmd('CopilotChatDebugInfo', function() + -- Get the log file path + local log_file_path = utils.get_log_file_path() + + -- Get the rplugin path + local rplugin_path = utils.get_remote_plugins_path() + + -- Create a popup with the log file path + local lines = { + 'CopilotChat.nvim Info:', + '- Log file path: ' .. log_file_path, + '- Rplugin path: ' .. rplugin_path, + 'If you are facing issues, run `:checkhealth CopilotChat` and share the output.', + 'There is a common issue is "Ambiguous use of user-defined command". Please check the pin issues on the repository.', + 'Press `q` to close this window.', + 'Press `?` to open the rplugin file.', + } + + local width = 0 + for _, line in ipairs(lines) do + width = math.max(width, #line) end - end, { nargs = '*', range = true }) + local height = #lines + local opts = { + relative = 'editor', + width = width + 4, + height = height + 2, + row = (vim.o.lines - height) / 2 - 1, + col = (vim.o.columns - width) / 2, + style = 'minimal', + border = 'rounded', + } + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + vim.api.nvim_open_win(bufnr, true, opts) + + -- Bind 'q' to close the window + vim.api.nvim_buf_set_keymap( + bufnr, + 'n', + 'q', + 'close', + { noremap = true, silent = true } + ) + + -- Bind `?` to open remote plugin detail + vim.api.nvim_buf_set_keymap( + bufnr, + 'n', + '?', + -- Close the current window and open the rplugin file + 'closeedit ' + .. rplugin_path + .. '', + { noremap = true, silent = true } + ) + end, { + nargs = '*', + range = true, + }) + + utils.log_info( + 'Execute ":UpdateRemotePlugins" and restart Neovim before starting a chat with Copilot.' + ) + utils.log_info('If issues arise, run ":healthcheck" and share the output.') end return M diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index bcae76ef..3d83bdc3 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -1,5 +1,26 @@ local M = {} +local log = require('CopilotChat.vlog') + +--- Get the log file path +---@return string +M.get_log_file_path = function() + return log.get_log_file() +end + +-- The CopilotChat.nvim is built using remote plugins. +-- This is the path to the rplugin.vim file. +-- Refer https://neovim.io/doc/user/remote_plugin.html#%3AUpdateRemotePlugins +-- @return string +M.get_remote_plugins_path = function() + local os = vim.loop.os_uname().sysname + if os == 'Linux' or os == 'Darwin' then + return '~/.local/share/nvim/rplugin.vim' + else + return '~/AppData/Local/nvim/rplugin.vim' + end +end + --- Create custom command ---@param cmd string The command name ---@param func function The function to execute @@ -9,4 +30,26 @@ M.create_cmd = function(cmd, func, opt) vim.api.nvim_create_user_command(cmd, func, opt) end +--- Log info +---@vararg any +M.log_info = function(...) + -- Only save log when debug is on + if not _COPILOT_CHAT_GLOBAL_CONFIG.debug then + return + end + + log.info(...) +end + +--- Log error +---@vararg any +M.log_error = function(...) + -- Only save log when debug is on + if not _COPILOT_CHAT_GLOBAL_CONFIG.debug then + return + end + + log.error(...) +end + return M diff --git a/lua/CopilotChat/vlog.lua b/lua/CopilotChat/vlog.lua new file mode 100644 index 00000000..972a4342 --- /dev/null +++ b/lua/CopilotChat/vlog.lua @@ -0,0 +1,151 @@ +-- -- log.lua +-- +-- Inspired by rxi/log.lua +-- Modified by tjdevries and can be found at github.com/tjdevries/vlog.nvim +-- +-- This library is free software; you can redistribute it and/or modify it +-- under the terms of the MIT license. See LICENSE for details. + +-- User configuration section +local default_config = { + -- Name of the plugin. Prepended to log messages + plugin = 'CopilotChat.nvim', + + -- Should print the output to neovim while running + use_console = false, + + -- Should highlighting be used in console (using echohl) + highlights = true, + + -- Should write to a file + use_file = true, + + -- Any messages above this level will be logged. + level = 'trace', + + -- Level configuration + modes = { + { name = 'trace', hl = 'Comment' }, + { name = 'debug', hl = 'Comment' }, + { name = 'info', hl = 'None' }, + { name = 'warn', hl = 'WarningMsg' }, + { name = 'error', hl = 'ErrorMsg' }, + { name = 'fatal', hl = 'ErrorMsg' }, + }, + + -- Can limit the number of decimals displayed for floats + float_precision = 0.01, +} + +-- {{{ NO NEED TO CHANGE +local log = {} + +local unpack = unpack or table.unpack + +log.get_log_file = function() + return string.format('%s/%s.log', vim.fn.stdpath('state'), default_config.plugin) +end + +log.new = function(config, standalone) + config = vim.tbl_deep_extend('force', default_config, config) + + local outfile = log.get_log_file() + + local obj + if standalone then + obj = log + else + obj = {} + end + + local levels = {} + for i, v in ipairs(config.modes) do + levels[v.name] = i + end + + local round = function(x, increment) + increment = increment or 1 + x = x / increment + return (x > 0 and math.floor(x + 0.5) or math.ceil(x - 0.5)) * increment + end + + local make_string = function(...) + local t = {} + for i = 1, select('#', ...) do + local x = select(i, ...) + + if type(x) == 'number' and config.float_precision then + x = tostring(round(x, config.float_precision)) + elseif type(x) == 'table' then + x = vim.inspect(x) + else + x = tostring(x) + end + + t[#t + 1] = x + end + return table.concat(t, ' ') + end + + local log_at_level = function(level, level_config, message_maker, ...) + -- Return early if we're below the config.level + if level < levels[config.level] then + return + end + local nameupper = level_config.name:upper() + + local msg = message_maker(...) + local info = debug.getinfo(2, 'Sl') + local lineinfo = info.short_src .. ':' .. info.currentline + + -- Output to console + if config.use_console then + local console_string = + string.format('[%-6s%s] %s: %s', nameupper, os.date('%H:%M:%S'), lineinfo, msg) + + if config.highlights and level_config.hl then + vim.cmd(string.format('echohl %s', level_config.hl)) + end + + local split_console = vim.split(console_string, '\n') + for _, v in ipairs(split_console) do + vim.cmd(string.format([[echom "[%s] %s"]], config.plugin, vim.fn.escape(v, '"'))) + end + + if config.highlights and level_config.hl then + vim.cmd('echohl NONE') + end + end + + -- Output to log file + if config.use_file then + local fp = io.open(outfile, 'a') + local str = string.format('[%-6s%s] %s: %s\n', nameupper, os.date(), lineinfo, msg) + fp:write(str) + fp:close() + end + end + + for i, x in ipairs(config.modes) do + obj[x.name] = function(...) + return log_at_level(i, x, make_string, ...) + end + + obj[('fmt_%s'):format(x.name)] = function() + return log_at_level(i, x, function(...) + local passed = { ... } + local fmt = table.remove(passed, 1) + local inspected = {} + for _, v in ipairs(passed) do + table.insert(inspected, vim.inspect(v)) + end + return string.format(fmt, unpack(inspected)) + end) + end + end +end + +log.new(default_config, true) +-- }}} +-- +return log diff --git a/renovate.json b/renovate.json index 5db72dd6..22a99432 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended" - ] + "extends": ["config:recommended"] } diff --git a/requirements.txt b/requirements.txt index 75a67aa0..510c3322 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ python-dotenv requests pynvim==0.5.0 prompt-toolkit +tiktoken diff --git a/rplugin/python3/copilot-plugin.py b/rplugin/python3/copilot-plugin.py index 170b23fd..ec9b4b5b 100644 --- a/rplugin/python3/copilot-plugin.py +++ b/rplugin/python3/copilot-plugin.py @@ -1,103 +1,64 @@ -import os -import time - -import copilot -import dotenv -import prompts import pynvim +from handlers.inplace_chat_handler import InPlaceChatHandler +from handlers.vsplit_chat_handler import VSplitChatHandler +from mypynvim.core.nvim import MyNvim -dotenv.load_dotenv() +PLUGIN_MAPPING_CMD = "CopilotChatMapping" +PLUGIN_AUTOCMD_CMD = "CopilotChatAutocmd" @pynvim.plugin -class CopilotChatPlugin(object): +class CopilotPlugin(object): def __init__(self, nvim: pynvim.Nvim): - self.nvim = nvim - self.copilot = copilot.Copilot(os.getenv("COPILOT_TOKEN")) - if self.copilot.github_token is None: - req = self.copilot.request_auth() - self.nvim.out_write( - f"Please visit {req['verification_uri']} and enter the code {req['user_code']}\n" - ) - current_time = time.time() - wait_until = current_time + req["expires_in"] - while self.copilot.github_token is None: - self.copilot.poll_auth(req["device_code"]) - time.sleep(req["interval"]) - if time.time() > wait_until: - self.nvim.out_write("Timed out waiting for authentication\n") - return - self.nvim.out_write("Successfully authenticated with Copilot\n") - self.copilot.authenticate() - - @pynvim.command("CopilotChat", nargs="1") - def copilotChat(self, args: list[str]): - if self.copilot.github_token is None: - self.nvim.out_write("Please authenticate with Copilot first\n") - return - - # Start the spinner - self.nvim.exec_lua('require("CopilotChat.spinner").show()') - - prompt = " ".join(args) - if prompt == "/fix": - prompt = prompts.FIX_SHORTCUT - elif prompt == "/test": - prompt = prompts.TEST_SHORTCUT - elif prompt == "/explain": - prompt = prompts.EXPLAIN_SHORTCUT - - # Get code from the unnamed register - code = self.nvim.eval("getreg('\"')") - file_type = self.nvim.eval("expand('%')").split(".")[-1] - - # Get the view option from the command - view_option = self.nvim.eval("g:copilot_chat_view_option") - - # Check if we're already in a chat buffer - if self.nvim.eval("getbufvar(bufnr(), '&buftype')") != "nofile": - # Create a new scratch buffer to hold the chat - if view_option == "split": - self.nvim.command("vnew") - else: - self.nvim.command("enew") - # Set the buffer type to nofile and hide it when it's not active - self.nvim.command("setlocal buftype=nofile bufhidden=hide noswapfile") - # Set filetype as markdown and wrap with linebreaks - self.nvim.command("setlocal filetype=markdown wrap linebreak") + self.nvim: MyNvim = MyNvim(nvim, PLUGIN_MAPPING_CMD, PLUGIN_AUTOCMD_CMD) + self.vsplit_chat_handler = None + self.inplace_chat_handler = None - # Get the current buffer - buf = self.nvim.current.buffer - self.nvim.api.buf_set_option(buf, "fileencoding", "utf-8") + def init_vsplit_chat_handler(self): + if self.vsplit_chat_handler is None: + self.vsplit_chat_handler = VSplitChatHandler(self.nvim) - # Add start separator - start_separator = f"""### User -{prompt} - -### Copilot - -""" - buf.append(start_separator.split("\n"), -1) - - # Add chat messages - for token in self.copilot.ask(prompt, code, language=file_type): - buffer_lines = self.nvim.api.buf_get_lines(buf, 0, -1, 0) - last_line_row = len(buffer_lines) - 1 - last_line = buffer_lines[-1] - last_line_col = len(last_line.encode("utf-8")) - - self.nvim.api.buf_set_text( - buf, - last_line_row, - last_line_col, - last_line_row, - last_line_col, - token.split("\n"), - ) - - # Stop the spinner - self.nvim.exec_lua('require("CopilotChat.spinner").hide()') - - # Add end separator - end_separator = "\n---\n" - buf.append(end_separator.split("\n"), -1) + @pynvim.command("CopilotChat", nargs="1") + def copilot_agent_cmd(self, args: list[str]): + self.init_vsplit_chat_handler() + if self.vsplit_chat_handler: + file_type = self.nvim.current.buffer.options["filetype"] + self.vsplit_chat_handler.vsplit() + # Get code from the unnamed register + code = self.nvim.eval("getreg('\"')") + self.vsplit_chat_handler.chat(args[0], file_type, code) + + @pynvim.command("CopilotChatVisual", nargs="1", range="") + def copilot_agent_visual_cmd(self, args: list[str], range: list[int]): + self.init_vsplit_chat_handler() + if self.vsplit_chat_handler: + file_type = self.nvim.current.buffer.options["filetype"] + code_lines = self.nvim.current.buffer[range[0] - 1 : range[1]] + code = "\n".join(code_lines) + self.vsplit_chat_handler.vsplit() + self.vsplit_chat_handler.chat(args[0], file_type, code) + + def init_inplace_chat_handler(self): + if self.inplace_chat_handler is None: + self.inplace_chat_handler = InPlaceChatHandler(self.nvim) + + # Those commands are used by the plugin, internal use only + @pynvim.command(PLUGIN_MAPPING_CMD, nargs="*") + def plugin_mapping_cmd(self, args): + bufnr, mapping = args + self.nvim.key_mapper.execute(bufnr, mapping) + + @pynvim.command(PLUGIN_AUTOCMD_CMD, nargs="*") + def plugin_autocmd_cmd(self, args): + event, id, bufnr = args + self.nvim.autocmd_mapper.execute(event, id, bufnr) + + @pynvim.command("CopilotChatInPlace", nargs="*", range="") + def inplace_cmd(self, args: list[str], range: list[int]): + self.init_inplace_chat_handler() + if self.inplace_chat_handler: + file_type = self.nvim.current.buffer.options["filetype"] + code_lines = self.nvim.current.buffer[range[0] - 1 : range[1]] + code = "\n".join(code_lines) + user_buffer = self.nvim.current.buffer + self.inplace_chat_handler.mount(code, file_type, range, user_buffer) diff --git a/rplugin/python3/copilot.py b/rplugin/python3/copilot.py index 0faea917..ee471b64 100644 --- a/rplugin/python3/copilot.py +++ b/rplugin/python3/copilot.py @@ -87,22 +87,26 @@ def authenticate(self): self.token = self.session.get(url, headers=headers).json() - def ask(self, prompt: str, code: str, language: str = ""): + def ask( + self, + system_prompt: str, + prompt: str, + code: str, + language: str = "", + model: str = "gpt-4", + ): + if not self.token: + self.authenticate() # If expired, reauthenticate if self.token.get("expires_at") <= round(time.time()): self.authenticate() + if not system_prompt: + system_prompt = prompts.COPILOT_INSTRUCTIONS url = "https://api.githubcopilot.com/chat/completions" self.chat_history.append(typings.Message(prompt, "user")) - system_prompt = prompts.COPILOT_INSTRUCTIONS - if prompt == prompts.FIX_SHORTCUT: - system_prompt = prompts.COPILOT_FIX - elif prompt == prompts.TEST_SHORTCUT: - system_prompt = prompts.COPILOT_TESTS - elif prompt == prompts.EXPLAIN_SHORTCUT: - system_prompt = prompts.COPILOT_EXPLAIN data = utilities.generate_request( - self.chat_history, code, language, system_prompt=system_prompt + self.chat_history, code, language, system_prompt=system_prompt, model=model ) full_response = "" @@ -110,6 +114,18 @@ def ask(self, prompt: str, code: str, language: str = ""): response = self.session.post( url, headers=self._headers(), json=data, stream=True ) + if response.status_code != 200: + error_messages = { + 401: "Unauthorized. Make sure you have access to Copilot Chat.", + 500: "Internal server error. Please try again later.", + 400: "The developer of this plugin has made a mistake. Please report this issue.", + 419: "You have been rate limited. Please try again later.", + } + raise Exception( + error_messages.get( + response.status_code, f"Unknown error: {response.status_code}" + ) + ) for line in response.iter_lines(): line = line.decode("utf-8").replace("data: ", "").strip() if line.startswith("[DONE]"): @@ -189,7 +205,7 @@ def main(): code = get_input(session, "\n\nCode: \n") print("\n\nAI Response:") - for response in copilot.ask(user_prompt, code): + for response in copilot.ask(None, user_prompt, code): print(response, end="", flush=True) diff --git a/rplugin/python3/handlers/chat_handler.py b/rplugin/python3/handlers/chat_handler.py new file mode 100644 index 00000000..3fe7b3d2 --- /dev/null +++ b/rplugin/python3/handlers/chat_handler.py @@ -0,0 +1,248 @@ +import time +from datetime import datetime +from typing import Optional, cast + +import prompts as system_prompts +from copilot import Copilot +from mypynvim.core.buffer import MyBuffer +from mypynvim.core.nvim import MyNvim + + +def is_module_installed(name): + try: + __import__(name) + return True + except ImportError: + return False + + +class ChatHandler: + def __init__(self, nvim: MyNvim, buffer: MyBuffer): + self.nvim: MyNvim = nvim + self.copilot: Copilot = None + self.buffer: MyBuffer = buffer + + # public + + def chat( + self, + prompt: str, + filetype: str, + code: str = "", + winnr: int = 0, + system_prompt: Optional[str] = None, + disable_start_separator: bool = False, + disable_end_separator: bool = False, + model: str = "gpt-4", + ): + no_annoyance = self.nvim.eval("g:copilot_chat_disable_separators") == "yes" + if system_prompt is None: + system_prompt = self._construct_system_prompt(prompt) + # Start the spinner + self.nvim.exec_lua('require("CopilotChat.spinner").show()') + + self.nvim.exec_lua( + 'require("CopilotChat.utils").log_info(...)', f"Chatting with {model} model" + ) + + if not disable_start_separator: + self._add_start_separator( + system_prompt, prompt, code, filetype, winnr, no_annoyance + ) + + self._add_chat_messages(system_prompt, prompt, code, filetype, model) + + # Stop the spinner + self.nvim.exec_lua('require("CopilotChat.spinner").hide()') + + if not disable_end_separator: + self._add_end_separator(model, no_annoyance) + + # private + + def _construct_system_prompt(self, prompt: str): + system_prompt = system_prompts.COPILOT_INSTRUCTIONS + if prompt == system_prompts.FIX_SHORTCUT: + system_prompt = system_prompts.COPILOT_FIX + elif prompt == system_prompts.TEST_SHORTCUT: + system_prompt = system_prompts.COPILOT_TESTS + elif prompt == system_prompts.EXPLAIN_SHORTCUT: + system_prompt = system_prompts.COPILOT_EXPLAIN + return system_prompt + + def _add_start_separator( + self, + system_prompt: str, + prompt: str, + code: str, + file_type: str, + winnr: int, + no_annoyance: bool = False, + ): + if is_module_installed("tiktoken") and not no_annoyance: + self._add_start_separator_with_token_count( + system_prompt, prompt, code, file_type, winnr + ) + else: + self._add_regular_start_separator( + system_prompt, prompt, code, file_type, winnr, no_annoyance + ) + + def _add_regular_start_separator( + self, + system_prompt: str, + prompt: str, + code: str, + file_type: str, + winnr: int, + no_annoyance: bool = False, + ): + if code and not no_annoyance: + code = f"\n \nCODE:\n```{file_type}\n{code}\n```" + + last_row_before = len(self.buffer.lines()) + system_prompt_height = len(system_prompt.split("\n")) + code_height = len(code.split("\n")) + + start_separator = ( + f"""### User + +SYSTEM PROMPT: +``` +{system_prompt} +``` +{prompt}{code} + +### Copilot + +""" + if not no_annoyance + else f"### User\n{prompt}\n\n### Copilot\n\n" + ) + self.buffer.append(start_separator.split("\n")) + + if no_annoyance: + return + self._add_folds(code, code_height, last_row_before, system_prompt_height, winnr) + + def _add_start_separator_with_token_count( + self, + system_prompt: str, + prompt: str, + code: str, + file_type: str, + winnr: int, + ): + import tiktoken + + encoding = tiktoken.encoding_for_model("gpt-4") + + num_total_tokens = len(encoding.encode(f"{system_prompt}\n{prompt}\n{code}")) + num_system_tokens = len(encoding.encode(system_prompt)) + num_prompt_tokens = len(encoding.encode(prompt)) + num_code_tokens = len(encoding.encode(code)) + + if code: + code = f"\n \nCODE: {num_code_tokens} Tokens \n```{file_type}\n{code}\n```" + + last_row_before = len(self.buffer.lines()) + system_prompt_height = len(system_prompt.split("\n")) + code_height = len(code.split("\n")) + + start_separator = f"""### User + +SYSTEM PROMPT: {num_system_tokens} Tokens +``` +{system_prompt} +``` +{prompt}{code} + +### Copilot + +""" + self.buffer.append(start_separator.split("\n")) + + last_row_after = last_row_before + system_prompt_height + 5 + self.buffer.eol(last_row_before, f"{num_total_tokens} Total Tokens", "@float") + self.buffer.eol( + last_row_after, f"{num_prompt_tokens} Tokens", "NightflySteelBlue" + ) + + self._add_folds(code, code_height, last_row_before, system_prompt_height, winnr) + + def _add_folds( + self, + code: str, + code_height: int, + last_row_before: int, + system_prompt_height: int, + winnr: int, + ): + self.nvim.command("set foldmethod=manual") + system_fold_start = last_row_before + 2 + system_fold_end = system_fold_start + system_prompt_height + 3 + main_command = f"{system_fold_start}, {system_fold_end} fold | normal! Gzz" + full_command = f"call win_execute({winnr}, '{main_command}')" + self.nvim.command(full_command) + + if code != "": + code_fold_start = system_fold_end + 2 + code_fold_end = code_fold_start + code_height - 1 + main_command = f"{code_fold_start}, {code_fold_end} fold | normal! G" + full_command = f"call win_execute({winnr}, '{main_command}')" + self.nvim.command(full_command) + + def _add_chat_messages( + self, system_prompt: str, prompt: str, code: str, file_type: str, model: str + ): + if self.copilot is None: + self.copilot = Copilot() + if self.copilot.github_token is None: + req = self.copilot.request_auth() + self.nvim.out_write( + f"Please visit {req['verification_uri']} and enter the code {req['user_code']}\n" + ) + current_time = time.time() + wait_until = current_time + req["expires_in"] + while self.copilot.github_token is None: + self.copilot.poll_auth(req["device_code"]) + time.sleep(req["interval"]) + if time.time() > wait_until: + self.nvim.out_write("Timed out waiting for authentication\n") + return + self.nvim.out_write("Successfully authenticated with Copilot\n") + self.copilot.authenticate() + + for token in self.copilot.ask( + system_prompt, prompt, code, language=cast(str, file_type), model=model + ): + self.nvim.exec_lua( + 'require("CopilotChat.utils").log_info(...)', f"Token: {token}" + ) + buffer_lines = cast(list[str], self.buffer.lines()) + last_line_row = len(buffer_lines) - 1 + last_line_col = len(buffer_lines[-1]) + + self.nvim.api.buf_set_text( + self.buffer.number, + last_line_row, + last_line_col, + last_line_row, + last_line_col, + token.split("\n"), + ) + + def _add_end_separator(self, model: str, no_annoyance: bool = False): + current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + model_info = f"\n#### Answer provided by Copilot (Model: `{model}`) on {current_datetime}." + additional_instructions = ( + "\n> For additional queries, please use the `CopilotChat` command." + ) + disclaimer = "\n> Please be aware that the AI's output may not always be accurate. Always cross-verify the output." + + end_message = model_info + additional_instructions + disclaimer + + if no_annoyance: + end_message = "\n" + current_datetime + "\n---\n" + + self.buffer.append(end_message.split("\n")) diff --git a/rplugin/python3/handlers/inplace_chat_handler.py b/rplugin/python3/handlers/inplace_chat_handler.py new file mode 100644 index 00000000..f0d784f2 --- /dev/null +++ b/rplugin/python3/handlers/inplace_chat_handler.py @@ -0,0 +1,336 @@ +import prompts as system_prompts +from handlers.chat_handler import ChatHandler +from mypynvim.core.buffer import MyBuffer +from mypynvim.core.nvim import MyNvim +from mypynvim.ui_components.layout import Box, Layout +from mypynvim.ui_components.popup import PopUp + +# Define constants for the models +MODEL_GPT4 = "gpt-4" +MODEL_GPT35_TURBO = "gpt-3.5-turbo" + + +# TODO: change the layout, e.g: move to right side of the screen +# TODO: Abort request if the user closes the layout +class InPlaceChatHandler: + """This class handles in-place chat functionality.""" + + def __init__(self, nvim: MyNvim): + """Initialize the InPlaceChatHandler with the given nvim instance.""" + self.nvim: MyNvim = nvim + self.diff_mode: bool = False + self.model: str = MODEL_GPT4 + self.system_prompt: str = "SENIOR_DEVELOPER_PROMPT" + + # Add user prompts collection + self.user_prompts = self.nvim.eval("g:copilot_chat_user_prompts") + self.current_user_prompt = 0 + + # Initialize popups + self.original_popup = PopUp(nvim, title="Original") + self.copilot_popup = PopUp( + nvim, + title=f"Copilot ({self.model}, {self.system_prompt})", + opts={"wrap": True, "linebreak": True}, + ) + self.prompt_popup = PopUp( + nvim, title="Prompt", enter=True, padding={"left": 1, "right": 1} + ) + self.help_popup = PopUp(nvim, title="Help") + + self.popups = [ + self.original_popup, + self.copilot_popup, + self.prompt_popup, + ] + + # Initialize layout base on help text option + self.help_popup_visible = self.nvim.eval("g:copilot_chat_show_help") == "yes" + if self.help_popup_visible: + self.layout = self._create_layout() + self.popups.append(self.help_popup) + else: + self.layout = self._create_layout_without_help() + + # Initialize chat handler + self.chat_handler = ChatHandler(nvim, self.copilot_popup.buffer) + + # Set keymaps and help content + self._set_keymaps() + self._set_help_content() + + def _create_layout(self): + """Create the layout for the chat handler.""" + return Layout( + self.nvim, + Box( + [ + Box( + [ + Box([self.original_popup]), + Box([self.copilot_popup]), + ], + size=["50%", "50%"], + direction="row", + ), + Box( + [Box([self.prompt_popup]), Box([self.help_popup])], + size=["50%", "50%"], + direction="row", + ), + ], + size=["80%", "20%"], + direction="col", + ), + width="80%", + height="60%", + relative="editor", + row="50%", + col="50%", + ) + + def _create_layout_without_help(self): + """Create the layout with help for the chat handler.""" + return Layout( + self.nvim, + Box( + [ + Box( + [ + Box([self.original_popup]), + Box([self.copilot_popup]), + ], + size=["50%", "50%"], + direction="row", + ), + Box( + [Box([self.prompt_popup]), Box([])], + size=["100%", "0%"], + direction="row", + ), + ], + size=["80%", "20%"], + direction="col", + ), + width="80%", + height="60%", + relative="editor", + row="50%", + col="50%", + ) + + def mount( + self, original_code: str, filetype: str, range: list[int], user_buffer: MyBuffer + ): + """Mount the chat handler with the given parameters.""" + self.original_code = original_code + self.filetype = filetype + self.range = [range[0] - 1, range[1]] + self.user_buffer = user_buffer + + self.original_popup.buffer.lines(original_code) + self.original_popup.buffer.options["filetype"] = filetype + + self.copilot_popup.buffer.options["filetype"] = "markdown" + + self.layout.mount() + + def _replace_original_code(self): + """Replace the original code with the new code.""" + new_lines = self.copilot_popup.buffer.lines() + if new_lines[0].startswith("```"): + new_lines = new_lines[1:-1] + self.user_buffer.lines(new_lines, self.range[0], self.range[1]) + self.layout.unmount() + self.nvim.command("norm! ^") + + def _diff(self): + """Show the difference between the original code and the new code.""" + if not self.diff_mode: + self.original_popup.window.command("diffthis") + self.copilot_popup.window.command("diffthis") + self.diff_mode = True + else: + self.original_popup.window.command("diffoff") + self.diff_mode = False + + def _chat(self): + """Start a chat session.""" + self.copilot_popup.buffer.lines("") + self.copilot_popup.window.command("norm! gg") + prompt_lines = self.prompt_popup.buffer.lines() + prompt = "\n".join(prompt_lines) + self.chat_handler.chat( + prompt, + self.filetype, + self.original_code, + self.copilot_popup.window.handle, + system_prompt=system_prompts.__dict__[self.system_prompt], + disable_start_separator=True, + disable_end_separator=True, + model=self.model, + ) + + def _set_prompt(self, prompt: str): + self.prompt_popup.buffer.lines(prompt) + + def _set_next_user_prompt(self): + self.current_user_prompt = (self.current_user_prompt + 1) % len( + self.user_prompts + ) + prompt = list(self.user_prompts.keys())[self.current_user_prompt] + self.prompt_popup.buffer.lines(self.user_prompts[prompt]) + + def _set_previous_user_prompt(self): + self.current_user_prompt = (self.current_user_prompt - 1) % len( + self.user_prompts + ) + prompt = list(self.user_prompts.keys())[self.current_user_prompt] + self.prompt_popup.buffer.lines(self.user_prompts[prompt]) + + def _toggle_model(self): + if self.model == MODEL_GPT4: + self.model = MODEL_GPT35_TURBO + else: + self.model = MODEL_GPT4 + self.copilot_popup.original_config.title = ( + f"Copilot ({self.model}, {self.system_prompt})" + ) + self.copilot_popup.config.title = ( + f"Copilot ({self.model}, {self.system_prompt})" + ) + self.copilot_popup.unmount() + self.copilot_popup.mount(controlled=True) + + def _toggle_system_model(self): + # Create a list of all system prompts and add the current system prompt + system_prompts = [ + "SENIOR_DEVELOPER_PROMPT", + "COPILOT_EXPLAIN", + "COPILOT_TESTS", + "COPILOT_FIX", + "COPILOT_WORKSPACE", + "TEST_SHORTCUT", + "EXPLAIN_SHORTCUT", + "FIX_SHORTCUT", + ] + + # Get the index of the current system prompt + current_system_prompt_index = system_prompts.index(self.system_prompt) + + # Set the next system prompt + self.system_prompt = system_prompts[ + (current_system_prompt_index + 1) % len(system_prompts) + ] + + self.copilot_popup.original_config.title = ( + f"Copilot ({self.model}, {self.system_prompt})" + ) + self.copilot_popup.config.title = ( + f"Copilot ({self.model}, {self.system_prompt})" + ) + self.copilot_popup.unmount() + self.copilot_popup.mount(controlled=True) + + def _set_keymaps(self): + """Set the keymaps for the chat handler.""" + self.prompt_popup.map("n", "", lambda: self._chat()) + self.prompt_popup.map("n", "", lambda: self._replace_original_code()) + self.prompt_popup.map("n", "", lambda: self._diff()) + self.prompt_popup.map("n", "", lambda cb=self._toggle_model: cb()) + self.prompt_popup.map("n", "", lambda cb=self._toggle_system_model: cb()) + + self.prompt_popup.map( + "n", "'", lambda: self._set_prompt(system_prompts.PROMPT_SIMPLE_DOCSTRING) + ) + self.prompt_popup.map( + "n", "s", lambda: self._set_prompt(system_prompts.PROMPT_SEPARATE) + ) + + self.prompt_popup.map( + "i", "", lambda: (self.nvim.feed(""), self._chat()) + ) + + self.prompt_popup.map( + "n", + "", + lambda: self._set_next_user_prompt(), + ) + + self.prompt_popup.map( + "n", + "", + lambda: self._set_previous_user_prompt(), + ) + + for i, popup in enumerate(self.popups): + popup.buffer.map("n", "q", lambda: self.layout.unmount()) + popup.buffer.map("n", "", lambda: self._clear_chat_history()) + popup.buffer.map("n", "?", lambda: self._toggle_help()) + popup.buffer.map( + "n", + "", + lambda i=i: self.popups[(i + 1) % len(self.popups)].focus(), + ) + + def _clear_chat_history(self): + """Clear the chat history in the copilot popup.""" + self.copilot_popup.buffer.lines([]) + + def _set_help_content(self): + """Set the content for the help popup.""" + help_content = [ + "Navigation:", + " : Switch focus between popups", + " q: Close layout", + " ?: Toggle help content", + "", + "Chat in Normal Mode:", + " : Submit prompt to Copilot", + " : Replace old code with new", + " : Show code differences", + " : Clear chat history", + "", + "Chat in Insert Mode:", + " : Start chat and submit prompt to Copilot", + "", + "Prompt Binding:", + " ': Set prompt to SIMPLE_DOCSTRING", + " s: Set prompt to SEPARATE", + " : Get the previous user prompt", + " : Set prompt to next item in user prompts", + "", + "Model:", + " : Toggle AI model", + " : Set system prompt to next item in system prompts", + "", + "User prompts:", + ] + + for prompt in self.user_prompts: + help_content.append(f" {prompt}: {self.user_prompts[prompt]}") + + self.help_popup.buffer.lines(help_content) + + def _toggle_help(self): + """Toggle the visibility of the help popup.""" + self.layout.unmount() + if self.help_popup_visible: + self.popups = [ + self.original_popup, + self.copilot_popup, + self.prompt_popup, + ] + self.layout = self._create_layout_without_help() + else: + self.popups = [ + self.original_popup, + self.copilot_popup, + self.prompt_popup, + self.help_popup, + ] + self.layout = self._create_layout() + + self.help_popup_visible = not self.help_popup_visible + self._set_keymaps() + self.layout.mount() diff --git a/rplugin/python3/handlers/vsplit_chat_handler.py b/rplugin/python3/handlers/vsplit_chat_handler.py new file mode 100644 index 00000000..191fd6b2 --- /dev/null +++ b/rplugin/python3/handlers/vsplit_chat_handler.py @@ -0,0 +1,38 @@ +from handlers.chat_handler import ChatHandler +from mypynvim.core.buffer import MyBuffer +from mypynvim.core.nvim import MyNvim + + +class VSplitChatHandler(ChatHandler): + def __init__(self, nvim: MyNvim): + self.nvim: MyNvim = nvim + self.copilot = None + self.buffer: MyBuffer = MyBuffer.new( + self.nvim, + { + "filetype": "markdown", + }, + ) + + def vsplit(self): + var_key = "copilot_chat" + for window in self.nvim.windows: + try: + if window.vars[var_key]: + self.nvim.current.window = window + return + except Exception: + pass + + self.buffer.vsplit( + { + "wrap": True, + "linebreak": True, + "conceallevel": 2, + "concealcursor": "n", + } + ) + self.nvim.current.window.vars[var_key] = True + + def chat(self, prompt: str, filetype: str, code: str = ""): + super().chat(prompt, filetype, code, self.nvim.current.window.handle) diff --git a/rplugin/python3/mypynvim/core/autocmdmapper.py b/rplugin/python3/mypynvim/core/autocmdmapper.py new file mode 100644 index 00000000..7ba731b8 --- /dev/null +++ b/rplugin/python3/mypynvim/core/autocmdmapper.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, Union + +if TYPE_CHECKING: + from .nvim import MyNvim + + +class AutocmdMapper: + def __init__(self, nvim: MyNvim): + self.nvim: MyNvim = nvim + self._autocmd_callback_library = {} + + def buf_set( + self, + events: Union[str, list[str]], + id: str, + bufnr: int, + callback: Callable, + ): + if isinstance(events, str): + events = [events] + + for e in events: + if not self._autocmd_callback_library.get(e): + self._autocmd_callback_library[e] = {} + + if not self._autocmd_callback_library[e].get(id): + self._autocmd_callback_library[e][id] = {} + + self._autocmd_callback_library[e][id][str(bufnr)] = callback + + self.nvim.api.create_autocmd( + e, + { + "buffer": bufnr, + "command": f"{self.nvim._autocmd_command} {e} {id} {bufnr}", + }, + ) + + def execute(self, event: str, id: str, bufnr: int): + try: + self._autocmd_callback_library[event][id][bufnr]() + except KeyError: + self.nvim.notify(f"KeyError: {event} {id} {bufnr}") diff --git a/rplugin/python3/mypynvim/core/buffer.py b/rplugin/python3/mypynvim/core/buffer.py new file mode 100644 index 00000000..c11db7f7 --- /dev/null +++ b/rplugin/python3/mypynvim/core/buffer.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Dict, Union + +from pynvim.api import Buffer + +if TYPE_CHECKING: + from .nvim import MyNvim + + +class MyBuffer(Buffer): + def __init__(self, nvim: MyNvim, buffer: Buffer, opts: dict[str, Any] = {}): + self.buf: Buffer = buffer + self.nvim: MyNvim = nvim + self.namespace: int = self.nvim.api.create_namespace(str(self.buf.handle)) + self.option(opts) + + def __getattr__(self, attr): + return getattr(self.buf, attr) + + @classmethod + def new(cls, nvim: MyNvim, opts: dict[str, Any] = {}): + buffer = nvim.api.create_buf(False, True) + return cls(nvim, buffer, opts) + + # mutate methods + + def option(self, option: Union[str, Dict[str, Any]], value: Any = None): + if isinstance(option, str): + if value is None: + return self.nvim.api.buf_get_option(self.buf.handle, option) + else: + self.nvim.api.buf_set_option(self.buf.handle, option, value) + elif isinstance(option, dict): + for opt, val in option.items(): + self.nvim.api.buf_set_option(self.buf.handle, opt, val) + + def map(self, modes: Union[str, list[str]], lhs: str, rhs: Union[str, Callable]): + if isinstance(modes, str): + modes = [modes] + for mode in modes: + self.nvim.key_mapper.buf_set(self.buf.handle, mode, lhs, rhs) + + def autocmd(self, event: Union[str, list[str]], id: str, callback: Callable): + self.nvim.autocmd_mapper.buf_set(event, id, self.handle, callback) + + def var(self, name: str, value: Any = None): + if value is None: + return self.nvim.api.buf_get_var(self.buf.handle, name) + else: + self.nvim.api.buf_set_var(self.buf.handle, name, value) + + def lines( + self, + replacement: Union[str, list[str], None] = None, + start: int = 0, + end: int = -1, + ) -> list[str]: + if replacement is not None: + if isinstance(replacement, str): + replacement = replacement.split("\n") + self.nvim.api.buf_set_lines(self.buf.handle, start, end, False, replacement) + return self.nvim.api.buf_get_lines(self.buf.handle, start, end, False) + + def clear(self): + self.nvim.api.buf_set_lines(self.buf.handle, 0, -1, True, []) + + def append(self, lines: list[str] | list[Any]): + self.nvim.api.buf_set_lines(self.buf.handle, -1, -1, True, lines) + + # extmark methods + + def eol(self, line: int, content: str = "", hl_group: str = "Normal"): + self.nvim.api.buf_set_extmark( + self.buf.number, + self.namespace, + line, # row + 0, # col + { + "virt_text": [[content, hl_group]], + "virt_text_pos": "eol", + }, + ) + + # mount methods + + def vsplit(self, opts: dict[str, Any] = {}): + self.nvim.api.command(f"vsplit | buffer {self.buf.handle}") + winnr = self.nvim.api.get_current_win() + for opt, val in opts.items(): + self.nvim.api.win_set_option(winnr, opt, val) diff --git a/rplugin/python3/mypynvim/core/keymapper.py b/rplugin/python3/mypynvim/core/keymapper.py new file mode 100644 index 00000000..4c93ee6c --- /dev/null +++ b/rplugin/python3/mypynvim/core/keymapper.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, Union + +if TYPE_CHECKING: + from .nvim import MyNvim + + +class Keymapper: + def __init__(self, nvim: MyNvim): + self.nvim: MyNvim = nvim + self._keymap_callback_library = {} + + def buf_set( + self, + bufnr: int, + mode: str, + lhs: str, + rhs: Union[str, Callable], + ): + if callable(rhs): + escaped_lhs = lhs.replace("<", "_").replace(">", "_") + if not self._keymap_callback_library.get(str(bufnr)): + self._keymap_callback_library[str(bufnr)] = {} + self._keymap_callback_library[str(bufnr)][escaped_lhs] = rhs + rhs = f"{self.nvim._mapping_command} {bufnr} {escaped_lhs}" + + self.nvim.api.buf_set_keymap(bufnr, mode, lhs, rhs, {"noremap": True}) + + def execute(self, bufnr: int, mapping: str): + if bufnr is not None: + callback = self._keymap_callback_library[bufnr][mapping] + if callable(callback): + callback() diff --git a/rplugin/python3/mypynvim/core/nvim.py b/rplugin/python3/mypynvim/core/nvim.py new file mode 100644 index 00000000..4eeba15d --- /dev/null +++ b/rplugin/python3/mypynvim/core/nvim.py @@ -0,0 +1,96 @@ +from typing import Iterable, Union + +from pynvim import Nvim +from pynvim.api.nvim import Current + +from .autocmdmapper import AutocmdMapper +from .buffer import MyBuffer +from .keymapper import Keymapper +from .window import MyWindow + + +class MyNvim(Nvim): + def __init__(self, nvim: Nvim, mapping_command: str, autocmd_command: str): + self.nvim: Nvim = nvim + self.key_mapper = Keymapper(self) + self.autocmd_mapper = AutocmdMapper(self) + self.current = MyCurrent(self) + self._mapping_command = mapping_command + self._autocmd_command = autocmd_command + + def __getattr__(self, attr): + return getattr(self.nvim, attr) + + # native api methods + + def notify( + self, msg: Union[str, int, bool, list[str], list[int]], level: str = "info" + ): + if isinstance(msg, str): + msg = msg.split("\n") + if len(msg) == 1: + msg = f'"{msg[0]}"' + self.nvim.exec_lua(f"vim.notify(({msg}), '{level}')") + return + else: + msg = str(msg) + msg = "{" + msg[1:-1] + "}" + elif isinstance(msg, Iterable): + msg = str(msg) + msg = "{" + msg[1:-1] + "}" + else: + msg = f"{msg}" + + self.nvim.exec_lua(f"vim.notify(vim.inspect({msg}), '{level}')") + + # custom api methods + + def feed(self, keys: str, mode: str = "n"): + codes = self.nvim.api.replace_termcodes(keys, True, True, True) + self.nvim.api.feedkeys(codes, mode, False) + + def move_cursor_to_previous_window(self): + self.feed("p") + + # window methods + + @property + def windows(self) -> list[MyWindow]: + return [MyWindow(self, win) for win in self.nvim.windows] + + def win(self, winnr: int) -> MyWindow: + return MyWindow(self, self.nvim.windows[winnr]) + + # buffer methods + + @property + def buffers(self) -> list[MyBuffer]: + return [MyBuffer(self, buf) for buf in self.nvim.buffers] + + def buf(self, bufnr: int) -> MyBuffer: + return MyBuffer(self, self.nvim.buffers[bufnr]) + + +class MyCurrent(Current): + def __init__(self, mynvim: MyNvim): + self.mynvim: MyNvim = mynvim + self.nvim = mynvim.nvim + + def __getattr__(self, attr): + return getattr(self.nvim.current, attr) + + @property + def buffer(self) -> MyBuffer: + return MyBuffer(self.mynvim, self.nvim.request("nvim_get_current_buf")) + + @buffer.setter + def buffer(self, buffer: Union[MyBuffer, int]) -> None: + return self.nvim.request("nvim_set_current_buf", buffer) + + @property + def window(self) -> MyWindow: + return MyWindow(self.mynvim, self.nvim.request("nvim_get_current_win")) + + @window.setter + def window(self, window: Union[MyWindow, int]) -> None: + return self.mynvim.request("nvim_set_current_win", window) diff --git a/rplugin/python3/mypynvim/core/window.py b/rplugin/python3/mypynvim/core/window.py new file mode 100644 index 00000000..0aadfc4f --- /dev/null +++ b/rplugin/python3/mypynvim/core/window.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pynvim.api import Window + +from .buffer import MyBuffer + +if TYPE_CHECKING: + from .nvim import MyNvim + + +class MyWindow(Window): + def __init__(self, nvim: MyNvim, window: Window): + self.win: Window = window + self.nvim: MyNvim = nvim + + def __getattr__(self, attr): + return getattr(self.win, attr) + + def command(self, command: str): + full_command = f"call win_execute({self.win.handle}, '{command}')" + self.nvim.command(full_command) + + @property + def buffer(self) -> MyBuffer: + return MyBuffer( + self.nvim, self.nvim.request("nvim_win_get_buf", self.win.handle) + ) + + @buffer.setter + def buffer(self, buffer: MyBuffer): + return self.nvim.request("nvim_win_set_buf", self.win.handle, buffer.handle) diff --git a/rplugin/python3/mypynvim/ui_components/calculator.py b/rplugin/python3/mypynvim/ui_components/calculator.py new file mode 100644 index 00000000..1f19d4ab --- /dev/null +++ b/rplugin/python3/mypynvim/ui_components/calculator.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, Union + +from mypynvim.core.nvim import MyNvim + +if TYPE_CHECKING: + from .popup import PopUpConfiguration + + +@dataclass +class Calculator: + nvim: "MyNvim" + + def absolute(self, config: PopUpConfiguration) -> PopUpConfiguration: + return self._convert_relative_values_to_absolute(config) + + def center(self, config: PopUpConfiguration) -> PopUpConfiguration: + config = self._convert_relative_values_to_absolute(config) + config = self._center_configuration_row_col(config) + return config + + def _center_configuration_row_col(self, config: PopUpConfiguration): + """Centers the row and col properties based on width and height.""" + if config.relative in ["editor", "win"]: + config.row = int(config.row) - int(config.height) // 2 - 1 + config.col = int(config.col) - int(config.width) // 2 - 1 + return config + + def _convert_relative_values_to_absolute(self, config: PopUpConfiguration): + """Converts relative values to absolute values.""" + for property in ["width", "height", "row", "col"]: + current_value = getattr(config, property) + absolute_value = self._percentage_to_absolute( + current_value, config.relative, property + ) + setattr(config, property, absolute_value) + return config + + def _percentage_to_absolute( + self, + value: Union[int, str], + relative: Literal["editor", "win", "cursor"], + property: str, + ) -> int: + """ + Converts percentage string values to absolute int values. + If the value is already an int, it is returned as is. + """ + + if isinstance(value, int): + return value + + max = self._get_max_value_for_property(relative, property) + return int(int(value.rstrip("%")) / 100.0 * max) + + def _get_max_value_for_property( + self, relative: Literal["editor", "win", "cursor"], property: str + ) -> int: + """Based on relative configuration, returns the max value for a given property.""" + + def get_editor_dimensions(): + source = self.nvim.options + width, height = source["columns"], source["lines"] + return {"row": height, "col": width, "width": width, "height": height} + + def get_window_dimensions(): + source = self.nvim.current.window + width, height = source.width, source.height + return {"row": height, "col": width, "width": width, "height": height} + + max_values = { + "editor": get_editor_dimensions(), + "win": get_window_dimensions(), + "cursor": get_window_dimensions(), + } + return max_values[relative][property] diff --git a/rplugin/python3/mypynvim/ui_components/layout.py b/rplugin/python3/mypynvim/ui_components/layout.py new file mode 100644 index 00000000..dfea8c87 --- /dev/null +++ b/rplugin/python3/mypynvim/ui_components/layout.py @@ -0,0 +1,211 @@ +from dataclasses import dataclass +from typing import Callable, Literal, Optional, Union, cast + +from mypynvim.core.nvim import MyNvim + +from .calculator import Calculator +from .popup import PopUp +from .types import PopUpConfiguration, Relative + + +class Box: + width: int = 0 + height: int = 0 + row: int = 0 + col: int = 0 + relative: Relative = "editor" + + def __init__( + self, + items: Union[list["Box"], list[PopUp]], + size: list[str] = ["100%"], + direction: Literal["row", "col"] = "row", + gap: int = 0, + ): + """Initialize a Box with items, size, direction and gap.""" + self.size = size + self.direction = direction + self.items = items + self.gap = gap + + def set_base_dimensions( + self, width: int, height: int, row: int, col: int, relative: Relative + ): + """Set the base dimensions of the Box.""" + self.width = width + self.height = height + self.row = row + self.col = col + self.relative = relative + + self.last_child_row = row + self.last_child_col = col + + def mount(self): + """Mount the Box.""" + if self._has_box_items(): + for child in self.items: + cast(Box, child).mount() + elif self._has_popup_items(): + for child in self.items: + cast(PopUp, child).mount(controlled=True) + + def unmount(self): + """Unmount the Box.""" + for child in self.items: + child.unmount() + + def process(self): + """Process the Box.""" + for child in self.items: + child.set_layout(self.layout) + + self._process_size() + if self._has_box_items(): + self._process_box_items() + if self._has_popup_items(): + self._process_popup_items() + + def set_layout(self, layout: "Layout"): + self.layout = layout + + def _process_size(self): + """Compute the Box size to integers.""" + for index, size in enumerate(self.size): + if isinstance(size, str): + self.size[index] = size.rstrip("%") + + def _process_box_items(self): + """Process the Box items.""" + if self.direction == "row": + self._process_row_box_items() + elif self.direction == "col": + self._process_col_box_items() + + for child in self.items: + cast(Box, child).process() + + def _process_row_box_items(self): + """Process the row box items.""" + for index, child in enumerate(self.items): + offset = 0 if index == len(self.items) else self.gap + 2 + child_width = int(self.width / 100 * int(self.size[index])) - offset + child_col = self.last_child_col + (offset * index) + self.last_child_col = child_col + child_width - (offset * index) + cast(Box, child).set_base_dimensions( + width=child_width, + height=self.height, + row=self.row, + col=child_col, + relative=self.relative, + ) + + def _process_col_box_items(self): + """Process the column box items.""" + for index, child in enumerate(self.items): + offset = 0 if index == len(self.items) else self.gap + 2 + child_height = int(self.height / 100 * int(self.size[index])) - offset + 1 + child_row = self.last_child_row + (offset * index) + self.last_child_row = child_row + child_height - (offset * index) + cast(Box, child).set_base_dimensions( + width=self.width, + height=child_height, + row=child_row, + col=self.col, + relative=self.relative, + ) + + def _process_popup_items(self): + """Process the PopUp items.""" + for child in self.items: + cast(PopUp, child).define_controlled_configurations( + width=self.width, + height=self.height, + row=self.row, + col=self.col, + relative=self.relative, + ) + + def _has_box_items(self) -> bool: + """Check if the Box has Box items.""" + return isinstance(self.items, list) and all( + isinstance(item, Box) for item in self.items + ) + + def _has_popup_items(self) -> bool: + """Check if the Box has PopUp items.""" + return isinstance(self.items, list) and all( + isinstance(item, PopUp) for item in self.items + ) + + +@dataclass +class Layout: + nvim: MyNvim + box: Box + width: Union[int, str] + height: Union[int, str] + row: Union[int, str] + col: Union[int, str] + relative: Relative = "editor" + + last_popup: Optional[PopUp] = None + + mounting: bool = False + unmounting: bool = False + + has_been_mounted: bool = False + post_first_mount_callback: Optional[Callable] = None + + def _prepare_for_mount(self): + self._configure_popup() + self._calculate_absolute_config() + self._set_box_base_dimensions() + self.box.set_layout(self) + self.box.process() + + def _configure_popup(self): + self.config = PopUpConfiguration( + width=self.width, + height=self.height, + row=self.row, + col=self.col, + relative=self.relative, + ) + + def _calculate_absolute_config(self): + self.absolute_config = Calculator(self.nvim).center(self.config) + + def _set_box_base_dimensions(self): + self.box.set_base_dimensions( + width=int(self.absolute_config.width), + height=int(self.absolute_config.height), + row=int(self.absolute_config.row), + col=int(self.absolute_config.col), + relative=self.relative, + ) + + def mount(self): + """Mount the Layout. Focus the last popup if any.""" + self.mounting = True + self._prepare_for_mount() + self.box.mount() + self.mounting = False + + if not self.has_been_mounted: + self.has_been_mounted = True + if self.post_first_mount_callback: + self.post_first_mount_callback() + + if self.last_popup: + self.last_popup.focus() + + def unmount(self): + """Unmount the Layout.""" + self.unmounting = True + self.box.unmount() + self.unmounting = False + + def set_last_popup(self, popup: PopUp): + if not self.mounting and not self.unmounting: + self.last_popup = popup diff --git a/rplugin/python3/mypynvim/ui_components/popup.py b/rplugin/python3/mypynvim/ui_components/popup.py new file mode 100644 index 00000000..cab2bac9 --- /dev/null +++ b/rplugin/python3/mypynvim/ui_components/popup.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union + +if TYPE_CHECKING: + from mypynvim.core.nvim import MyNvim + + from .layout import Layout + + +from mypynvim.core.buffer import MyBuffer +from mypynvim.core.window import MyWindow + +from .calculator import Calculator +from .types import PaddingKeys, PopUpConfiguration, Relative + + +@dataclass +class Padding: + top: int = 0 + right: int = 0 + bottom: int = 0 + left: int = 0 + + +class PopUp: + def __init__( + self, + nvim: MyNvim, + preset: Optional[PopUpConfiguration] = None, + padding: PaddingKeys = {}, + enter: bool = False, + opts={}, + **kwargs, + ): + self.nvim: MyNvim = nvim + self.calculator: Calculator = Calculator(self.nvim) + self.enter: bool = enter + self.opts: Dict[str, Any] = opts + + # preset configuration + if preset is None: + preset = PopUpConfiguration() + for key, value in kwargs.items(): + setattr(preset, key, value) + self.original_config: PopUpConfiguration = preset + + # main window + self.buffer: MyBuffer = MyBuffer.new(self.nvim) + self._set_default_keymaps() + self._set_default_autocmds() + + # padding window + self.pd: Padding = Padding(**padding) + self.pd_buffer: MyBuffer = MyBuffer.new(self.nvim) + + def mount(self, controlled: bool = False): + """ + Mounts the PopUp. + + Resets the configuration to its original values when PopUp was instantiated. + Then computes the absolute values for the configuration. + Then mutate main window configuration & mounts the padding window if any padding is set. + Then mounts the main window. + """ + + # reset config to original + self.config: PopUpConfiguration = deepcopy(self.original_config) + self.pd_config: PopUpConfiguration = PopUpConfiguration() + + if controlled: + self._set_controlled_configurations() + else: + self._set_uncontrolled_configurations() + + # mount padding window if any padding is set + if self._has_padding(): + pd_window = self.nvim.api.open_win( + self.pd_buffer, False, self.pd_config.__dict__ + ) + self.pd_window = MyWindow(self.nvim, pd_window) + # mount main window + window = self.nvim.api.open_win(self.buffer, self.enter, self.config.__dict__) + self.window = MyWindow(self.nvim, window) + + self._set_main_window_options() + + def unmount(self): + """Unmounts the PopUp.""" + self.nvim.api.win_close(self.window, True) + if self._has_padding(): + self.nvim.api.win_close(self.pd_window, True) + + def map(self, mode: str, key: str, rhs: Union[str, Callable]): + """Maps a key to a function in the main window.""" + self.buffer.map(mode, key, rhs) + + def focus(self): + """Make the popup active.""" + self.nvim.current.window = self.window + + def set_layout(self, layout: Layout): + self.layout = layout + + def define_controlled_configurations( + self, width: int, height: int, row: int, col: int, relative: Relative = "editor" + ): + self.controlled_config = deepcopy(self.original_config) + self.controlled_config.width = width + self.controlled_config.height = height + self.controlled_config.row = row + self.controlled_config.col = col + self.controlled_config.relative = relative + + def _set_main_window_options(self): + for key, value in self.opts.items(): + self.window.options[key] = value + + def _set_controlled_configurations(self): + """Mutates the configuration of controlled PopUp.""" + self.config = self.controlled_config + if self._has_padding(): + self._mutate_configurations_for_padding() + + def _set_uncontrolled_configurations(self): + """Mutates the configuration of uncontrolled PopUp.""" + self.config = self.calculator.center(self.config) + if self._has_padding(): + self._mutate_configurations_for_padding() + + def _has_padding(self) -> bool: + return any([self.pd.top, self.pd.right, self.pd.bottom, self.pd.left]) + + def _mutate_configurations_for_padding(self): + """ + Mutates the configuration to account for padding. + + This is done by making the padding window takes place of the original window. + (Effectively replacing the original window with the padding window) + Then the original window is resized (shrinked) to fit within the padding window. + """ + + # set window config for padding window + vars(self.pd_config).update(vars(self.config)) + + # shrink content window + self.config.title = "" + self.config.width = int(self.config.width) - int(self.pd.left) - self.pd.right + self.config.height = int(self.config.height) - int(self.pd.top) - self.pd.bottom + self.config.row = int(self.config.row) + self.pd.top + 1 + self.config.col = int(self.config.col) + self.pd.left + 1 + self.config.border = "none" + + def _set_default_keymaps(self): + self.buffer.map("n", "q", lambda: self.unmount()) + + def _set_default_autocmds(self): + self.buffer.autocmd( + "BufEnter", + "update_last_popup_for_Layout", + lambda: self.layout.set_last_popup(self), + ) diff --git a/rplugin/python3/mypynvim/ui_components/types.py b/rplugin/python3/mypynvim/ui_components/types.py new file mode 100644 index 00000000..a8d2ac39 --- /dev/null +++ b/rplugin/python3/mypynvim/ui_components/types.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from typing import Dict, Literal, TypedDict, Union + +Relative = Literal["editor", "win", "cursor"] +PaddingKeys = Dict[Literal["top", "right", "bottom", "left"], int] + + +@dataclass +class PopUpConfiguration: + relative: Relative = "editor" + anchor: Literal["NW", "NE", "SW", "SE"] = "NW" + width: Union[int, str] = 40 + height: Union[int, str] = 10 + row: Union[int, str] = 0 + col: Union[int, str] = 0 + zindex: int = 500 + style: Literal["minimal"] = "minimal" + border: Union[ + list[str], + Literal["none", "single", "double", "solid", "shadow", "background"], + ] = "single" + title: str = "" + title_pos: Literal["left", "center", "right"] = "center" + noautocmd: bool = False + + +class PopUpArgs(TypedDict, total=False): + relative: Relative + anchor: Literal["NW", "NE", "SW", "SE"] + width: Union[int, str] + height: Union[int, str] + row: Union[int, str] + col: Union[int, str] + zindex: int + style: Literal["minimal"] + border: Union[ + list[str], + Literal["none", "single", "double", "solid", "shadow", "background"], + ] + title: str + title_pos: Literal["left", "center", "right"] + noautocmd: bool diff --git a/rplugin/python3/prompts.py b/rplugin/python3/prompts.py index 8cbb25e8..1290cbb0 100644 --- a/rplugin/python3/prompts.py +++ b/rplugin/python3/prompts.py @@ -142,25 +142,34 @@ EXPLAIN_SHORTCUT = "Write a explanation for the code above as paragraphs of text." FIX_SHORTCUT = ( "There is a problem in this code. Rewrite the code to show it with the bug fixed." - "" ) - EMBEDDING_KEYWORDS = """You are a coding assistant who help the user answer questions about code in their workspace by providing a list of relevant keywords they can search for to answer the question. The user will provide you with potentially relevant information from the workspace. This information may be incomplete. DO NOT ask the user for additional information or clarification. DO NOT try to answer the user's question directly. + # Additional Rules + Think step by step: 1. Read the user's question to understand what they are asking about their workspace. + 2. If there are pronouns in the question, such as 'it', 'that', 'this', try to understand what they refer to by looking at the rest of the question and the conversation history. + 3. Output a precise version of question that resolves all pronouns to the nouns they stand for. Be sure to preserve the exact meaning of the question by only changing ambiguous pronouns. + 4. Then output a short markdown list of up to 8 relevant keywords that user could try searching for to answer their question. These keywords could used as file name, symbol names, abbreviations, or comments in the relevant code. Put the keywords most relevant to the question first. Do not include overly generic keywords. Do not repeat keywords. + 5. For each keyword in the markdown list of related keywords, if applicable add a comma separated list of variations after it. For example: for 'encode' possible variations include 'encoding', 'encoded', 'encoder', 'encoders'. Consider synonyms and plural forms. Do not repeat variations. + # Examples + User: Where's the code for base64 encoding? + Response: + Where's the code for base64 encoding? + - base64 encoding, base64 encoder, base64 encode - base64, base 64 - encode, encoded, encoder, encoders @@ -180,31 +189,63 @@ The user works in an IDE called Neovim which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal. The active document is the source code the user is looking at right now. You can only give one reply for each conversation turn. + Additional Rules Think step by step: + 1. Read the provided relevant workspace information (code excerpts, file names, and symbols) to understand the user's workspace. + 2. Consider how to answer the user's prompt based on the provided information and your specialized coding knowledge. Always assume that the user is asking about the code in their workspace instead of asking a general programming question. Prefer using variables, functions, types, and classes from the workspace over those from the standard library. + 3. Generate a response that clearly and accurately answers the user's question. In your response, add fully qualified links for referenced symbols (example: [`namespace.VariableName`](path/to/file.ts)) and links for files (example: [path/to/file](path/to/file.ts)) so that the user can open them. If you do not have enough information to answer the question, respond with "I'm sorry, I can't answer that question with what I currently know about your workspace". + Remember that you MUST add links for all referenced symbols from the workspace and fully qualify the symbol name in the link, for example: [`namespace.functionName`](path/to/util.ts). Remember that you MUST add links for all workspace files, for example: [path/to/file.js](path/to/file.js) + Examples: Question: What file implements base64 encoding? + Response: Base64 encoding is implemented in [src/base64.ts](src/base64.ts) as [`encode`](src/base64.ts) function. + + Question: How can I join strings with newlines? + Response: You can use the [`joinLines`](src/utils/string.ts) function from [src/utils/string.ts](src/utils/string.ts) to join multiple strings with newlines. + + Question: How do I build this project? + Response: To build this TypeScript project, run the `build` script in the [package.json](package.json) file: + ```sh npm run build ``` + + Question: How do I read a file? + Response: To read a file, you can use a [`FileReader`](src/fs/fileReader.ts) class from [src/fs/fileReader.ts](src/fs/fileReader.ts). """ +TEST_SHORTCUT = "Write a set of detailed unit test functions for the code above." +EXPLAIN_SHORTCUT = "Write a explanation for the code above as paragraphs of text." +FIX_SHORTCUT = ( + "There is a problem in this code. Rewrite the code to show it with the bug fixed." +) + +SENIOR_DEVELOPER_PROMPT = """ +You're a 10x senior developer that is an expert in programming. +Your job is to change the user's code according to their needs. +Your job is only to change / edit the code. +Your code output should keep the same level of indentation as the user's code. +You MUST add whitespace in the beginning of each line as needed to match the user's code. +""" +PROMPT_SIMPLE_DOCSTRING = "add simple docstring to this code" +PROMPT_SEPARATE = "add comments separating the code into sections" diff --git a/rplugin/python3/utilities.py b/rplugin/python3/utilities.py index 2c1a14a0..bc0540a7 100644 --- a/rplugin/python3/utilities.py +++ b/rplugin/python3/utilities.py @@ -15,6 +15,7 @@ def generate_request( code_excerpt: str, language: str = "", system_prompt=prompts.COPILOT_INSTRUCTIONS, + model="gpt-4", ): messages = [ { @@ -39,7 +40,7 @@ def generate_request( ) return { "intent": True, - "model": "gpt-4", + "model": model, "n": 1, "stream": True, "temperature": 0.1,