diff --git a/.gp.md b/.gp.md index 9636e6a4..9421dbae 100644 --- a/.gp.md +++ b/.gp.md @@ -23,17 +23,27 @@ end Module has following structure: ```lua -local _H = {} +local uv = vim.uv or vim.loop + +local config = require("gp.config") + local M = { - _H = _H, -- helper functions _Name = "Gp", -- plugin name - _handles = {}, -- handles for running processes - _queries = {}, -- table of latest queries _state = {}, -- table of state variables agents = {}, -- table of agents cmd = {}, -- default command functions config = {}, -- config variables hooks = {}, -- user defined command functions + defaults = require("gp.defaults"), -- some useful defaults + deprecator = require("gp.deprecator"), -- handle deprecated options + helpers = require("gp.helper"), -- helper functions + imager = require("gp.imager"), -- imager module + logger = require("gp.logger"), -- logger module + render = require("gp.render"), -- render module + spinner = require("gp.spinner"), -- spinner module + tasker = require("gp.tasker"), -- tasker module + vault = require("gp.vault"), -- vault module + whisper = require("gp.whisper"), -- whisper module } ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a4fdb2..654bb526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,64 @@ # Changelog +## [3.7.0](https://github.com/Robitx/gp.nvim/compare/v3.6.1...v3.7.0) (2024-08-04) + + +### Features + +* remember last chat without symlinks ([#176](https://github.com/Robitx/gp.nvim/issues/176)) ([df9adc2](https://github.com/Robitx/gp.nvim/commit/df9adc22450c052c9228714cde9b9cf90d6ca3e5)) +* copilot with gpt4-o ([c782f9a](https://github.com/Robitx/gp.nvim/commit/c782f9ace9c95f42c3e169df8366537d8980a62f)) +* better state logging ([63098a5](https://github.com/Robitx/gp.nvim/commit/63098a530a0fd5ba6dae5d7fb45236d9290ac8c2)) +* lazy load secrets (issue: [#152](https://github.com/Robitx/gp.nvim/issues/152)) ([4cea5ae](https://github.com/Robitx/gp.nvim/commit/4cea5aecd1bc4ce0081d2407710ba4741f193b6e)) +* configurable default agents ([#85](https://github.com/Robitx/gp.nvim/issues/85), [#148](https://github.com/Robitx/gp.nvim/issues/148)) ([49d1986](https://github.com/Robitx/gp.nvim/commit/49d1986aa98ef748397594aa26e137dbc9cb2798)) + +### Bug Fixes + +* skip BufEnter logic if buf already prepared (issue: [#139](https://github.com/Robitx/gp.nvim/issues/139)) ([2c3d818](https://github.com/Robitx/gp.nvim/commit/2c3d818a47a9b156af921c9b768c7a31dcccf00f)) + +## [3.6.1](https://github.com/Robitx/gp.nvim/compare/v3.6.0...v3.6.1) (2024-08-01) + + +### Bug Fixes + +* remove code remnant ([4c2f1d4](https://github.com/Robitx/gp.nvim/commit/4c2f1d42083905e41fe68f0fe8bc6f1b920b45e5)) + +## [3.6.0](https://github.com/Robitx/gp.nvim/compare/v3.5.1...v3.6.0) (2024-08-01) + + +### Features + +* configurable zindex with default to 49 (resolve: [#132](https://github.com/Robitx/gp.nvim/issues/132)) ([6dca8ea](https://github.com/Robitx/gp.nvim/commit/6dca8ead9ffcfdb97d09a97369613ddd30170605)) + + +### Bug Fixes + +* agent refreshing for default commands ([d5fcd00](https://github.com/Robitx/gp.nvim/commit/d5fcd00b06d2dab95481f15c79eb1455ff3a4da7)) +* win32 detection ([6d0f1b5](https://github.com/Robitx/gp.nvim/commit/6d0f1b5f23c3353b89d8ebadb397a5652e29cead)) + +## [3.5.1](https://github.com/Robitx/gp.nvim/compare/v3.5.0...v3.5.1) (2024-07-31) + + +### Bug Fixes + +* symbolic links on Windows without admin rights ([#177](https://github.com/Robitx/gp.nvim/issues/177)) ([0f3b5bd](https://github.com/Robitx/gp.nvim/commit/0f3b5bd090871471890502a22fda3ee1abb7c8a2)) + +## [3.5.0](https://github.com/Robitx/gp.nvim/compare/v3.4.1...v3.5.0) (2024-07-29) + + +### Features + +* capture the preceding number for ChatRespond ([#178](https://github.com/Robitx/gp.nvim/issues/178)) ([14a37df](https://github.com/Robitx/gp.nvim/commit/14a37dfed125782a5a337b26c06201a30d02ca6e)) +* configurable sensitive logging to file ([7794e8a](https://github.com/Robitx/gp.nvim/commit/7794e8adf361682ab1488bd910be4ba3828aab03)) +* deprecate image_ conf vars in favor of nesting under image table ([dcf116a](https://github.com/Robitx/gp.nvim/commit/dcf116a3390150e2d975e8e74be5fec7c35370e3)) +* **logger:** sensitive flag ([85a5f1c](https://github.com/Robitx/gp.nvim/commit/85a5f1cfd976a70677092165b5b1923c9acf9638)) +* reuse chat_confirm_delete shortcut in chat picker ([919fdd4](https://github.com/Robitx/gp.nvim/commit/919fdd49fa42a9c2bef3ce85f1532d891c71b953)) +* truncating log file and GpInspectLog ([bf38d16](https://github.com/Robitx/gp.nvim/commit/bf38d16e7151db86287ca54b167b8afd990a632a)) + + +### Bug Fixes + +* rm obsolete api key validation ([352b0c3](https://github.com/Robitx/gp.nvim/commit/352b0c363bfb1574528743f5771dbd1efbba0046)) + ## [3.4.1](https://github.com/Robitx/gp.nvim/compare/v3.4.0...v3.4.1) (2024-07-26) diff --git a/README.md b/README.md index 6b7d88b2..2b8d820b 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Voice commands (`:GpWhisper*`) depend on `SoX` (Sound eXchange) to handle audio Below is a linked snippet with the default values, but I suggest starting with minimal config possible (just `openai_api_key` if you don't have `OPENAI_API_KEY` env set up). Defaults change over time to improve things, options might get deprecated and so on - it's better to change only things where the default doesn't fit your needs. -https://github.com/Robitx/gp.nvim/blob/33812a62d6e3a34a10d24c696106337a5e2ef4b3/lua/gp/config.lua#L9-L568 +https://github.com/Robitx/gp.nvim/blob/49d1986aa98ef748397594aa26e137dbc9cb2798/lua/gp/config.lua#L9-L603 # Usage diff --git a/doc/gp.nvim.txt b/doc/gp.nvim.txt index 00166268..afd6a229 100644 --- a/doc/gp.nvim.txt +++ b/doc/gp.nvim.txt @@ -1,4 +1,4 @@ -*gp.nvim.txt* For Neovim Last change: 2024 July 26 +*gp.nvim.txt* For Neovim Last change: 2024 August 04 ============================================================================== Table of Contents *gp.nvim-table-of-contents* @@ -252,7 +252,7 @@ options might get deprecated and so on - it’s better to change only things where the default doesn’t fit your needs. -https://github.com/Robitx/gp.nvim/blob/33812a62d6e3a34a10d24c696106337a5e2ef4b3/lua/gp/config.lua#L9-L568 +https://github.com/Robitx/gp.nvim/blob/49d1986aa98ef748397594aa26e137dbc9cb2798/lua/gp/config.lua#L9-L603 ============================================================================== diff --git a/lua/gp/config.lua b/lua/gp/config.lua index 7629a14b..00a52144 100644 --- a/lua/gp/config.lua +++ b/lua/gp/config.lua @@ -49,10 +49,12 @@ local config = { ollama = { disable = true, endpoint = "http://localhost:11434/v1/chat/completions", + secret = "dummy_secret", }, lmstudio = { disable = true, endpoint = "http://localhost:1234/v1/chat/completions", + secret = "dummy_secret", }, googleai = { disable = true, @@ -77,12 +79,18 @@ local config = { -- curl_params = { "--proxy", "http://X.X.X.X:XXXX" } curl_params = {}, - -- log file location + -- log file location log_file = vim.fn.stdpath("log"):gsub("/$", "") .. "/gp.nvim.log", + -- write sensitive data to log file for debugging purposes (like api keys) + log_sensitive = false, -- directory for persisting state dynamically changed by user (like model or persona) state_dir = vim.fn.stdpath("data"):gsub("/$", "") .. "/gp/persisted", + -- default agent names set during startup, if nil last used agent is used + default_command_agent = nil, + default_chat_agent = nil, + -- default command agents (model + persona) -- name, model and system_prompt are mandatory fields -- to use agent for chat set chat = true, for command set command = true @@ -118,7 +126,7 @@ local config = { chat = true, command = false, -- string with model name or table with model name and parameters - model = { model = "gpt-4", temperature = 1.1, top_p = 1 }, + model = { model = "gpt-4o", temperature = 1.1, top_p = 1 }, -- system prompt (use this to specify the persona/role of the AI) system_prompt = require("gp.defaults").chat_system_prompt, }, @@ -216,7 +224,7 @@ local config = { chat = false, command = true, -- string with the Copilot engine name or table with engine name and parameters if applicable - model = { model = "gpt-4", temperature = 0.8, top_p = 1, n = 1 }, + model = { model = "gpt-4o", temperature = 0.8, top_p = 1, n = 1 }, -- system prompt (use this to specify the persona/role of the AI) system_prompt = require("gp.defaults").code_system_prompt, }, @@ -334,6 +342,9 @@ local config = { style_popup_margin_top = 2, style_popup_max_width = 160, + -- in case of visibility colisions with other plugins, you can increase/decrease zindex + zindex = 49, + -- command config and templates below are used by commands like GpRewrite, GpEnew, etc. -- command prompt prefix for asking user for input (supports {{agent}} template variable) command_prompt_prefix_template = "πŸ€– {{agent}} ~ ", @@ -360,146 +371,159 @@ local config = { -- by eliminating silence and speeding up the tempo of the recording -- we can reduce the cost by 50% or more and get the results faster - -- OpenAI audio/transcriptions api endpoint to transcribe audio to text - whisper_api_endpoint = "https://api.openai.com/v1/audio/transcriptions", - -- directory for storing whisper files - whisper_dir = (os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp") .. "/gp_whisper", - -- multiplier of RMS level dB for threshold used by sox to detect silence vs speech - -- decibels are negative, the recording is normalized to -3dB => - -- increase this number to pick up more (weaker) sounds as possible speech - -- decrease this number to pick up only louder sounds as possible speech - -- you can disable silence trimming by setting this a very high number (like 1000.0) - whisper_silence = "1.75", - -- whisper tempo (1.0 is normal speed) - whisper_tempo = "1.75", - -- The language of the input audio, in ISO-639-1 format. - whisper_language = "en", - -- command to use for recording can be nil (unset) for automatic selection - -- string ("sox", "arecord", "ffmpeg") or table with command and arguments: - -- sox is the most universal, but can have start/end cropping issues caused by latency - -- arecord is linux only, but has no cropping issues and is faster - -- ffmpeg in the default configuration is macos only, but can be used on any platform - -- (see https://trac.ffmpeg.org/wiki/Capture/Desktop for more info) - -- below is the default configuration for all three commands: - -- whisper_rec_cmd = {"sox", "-c", "1", "--buffer", "32", "-d", "rec.wav", "trim", "0", "60:00"}, - -- whisper_rec_cmd = {"arecord", "-c", "1", "-f", "S16_LE", "-r", "48000", "-d", "3600", "rec.wav"}, - -- whisper_rec_cmd = {"ffmpeg", "-y", "-f", "avfoundation", "-i", ":0", "-t", "3600", "rec.wav"}, - whisper_rec_cmd = nil, + whisper = { + -- you can disable whisper completely by whisper = {disable = true} + disable = false, + + -- OpenAI audio/transcriptions api endpoint to transcribe audio to text + endpoint = "https://api.openai.com/v1/audio/transcriptions", + -- directory for storing whisper files + store_dir = (os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp") .. "/gp_whisper", + -- multiplier of RMS level dB for threshold used by sox to detect silence vs speech + -- decibels are negative, the recording is normalized to -3dB => + -- increase this number to pick up more (weaker) sounds as possible speech + -- decrease this number to pick up only louder sounds as possible speech + -- you can disable silence trimming by setting this a very high number (like 1000.0) + silence = "1.75", + -- whisper tempo (1.0 is normal speed) + tempo = "1.75", + -- The language of the input audio, in ISO-639-1 format. + language = "en", + -- command to use for recording can be nil (unset) for automatic selection + -- string ("sox", "arecord", "ffmpeg") or table with command and arguments: + -- sox is the most universal, but can have start/end cropping issues caused by latency + -- arecord is linux only, but has no cropping issues and is faster + -- ffmpeg in the default configuration is macos only, but can be used on any platform + -- (see https://trac.ffmpeg.org/wiki/Capture/Desktop for more info) + -- below is the default configuration for all three commands: + -- whisper_rec_cmd = {"sox", "-c", "1", "--buffer", "32", "-d", "rec.wav", "trim", "0", "60:00"}, + -- whisper_rec_cmd = {"arecord", "-c", "1", "-f", "S16_LE", "-r", "48000", "-d", "3600", "rec.wav"}, + -- whisper_rec_cmd = {"ffmpeg", "-y", "-f", "avfoundation", "-i", ":0", "-t", "3600", "rec.wav"}, + rec_cmd = nil, + }, -- image generation settings - -- image prompt prefix for asking user for input (supports {{agent}} template variable) - image_prompt_prefix_template = "πŸ–ŒοΈ {{agent}} ~ ", - -- image prompt prefix for asking location to save the image - image_prompt_save = "πŸ–ŒοΈπŸ’Ύ ~ ", - -- default folder for saving images - image_dir = (os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp") .. "/gp_images", - -- default image agents (model + settings) - -- to remove some default agent completely set it like: - -- image_agents = { { name = "DALL-E-3-1024x1792-vivid", disable = true, }, ... }, - image_agents = { - { - name = "ExampleDisabledAgent", - disable = true, - }, - { - name = "DALL-E-3-1024x1024-vivid", - model = "dall-e-3", - quality = "standard", - style = "vivid", - size = "1024x1024", - }, - { - name = "DALL-E-3-1792x1024-vivid", - model = "dall-e-3", - quality = "standard", - style = "vivid", - size = "1792x1024", - }, - { - name = "DALL-E-3-1024x1792-vivid", - model = "dall-e-3", - quality = "standard", - style = "vivid", - size = "1024x1792", - }, - { - name = "DALL-E-3-1024x1024-natural", - model = "dall-e-3", - quality = "standard", - style = "natural", - size = "1024x1024", - }, - { - name = "DALL-E-3-1792x1024-natural", - model = "dall-e-3", - quality = "standard", - style = "natural", - size = "1792x1024", - }, - { - name = "DALL-E-3-1024x1792-natural", - model = "dall-e-3", - quality = "standard", - style = "natural", - size = "1024x1792", - }, - { - name = "DALL-E-3-1024x1024-vivid-hd", - model = "dall-e-3", - quality = "hd", - style = "vivid", - size = "1024x1024", - }, - { - name = "DALL-E-3-1792x1024-vivid-hd", - model = "dall-e-3", - quality = "hd", - style = "vivid", - size = "1792x1024", - }, - { - name = "DALL-E-3-1024x1792-vivid-hd", - model = "dall-e-3", - quality = "hd", - style = "vivid", - size = "1024x1792", - }, - { - name = "DALL-E-3-1024x1024-natural-hd", - model = "dall-e-3", - quality = "hd", - style = "natural", - size = "1024x1024", - }, - { - name = "DALL-E-3-1792x1024-natural-hd", - model = "dall-e-3", - quality = "hd", - style = "natural", - size = "1792x1024", - }, - { - name = "DALL-E-3-1024x1792-natural-hd", - model = "dall-e-3", - quality = "hd", - style = "natural", - size = "1024x1792", + image = { + -- you can disable image generation logic completely by image = {disable = true} + disable = false, + + -- openai api key (string or table with command and arguments) + -- secret = { "cat", "path_to/openai_api_key" }, + -- secret = { "bw", "get", "password", "OPENAI_API_KEY" }, + -- secret = "sk-...", + -- secret = os.getenv("env_name.."), + -- if missing openai_api_key is used + secret = os.getenv("OPENAI_API_KEY"), + + -- image prompt prefix for asking user for input (supports {{agent}} template variable) + prompt_prefix_template = "πŸ–ŒοΈ {{agent}} ~ ", + -- image prompt prefix for asking location to save the image + prompt_save = "πŸ–ŒοΈπŸ’Ύ ~ ", + -- default folder for saving images + store_dir = (os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp") .. "/gp_images", + -- default image agents (model + settings) + -- to remove some default agent completely set it like: + -- image.agents = { { name = "DALL-E-3-1024x1792-vivid", disable = true, }, ... }, + agents = { + { + name = "ExampleDisabledAgent", + disable = true, + }, + { + name = "DALL-E-3-1024x1024-vivid", + model = "dall-e-3", + quality = "standard", + style = "vivid", + size = "1024x1024", + }, + { + name = "DALL-E-3-1792x1024-vivid", + model = "dall-e-3", + quality = "standard", + style = "vivid", + size = "1792x1024", + }, + { + name = "DALL-E-3-1024x1792-vivid", + model = "dall-e-3", + quality = "standard", + style = "vivid", + size = "1024x1792", + }, + { + name = "DALL-E-3-1024x1024-natural", + model = "dall-e-3", + quality = "standard", + style = "natural", + size = "1024x1024", + }, + { + name = "DALL-E-3-1792x1024-natural", + model = "dall-e-3", + quality = "standard", + style = "natural", + size = "1792x1024", + }, + { + name = "DALL-E-3-1024x1792-natural", + model = "dall-e-3", + quality = "standard", + style = "natural", + size = "1024x1792", + }, + { + name = "DALL-E-3-1024x1024-vivid-hd", + model = "dall-e-3", + quality = "hd", + style = "vivid", + size = "1024x1024", + }, + { + name = "DALL-E-3-1792x1024-vivid-hd", + model = "dall-e-3", + quality = "hd", + style = "vivid", + size = "1792x1024", + }, + { + name = "DALL-E-3-1024x1792-vivid-hd", + model = "dall-e-3", + quality = "hd", + style = "vivid", + size = "1024x1792", + }, + { + name = "DALL-E-3-1024x1024-natural-hd", + model = "dall-e-3", + quality = "hd", + style = "natural", + size = "1024x1024", + }, + { + name = "DALL-E-3-1792x1024-natural-hd", + model = "dall-e-3", + quality = "hd", + style = "natural", + size = "1792x1024", + }, + { + name = "DALL-E-3-1024x1792-natural-hd", + model = "dall-e-3", + quality = "hd", + style = "natural", + size = "1024x1792", + }, }, }, -- example hook functions (see Extend functionality section in the README) hooks = { + -- GpInspectPlugin provides a detailed inspection of the plugin state InspectPlugin = function(plugin, params) local bufnr = vim.api.nvim_create_buf(false, true) local copy = vim.deepcopy(plugin) local key = copy.config.openai_api_key or "" copy.config.openai_api_key = key:sub(1, 3) .. string.rep("*", #key - 6) .. key:sub(-3) - for provider, _ in pairs(copy.providers) do - local s = copy.providers[provider].secret - if s and type(s) == "string" then - copy.providers[provider].secret = s:sub(1, 3) .. string.rep("*", #s - 6) .. s:sub(-3) - end - end local plugin_info = string.format("Plugin structure:\n%s", vim.inspect(copy)) local params_info = string.format("Command params:\n%s", vim.inspect(params)) local lines = vim.split(plugin_info .. "\n" .. params_info, "\n") @@ -507,6 +531,17 @@ local config = { vim.api.nvim_win_set_buf(0, bufnr) end, + -- GpInspectLog for checking the log file + InspectLog = function(plugin, params) + local log_file = plugin.config.log_file + local buffer = plugin.helpers.get_buffer(log_file) + if not buffer then + vim.cmd("e " .. log_file) + else + vim.cmd("buffer " .. buffer) + end + end, + -- GpImplement rewrites the provided selection/range based on comments in it Implement = function(gp, params) local template = "Having following from {{filename}}:\n\n" diff --git a/lua/gp/deprecator.lua b/lua/gp/deprecator.lua new file mode 100644 index 00000000..cc243fb8 --- /dev/null +++ b/lua/gp/deprecator.lua @@ -0,0 +1,153 @@ +-------------------------------------------------------------------------------- +-- Deprecator module +-------------------------------------------------------------------------------- + +local logger = require("gp.logger") +local helpers = require("gp.helper") +local render = require("gp.render") + +local M = {} +M._deprecated = {} + +local switch_to_agent = "Please use `agents` table and switch agents in runtime via `:GpAgent XY`" + +local nested = function(variable, prefix) + local new_variable = variable:gsub(prefix .. "_", "") + return render.template( + "`{{old}}`\nPlease use `{{prefix}} = { {{new}} = ... }`", + { ["{{old}}"] = variable, ["{{new}}"] = new_variable, ["{{prefix}}"] = prefix } + ) +end + +local deprecated = { + chat_toggle_target = "`chat_toggle_target`\nPlease rename it to `toggle_target` which is also used by other commands", + command_model = "`command_model`\n" .. switch_to_agent, + command_system_prompt = "`command_system_prompt`\n" .. switch_to_agent, + chat_custom_instructions = "`chat_custom_instructions`\n" .. switch_to_agent, + chat_model = "`chat_model`\n" .. switch_to_agent, + chat_system_prompt = "`chat_system_prompt`\n" .. switch_to_agent, + command_prompt_prefix = "`command_prompt_prefix`\nPlease use `command_prompt_prefix_template`" + .. " with support for \n`{{agent}}` variable so you know which agent is currently active", + whisper_max_time = "`whisper_max_time`\nPlease use fully customizable `whisper_rec_cmd`", + + openai_api_endpoint = "`openai_api_endpoint`\n\n" + .. "Gp.nvim finally supports multiple LLM providers; sorry it took so long.\n" + .. "I've dreaded merging this, because I hate breaking people's setups.\n" + .. "But this change is necessary for future improvements.\n\n" + .. "Migration hints are below; for more help, try the readme docs or open an issue.\n\n" + .. "If you're using the `https://api.openai.com/v1/chat/completions` endpoint,\n" + .. "just drop `openai_api_endpoint` in your config and you're done." + .. "\n\nOtherwise sorry for probably breaking your setup, " + .. "please use `endpoint` and `secret` fields in:\n\nproviders " + .. "= {\n openai = {\n endpoint = '...',\n secret = '...'\n }," + .. "\n -- azure = {...},\n -- copilot = {...},\n -- ollama = {...},\n -- googleai= {...},\n -- pplx = {...},\n -- anthropic = {...},\n},\n" + .. "\nThe `openai_api_key` is still supported for backwards compatibility,\n" + .. "and automatically converted to `providers.openai.secret` if the new config is not set.", + image_dir = "`image_dir`\nPlease use `image = { store_dir = ... }`", + whisper_dir = "`whisper_dir`\nPlease use `whisper = { store_dir = ... }`", + whisper_api_endpoint = "`whisper_api_endpoint`\nPlease use `whisper = { endpoint = ... }`", +} + +M.is_valid = function(k, v) + if deprecated[k] then + table.insert(M._deprecated, { name = k, msg = deprecated[k], value = v }) + return false + end + if helpers.starts_with(k, "image_") then + table.insert(M._deprecated, { name = k, msg = nested(k, "image"), value = v }) + return false + end + if helpers.starts_with(k, "whisper_") then + table.insert(M._deprecated, { name = k, msg = nested(k, "whisper"), value = v }) + return false + end + return true +end + +M.report = function() + if #M._deprecated == 0 then + return + end + + local msg = "Hey there, I have good news and bad news for you." + .. "\n\nThe good news is that you've updated Gp.nvim and got some new features." + .. "\nThe bad news is that some of the config options you are using are deprecated." + .. "\n\nThis is shown only at startup and deprecated options are ignored" + .. "\nso everything should work without problems and you can deal with this later." + .. "\n\nYou can check deprecated options any time with `:checkhealth gp`" + .. "\nSorry for the inconvenience and thank you for using Gp.nvim." + .. "\n\n********************************************************************************" + .. "\n********************************************************************************" + table.sort(M._deprecated, function(a, b) + return a.msg < b.msg + end) + for _, v in ipairs(M._deprecated) do + msg = msg .. "\n\n- " .. v.msg + end + + logger.info(msg) +end + +local examplePromptHook = [[ +UnitTests = function(gp, params) + local template = "I have the following code from {{filename}}:\n\n" + .. "```{{filetype}}\n{{selection}}\n```\n\n" + .. "Please respond by writing table driven unit tests for the code above." + local agent = gp.get_command_agent() + gp.Prompt(params, gp.Target.vnew, agent, template) +end, +]] + +M.has_old_prompt_signature = function(agent) + if not agent or not type(agent) == "table" or not agent.provider then + logger.warning( + "The `gp.Prompt` method signature has changed.\n" + .. "Please update your hook functions as demonstrated in the example below:\n\n" + .. examplePromptHook + .. "\nFor more information, refer to the 'Extend Functionality' section in the documentation." + ) + return true + end + return false +end + +local exampleChatHook = [[ +Translator = function(gp, params) + local chat_system_prompt = "You are a Translator, please translate between English and Chinese." + gp.cmd.ChatNew(params, chat_system_prompt) + + -- -- you can also create a chat with a specific fixed agent like this: + -- local agent = gp.get_chat_agent("ChatGPT4o") + -- gp.cmd.ChatNew(params, chat_system_prompt, agent) +end, +]] + +M.has_old_chat_signature = function(agent) + if agent then + if not type(agent) == "table" or not agent.provider then + logger.warning( + "The `gp.cmd.ChatNew` method signature has changed.\n" + .. "Please update your hook functions as demonstrated in the example below:\n\n" + .. exampleChatHook + .. "\nFor more information, refer to the 'Extend Functionality' section in the documentation." + ) + return true + end + end + return false +end + +M.check_health = function() + if #M._deprecated == 0 then + vim.health.ok("no deprecated config options") + return + end + + local msg = "deprecated config option(s) in setup():" + for _, v in ipairs(M._deprecated) do + msg = msg .. "\n\n- " .. v.msg + end + vim.health.warn(msg) +end + +return M diff --git a/lua/gp/dispatcher.lua b/lua/gp/dispatcher.lua new file mode 100644 index 00000000..789d6b76 --- /dev/null +++ b/lua/gp/dispatcher.lua @@ -0,0 +1,483 @@ +-------------------------------------------------------------------------------- +-- Dispatcher handles the communication between the plugin and LLM providers. +-------------------------------------------------------------------------------- + +local logger = require("gp.logger") +local tasker = require("gp.tasker") +local vault = require("gp.vault") +local render = require("gp.render") +local helpers = require("gp.helper") + +local default_config = require("gp.config") + +local D = { + config = {}, + providers = {}, +} + +---@param opts table # user config +D.setup = function(opts) + logger.debug("dispatcher setup started\n" .. vim.inspect(opts)) + + D.config.curl_params = opts.curl_params or default_config.curl_params + + D.providers = vim.deepcopy(default_config.providers) + opts.providers = opts.providers or {} + for k, v in pairs(opts.providers) do + D.providers[k] = D.providers[k] or {} + D.providers[k].disable = false + for pk, pv in pairs(v) do + D.providers[k][pk] = pv + end + if next(v) == nil then + D.providers[k].disable = true + end + end + + -- remove invalid providers + for name, provider in pairs(D.providers) do + if type(provider) ~= "table" or provider.disable then + D.providers[name] = nil + elseif not provider.endpoint then + D.logger.warning("Provider " .. name .. " is missing endpoint") + D.providers[name] = nil + end + end + + for name, provider in pairs(D.providers) do + vault.add_secret(name, provider.secret) + provider.secret = nil + end + + logger.debug("dispatcher setup finished\n" .. vim.inspect(D)) +end + +---@param messages table +---@param model string | table +---@param provider string | nil +D.prepare_payload = function(messages, model, provider) + if type(model) == "string" then + return { + model = model, + stream = true, + messages = messages, + } + end + + if provider == "googleai" then + for i, message in ipairs(messages) do + if message.role == "system" then + messages[i].role = "user" + end + if message.role == "assistant" then + messages[i].role = "model" + end + if message.content then + messages[i].parts = { + { + text = message.content, + }, + } + messages[i].content = nil + end + end + local i = 1 + while i < #messages do + if messages[i].role == messages[i + 1].role then + table.insert(messages[i].parts, { + text = messages[i + 1].parts[1].text, + }) + table.remove(messages, i + 1) + else + i = i + 1 + end + end + local payload = { + contents = messages, + safetySettings = { + { + category = "HARM_CATEGORY_HARASSMENT", + threshold = "BLOCK_NONE", + }, + { + category = "HARM_CATEGORY_HATE_SPEECH", + threshold = "BLOCK_NONE", + }, + { + category = "HARM_CATEGORY_SEXUALLY_EXPLICIT", + threshold = "BLOCK_NONE", + }, + { + category = "HARM_CATEGORY_DANGEROUS_CONTENT", + threshold = "BLOCK_NONE", + }, + }, + generationConfig = { + temperature = math.max(0, math.min(2, model.temperature or 1)), + maxOutputTokens = model.max_tokens or 8192, + topP = math.max(0, math.min(1, model.top_p or 1)), + topK = model.top_k or 100, + }, + model = model.model, + } + return payload + end + + if provider == "anthropic" then + local system = "" + local i = 1 + while i < #messages do + if messages[i].role == "system" then + system = system .. messages[i].content .. "\n" + table.remove(messages, i) + else + i = i + 1 + end + end + + local payload = { + model = model.model, + stream = true, + messages = messages, + system = system, + max_tokens = model.max_tokens or 4096, + temperature = math.max(0, math.min(2, model.temperature or 1)), + top_p = math.max(0, math.min(1, model.top_p or 1)), + } + return payload + end + + if provider == "copilot" and model.model == "gpt-4o" then + model.model = "gpt-4o-2024-05-13" + end + + return { + model = model.model, + stream = true, + messages = messages, + temperature = math.max(0, math.min(2, model.temperature or 1)), + top_p = math.max(0, math.min(1, model.top_p or 1)), + } +end + +-- gpt query +---@param buf number | nil # buffer number +---@param provider string # provider name +---@param payload table # payload for api +---@param handler function # response handler +---@param on_exit function | nil # optional on_exit handler +---@param callback function | nil # optional callback handler +local query = function(buf, provider, payload, handler, on_exit, callback) + -- make sure handler is a function + if type(handler) ~= "function" then + logger.error( + string.format("query() expects a handler function, but got %s:\n%s", type(handler), vim.inspect(handler)) + ) + return + end + + local qid = helpers.uuid() + tasker.set_query(qid, { + timestamp = os.time(), + buf = buf, + provider = provider, + payload = payload, + handler = handler, + on_exit = on_exit, + raw_response = "", + response = "", + first_line = -1, + last_line = -1, + ns_id = nil, + ex_id = nil, + }) + + local out_reader = function() + local buffer = "" + + ---@param lines_chunk string + local function process_lines(lines_chunk) + local qt = tasker.get_query(qid) + if not qt then + return + end + + local lines = vim.split(lines_chunk, "\n") + for _, line in ipairs(lines) do + if line ~= "" and line ~= nil then + qt.raw_response = qt.raw_response .. line .. "\n" + end + line = line:gsub("^data: ", "") + local content = "" + if line:match("choices") and line:match("delta") and line:match("content") then + line = vim.json.decode(line) + if line.choices[1] and line.choices[1].delta and line.choices[1].delta.content then + content = line.choices[1].delta.content + end + end + + if qt.provider == "anthropic" and line:match('"text":') then + if line:match("content_block_start") or line:match("content_block_delta") then + line = vim.json.decode(line) + if line.delta and line.delta.text then + content = line.delta.text + end + if line.content_block and line.content_block.text then + content = line.content_block.text + end + end + end + + if qt.provider == "googleai" then + if line:match('"text":') then + content = vim.json.decode("{" .. line .. "}").text + end + end + + if content and type(content) == "string" then + qt.response = qt.response .. content + handler(qid, content) + end + end + end + + -- closure for uv.read_start(stdout, fn) + return function(err, chunk) + local qt = tasker.get_query(qid) + if not qt then + return + end + + if err then + logger.error(qt.provider .. " query stdout error: " .. vim.inspect(err)) + elseif chunk then + -- add the incoming chunk to the buffer + buffer = buffer .. chunk + local last_newline_pos = buffer:find("\n[^\n]*$") + if last_newline_pos then + local complete_lines = buffer:sub(1, last_newline_pos - 1) + -- save the rest of the buffer for the next chunk + buffer = buffer:sub(last_newline_pos + 1) + + process_lines(complete_lines) + end + -- chunk is nil when EOF is reached + else + -- if there's remaining data in the buffer, process it + if #buffer > 0 then + process_lines(buffer) + end + + if qt.response == "" then + logger.error(qt.provider .. " response is empty: \n" .. vim.inspect(qt.raw_response)) + end + + -- optional on_exit handler + if type(on_exit) == "function" then + on_exit(qid) + if qt.ns_id and qt.buf then + vim.schedule(function() + vim.api.nvim_buf_clear_namespace(qt.buf, qt.ns_id, 0, -1) + end) + end + end + + -- optional callback handler + if type(callback) == "function" then + vim.schedule(function() + callback(qt.response) + end) + end + end + end + end + + ---TODO: this could be moved to a separate function returning endpoint and headers + local endpoint = D.providers[provider].endpoint + local headers = {} + + local secret = provider + if provider == "copilot" then + secret = "copilot_bearer" + end + local bearer = vault.get_secret(secret) + if not bearer then + logger.warning(provider .. " bearer token is missing") + return + end + + if provider == "copilot" then + headers = { + "-H", + "editor-version: vscode/1.85.1", + "-H", + "Authorization: Bearer " .. bearer, + } + elseif provider == "openai" then + headers = { + "-H", + "Authorization: Bearer " .. bearer, + -- backwards compatibility + "-H", + "api-key: " .. bearer, + } + elseif provider == "googleai" then + headers = {} + endpoint = render.template_replace(endpoint, "{{secret}}", bearer) + endpoint = render.template_replace(endpoint, "{{model}}", payload.model) + payload.model = nil + elseif provider == "anthropic" then + headers = { + "-H", + "x-api-key: " .. bearer, + "-H", + "anthropic-version: 2023-06-01", + "-H", + "anthropic-beta: messages-2023-12-15", + } + elseif provider == "azure" then + headers = { + "-H", + "api-key: " .. bearer, + } + endpoint = render.template_replace(endpoint, "{{model}}", payload.model) + else -- default to openai compatible headers + headers = { + "-H", + "Authorization: Bearer " .. bearer, + } + end + + local curl_params = vim.deepcopy(D.config.curl_params or {}) + local args = { + "--no-buffer", + "-s", + endpoint, + "-H", + "Content-Type: application/json", + "-d", + vim.json.encode(payload), + --[[ "--doesnt_exist" ]] + } + + for _, arg in ipairs(args) do + table.insert(curl_params, arg) + end + + for _, header in ipairs(headers) do + table.insert(curl_params, header) + end + + tasker.run(buf, "curl", curl_params, nil, out_reader(), nil) +end + +-- gpt query +---@param buf number | nil # buffer number +---@param provider string # provider name +---@param payload table # payload for api +---@param handler function # response handler +---@param on_exit function | nil # optional on_exit handler +---@param callback function | nil # optional callback handler +D.query = function(buf, provider, payload, handler, on_exit, callback) + if provider == "copilot" then + return vault.run_with_secret(provider, function() + vault.refresh_copilot_bearer(function() + query(buf, provider, payload, handler, on_exit, callback) + end) + end) + end + vault.run_with_secret(provider, function() + query(buf, provider, payload, handler, on_exit, callback) + end) +end + +-- response handler +---@param buf number | nil # buffer to insert response into +---@param win number | nil # window to insert response into +---@param line number | nil # line to insert response into +---@param first_undojoin boolean | nil # whether to skip first undojoin +---@param prefix string | nil # prefix to insert before each response line +---@param cursor boolean # whether to move cursor to the end of the response +D.create_handler = function(buf, win, line, first_undojoin, prefix, cursor) + buf = buf or vim.api.nvim_get_current_buf() + prefix = prefix or "" + local first_line = line or vim.api.nvim_win_get_cursor(win or 0)[1] - 1 + local finished_lines = 0 + local skip_first_undojoin = not first_undojoin + + local hl_handler_group = "GpHandlerStandout" + vim.cmd("highlight default link " .. hl_handler_group .. " CursorLine") + + local ns_id = vim.api.nvim_create_namespace("GpHandler_" .. helpers.uuid()) + + local ex_id = vim.api.nvim_buf_set_extmark(buf, ns_id, first_line, 0, { + strict = false, + right_gravity = false, + }) + + local response = "" + return vim.schedule_wrap(function(qid, chunk) + local qt = tasker.get_query(qid) + if not qt then + return + end + -- if buf is not valid, stop + if not vim.api.nvim_buf_is_valid(buf) then + return + end + -- undojoin takes previous change into account, so skip it for the first chunk + if skip_first_undojoin then + skip_first_undojoin = false + else + helpers.undojoin(buf) + end + + if not qt.ns_id then + qt.ns_id = ns_id + end + + if not qt.ex_id then + qt.ex_id = ex_id + end + + first_line = vim.api.nvim_buf_get_extmark_by_id(buf, ns_id, ex_id, {})[1] + + -- clean previous response + local line_count = #vim.split(response, "\n") + vim.api.nvim_buf_set_lines(buf, first_line + finished_lines, first_line + line_count, false, {}) + + -- append new response + response = response .. chunk + helpers.undojoin(buf) + + -- prepend prefix to each line + local lines = vim.split(response, "\n") + for i, l in ipairs(lines) do + lines[i] = prefix .. l + end + + local unfinished_lines = {} + for i = finished_lines + 1, #lines do + table.insert(unfinished_lines, lines[i]) + end + + vim.api.nvim_buf_set_lines(buf, first_line + finished_lines, first_line + finished_lines, false, unfinished_lines) + + local new_finished_lines = math.max(0, #lines - 1) + for i = finished_lines, new_finished_lines do + vim.api.nvim_buf_add_highlight(buf, qt.ns_id, hl_handler_group, first_line + i, 0, -1) + end + finished_lines = new_finished_lines + + local end_line = first_line + #vim.split(response, "\n") + qt.first_line = first_line + qt.last_line = end_line - 1 + + -- move cursor to the end of the response + if cursor then + helpers.cursor_to_line(end_line, buf, win) + end + end) +end + +return D diff --git a/lua/gp/health.lua b/lua/gp/health.lua index 6d66a2b9..74f20913 100644 --- a/lua/gp/health.lua +++ b/lua/gp/health.lua @@ -1,3 +1,7 @@ +-------------------------------------------------------------------------------- +-- :checkhealth gp +-------------------------------------------------------------------------------- + local M = {} function M.check() @@ -14,20 +18,6 @@ function M.check() else vim.health.error("require('gp').setup() has not been called") end - - --TODO: obsolete - ---@diagnostic disable-next-line: undefined-field - local api_key = gp.config.openai_api_key - - if type(api_key) == "table" then - vim.health.error( - "require('gp').setup({openai_api_key: ???}) is still an unresolved command: " .. vim.inspect(api_key) - ) - elseif api_key and string.match(api_key, "%S") then - vim.health.ok("config.openai_api_key is set") - else - vim.health.error("require('gp').setup({openai_api_key: ???}) is not set: " .. vim.inspect(api_key)) - end end if vim.fn.executable("curl") == 1 then @@ -42,43 +32,8 @@ function M.check() vim.health.error("grep is not installed") end - if vim.fn.executable("ln") == 1 then - vim.health.ok("ln is installed") - else - vim.health.error("ln is not installed") - end - - if vim.fn.executable("sox") == 1 then - vim.health.ok("sox is installed") - local output = vim.fn.system("sox -h | grep -i mp3 | wc -l 2>/dev/null") - if output:sub(1, 1) == "0" then - vim.health.error("sox is not compiled with mp3 support" .. "\n on debian/ubuntu install libsox-fmt-mp3") - else - vim.health.ok("sox is compiled with mp3 support") - end - else - vim.health.warn("sox is not installed") - end - - if vim.fn.executable("arecord") == 1 then - vim.health.ok("arecord found - will be used for recording (sox for post-processing)") - elseif vim.fn.executable("ffmpeg") == 1 then - local devices = vim.fn.system("ffmpeg -devices -v quiet | grep -i avfoundation | wc -l") - devices = string.gsub(devices, "^%s*(.-)%s*$", "%1") - if devices == "1" then - vim.health.ok("ffmpeg with avfoundation found - will be used for recording (sox for post-processing)") - end - end - - if gp._deprecated and #gp._deprecated > 0 then - local msg = "deprecated config option(s) in setup():" - for _, v in ipairs(gp._deprecated) do - msg = msg .. "\n\n- " .. v.msg - end - vim.health.warn(msg) - else - vim.health.ok("no deprecated config options") - end + require("gp.whisper").check_health() + require("gp.deprecator").check_health() end return M diff --git a/lua/gp/helper.lua b/lua/gp/helper.lua new file mode 100644 index 00000000..b3f1c39c --- /dev/null +++ b/lua/gp/helper.lua @@ -0,0 +1,291 @@ +-------------------------------------------------------------------------------- +-- Generic independent helper functions +-------------------------------------------------------------------------------- + +local logger = require("gp.logger") + +local _H = {} + +---@param keys string # string of keystrokes +---@param mode string # string of vim mode ('n', 'i', 'c', etc.), default is 'n' +_H.feedkeys = function(keys, mode) + mode = mode or "n" + keys = vim.api.nvim_replace_termcodes(keys, true, false, true) + vim.api.nvim_feedkeys(keys, mode, true) +end + +---@param buffers table # table of buffers +---@param mode table | string # mode(s) to set keymap for +---@param key string # shortcut key +---@param callback function | string # callback or string to set keymap +---@param desc string | nil # optional description for keymap +_H.set_keymap = function(buffers, mode, key, callback, desc) + logger.debug( + "registering shortcut:" + .. " mode: " + .. vim.inspect(mode) + .. " key: " + .. key + .. " buffers: " + .. vim.inspect(buffers) + .. " callback: " + .. vim.inspect(callback) + ) + for _, buf in ipairs(buffers) do + vim.keymap.set(mode, key, callback, { + noremap = true, + silent = true, + nowait = true, + buffer = buf, + desc = desc, + }) + end +end + +---@param events string | table # events to listen to +---@param buffers table | nil # buffers to listen to (nil for all buffers) +---@param callback function # callback to call +---@param gid number # augroup id +_H.autocmd = function(events, buffers, callback, gid) + if buffers then + for _, buf in ipairs(buffers) do + vim.api.nvim_create_autocmd(events, { + group = gid, + buffer = buf, + callback = vim.schedule_wrap(callback), + }) + end + else + vim.api.nvim_create_autocmd(events, { + group = gid, + callback = vim.schedule_wrap(callback), + }) + end +end + +---@param file_name string # name of the file for which to delete buffers +_H.delete_buffer = function(file_name) + -- iterate over buffer list and close all buffers with the same name + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) and vim.api.nvim_buf_get_name(b) == file_name then + vim.api.nvim_buf_delete(b, { force = true }) + end + end +end + +---@param file string | nil # name of the file to delete +_H.delete_file = function(file) + logger.debug("deleting file: " .. vim.inspect(file)) + if file == nil then + return + end + _H.delete_buffer(file) + os.remove(file) +end + +---@param file_name string # name of the file for which to get buffer +---@return number | nil # buffer number +_H.get_buffer = function(file_name) + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) then + if _H.ends_with(vim.api.nvim_buf_get_name(b), file_name) then + return b + end + end + end + return nil +end + +---@return string # returns unique uuid +_H.uuid = function() + local random = math.random + local template = "xxxxxxxx_xxxx_4xxx_yxxx_xxxxxxxxxxxx" + local result = string.gsub(template, "[xy]", function(c) + local v = (c == "x") and random(0, 0xf) or random(8, 0xb) + return string.format("%x", v) + end) + return result +end + +---@param name string # name of the augroup +---@param opts table | nil # options for the augroup +---@return number # returns augroup id +_H.create_augroup = function(name, opts) + return vim.api.nvim_create_augroup(name .. "_" .. _H.uuid(), opts or { clear = true }) +end + +---@param buf number # buffer number +---@return number # returns the first line with content of specified buffer +_H.last_content_line = function(buf) + buf = buf or vim.api.nvim_get_current_buf() + -- go from end and return number of last nonwhitespace line + local line = vim.api.nvim_buf_line_count(buf) + while line > 0 do + local content = vim.api.nvim_buf_get_lines(buf, line - 1, line, false)[1] + if content:match("%S") then + return line + end + line = line - 1 + end + return 0 +end + +---@param buf number # buffer number +---@return string # returns filetype of specified buffer +_H.get_filetype = function(buf) + return vim.api.nvim_get_option_value("filetype", { buf = buf }) +end + +---@param line number # line number +---@param buf number # buffer number +---@param win number | nil # window number +_H.cursor_to_line = function(line, buf, win) + -- don't manipulate cursor if user is elsewhere + if buf ~= vim.api.nvim_get_current_buf() then + return + end + + -- check if win is valid + if not win or not vim.api.nvim_win_is_valid(win) then + return + end + + -- move cursor to the line + vim.api.nvim_win_set_cursor(win, { line, 0 }) +end + +---@param str string # string to check +---@param start string # string to check for +_H.starts_with = function(str, start) + return str:sub(1, #start) == start +end + +---@param str string # string to check +---@param ending string # string to check for +_H.ends_with = function(str, ending) + return ending == "" or str:sub(-#ending) == ending +end + +-- helper function to find the root directory of the current git repository +---@param path string | nil # optional path to start searching from +---@return string # returns the path of the git root dir or an empty string if not found +_H.find_git_root = function(path) + logger.debug("finding git root for path: " .. vim.inspect(path)) + local cwd = vim.fn.expand("%:p:h") + if path then + cwd = vim.fn.fnamemodify(path, ":p:h") + end + while cwd ~= "/" do + local files = vim.fn.readdir(cwd) + if vim.tbl_contains(files, ".git") then + logger.debug("found git root: " .. cwd) + return cwd + end + cwd = vim.fn.fnamemodify(cwd, ":h") + end + logger.debug("git root not found") + return "" +end + +---@param buf number # buffer number +_H.undojoin = function(buf) + if not buf or not vim.api.nvim_buf_is_loaded(buf) then + return + end + local status, result = pcall(vim.cmd.undojoin) + if not status then + if result:match("E790") then + return + end + logger.error("Error running undojoin: " .. vim.inspect(result)) + end +end + +---@param tbl table # the table to be stored +---@param file_path string # the file path where the table will be stored as json +_H.table_to_file = function(tbl, file_path) + local json = vim.json.encode(tbl) + + local file = io.open(file_path, "w") + if not file then + logger.warning("Failed to open file for writing: " .. file_path) + return + end + file:write(json) + file:close() +end + +---@param file_path string # the file path from where to read the json into a table +---@return table | nil # the table read from the file, or nil if an error occurred +_H.file_to_table = function(file_path) + local file, err = io.open(file_path, "r") + if not file then + logger.warning("Failed to open file for reading: " .. file_path .. "\nError: " .. err) + return nil + end + local content = file:read("*a") + file:close() + + if content == nil or content == "" then + logger.warning("Failed to read any content from file: " .. file_path) + return nil + end + + local tbl = vim.json.decode(content) + return tbl +end + +---@param dir string # directory to prepare +---@param name string | nil # name of the directory +---@return string # returns resolved directory path +_H.prepare_dir = function(dir, name) + local odir = dir + dir = dir:gsub("/$", "") + name = name and name .. " " or "" + if vim.fn.isdirectory(dir) == 0 then + logger.debug("creating " .. name .. "directory: " .. dir) + vim.fn.mkdir(dir, "p") + end + + dir = vim.fn.resolve(dir) + + logger.debug("resolved " .. name .. "directory:\n" .. odir .. " -> " .. dir) + return dir +end + +---@param cmd_name string # name of the command +---@param cmd_func function # function to be executed when the command is called +---@param completion function | table | nil # optional function returning table for completion +---@param desc string | nil # description of the command +_H.create_user_command = function(cmd_name, cmd_func, completion, desc) + logger.debug("creating user command: " .. cmd_name) + vim.api.nvim_create_user_command(cmd_name, cmd_func, { + nargs = "*", + range = true, + desc = desc or "Gp.nvim command", + complete = function(arg_lead, cmd_line, cursor_pos) + logger.debug( + "completing user command: " + .. cmd_name + .. "\narg_lead: " + .. arg_lead + .. "\ncmd_line: " + .. cmd_line + .. "\ncursor_pos: " + .. cursor_pos + ) + if not completion then + return {} + end + if type(completion) == "function" then + return completion(arg_lead, cmd_line, cursor_pos) or {} + end + if type(completion) == "table" then + return completion + end + return {} + end, + }) +end + +return _H diff --git a/lua/gp/imager.lua b/lua/gp/imager.lua new file mode 100644 index 00000000..1a2f99b7 --- /dev/null +++ b/lua/gp/imager.lua @@ -0,0 +1,271 @@ +-------------------------------------------------------------------------------- +-- Imager module for generating images +-------------------------------------------------------------------------------- + +local logger = require("gp.logger") +local tasker = require("gp.tasker") +local spinner = require("gp.spinner") +local render = require("gp.render") +local helpers = require("gp.helper") +local vault = require("gp.vault") + +local default_config = require("gp.config") + +local I = { + config = {}, + _state = {}, + _agents = {}, + cmd = {}, + disabled = false, +} + +---@param opts table # user config +I.setup = function(opts) + logger.debug("imager setup started\n" .. vim.inspect(opts)) + + I.config = vim.deepcopy(default_config.image) + + if opts.disable then + I.disabled = true + logger.debug("imager is disabled") + return + end + + I.agents = {} + for _, v in pairs(I.config.agents) do + I.agents[v.name] = v + end + I.config.agents = nil + + opts.agents = opts.agents or {} + for _, v in pairs(opts.agents) do + I.agents[v.name] = v + end + opts.agents = nil + + for k, v in pairs(opts) do + I.config[k] = v + end + + I.config.store_dir = helpers.prepare_dir(I.config.store_dir, "imager store") + I.config.state_dir = helpers.prepare_dir(I.config.state_dir, "imager state") + + for name, agent in pairs(I.agents) do + if type(agent) ~= "table" or agent.disable then + logger.debug("imager agent " .. name .. " disabled") + I.agents[name] = nil + elseif not agent.model then + logger.warning( + "Image agent " + .. name + .. " is missing model\n" + .. "If you want to disable an agent, use: { name = '" + .. name + .. "', disable = true }," + ) + I.agents[name] = nil + end + end + + for name, _ in pairs(I.agents) do + table.insert(I._agents, name) + end + table.sort(I._agents) + + I.refresh() + + for cmd, _ in pairs(I.cmd) do + helpers.create_user_command(I.config.cmd_prefix .. cmd, I.cmd[cmd], function() + if cmd == "ImageAgent" then + return I._agents + end + + return {} + end) + end + + vault.add_secret("imager_secret", I.config.secret) + I.config.secret = nil + + logger.debug("imager setup finished") +end + +I.refresh = function() + logger.debug("imager state refresh") + + local state_file = I.config.state_dir .. "/imager_state.json" + + local state = {} + if vim.fn.filereadable(state_file) ~= 0 then + state = helpers.file_to_table(state_file) or {} + end + + logger.debug("imager loaded state:\n" .. vim.inspect(state)) + + I._state.agent = I._state.agent or state.agent or nil + if not I._state.agent == nil or not I.agents[I._state.agent] then + I._state.agent = I._agents[1] + end + + helpers.table_to_file(I._state, state_file) +end + +I.cmd.ImageAgent = function(params) + local agent_name = string.gsub(params.args, "^%s*(.-)%s*$", "%1") + if agent_name == "" then + logger.info("imager agent: " .. (I._state.agent or "none")) + return + end + + if not I.agents[agent_name] then + logger.warning("imager unknown agent: " .. agent_name) + return + end + + I._state.agent = agent_name + logger.info("imager agent: " .. I._state.agent) + + I.refresh() +end + +---@return table # { cmd_prefix, name, model, quality, style, size } +I.get_image_agent = function() + local template = I.config.prompt_prefix_template + local name = I._state.agent + local cmd_prefix = render.template(template, { ["{{agent}}"] = name }) + local model = I.agents[name].model + local quality = I.agents[name].quality + local style = I.agents[name].style + local size = I.agents[name].size + return { cmd_prefix = cmd_prefix, name = name, model = model, quality = quality, style = style, size = size } +end + +I.cmd.Image = function(params) + local prompt = params.args + local agent = I.get_image_agent() + if prompt == "" then + vim.ui.input({ prompt = agent.cmd_prefix }, function(input) + prompt = input + if not prompt then + return + end + I.generate_image(prompt, agent.model, agent.quality, agent.style, agent.size) + end) + else + I.generate_image(prompt, agent.model, agent.quality, agent.style, agent.size) + end +end + +local generate_image = function(prompt, model, quality, style, size) + local bearer = vault.get_secret("imager_secret") + if not bearer then + return + end + + local cmd = "curl" + local payload = { + model = model, + prompt = prompt, + n = 1, + size = size, + style = style, + quality = quality, + } + local args = { + -- "-s", + "-H", + "Content-Type: application/json", + "-H", + "Authorization: Bearer " .. bearer, + "-d", + vim.json.encode(payload), + "https://api.openai.com/v1/images/generations", + } + + local qid = helpers.uuid() + tasker.set_query(qid, { + timestamp = os.time(), + payload = payload, + raw_response = "", + error = "", + url = "", + prompt = "", + save_path = "", + save_raw_response = "", + save_error = "", + }) + local query = tasker.get_query(qid) + + spinner.start_spinner("Generating image...") + + tasker.run(nil, cmd, args, function(code, signal, stdout_data, stderr_data) + spinner.stop_spinner() + query.raw_response = stdout_data + query.error = stderr_data + if code ~= 0 then + logger.error( + "Image generation exited: code: " + .. code + .. " signal: " + .. signal + .. " stdout: " + .. stdout_data + .. " stderr: " + .. stderr_data + ) + return + end + local result = vim.json.decode(stdout_data) + query.parsed_response = vim.inspect(result) + if result and result.data and result.data[1] and result.data[1].url then + local image_url = result.data[1].url + query.url = image_url + -- query.prompt = result.data[1].prompt + vim.ui.input( + { prompt = I.config.prompt_save, completion = "file", default = I.config.store_dir }, + function(save_path) + if not save_path or save_path == "" then + logger.info("Image URL: " .. image_url) + return + end + query.save_path = save_path + spinner.start_spinner("Saving image...") + tasker.run( + nil, + "curl", + { "-s", "-o", save_path, image_url }, + function(save_code, save_signal, save_stdout_data, save_stderr_data) + spinner.stop_spinner() + query.save_raw_response = save_stdout_data + query.save_error = save_stderr_data + if save_code == 0 then + logger.info("Image saved to: " .. save_path) + else + logger.error( + "Failed to save image: path: " + .. save_path + .. " code: " + .. save_code + .. " signal: " + .. save_signal + .. " stderr: " + .. save_stderr_data + ) + end + end + ) + end + ) + else + logger.error("Image generation failed: " .. vim.inspect(stdout_data)) + end + end) +end + +I.generate_image = function(prompt, model, quality, style, size) + vault.run_with_secret("imager_secret", function() + generate_image(prompt, model, quality, style, size) + end) +end + +return I diff --git a/lua/gp/init.lua b/lua/gp/init.lua index 8cdd6fd7..8e8b0c89 100644 --- a/lua/gp/init.lua +++ b/lua/gp/init.lua @@ -1,712 +1,42 @@ -- Gp (GPT prompt) lua plugin for Neovim -- https://github.com/Robitx/gp.nvim/ --------------------------------------------------------------------------------- --- Default config --------------------------------------------------------------------------------- - -local config = require("gp.config") - -local switch_to_agent = "Please use `agents` table and switch agents in runtime via `:GpAgent XY`" -local deprecated = { - chat_toggle_target = "`chat_toggle_target`\nPlease rename it to `toggle_target` which is also used by other commands", - command_model = "`command_model`\n" .. switch_to_agent, - command_system_prompt = "`command_system_prompt`\n" .. switch_to_agent, - chat_custom_instructions = "`chat_custom_instructions`\n" .. switch_to_agent, - chat_model = "`chat_model`\n" .. switch_to_agent, - chat_system_prompt = "`chat_system_prompt`\n" .. switch_to_agent, - command_prompt_prefix = "`command_prompt_prefix`\nPlease use `command_prompt_prefix_template`" - .. " with support for \n`{{agent}}` variable so you know which agent is currently active", - whisper_max_time = "`whisper_max_time`\nPlease use fully customizable `whisper_rec_cmd`", - - openai_api_endpoint = "`openai_api_endpoint`\n\n" - .. "********************************************************************************\n" - .. "********************************************************************************\n" - .. "Gp.nvim finally supports multiple LLM providers; sorry it took so long.\n" - .. "I've dreaded merging this, because I hate breaking people's setups.\n" - .. "But this change is necessary for future improvements.\n\n" - .. "Migration hints are below; for more help, try the readme docs or open an issue.\n" - .. "********************************************************************************\n" - .. "********************************************************************************\n\n" - .. "If you're using the `https://api.openai.com/v1/chat/completions` endpoint,\n" - .. "just drop `openai_api_endpoint` in your config and you're done." - .. "\n\nOtherwise sorry for probably breaking your setup, " - .. "please use `endpoint` and `secret` fields in:\n\nproviders " - .. "= {\n openai = {\n endpoint = '...',\n secret = '...'\n }," - .. "\n -- azure = {...},\n -- copilot = {...},\n -- ollama = {...},\n -- googleai= {...},\n -- pplx = {...},\n -- anthropic = {...},\n},\n" - .. "\nThe `openai_api_key` is still supported for backwards compatibility,\n" - .. "and automatically converted to `providers.openai.secret` if the new config is not set.", -} - -------------------------------------------------------------------------------- -- Module structure -------------------------------------------------------------------------------- +local config = require("gp.config") -local _H = {} local M = { - _H = _H, -- helper functions _Name = "Gp", -- plugin name - _handles = {}, -- handles for running processes - _queries = {}, -- table of latest queries _state = {}, -- table of state variables - _deprecated = {}, -- table of deprecated options agents = {}, -- table of agents - image_agents = {}, -- table of image agents cmd = {}, -- default command functions config = {}, -- config variables hooks = {}, -- user defined command functions - spinner = require("gp.spinner"), -- spinner module defaults = require("gp.defaults"), -- some useful defaults + deprecator = require("gp.deprecator"), -- handle deprecated options + dispatcher = require("gp.dispatcher"), -- handle communication with LLM providers + helpers = require("gp.helper"), -- helper functions + imager = require("gp.imager"), -- image generation module logger = require("gp.logger"), -- logger module + render = require("gp.render"), -- render module + spinner = require("gp.spinner"), -- spinner module + tasker = require("gp.tasker"), -- tasker module + vault = require("gp.vault"), -- handles secrets + whisper = require("gp.whisper"), -- whisper module } --------------------------------------------------------------------------------- --- Generic helper functions --------------------------------------------------------------------------------- - ----@param fn function # function to wrap so it only gets called once -_H.once = function(fn) - local once = false - return function(...) - if once then - return - end - once = true - fn(...) - end -end - ----@param keys string # string of keystrokes ----@param mode string # string of vim mode ('n', 'i', 'c', etc.), default is 'n' -_H.feedkeys = function(keys, mode) - mode = mode or "n" - keys = vim.api.nvim_replace_termcodes(keys, true, false, true) - vim.api.nvim_feedkeys(keys, mode, true) -end - ----@param buffers table # table of buffers ----@param mode table | string # mode(s) to set keymap for ----@param key string # shortcut key ----@param callback function | string # callback or string to set keymap ----@param desc string | nil # optional description for keymap -_H.set_keymap = function(buffers, mode, key, callback, desc) - for _, buf in ipairs(buffers) do - vim.keymap.set(mode, key, callback, { - noremap = true, - silent = true, - nowait = true, - buffer = buf, - desc = desc, - }) - end -end - ----@param events string | table # events to listen to ----@param buffers table | nil # buffers to listen to (nil for all buffers) ----@param callback function # callback to call ----@param gid number # augroup id -_H.autocmd = function(events, buffers, callback, gid) - if buffers then - for _, buf in ipairs(buffers) do - vim.api.nvim_create_autocmd(events, { - group = gid, - buffer = buf, - callback = vim.schedule_wrap(callback), - }) - end - else - vim.api.nvim_create_autocmd(events, { - group = gid, - callback = vim.schedule_wrap(callback), - }) - end -end - ----@param file_name string # name of the file for which to delete buffers -_H.delete_buffer = function(file_name) - -- iterate over buffer list and close all buffers with the same name - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(b) and vim.api.nvim_buf_get_name(b) == file_name then - vim.api.nvim_buf_delete(b, { force = true }) - end - end -end - ----@param file string | nil # name of the file to delete -_H.delete_file = function(file) - if file == nil then - return - end - M._H.delete_buffer(file) - os.remove(file) -end - ----@param file_name string # name of the file for which to get buffer ----@return number | nil # buffer number -_H.get_buffer = function(file_name) - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_valid(b) then - if _H.ends_with(vim.api.nvim_buf_get_name(b), file_name) then - return b - end - end - end - return nil -end - ----@return string # returns unique uuid -_H.uuid = function() - local random = math.random - local template = "xxxxxxxx_xxxx_4xxx_yxxx_xxxxxxxxxxxx" - local result = string.gsub(template, "[xy]", function(c) - local v = (c == "x") and random(0, 0xf) or random(8, 0xb) - return string.format("%x", v) - end) - return result -end - ----@param name string # name of the augroup ----@param opts table | nil # options for the augroup ----@return number # returns augroup id -_H.create_augroup = function(name, opts) - return vim.api.nvim_create_augroup(name .. "_" .. _H.uuid(), opts or { clear = true }) -end - --- stop receiving gpt responses for all processes and clean the handles ----@param signal number | nil # signal to send to the process -M.cmd.Stop = function(signal) - if M._handles == {} then - return - end - - for _, handle_info in ipairs(M._handles) do - if handle_info.handle ~= nil and not handle_info.handle:is_closing() then - vim.loop.kill(handle_info.pid, signal or 15) - end - end - - M._handles = {} -end - --- add a process handle and its corresponding pid to the _handles table ----@param handle userdata # the Lua uv handle ----@param pid number # the process id ----@param buf number | nil # buffer number -M.add_handle = function(handle, pid, buf) - table.insert(M._handles, { handle = handle, pid = pid, buf = buf }) -end - ---- Check if there is no other pid running for the given buffer ----@param buf number | nil # buffer number ----@return boolean -M.can_handle = function(buf) - if buf == nil then - return true - end - for _, handle_info in ipairs(M._handles) do - if handle_info.buf == buf then - return false - end - end - return true -end - --- remove a process handle from the _handles table using its pid ----@param pid number # the process id to find the corresponding handle -M.remove_handle = function(pid) - for i, handle_info in ipairs(M._handles) do - if handle_info.pid == pid then - table.remove(M._handles, i) - return - end - end -end - ----@param buf number # buffer number -_H.undojoin = function(buf) - if not buf or not vim.api.nvim_buf_is_loaded(buf) then - return - end - local status, result = pcall(vim.cmd.undojoin) - if not status then - if result:match("E790") then - return - end - M.logger.error("Error running undojoin: " .. vim.inspect(result)) - end -end - ----@param buf number | nil # buffer number ----@param cmd string # command to execute ----@param args table # arguments for command ----@param callback function | nil # exit callback function(code, signal, stdout_data, stderr_data) ----@param out_reader function | nil # stdout reader function(err, data) ----@param err_reader function | nil # stderr reader function(err, data) -_H.process = function(buf, cmd, args, callback, out_reader, err_reader) - local handle, pid - local stdout = vim.loop.new_pipe(false) - local stderr = vim.loop.new_pipe(false) - local stdout_data = "" - local stderr_data = "" - - if not M.can_handle(buf) then - M.logger.warning("Another Gp process is already running for this buffer.") - return - end - - local on_exit = _H.once(vim.schedule_wrap(function(code, signal) - stdout:read_stop() - stderr:read_stop() - stdout:close() - stderr:close() - if handle and not handle:is_closing() then - handle:close() - end - if callback then - callback(code, signal, stdout_data, stderr_data) - end - M.remove_handle(pid) - end)) - - handle, pid = vim.loop.spawn(cmd, { - args = args, - stdio = { nil, stdout, stderr }, - hide = true, - detach = true, - }, on_exit) - - M.add_handle(handle, pid, buf) - - vim.loop.read_start(stdout, function(err, data) - if err then - M.logger.error("Error reading stdout: " .. vim.inspect(err)) - end - if data then - stdout_data = stdout_data .. data - end - if out_reader then - out_reader(err, data) - end - end) - - vim.loop.read_start(stderr, function(err, data) - if err then - M.logger.error("Error reading stderr: " .. vim.inspect(err)) - end - if data then - stderr_data = stderr_data .. data - end - if err_reader then - err_reader(err, data) - end - end) -end - ----@param buf number | nil # buffer number ----@param directory string # directory to search in ----@param pattern string # pattern to search for ----@param callback function # callback function(results, regex) --- results: table of elements with file, lnum and line --- regex: string - final regex used for search -_H.grep_directory = function(buf, directory, pattern, callback) - pattern = pattern or "" - -- replace spaces with wildcards - pattern = pattern:gsub("%s+", ".*") - -- strip leading and trailing non alphanumeric characters - local re = pattern:gsub("^%W*(.-)%W*$", "%1") - - _H.process(buf, "grep", { "-irEn", "--null", pattern, directory }, function(c, _, stdout, _) - local results = {} - if c ~= 0 then - callback(results, re) - return - end - for _, line in ipairs(vim.split(stdout, "\n")) do - line = line:gsub("^%s*(.-)%s*$", "%1") - -- line contains non whitespace characters - if line:match("%S") then - -- extract file path (until zero byte) - local file = line:match("^(.-)%z") - -- substract dir from file - local filename = vim.fn.fnamemodify(file, ":t") - local line_number = line:match("%z(%d+):") - local line_text = line:match("%z%d+:(.*)") - table.insert(results, { - file = filename, - lnum = line_number, - line = line_text, - }) - -- extract line number - end - end - table.sort(results, function(a, b) - if a.file == b.file then - return a.lnum < b.lnum - else - return a.file > b.file - end - end) - callback(results, re) - end) -end - ----@param buf number | nil # buffer number ----@param title string # title of the popup ----@param size_func function # size_func(editor_width, editor_height) -> width, height, row, col ----@param opts table # options - gid=nul, on_leave=false, persist=false ----@param style table # style - border="single" ----returns table with buffer, window, close function, resize function -_H.create_popup = function(buf, title, size_func, opts, style) - opts = opts or {} - style = style or {} - local border = style.border or "single" - - -- create buffer - buf = buf or vim.api.nvim_create_buf(false, not opts.persist) - - -- setting to the middle of the editor - local options = { - relative = "editor", - -- dummy values gets resized later - width = 10, - height = 10, - row = 10, - col = 10, - style = "minimal", - border = border, - title = title, - title_pos = "center", - } - - -- open the window and return the buffer - local win = vim.api.nvim_open_win(buf, true, options) - - local resize = function() - -- get editor dimensions - local ew = vim.api.nvim_get_option("columns") - local eh = vim.api.nvim_get_option("lines") - - local w, h, r, c = size_func(ew, eh) - - -- setting to the middle of the editor - local o = { - relative = "editor", - -- half of the editor width - width = math.floor(w), - -- half of the editor height - height = math.floor(h), - -- center of the editor - row = math.floor(r), - -- center of the editor - col = math.floor(c), - } - vim.api.nvim_win_set_config(win, o) - end - - local pgid = opts.gid or M._H.create_augroup("GpPopup", { clear = true }) - - -- cleanup on exit - local close = _H.once(function() - vim.schedule(function() - -- delete only internal augroups - if not opts.gid then - vim.api.nvim_del_augroup_by_id(pgid) - end - if win and vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) - end - if opts.persist then - return - end - if vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_delete(buf, { force = true }) - end - end) - end) - - -- resize on vim resize - _H.autocmd("VimResized", { buf }, resize, pgid) - - -- cleanup on buffer exit - _H.autocmd({ "BufWipeout", "BufHidden", "BufDelete" }, { buf }, close, pgid) - - -- optional cleanup on buffer leave - if opts.on_leave then - -- close when entering non-popup buffer - _H.autocmd({ "BufEnter" }, nil, function(event) - local b = event.buf - if b ~= buf then - close() - -- make sure to set current buffer after close - vim.schedule(vim.schedule_wrap(function() - vim.api.nvim_set_current_buf(b) - end)) - end - end, pgid) - end - - -- cleanup on escape exit - if opts.escape then - _H.set_keymap({ buf }, "n", "", close, title .. " close on escape") - _H.set_keymap({ buf }, { "n", "v", "i" }, "", close, title .. " close on escape") - end - - resize() - return buf, win, close, resize -end - ----@param buf number # buffer number ----@return number # returns the first line with content of specified buffer -_H.last_content_line = function(buf) - buf = buf or vim.api.nvim_get_current_buf() - -- go from end and return number of last nonwhitespace line - local line = vim.api.nvim_buf_line_count(buf) - while line > 0 do - local content = vim.api.nvim_buf_get_lines(buf, line - 1, line, false)[1] - if content:match("%S") then - return line - end - line = line - 1 - end - return 0 -end - ----@param buf number # buffer number ----@return string # returns filetype of specified buffer -_H.get_filetype = function(buf) - return vim.api.nvim_buf_get_option(buf, "filetype") -end - --- returns rendered template with specified key replaced by value -_H.template_replace = function(template, key, value) - if template == nil then - return nil - end - - if value == nil then - return template:gsub(key, "") - end - - if type(value) == "table" then - value = table.concat(value, "\n") - end - - value = value:gsub("%%", "%%%%") - template = template:gsub(key, value) - template = template:gsub("%%%%", "%%") - return template -end - ----@param template string | nil # template string ----@param key_value_pairs table # table with key value pairs ----@return string | nil # returns rendered template with keys replaced by values from key_value_pairs -_H.template_render = function(template, key_value_pairs) - if template == nil then - return nil - end - - for key, value in pairs(key_value_pairs) do - template = _H.template_replace(template, key, value) - end - - return template -end - ----@param line number # line number ----@param buf number # buffer number ----@param win number | nil # window number -_H.cursor_to_line = function(line, buf, win) - -- don't manipulate cursor if user is elsewhere - if buf ~= vim.api.nvim_get_current_buf() then - return - end - - -- check if win is valid - if not win or not vim.api.nvim_win_is_valid(win) then - return - end - - -- move cursor to the line - vim.api.nvim_win_set_cursor(win, { line, 0 }) -end - ----@param str string # string to check ----@param start string # string to check for -_H.starts_with = function(str, start) - return str:sub(1, #start) == start -end - ----@param str string # string to check ----@param ending string # string to check for -_H.ends_with = function(str, ending) - return ending == "" or str:sub(-#ending) == ending -end - -------------------------------------------------------------------------------- -- Module helper functions and variables -------------------------------------------------------------------------------- ----@param tbl table # the table to be stored ----@param file_path string # the file path where the table will be stored as json -M.table_to_file = function(tbl, file_path) - local json = vim.json.encode(tbl) - - local file = io.open(file_path, "w") - if not file then - M.logger.warning("Failed to open file for writing: " .. file_path) - return - end - file:write(json) - file:close() -end - ----@param file_path string # the file path from where to read the json into a table ----@return table | nil # the table read from the file, or nil if an error occurred -M.file_to_table = function(file_path) - local file, err = io.open(file_path, "r") - if not file then - M.logger.warning("Failed to open file for reading: " .. file_path .. "\nError: " .. err) - return nil - end - local content = file:read("*a") - file:close() - - if content == nil or content == "" then - M.logger.warning("Failed to read any content from file: " .. file_path) - return nil - end - - local tbl = vim.json.decode(content) - return tbl -end - --- helper function to find the root directory of the current git repository ----@param path string | nil # optional path to start searching from ----@return string # returns the path of the git root dir or an empty string if not found -_H.find_git_root = function(path) - local cwd = vim.fn.expand("%:p:h") - if path then - cwd = vim.fn.fnamemodify(path, ":p:h") - end - while cwd ~= "/" do - local files = vim.fn.readdir(cwd) - if vim.tbl_contains(files, ".git") then - return cwd - end - cwd = vim.fn.fnamemodify(cwd, ":h") - end - return "" -end - --- tries to find an .gp.md file in the root of current git repo ----@return string # returns instructions from the .gp.md file -M.repo_instructions = function() - local git_root = _H.find_git_root() - - if git_root == "" then - return "" - end - - local instruct_file = git_root .. "/.gp.md" - - if vim.fn.filereadable(instruct_file) == 0 then - return "" - end - - local lines = vim.fn.readfile(instruct_file) - return table.concat(lines, "\n") -end - -M.template_render = function(template, command, selection, filetype, filename) - local git_root = _H.find_git_root(filename) - if git_root ~= "" then - local git_root_plus_one = vim.fn.fnamemodify(git_root, ":h") - if git_root_plus_one ~= "" then - filename = filename:sub(#git_root_plus_one + 2) - end - end - - local key_value_pairs = { - ["{{command}}"] = command, - ["{{selection}}"] = selection, - ["{{filetype}}"] = filetype, - ["{{filename}}"] = filename, - } - return _H.template_render(template, key_value_pairs) -end - ----@param params table # table with command args ----@param origin_buf number # selection origin buffer ----@param target_buf number # selection target buffer -M.append_selection = function(params, origin_buf, target_buf) - -- prepare selection - local lines = vim.api.nvim_buf_get_lines(origin_buf, params.line1 - 1, params.line2, false) - local selection = table.concat(lines, "\n") - if selection ~= "" then - local filetype = M._H.get_filetype(origin_buf) - local fname = vim.api.nvim_buf_get_name(origin_buf) - local rendered = M.template_render(M.config.template_selection, "", selection, filetype, fname) - if rendered then - selection = rendered - end +local agent_completion = function() + local buf = vim.api.nvim_get_current_buf() + local file_name = vim.api.nvim_buf_get_name(buf) + if M.not_chat(buf, file_name) == nil then + return M._chat_agents end - - -- delete whitespace lines at the end of the file - local last_content_line = M._H.last_content_line(target_buf) - vim.api.nvim_buf_set_lines(target_buf, last_content_line, -1, false, {}) - - -- insert selection lines - lines = vim.split("\n" .. selection, "\n") - vim.api.nvim_buf_set_lines(target_buf, last_content_line, -1, false, lines) -end - -function M.refresh_copilot_bearer() - if not M.providers.copilot or not M.providers.copilot.secret then - return - end - local secret = M.providers.copilot.secret - - if type(secret) == "table" then - return - end - - local bearer = M._state.copilot_bearer or {} - if bearer.token and bearer.expires_at and bearer.expires_at > os.time() then - return - end - - local curl_params = vim.deepcopy(M.config.curl_params or {}) - local args = { - "-s", - "-v", - "https://api.github.com/copilot_internal/v2/token", - "-H", - "Content-Type: application/json", - "-H", - "accept: */*", - "-H", - "authorization: token " .. secret, - "-H", - "editor-version: vscode/1.90.2", - "-H", - "editor-plugin-version: copilot-chat/0.17.2024062801", - "-H", - "user-agent: GitHubCopilotChat/0.17.2024062801", - } - - for _, arg in ipairs(args) do - table.insert(curl_params, arg) - end - - M._H.process(nil, "curl", curl_params, function(code, signal, stdout, stderr) - if code ~= 0 then - M.logger.error(string.format("Copilot bearer resolve exited: %d, %d", code, signal, stderr)) - return - end - - M._state.copilot_bearer = vim.json.decode(stdout) - M.refresh_state() - end, nil, nil) + return M._command_agents end -- setup function @@ -727,15 +57,49 @@ M.setup = function(opts) -- reset M.config M.config = vim.deepcopy(config) + local curl_params = opts.curl_params or M.config.curl_params + local cmd_prefix = opts.cmd_prefix or M.config.cmd_prefix + local state_dir = opts.state_dir or M.config.state_dir + local openai_api_key = opts.openai_api_key or M.config.openai_api_key + + M.logger.setup(opts.log_file or M.config.log_file, opts.log_sensitive) + + M.vault.setup({ state_dir = state_dir, curl_params = curl_params }) + + M.vault.add_secret("openai_api_key", openai_api_key) + M.config.openai_api_key = nil + opts.openai_api_key = nil + + M.dispatcher.setup({ providers = opts.providers, curl_params = curl_params }) + M.config.providers = nil + opts.providers = nil + + local image_opts = opts.image or {} + image_opts.state_dir = state_dir + image_opts.cmd_prefix = cmd_prefix + image_opts.secret = image_opts.secret or openai_api_key + M.imager.setup(image_opts) + M.config.image = nil + opts.image = nil + + local whisper_opts = opts.whisper or {} + whisper_opts.style_popup_border = opts.style_popup_border or M.config.style_popup_border + whisper_opts.curl_params = curl_params + whisper_opts.cmd_prefix = cmd_prefix + M.whisper.setup(whisper_opts) + M.config.whisper = nil + opts.whisper = nil + -- merge nested tables - local mergeTables = { "hooks", "agents", "image_agents", "providers" } + local mergeTables = { "hooks", "agents" } for _, tbl in ipairs(mergeTables) do M[tbl] = M[tbl] or {} - ---@diagnostic disable-next-line: param-type-mismatch + ---@diagnostic disable-next-line for k, v in pairs(M.config[tbl]) do - if tbl == "hooks" or tbl == "providers" then + if tbl == "hooks" then M[tbl][k] = v - elseif tbl == "agents" or tbl == "image_agents" then + elseif tbl == "agents" then + ---@diagnostic disable-next-line M[tbl][v.name] = v end end @@ -745,16 +109,7 @@ M.setup = function(opts) for k, v in pairs(opts[tbl]) do if tbl == "hooks" then M[tbl][k] = v - elseif tbl == "providers" then - M[tbl][k] = M[tbl][k] or {} - M[tbl][k].disable = false - for pk, pv in pairs(v) do - M[tbl][k][pk] = pv - end - if next(v) == nil then - M[tbl][k] = nil - end - elseif tbl == "agents" or tbl == "image_agents" then + elseif tbl == "agents" then M[tbl][v.name] = v end end @@ -762,44 +117,16 @@ M.setup = function(opts) end for k, v in pairs(opts) do - if deprecated[k] then - table.insert(M._deprecated, { name = k, msg = deprecated[k], value = v }) - else + if M.deprecator.is_valid(k, v) then M.config[k] = v end end - - M.logger.set_log_file(M.config.log_file) - - if #M._deprecated > 0 then - local msg = "Hey there, I have good news and bad news for you.\n" - .. "\nThe good news is that you've updated gp.nvim and got some new features." - .. "\nThe bad news is that some of the config options you are using are deprecated:" - table.sort(M._deprecated, function(a, b) - return a.msg < b.msg - end) - for _, v in ipairs(M._deprecated) do - msg = msg .. "\n\n- " .. v.msg - end - msg = msg - .. "\n\nThis is shown only at startup and deprecated options are ignored" - .. "\nso everything should work without problems and you can deal with this later." - .. "\n\nYou can check deprecated options any time with `:checkhealth gp`" - .. "\nSorry for the inconvenience and thank you for using gp.nvim." - M.logger.info(msg) - end + M.deprecator.report() -- make sure _dirs exists for k, v in pairs(M.config) do if k:match("_dir$") and type(v) == "string" then - local dir = v:gsub("/$", "") - M.config[k] = dir - if vim.fn.isdirectory(dir) == 0 then - if k ~= "whisper_dir" and k ~= "image_dir" then - M.logger.info("creating directory " .. dir) - end - vim.fn.mkdir(dir, "p") - end + M.config[k] = M.helpers.prepare_dir(v, k) end end @@ -820,41 +147,13 @@ M.setup = function(opts) end end - for name, agent in pairs(M.image_agents) do - if type(agent) ~= "table" or agent.disable then - M.image_agents[name] = nil - elseif not agent.model then - M.logger.warning( - "Image agent " - .. name - .. " is missing model\n" - .. "If you want to disable an agent, use: { name = '" - .. name - .. "', disable = true }," - ) - M.image_agents[name] = nil - end - end - - -- remove invalid providers - for name, provider in pairs(M.providers) do - if type(provider) ~= "table" or provider.disable then - M.providers[name] = nil - elseif not provider.endpoint then - M.logger.warning("Provider " .. name .. " is missing endpoint") - M.providers[name] = nil - end - end - -- prepare agent completions M._chat_agents = {} M._command_agents = {} for name, agent in pairs(M.agents) do - if not M.agents[name].provider then - M.agents[name].provider = "openai" - end + M.agents[name].provider = M.agents[name].provider or "openai" - if M.providers[M.agents[name].provider] then + if M.dispatcher.providers[M.agents[name].provider] then if agent.command then table.insert(M._command_agents, name) end @@ -868,19 +167,26 @@ M.setup = function(opts) table.sort(M._chat_agents) table.sort(M._command_agents) - M._image_agents = {} - for name, _ in pairs(M.image_agents) do - table.insert(M._image_agents, name) + M.refresh_state() + + if M.config.default_command_agent then + M.refresh_state({ command_agent = M.config.default_command_agent }) end - table.sort(M._image_agents) - M.refresh_state() + if M.config.default_chat_agent then + M.refresh_state({ chat_agent = M.config.default_chat_agent }) + end -- register user commands for hook, _ in pairs(M.hooks) do - vim.api.nvim_create_user_command(M.config.cmd_prefix .. hook, function(params) - M.call_hook(hook, params) - end, { nargs = "?", range = true, desc = "GPT Prompt plugin" }) + M.helpers.create_user_command(M.config.cmd_prefix .. hook, function(params) + if M.hooks[hook] ~= nil then + M.refresh_state() + M.logger.debug("running hook: " .. hook) + return M.hooks[hook](M, params) + end + M.logger.error("The hook '" .. hook .. "' does not exist.") + end) end local completions = { @@ -888,38 +194,17 @@ M.setup = function(opts) ChatPaste = { "popup", "split", "vsplit", "tabnew" }, ChatToggle = { "popup", "split", "vsplit", "tabnew" }, Context = { "popup", "split", "vsplit", "tabnew" }, + Agent = agent_completion, } -- register default commands for cmd, _ in pairs(M.cmd) do if M.hooks[cmd] == nil then - vim.api.nvim_create_user_command(M.config.cmd_prefix .. cmd, function(params) + M.helpers.create_user_command(M.config.cmd_prefix .. cmd, function(params) + M.logger.debug("running command: " .. cmd) + M.refresh_state() M.cmd[cmd](params) - end, { - nargs = "?", - range = true, - desc = "GPT Prompt plugin", - complete = function() - if completions[cmd] then - return completions[cmd] - end - - if cmd == "Agent" then - local buf = vim.api.nvim_get_current_buf() - local file_name = vim.api.nvim_buf_get_name(buf) - if M.not_chat(buf, file_name) == nil then - return M._chat_agents - end - return M._command_agents - end - - if cmd == "ImageAgent" then - return M._image_agents - end - - return {} - end, - }) + end, completions[cmd]) end end @@ -933,133 +218,64 @@ M.setup = function(opts) M.logger.error("curl is not installed, run :checkhealth gp") end - for name, _ in pairs(M.providers) do - M.resolve_secret(name) - end - if not M.providers.openai then - M.providers.openai = {} - M.resolve_secret("openai", function() - M.providers.openai = nil - end) - end + M.logger.debug("setup finished") end ----@provider string # provider name -function M.resolve_secret(provider, callback) - local post_process = function() - local p = M.providers[provider] - if p.secret and type(p.secret) == "string" then - p.secret = p.secret:gsub("^%s*(.-)%s*$", "%1") - end - - if provider == "copilot" then - M.refresh_copilot_bearer() - end - - -- backwards compatibility - if provider == "openai" then - M.config.openai_api_key = M.providers[provider].secret - end +---@param update table | nil # table with options +M.refresh_state = function(update) + local state_file = M.config.state_dir .. "/state.json" + update = update or {} - if callback then - callback() - end - end + local old_state = vim.deepcopy(M._state) - -- backwards compatibility - if provider == "openai" then - M.providers[provider].secret = M.providers[provider].secret or M.config.openai_api_key - end - - local secret = M.providers[provider].secret - if secret and type(secret) == "table" then - ---@diagnostic disable-next-line: param-type-mismatch - local copy = vim.deepcopy(secret) - ---@diagnostic disable-next-line: param-type-mismatch - local cmd = table.remove(copy, 1) - local args = copy - ---@diagnostic disable-next-line: param-type-mismatch - _H.process(nil, cmd, args, function(code, signal, stdout_data, stderr_data) - if code == 0 then - local content = stdout_data:match("^%s*(.-)%s*$") - if not string.match(content, "%S") then - M.logger.warning( - "response from the config.providers." .. provider .. ".secret command " .. vim.inspect(secret) .. " is empty" - ) - return - end - M.providers[provider].secret = content - post_process() - else - M.logger.warning( - "config.providers." - .. provider - .. ".secret command " - .. vim.inspect(secret) - .. " to retrieve the secret failed:\ncode: " - .. code - .. ", signal: " - .. signal - .. "\nstdout: " - .. stdout_data - .. "\nstderr: " - .. stderr_data - ) - end - end) - else - post_process() + local disk_state = {} + if vim.fn.filereadable(state_file) ~= 0 then + disk_state = M.helpers.file_to_table(state_file) or {} end -end - --- TODO: obsolete -M.valid_api_key = function() - local api_key = M.config.openai_api_key - if type(api_key) == "table" then - M.logger.error("openai_api_key is still an unresolved command: " .. vim.inspect(api_key)) - return false + if not disk_state.updated then + local last = M.config.chat_dir .. "/last.md" + if vim.fn.filereadable(last) == 1 then + os.remove(last) + end end - if api_key and string.match(api_key, "%S") then - return true + if not M._state.updated or (disk_state.updated and M._state.updated < disk_state.updated) then + M._state = vim.deepcopy(disk_state) end + M._state.updated = os.time() - M.logger.error("config.openai_api_key is not set: " .. vim.inspect(api_key) .. " run :checkhealth gp") - return false -end - -M.refresh_state = function() - local state_file = M.config.state_dir .. "/state.json" - - local state = {} - if vim.fn.filereadable(state_file) ~= 0 then - state = M.file_to_table(state_file) or {} + for k, v in pairs(update) do + M._state[k] = v end - M._state.chat_agent = M._state.chat_agent or state.chat_agent or nil - if M._state.chat_agent == nil or not M.agents[M._state.chat_agent] then + if not M._state.chat_agent or not M.agents[M._state.chat_agent] then M._state.chat_agent = M._chat_agents[1] end - M._state.command_agent = M._state.command_agent or state.command_agent or nil - if not M._state.command_agent == nil or not M.agents[M._state.command_agent] then + if not M._state.command_agent or not M.agents[M._state.command_agent] then M._state.command_agent = M._command_agents[1] end - M._state.image_agent = M._state.image_agent or state.image_agent or nil - if not M._state.image_agent == nil or not M.image_agents[M._state.image_agent] then - M._state.image_agent = M._image_agents[1] + if M._state.last_chat and vim.fn.filereadable(M._state.last_chat) == 0 then + M._state.last_chat = nil end - local bearer = M._state.copilot_bearer or state.copilot_bearer or nil - if bearer and bearer.expires_at and bearer.expires_at < os.time() then - bearer = nil - M.refresh_copilot_bearer() + for k, _ in pairs(M._state) do + if M._state[k] ~= old_state[k] or M._state[k] ~= disk_state[k] then + M.logger.debug( + string.format( + "state[%s]: disk=%s old=%s new=%s", + k, + vim.inspect(disk_state[k]), + vim.inspect(old_state[k]), + vim.inspect(M._state[k]) + ) + ) + end end - M._state.copilot_bearer = bearer - M.table_to_file(M._state, state_file) + M.helpers.table_to_file(M._state, state_file) M.prepare_commands() @@ -1109,491 +325,58 @@ M.prepare_commands = function() -- uppercase first letter local command = name:gsub("^%l", string.upper) - local agent = M.get_command_agent() - -- popup is like ephemeral one off chat - if target == M.Target.popup then - agent = M.get_chat_agent() - end - local cmd = function(params, whisper) + local agent = M.get_command_agent() + -- popup is like ephemeral one off chat + if target == M.Target.popup then + agent = M.get_chat_agent() + end + -- template is chosen dynamically based on mode in which the command is called local template = M.config.template_command if params.range == 2 then - template = M.config.template_selection - -- rewrite needs custom template - if target == M.Target.rewrite then - template = M.config.template_rewrite - end - if target == M.Target.append then - template = M.config.template_append - end - if target == M.Target.prepend then - template = M.config.template_prepend - end - end - M.Prompt(params, target, agent, template, agent.cmd_prefix, whisper) - end - - M.cmd[command] = function(params) - cmd(params) - end - - M.cmd["Whisper" .. command] = function(params) - M.Whisper(M.config.whisper_language, function(text) - vim.schedule(function() - cmd(params, text) - end) - end) - end - end -end - --- hook caller -M.call_hook = function(name, params) - if M.hooks[name] ~= nil then - return M.hooks[name](M, params) - end - M.logger.error("The hook '" .. name .. "' does not exist.") -end - ----@param messages table ----@param model string | table ----@param provider string | nil -M.prepare_payload = function(messages, model, provider) - if type(model) == "string" then - return { - model = model, - stream = true, - messages = messages, - } - end - - if provider == "googleai" then - for i, message in ipairs(messages) do - if message.role == "system" then - messages[i].role = "user" - end - if message.role == "assistant" then - messages[i].role = "model" - end - if message.content then - messages[i].parts = { - { - text = message.content, - }, - } - messages[i].content = nil - end - end - local i = 1 - while i < #messages do - if messages[i].role == messages[i + 1].role then - table.insert(messages[i].parts, { - text = messages[i + 1].parts[1].text, - }) - table.remove(messages, i + 1) - else - i = i + 1 - end - end - local payload = { - contents = messages, - safetySettings = { - { - category = "HARM_CATEGORY_HARASSMENT", - threshold = "BLOCK_NONE", - }, - { - category = "HARM_CATEGORY_HATE_SPEECH", - threshold = "BLOCK_NONE", - }, - { - category = "HARM_CATEGORY_SEXUALLY_EXPLICIT", - threshold = "BLOCK_NONE", - }, - { - category = "HARM_CATEGORY_DANGEROUS_CONTENT", - threshold = "BLOCK_NONE", - }, - }, - generationConfig = { - temperature = math.max(0, math.min(2, model.temperature or 1)), - maxOutputTokens = model.max_tokens or 8192, - topP = math.max(0, math.min(1, model.top_p or 1)), - topK = model.top_k or 100, - }, - model = model.model, - } - return payload - end - - if provider == "anthropic" then - local system = "" - local i = 1 - while i < #messages do - if messages[i].role == "system" then - system = system .. messages[i].content .. "\n" - table.remove(messages, i) - else - i = i + 1 - end - end - - local payload = { - model = model.model, - stream = true, - messages = messages, - system = system, - max_tokens = model.max_tokens or 4096, - temperature = math.max(0, math.min(2, model.temperature or 1)), - top_p = math.max(0, math.min(1, model.top_p or 1)), - } - return payload - end - - return { - model = model.model, - stream = true, - messages = messages, - temperature = math.max(0, math.min(2, model.temperature or 1)), - top_p = math.max(0, math.min(1, model.top_p or 1)), - } -end - ----@param N number # number of queries to keep ----@param age number # age of queries to keep in seconds -function M.cleanup_old_queries(N, age) - local current_time = os.time() - - local query_count = 0 - for _ in pairs(M._queries) do - query_count = query_count + 1 - end - - if query_count <= N then - return - end - - for qid, query_data in pairs(M._queries) do - if current_time - query_data.timestamp > age then - M._queries[qid] = nil - end - end -end - ----@param qid string # query id ----@return table | nil # query data -function M.get_query(qid) - if not M._queries[qid] then - M.logger.error("Query with ID " .. tostring(qid) .. " not found.") - return nil - end - return M._queries[qid] -end - --- gpt query ----@param buf number | nil # buffer number ----@param provider string # provider name ----@param payload table # payload for api ----@param handler function # response handler ----@param on_exit function | nil # optional on_exit handler ----@param callback function | nil # optional callback handler -M.query = function(buf, provider, payload, handler, on_exit, callback) - -- make sure handler is a function - if type(handler) ~= "function" then - M.logger.error( - string.format("query() expects a handler function, but got %s:\n%s", type(handler), vim.inspect(handler)) - ) - return - end - - if not M.valid_api_key() then - return - end - - local qid = M._H.uuid() - M._queries[qid] = { - timestamp = os.time(), - buf = buf, - provider = provider, - payload = payload, - handler = handler, - on_exit = on_exit, - raw_response = "", - response = "", - first_line = -1, - last_line = -1, - ns_id = nil, - ex_id = nil, - } - - M.cleanup_old_queries(8, 60) - - local out_reader = function() - local buffer = "" - - ---@param lines_chunk string - local function process_lines(lines_chunk) - local qt = M.get_query(qid) - if not qt then - return - end - - local lines = vim.split(lines_chunk, "\n") - for _, line in ipairs(lines) do - if line ~= "" and line ~= nil then - qt.raw_response = qt.raw_response .. line .. "\n" - end - line = line:gsub("^data: ", "") - local content = "" - if line:match("choices") and line:match("delta") and line:match("content") then - line = vim.json.decode(line) - if line.choices[1] and line.choices[1].delta and line.choices[1].delta.content then - content = line.choices[1].delta.content - end - end - - if qt.provider == "anthropic" and line:match('"text":') then - if line:match("content_block_start") or line:match("content_block_delta") then - line = vim.json.decode(line) - if line.delta and line.delta.text then - content = line.delta.text - end - if line.content_block and line.content_block.text then - content = line.content_block.text - end - end - end - - if qt.provider == "googleai" then - if line:match('"text":') then - content = vim.json.decode("{" .. line .. "}").text - end - end - - if content and type(content) == "string" then - qt.response = qt.response .. content - handler(qid, content) - end - end - end - - -- closure for vim.loop.read_start(stdout, fn) - return function(err, chunk) - local qt = M.get_query(qid) - if not qt then - return - end - - if err then - M.logger.error(qt.provider .. " query stdout error: " .. vim.inspect(err)) - elseif chunk then - -- add the incoming chunk to the buffer - buffer = buffer .. chunk - local last_newline_pos = buffer:find("\n[^\n]*$") - if last_newline_pos then - local complete_lines = buffer:sub(1, last_newline_pos - 1) - -- save the rest of the buffer for the next chunk - buffer = buffer:sub(last_newline_pos + 1) - - process_lines(complete_lines) - end - -- chunk is nil when EOF is reached - else - -- if there's remaining data in the buffer, process it - if #buffer > 0 then - process_lines(buffer) - end - - if qt.response == "" then - M.logger.error(qt.provider .. " response is empty: \n" .. vim.inspect(qt.raw_response)) - end - - -- optional on_exit handler - if type(on_exit) == "function" then - on_exit(qid) - if qt.ns_id and qt.buf then - vim.schedule(function() - vim.api.nvim_buf_clear_namespace(qt.buf, qt.ns_id, 0, -1) - end) - end - end - - -- optional callback handler - if type(callback) == "function" then - vim.schedule(function() - callback(qt.response) - end) - end - end - end - end - - ---TODO: this could be moved to a separate function returning endpoint and headers - local endpoint = M.providers[provider].endpoint - local bearer = M.providers[provider].secret - local headers = {} - - if provider == "copilot" then - M.refresh_copilot_bearer() - ---@diagnostic disable-next-line: undefined-field - bearer = M._state.copilot_bearer.token or "" - headers = { - "-H", - "editor-version: vscode/1.85.1", - "-H", - "Authorization: Bearer " .. bearer, - } - elseif provider == "openai" then - headers = { - "-H", - "Authorization: Bearer " .. bearer, - -- backwards compatibility - "-H", - "api-key: " .. bearer, - } - elseif provider == "googleai" then - headers = {} - endpoint = M._H.template_replace(endpoint, "{{secret}}", bearer) - endpoint = M._H.template_replace(endpoint, "{{model}}", payload.model) - payload.model = nil - elseif provider == "anthropic" then - headers = { - "-H", - "x-api-key: " .. bearer, - "-H", - "anthropic-version: 2023-06-01", - "-H", - "anthropic-beta: messages-2023-12-15", - } - elseif provider == "azure" then - headers = { - "-H", - "api-key: " .. bearer, - } - endpoint = M._H.template_replace(endpoint, "{{model}}", payload.model) - else -- default to openai compatible headers - headers = { - "-H", - "Authorization: Bearer " .. bearer, - } - end - - local curl_params = vim.deepcopy(M.config.curl_params or {}) - local args = { - "--no-buffer", - "-s", - endpoint, - "-H", - "Content-Type: application/json", - "-d", - vim.json.encode(payload), - --[[ "--doesnt_exist" ]] - } - - for _, arg in ipairs(args) do - table.insert(curl_params, arg) - end - - for _, header in ipairs(headers) do - table.insert(curl_params, header) - end - - M._H.process(buf, "curl", curl_params, nil, out_reader(), nil) -end - --- response handler ----@param buf number | nil # buffer to insert response into ----@param win number | nil # window to insert response into ----@param line number | nil # line to insert response into ----@param first_undojoin boolean | nil # whether to skip first undojoin ----@param prefix string | nil # prefix to insert before each response line ----@param cursor boolean # whether to move cursor to the end of the response -M.create_handler = function(buf, win, line, first_undojoin, prefix, cursor) - buf = buf or vim.api.nvim_get_current_buf() - prefix = prefix or "" - local first_line = line or vim.api.nvim_win_get_cursor(win)[1] - 1 - local finished_lines = 0 - local skip_first_undojoin = not first_undojoin - - local hl_handler_group = "GpHandlerStandout" - vim.cmd("highlight default link " .. hl_handler_group .. " CursorLine") - - local ns_id = vim.api.nvim_create_namespace("GpHandler_" .. M._H.uuid()) - - local ex_id = vim.api.nvim_buf_set_extmark(buf, ns_id, first_line, 0, { - strict = false, - right_gravity = false, - }) - - local response = "" - return vim.schedule_wrap(function(qid, chunk) - local qt = M.get_query(qid) - if not qt then - return - end - -- if buf is not valid, stop - if not vim.api.nvim_buf_is_valid(buf) then - return - end - -- undojoin takes previous change into account, so skip it for the first chunk - if skip_first_undojoin then - skip_first_undojoin = false - else - M._H.undojoin(buf) - end - - if not qt.ns_id then - qt.ns_id = ns_id - end - - if not qt.ex_id then - qt.ex_id = ex_id - end - - first_line = vim.api.nvim_buf_get_extmark_by_id(buf, ns_id, ex_id, {})[1] - - -- clean previous response - local line_count = #vim.split(response, "\n") - vim.api.nvim_buf_set_lines(buf, first_line + finished_lines, first_line + line_count, false, {}) - - -- append new response - response = response .. chunk - M._H.undojoin(buf) - - -- prepend prefix to each line - local lines = vim.split(response, "\n") - for i, l in ipairs(lines) do - lines[i] = prefix .. l + template = M.config.template_selection + -- rewrite needs custom template + if target == M.Target.rewrite then + template = M.config.template_rewrite + end + if target == M.Target.append then + template = M.config.template_append + end + if target == M.Target.prepend then + template = M.config.template_prepend + end + end + if agent then + M.Prompt(params, target, agent, template, agent.cmd_prefix, whisper) + end end - local unfinished_lines = {} - for i = finished_lines + 1, #lines do - table.insert(unfinished_lines, lines[i]) + M.cmd[command] = function(params) + cmd(params) end - vim.api.nvim_buf_set_lines(buf, first_line + finished_lines, first_line + finished_lines, false, unfinished_lines) - - local new_finished_lines = math.max(0, #lines - 1) - for i = finished_lines, new_finished_lines do - vim.api.nvim_buf_add_highlight(buf, qt.ns_id, hl_handler_group, first_line + i, 0, -1) + if not M.whisper.disabled then + M.cmd["Whisper" .. command] = function(params) + M.whisper.Whisper(function(text) + vim.schedule(function() + cmd(params, text) + end) + end) + end end - finished_lines = new_finished_lines - - local end_line = first_line + #vim.split(response, "\n") - qt.first_line = first_line - qt.last_line = end_line - 1 + end +end - -- move cursor to the end of the response - if cursor then - M._H.cursor_to_line(end_line, buf, win) - end - end) +-- stop receiving gpt responses for all processes and clean the handles +---@param signal number | nil # signal to send to the process +M.cmd.Stop = function(signal) + M.tasker.stop(signal) end --------------------- +-------------------------------------------------------------------------------- -- Chat logic --------------------- +-------------------------------------------------------------------------------- M._toggle = {} @@ -1663,7 +446,7 @@ M.prep_md = function(buf) -- ensure normal mode vim.api.nvim_command("stopinsert") - M._H.feedkeys("", "xn") + M.helpers.feedkeys("", "xn") end ---@param buf number # buffer number @@ -1672,7 +455,8 @@ end M.not_chat = function(buf, file_name) file_name = vim.fn.resolve(file_name) local chat_dir = vim.fn.resolve(M.config.chat_dir) - if not _H.starts_with(file_name, chat_dir) then + + if not M.helpers.starts_with(file_name, chat_dir) then return "resolved file (" .. file_name .. ") not in chat dir (" .. chat_dir .. ")" end @@ -1722,6 +506,7 @@ M.display_chat_agent = function(buf, file_name) }) end +M._prepared_bufs = {} M.prep_chat = function(buf, file_name) if M.not_chat(buf, file_name) then return @@ -1731,10 +516,17 @@ M.prep_chat = function(buf, file_name) return end + M.refresh_state({ last_chat = file_name }) + if M._prepared_bufs[buf] then + M.logger.debug("buffer already prepared: " .. buf) + return + end + M._prepared_bufs[buf] = true + M.prep_md(buf) if M.config.chat_prompt_buf_type then - vim.api.nvim_buf_set_option(buf, "buftype", "prompt") + vim.api.nvim_set_option_value("buftype", "prompt", { buf = buf }) vim.fn.prompt_setprompt(buf, "") vim.fn.prompt_setcallback(buf, function() M.cmd.ChatRespond({ args = "" }) @@ -1760,23 +552,23 @@ M.prep_chat = function(buf, file_name) local cmd = M.config.cmd_prefix .. rc.command .. "" for _, mode in ipairs(rc.modes) do if mode == "n" or mode == "i" then - _H.set_keymap({ buf }, mode, rc.shortcut, function() + M.helpers.set_keymap({ buf }, mode, rc.shortcut, function() vim.api.nvim_command(M.config.cmd_prefix .. rc.command) -- go to normal mode vim.api.nvim_command("stopinsert") - M._H.feedkeys("", "xn") + M.helpers.feedkeys("", "xn") end, rc.comment) else - _H.set_keymap({ buf }, mode, rc.shortcut, ":'<,'>" .. cmd, rc.comment) + M.helpers.set_keymap({ buf }, mode, rc.shortcut, ":'<,'>" .. cmd, rc.comment) end end end local ds = M.config.chat_shortcut_delete - _H.set_keymap({ buf }, ds.modes, ds.shortcut, M.cmd.ChatDelete, "GPT prompt Chat Delete") + M.helpers.set_keymap({ buf }, ds.modes, ds.shortcut, M.cmd.ChatDelete, "GPT prompt Chat Delete") local ss = M.config.chat_shortcut_stop - _H.set_keymap({ buf }, ss.modes, ss.shortcut, M.cmd.Stop, "GPT prompt Chat Stop") + M.helpers.set_keymap({ buf }, ss.modes, ss.shortcut, M.cmd.Stop, "GPT prompt Chat Stop") -- conceal parameters in model header so it's not distracting if M.config.chat_conceal_model_params then @@ -1787,30 +579,12 @@ M.prep_chat = function(buf, file_name) vim.fn.matchadd("Conceal", [[^- role: .\{64,64\}\zs.*\ze]], 10, -1, { conceal = "…" }) vim.fn.matchadd("Conceal", [[^- role: .[^\\]*\zs\\.*\ze]], 10, -1, { conceal = "…" }) end - - -- make last.md a symlink to the last opened chat file - local last = M.config.chat_dir .. "/last.md" - if file_name ~= last then - os.execute("ln -sf " .. file_name .. " " .. last) - end -end - -M.prep_context = function(buf, file_name) - if not _H.ends_with(file_name, ".gp.md") then - return - end - - if buf ~= vim.api.nvim_get_current_buf() then - return - end - - M.prep_md(buf) end M.buf_handler = function() - local gid = M._H.create_augroup("GpBufHandler", { clear = true }) + local gid = M.helpers.create_augroup("GpBufHandler", { clear = true }) - _H.autocmd({ "BufEnter" }, nil, function(event) + M.helpers.autocmd({ "BufEnter" }, nil, function(event) local buf = event.buf if not vim.api.nvim_buf_is_valid(buf) then @@ -1824,7 +598,7 @@ M.buf_handler = function() M.prep_context(buf, file_name) end, gid) - _H.autocmd({ "WinEnter" }, nil, function(event) + M.helpers.autocmd({ "WinEnter" }, nil, function(event) local buf = event.buf if not vim.api.nvim_buf_is_valid(buf) then @@ -1888,9 +662,9 @@ M.open_buf = function(file_name, target, kind, toggle) local close, buf, win if target == M.BufTarget.popup then - local old_buf = M._H.get_buffer(file_name) + local old_buf = M.helpers.get_buffer(file_name) - buf, win, close, _ = M._H.create_popup(old_buf, M._Name .. " Popup", function(w, h) + buf, win, close, _ = M.render.popup(old_buf, M._Name .. " Popup", function(w, h) local top = M.config.style_popup_margin_top or 2 local bottom = M.config.style_popup_margin_bottom or 8 local left = M.config.style_popup_margin_left or 1 @@ -1901,6 +675,7 @@ M.open_buf = function(file_name, target, kind, toggle) return ww, wh, top, (w - ww) / 2 end, { on_leave = false, escape = false, persist = true }, { border = M.config.style_popup_border or "single", + zindex = M.config.zindex, }) if not toggle then @@ -1912,14 +687,14 @@ M.open_buf = function(file_name, target, kind, toggle) vim.api.nvim_command("silent 0read " .. file_name) vim.api.nvim_command("silent file " .. file_name) -- set the filetype to markdown - vim.api.nvim_buf_set_option(buf, "filetype", "markdown") + vim.api.nvim_set_option_value("filetype", "markdown", { buf = buf }) else -- move cursor to the beginning of the file and scroll to the end - M._H.feedkeys("ggG", "xn") + M.helpers.feedkeys("ggG", "xn") end -- delete whitespace lines at the end of the file - local last_content_line = M._H.last_content_line(buf) + local last_content_line = M.helpers.last_content_line(buf) vim.api.nvim_buf_set_lines(buf, last_content_line, -1, false, {}) -- insert a new line at the end of the file vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) @@ -1955,7 +730,7 @@ M.open_buf = function(file_name, target, kind, toggle) return buf end - vim.api.nvim_buf_set_option(buf, "buflisted", false) + vim.api.nvim_set_option_value("buflisted", false, { buf = buf }) if target == M.BufTarget.split or target == M.BufTarget.vsplit then close = function() @@ -1988,15 +763,7 @@ end M.new_chat = function(params, toggle, system_prompt, agent) M._toggle_close(M._toggle_kind.popup) - -- prepare filename - local time = os.date("%Y-%m-%d.%H-%M-%S") - local stamp = tostring(math.floor(vim.loop.hrtime() / 1000000) % 1000) - -- make sure stamp is 3 digits - while #stamp < 3 do - stamp = "0" .. stamp - end - time = time .. "." .. stamp - local filename = M.config.chat_dir .. "/" .. time .. ".md" + local filename = M.config.chat_dir .. "/" .. M.logger.now() .. ".md" -- encode as json if model is a table local model = "" @@ -2020,7 +787,7 @@ M.new_chat = function(params, toggle, system_prompt, agent) system_prompt = "" end - local template = M._H.template_render(M.config.chat_template or require("gp.defaults").chat_template, { + local template = M.render.template(M.config.chat_template or require("gp.defaults").chat_template, { ["{{filename}}"] = string.match(filename, "([^/]+)$"), ["{{optional_headers}}"] = model .. provider .. system_prompt, ["{{user_prefix}}"] = M.config.chat_user_prefix, @@ -2031,22 +798,6 @@ M.new_chat = function(params, toggle, system_prompt, agent) ["{{new_shortcut}}"] = M.config.chat_shortcut_new.shortcut, }) - -- local template = string.format( - -- M.config.chat_template or require("gp.defaults").chat_template, - -- string.match(filename, "([^/]+)$"), - -- model .. provider .. system_prompt, - -- M.config.chat_user_prefix, - -- M.config.chat_shortcut_respond.shortcut, - -- M.config.cmd_prefix, - -- M.config.chat_shortcut_stop.shortcut, - -- M.config.cmd_prefix, - -- M.config.chat_shortcut_delete.shortcut, - -- M.config.cmd_prefix, - -- M.config.chat_shortcut_new.shortcut, - -- M.config.cmd_prefix, - -- M.config.chat_user_prefix - -- ) - -- escape underscores (for markdown) template = template:gsub("_", "\\_") @@ -2061,43 +812,23 @@ M.new_chat = function(params, toggle, system_prompt, agent) local buf = M.open_buf(filename, target, M._toggle_kind.chat, toggle) if params.range == 2 then - M.append_selection(params, cbuf, buf) + M.render.append_selection(params, cbuf, buf, M.config.template_selection) end - M._H.feedkeys("G", "xn") + M.helpers.feedkeys("G", "xn") return buf end -local exampleChatHook = [[ -Translator = function(gp, params) - local chat_system_prompt = "You are a Translator, please translate between English and Chinese." - gp.cmd.ChatNew(params, chat_system_prompt) - - -- -- you can also create a chat with a specific fixed agent like this: - -- local agent = gp.get_chat_agent("ChatGPT4o") - -- gp.cmd.ChatNew(params, chat_system_prompt, agent) -end, -]] - ---@param params table ---@param system_prompt string | nil ---@param agent table | nil # obtained from get_command_agent or get_chat_agent ---@return number # buffer number M.cmd.ChatNew = function(params, system_prompt, agent) - if agent then - if not type(agent) == "table" or not agent.provider then - M.logger.warning( - "The `gp.cmd.ChatNew` method signature has changed.\n" - .. "Please update your hook functions as demonstrated in the example below:\n\n" - .. exampleChatHook - .. "\nFor more information, refer to the 'Extend Functionality' section in the documentation." - ) - return -1 - end + if M.deprecator.has_old_chat_signature(agent) then + return -1 end - local buf - -- if chat toggle is open, close it and start a new one + local buf if M._toggle_close(M._toggle_kind.chat) then params.args = params.args or "" if params.args == "" then @@ -2133,10 +864,8 @@ M.cmd.ChatToggle = function(params, system_prompt, agent) -- if the range is 2, we want to create a new chat file with the selection local buf if params.range ~= 2 then - -- check if last.md chat file exists and open it - local last = M.config.chat_dir .. "/last.md" - if vim.fn.filereadable(last) == 1 then - -- resolve symlink + local last = M._state.last_chat + if last and vim.fn.filereadable(last) == 1 then last = vim.fn.resolve(last) buf = M.open_buf(last, M.resolve_buf_target(params), M._toggle_kind.chat, true) end @@ -2144,7 +873,9 @@ M.cmd.ChatToggle = function(params, system_prompt, agent) buf = M.new_chat(params, true, system_prompt, agent) end - require("gp.context").setup_for_chat_buffer(buf) + if buf then + require("gp.context").setup_for_chat_buffer(buf) + end return buf end @@ -2159,10 +890,9 @@ M.cmd.ChatPaste = function(params) -- get current buffer local cbuf = vim.api.nvim_get_current_buf() - local last = M.config.chat_dir .. "/last.md" - -- make new chat if last doesn't exist - if vim.fn.filereadable(last) ~= 1 then + local last = M._state.last_chat + if not last or vim.fn.filereadable(last) ~= 1 then -- skip rest since new chat will handle snippet on it's own M.cmd.ChatNew(params, nil, nil) return @@ -2175,7 +905,7 @@ M.cmd.ChatPaste = function(params) local target = M.resolve_buf_target(params) last = vim.fn.resolve(last) - local buf = M._H.get_buffer(last) + local buf = M.helpers.get_buffer(last) local win_found = false if buf then for _, w in ipairs(vim.api.nvim_list_wins()) do @@ -2189,8 +919,8 @@ M.cmd.ChatPaste = function(params) end buf = win_found and buf or M.open_buf(last, target, M._toggle_kind.chat, true) - M.append_selection(params, cbuf, buf) - M._H.feedkeys("G", "xn") + M.render.append_selection(params, cbuf, buf, M.config.template_selection) + M.helpers.feedkeys("G", "xn") end M.cmd.ChatDelete = function() @@ -2199,21 +929,21 @@ M.cmd.ChatDelete = function() local file_name = vim.api.nvim_buf_get_name(buf) -- check if file is in the chat dir - if not _H.starts_with(file_name, vim.fn.resolve(M.config.chat_dir)) then + if not M.helpers.starts_with(file_name, vim.fn.resolve(M.config.chat_dir)) then M.logger.warning("File " .. vim.inspect(file_name) .. " is not in chat dir") return end -- delete without confirmation if not M.config.chat_confirm_delete then - M._H.delete_file(file_name) + M.helpers.delete_file(file_name) return end -- ask for confirmation vim.ui.input({ prompt = "Delete " .. file_name .. "? [y/N] " }, function(input) if input and input:lower() == "y" then - M._H.delete_file(file_name) + M.helpers.delete_file(file_name) end end) end @@ -2222,12 +952,7 @@ M.chat_respond = function(params) local buf = vim.api.nvim_get_current_buf() local win = vim.api.nvim_get_current_win() - if not M.valid_api_key() then - return - end - - if not M.can_handle(buf) then - M.logger.warning("Another Gp process is already running for this buffer.") + if M.tasker.is_busy(buf) then return end @@ -2318,7 +1043,7 @@ M.chat_respond = function(params) agent_suffix = M.config.chat_assistant_prefix[2] or "" end ---@diagnostic disable-next-line: cast-local-type - agent_suffix = M._H.template_render(agent_suffix, { ["{{agent}}"] = agent_name }) + agent_suffix = M.render.template(agent_suffix, { ["{{agent}}"] = agent_name }) local old_default_user_prefix = "πŸ—¨:" for index = start_index, end_index do @@ -2361,7 +1086,7 @@ M.chat_respond = function(params) end -- write assistant prompt - local last_content_line = M._H.last_content_line(buf) + local last_content_line = M.helpers.last_content_line(buf) vim.api.nvim_buf_set_lines(buf, last_content_line, last_content_line, false, { "", agent_prefix .. agent_suffix, "" }) -- insert requested context in the message the user just entered @@ -2369,20 +1094,20 @@ M.chat_respond = function(params) -- print(vim.inspect(messages[#messages])) -- call the model and write response - M.query( + M.dispatcher.query( buf, headers.provider or agent.provider, - M.prepare_payload(messages, headers.model or agent.model, headers.provider or agent.provider), - M.create_handler(buf, win, M._H.last_content_line(buf), true, "", not M.config.chat_free_cursor), + M.dispatcher.prepare_payload(messages, headers.model or agent.model, headers.provider or agent.provider), + M.dispatcher.create_handler(buf, win, M.helpers.last_content_line(buf), true, "", not M.config.chat_free_cursor), vim.schedule_wrap(function(qid) - local qt = M.get_query(qid) + local qt = M.tasker.get_query(qid) if not qt then return end -- write user prompt - last_content_line = M._H.last_content_line(buf) - M._H.undojoin(buf) + last_content_line = M.helpers.last_content_line(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines( buf, last_content_line, @@ -2392,11 +1117,11 @@ M.chat_respond = function(params) ) -- delete whitespace lines at the end of the file - last_content_line = M._H.last_content_line(buf) - M._H.undojoin(buf) + last_content_line = M.helpers.last_content_line(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, last_content_line, -1, false, {}) -- insert a new line at the end of the file - M._H.undojoin(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) -- if topic is ?, then generate it @@ -2409,13 +1134,13 @@ M.chat_respond = function(params) -- prepare invisible buffer for the model to write to local topic_buf = vim.api.nvim_create_buf(false, true) - local topic_handler = M.create_handler(topic_buf, nil, 0, false, "", false) + local topic_handler = M.dispatcher.create_handler(topic_buf, nil, 0, false, "", false) -- call the model - M.query( + M.dispatcher.query( nil, headers.provider or agent.provider, - M.prepare_payload(messages, headers.model or agent.model, headers.provider or agent.provider), + M.dispatcher.prepare_payload(messages, headers.model or agent.model, headers.provider or agent.provider), topic_handler, vim.schedule_wrap(function() -- get topic from invisible buffer @@ -2433,14 +1158,14 @@ M.chat_respond = function(params) end -- replace topic in current buffer - M._H.undojoin(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "# topic: " .. topic }) end) ) end if not M.config.chat_free_cursor then local line = vim.api.nvim_buf_line_count(buf) - M._H.cursor_to_line(line, buf, win) + M.helpers.cursor_to_line(line, buf, win) end vim.cmd("doautocmd User GpDone") end) @@ -2448,9 +1173,11 @@ M.chat_respond = function(params) end M.cmd.ChatRespond = function(params) - if params.args == "" then + if params.args == "" and vim.v.count == 0 then M.chat_respond(params) return + elseif params.args == "" and vim.v.count ~= 0 then + params.args = tostring(vim.v.count) end -- ensure args is a single positive number @@ -2486,27 +1213,28 @@ M.cmd.ChatFinder = function() local dir = M.config.chat_dir -- prepare unique group name and register augroup - local gid = M._H.create_augroup("GpChatFinder", { clear = true }) + local gid = M.helpers.create_augroup("GpChatFinder", { clear = true }) -- prepare three popup buffers and windows + local style = { border = M.config.style_chat_finder_border or "single", zindex = M.config.zindex } local ratio = M.config.style_chat_finder_preview_ratio or 0.5 local top = M.config.style_chat_finder_margin_top or 2 local bottom = M.config.style_chat_finder_margin_bottom or 8 local left = M.config.style_chat_finder_margin_left or 1 local right = M.config.style_chat_finder_margin_right or 2 - local picker_buf, picker_win, picker_close, picker_resize = M._H.create_popup( + local picker_buf, picker_win, picker_close, picker_resize = M.render.popup( nil, - "Picker: j/k |exit |open dd|del i|srch", + "Picker: j/k |exit |open " .. M.config.chat_shortcut_delete.shortcut .. "|del i|srch", function(w, h) local wh = h - top - bottom - 2 local ww = w - left - right - 2 return math.floor(ww * (1 - ratio)), wh, top, left end, { gid = gid }, - { border = M.config.style_chat_finder_border or "single" } + style ) - local preview_buf, preview_win, preview_close, preview_resize = M._H.create_popup( + local preview_buf, preview_win, preview_close, preview_resize = M.render.popup( nil, "Preview (edits are ephemeral)", function(w, h) @@ -2515,20 +1243,20 @@ M.cmd.ChatFinder = function() return ww * ratio, wh, top, left + math.ceil(ww * (1 - ratio)) + 2 end, { gid = gid }, - { border = M.config.style_chat_finder_border or "single" } + style ) - vim.api.nvim_buf_set_option(preview_buf, "filetype", "markdown") + vim.api.nvim_set_option_value("filetype", "markdown", { buf = preview_buf }) - local command_buf, command_win, command_close, command_resize = M._H.create_popup( + local command_buf, command_win, command_close, command_resize = M.render.popup( nil, "Search: /|navigate |picker |exit " - .. "/////|open/float/split/vsplit/tab/toggle", + .. "/////t|open/float/split/vsplit/tab/toggle", function(w, h) return w - left - right, 1, h - bottom, left end, { gid = gid }, - { border = M.config.style_chat_finder_border or "single" } + style ) -- set initial content of command buffer vim.api.nvim_buf_set_lines(command_buf, 0, -1, false, { M.config.chat_finder_pattern }) @@ -2544,7 +1272,7 @@ M.cmd.ChatFinder = function() local regex = "" -- clean up augroup and popup buffers/windows - local close = _H.once(function() + local close = M.tasker.once(function() vim.api.nvim_del_augroup_by_id(gid) picker_close() preview_close() @@ -2615,7 +1343,7 @@ M.cmd.ChatFinder = function() -- get last line of command buffer local cmd = vim.api.nvim_buf_get_lines(command_buf, -2, -1, false)[1] - _H.grep_directory(nil, dir, cmd, function(results, re) + M.tasker.grep_directory(nil, dir, cmd, function(results, re) if not vim.api.nvim_buf_is_valid(picker_buf) then return end @@ -2650,44 +1378,44 @@ M.cmd.ChatFinder = function() vim.api.nvim_command("startinsert!") -- resize on VimResized - _H.autocmd({ "VimResized" }, nil, resize, gid) + M.helpers.autocmd({ "VimResized" }, nil, resize, gid) -- moving cursor on picker window will update preview window - _H.autocmd({ "CursorMoved", "CursorMovedI" }, { picker_buf }, function() + M.helpers.autocmd({ "CursorMoved", "CursorMovedI" }, { picker_buf }, function() vim.api.nvim_command("stopinsert") refresh() end, gid) -- InsertEnter on picker or preview window will go to command window - _H.autocmd({ "InsertEnter" }, { picker_buf, preview_buf }, function() + M.helpers.autocmd({ "InsertEnter" }, { picker_buf, preview_buf }, function() vim.api.nvim_set_current_win(command_win) vim.api.nvim_command("startinsert!") end, gid) -- InsertLeave on command window will go to picker window - _H.autocmd({ "InsertLeave" }, { command_buf }, function() + M.helpers.autocmd({ "InsertLeave" }, { command_buf }, function() vim.api.nvim_set_current_win(picker_win) vim.api.nvim_command("stopinsert") end, gid) -- when preview becomes active call some function - _H.autocmd({ "WinEnter" }, { preview_buf }, function() + M.helpers.autocmd({ "WinEnter" }, { preview_buf }, function() -- go to normal mode vim.api.nvim_command("stopinsert") end, gid) -- when command buffer is written, execute it - _H.autocmd({ "TextChanged", "TextChangedI", "TextChangedP", "TextChangedT" }, { command_buf }, function() + M.helpers.autocmd({ "TextChanged", "TextChangedI", "TextChangedP", "TextChangedT" }, { command_buf }, function() vim.api.nvim_win_set_cursor(picker_win, { 1, 0 }) refresh_picker() end, gid) -- close on buffer delete - _H.autocmd({ "BufWipeout", "BufHidden", "BufDelete" }, { picker_buf, preview_buf, command_buf }, close, gid) + M.helpers.autocmd({ "BufWipeout", "BufHidden", "BufDelete" }, { picker_buf, preview_buf, command_buf }, close, gid) -- close by escape key on any window - _H.set_keymap({ picker_buf, preview_buf, command_buf }, "n", "", close) - _H.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n" }, "", close) + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, "n", "", close) + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n" }, "", close) ---@param target number ---@param toggle boolean @@ -2705,32 +1433,26 @@ M.cmd.ChatFinder = function() end -- enter on picker window will open file - _H.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", open_chat) - _H.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", open_chat) + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() open_chat(M.BufTarget.popup, false) end) - _H.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() open_chat(M.BufTarget.split, false) end) - _H.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() open_chat(M.BufTarget.vsplit, false) end) - _H.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() open_chat(M.BufTarget.tabnew, false) end) - _H.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "", function() + M.helpers.set_keymap({ picker_buf, preview_buf, command_buf }, { "i", "n", "v" }, "t", function() local target = M.resolve_buf_target(M.config.toggle_target) open_chat(target, true) end) - -- -- enter on preview window will go to picker window - -- _H.set_keymap({ command_buf }, "i", "", function() - -- vim.api.nvim_set_current_win(picker_win) - -- vim.api.nvim_command("stopinsert") - -- end) - -- tab in command window will cycle through lines in picker window - _H.set_keymap({ command_buf, picker_buf }, { "i", "n" }, "", function() + M.helpers.set_keymap({ command_buf, picker_buf }, { "i", "n" }, "", function() local index = vim.api.nvim_win_get_cursor(picker_win)[1] local next_index = index + 1 if next_index > #picker_files then @@ -2741,7 +1463,7 @@ M.cmd.ChatFinder = function() end) -- shift-tab in command window will cycle through lines in picker window - _H.set_keymap({ command_buf, picker_buf }, { "i", "n" }, "", function() + M.helpers.set_keymap({ command_buf, picker_buf }, { "i", "n" }, "", function() local index = vim.api.nvim_win_get_cursor(picker_win)[1] local next_index = index - 1 if next_index < 1 then @@ -2752,30 +1474,35 @@ M.cmd.ChatFinder = function() end) -- dd on picker or preview window will delete file - _H.set_keymap({ picker_buf, preview_buf }, "n", "dd", function() - local index = vim.api.nvim_win_get_cursor(picker_win)[1] - local file = picker_files[index] - - -- delete without confirmation - if not M.config.chat_confirm_delete then - M._H.delete_file(file) - refresh_picker() - return - end - - -- ask for confirmation - vim.ui.input({ prompt = "Delete " .. file .. "? [y/N] " }, function(input) - if input and input:lower() == "y" then - M._H.delete_file(file) + M.helpers.set_keymap( + { command_buf, picker_buf, preview_buf }, + { "i", "n", "v" }, + M.config.chat_shortcut_delete.shortcut, + function() + local index = vim.api.nvim_win_get_cursor(picker_win)[1] + local file = picker_files[index] + + -- delete without confirmation + if not M.config.chat_confirm_delete then + M.helpers.delete_file(file) refresh_picker() + return end - end) - end) + + -- ask for confirmation + vim.ui.input({ prompt = "Delete " .. file .. "? [y/N] " }, function(input) + if input and input:lower() == "y" then + M.helpers.delete_file(file) + refresh_picker() + end + end) + end + ) end --------------------- +-------------------------------------------------------------------------------- -- Prompt logic --------------------- +-------------------------------------------------------------------------------- M.cmd.Agent = function(params) local agent_name = string.gsub(params.args, "^%s*(.-)%s*$", "%1") @@ -2793,18 +1520,15 @@ M.cmd.Agent = function(params) local file_name = vim.api.nvim_buf_get_name(buf) local is_chat = M.not_chat(buf, file_name) == nil if is_chat and M.agents[agent_name].chat then - M._state.chat_agent = agent_name + M.refresh_state({ chat_agent = agent_name }) M.logger.info("Chat agent: " .. M._state.chat_agent) - elseif is_chat then - M.logger.warning(agent_name .. " is not a Chat agent") elseif M.agents[agent_name].command then - M._state.command_agent = agent_name + M.refresh_state({ command_agent = agent_name }) M.logger.info("Command agent: " .. M._state.command_agent) else - M.logger.warning(agent_name .. " is not a Command agent") + M.logger.warning(agent_name .. " is not a valid agent for current buffer") + M.refresh_state() end - - M.refresh_state() end M.cmd.NextAgent = function() @@ -2823,13 +1547,12 @@ M.cmd.NextAgent = function() local set_agent = function(agent_name) if is_chat then - M._state.chat_agent = agent_name - M.logger.info("Chat agent: " .. agent_name) + M.refresh_state({ chat_agent = agent_name }) + M.logger.info("Chat agent: " .. M._state.chat_agent) else - M._state.command_agent = agent_name - M.logger.info("Command agent: " .. agent_name) + M.refresh_state({ command_agent = agent_name }) + M.logger.info("Command agent: " .. M._state.command_agent) end - M.refresh_state() end for i, agent_name in ipairs(agent_list) do @@ -2850,10 +1573,11 @@ M.get_command_agent = function(name) name = M._state.command_agent end local template = M.config.command_prompt_prefix_template - local cmd_prefix = M._H.template_render(template, { ["{{agent}}"] = name }) + local cmd_prefix = M.render.template(template, { ["{{agent}}"] = name }) local model = M.agents[name].model local system_prompt = M.agents[name].system_prompt local provider = M.agents[name].provider + M.logger.debug("getting command agent: " .. name) return { cmd_prefix = cmd_prefix, name = name, @@ -2872,10 +1596,11 @@ M.get_chat_agent = function(name) name = M._state.chat_agent end local template = M.config.command_prompt_prefix_template - local cmd_prefix = M._H.template_render(template, { ["{{agent}}"] = name }) + local cmd_prefix = M.render.template(template, { ["{{agent}}"] = name }) local model = M.agents[name].model local system_prompt = M.agents[name].system_prompt local provider = M.agents[name].provider + M.logger.debug("getting chat agent: " .. name) return { cmd_prefix = cmd_prefix, name = name, @@ -2885,6 +1610,42 @@ M.get_chat_agent = function(name) } end +-- tries to find an .gp.md file in the root of current git repo +---@return string # returns instructions from the .gp.md file +M.repo_instructions = function() + local git_root = M.helpers.find_git_root() + + if git_root == "" then + return "" + end + + local instruct_file = git_root .. "/.gp.md" + + if vim.fn.filereadable(instruct_file) == 0 then + return "" + end + + local lines = vim.fn.readfile(instruct_file) + return table.concat(lines, "\n") +end + +M.prep_context = function(buf, file_name) + if not M.helpers.ends_with(file_name, ".gp.md") then + return + end + + if buf ~= vim.api.nvim_get_current_buf() then + return + end + if M._prepared_bufs[buf] then + M.logger.debug("buffer already prepared: " .. buf) + return + end + M._prepared_bufs[buf] = true + + M.prep_md(buf) +end + M.cmd.Context = function(params) M._toggle_close(M._toggle_kind.popup) -- if there is no selection, try to close context toggle @@ -2897,11 +1658,11 @@ M.cmd.Context = function(params) local cbuf = vim.api.nvim_get_current_buf() local file_name = "" - local buf = _H.get_buffer(".gp.md") + local buf = M.helpers.get_buffer(".gp.md") if buf then file_name = vim.api.nvim_buf_get_name(buf) else - local git_root = _H.find_git_root() + local git_root = M.helpers.find_git_root() if git_root == "" then M.logger.warning("Not in a git repository") return @@ -2921,37 +1682,21 @@ M.cmd.Context = function(params) buf = M.open_buf(file_name, target, M._toggle_kind.context, true) if params.range == 2 then - M.append_selection(params, cbuf, buf) + M.render.append_selection(params, cbuf, buf, M.config.template_selection) end - M._H.feedkeys("G", "xn") + M.helpers.feedkeys("G", "xn") end -local examplePromptHook = [[ -UnitTests = function(gp, params) - local template = "I have the following code from {{filename}}:\n\n" - .. "```{{filetype}}\n{{selection}}\n```\n\n" - .. "Please respond by writing table driven unit tests for the code above." - local agent = gp.get_command_agent() - gp.Prompt(params, gp.Target.vnew, agent, template) -end, -]] - ---@param params table # vim command parameters such as range, args, etc. ----@param target integer | function | table # where to put the response +---@param target number | function | table # where to put the response ---@param agent table # obtained from get_command_agent or get_chat_agent ---@param template string # template with model instructions ---@param prompt string | nil # nil for non interactive commads ---@param whisper string | nil # predefined input (e.g. obtained from Whisper) ---@param callback function | nil # callback after completing the prompt M.Prompt = function(params, target, agent, template, prompt, whisper, callback) - if not agent or not type(agent) == "table" or not agent.provider then - M.logger.warning( - "The `gp.Prompt` method signature has changed.\n" - .. "Please update your hook functions as demonstrated in the example below:\n\n" - .. examplePromptHook - .. "\nFor more information, refer to the 'Extend Functionality' section in the documentation." - ) + if M.deprecator.has_old_prompt_signature(agent) then return end @@ -2966,8 +1711,7 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) local buf = vim.api.nvim_get_current_buf() local win = vim.api.nvim_get_current_win() - if not M.can_handle(buf) then - M.logger.warning("Another Gp process is already running for this buffer.") + if M.tasker.is_busy(buf) then return end @@ -3025,7 +1769,7 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) local handler = function() end -- default on_exit strips trailing backticks if response was markdown snippet local on_exit = function(qid) - local qt = M.get_query(qid) + local qt = M.tasker.get_query(qid) if not qt then return end @@ -3061,10 +1805,10 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) end if not flm then - M._H.undojoin(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, fl, fl + 1, false, {}) else - M._H.undojoin(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, ll, ll + 1, false, {}) end ll = ll - 1 @@ -3073,10 +1817,10 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) -- if fl and ll starts with triple backticks, remove these lines if flc and llc and flc:match("^%s*```") and llc:match("^%s*```") then -- remove first line with undojoin - M._H.undojoin(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, fl, fl + 1, false, {}) -- remove last line - M._H.undojoin(buf) + M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, ll - 1, ll, false, {}) ll = ll - 2 end @@ -3113,10 +1857,10 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) -- prepare messages local messages = {} - local filetype = M._H.get_filetype(buf) + local filetype = M.helpers.get_filetype(buf) local filename = vim.api.nvim_buf_get_name(buf) - local sys_prompt = M.template_render(agent.system_prompt, command, selection, filetype, filename) + local sys_prompt = M.render.prompt_template(agent.system_prompt, command, selection, filetype, filename) sys_prompt = sys_prompt or "" table.insert(messages, { role = "system", content = sys_prompt }) @@ -3125,11 +1869,11 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) table.insert(messages, { role = "system", content = repo_instructions }) end - local user_prompt = M.template_render(template, command, selection, filetype, filename) + local user_prompt = M.render.prompt_template(template, command, selection, filetype, filename) table.insert(messages, { role = "user", content = user_prompt }) -- cancel possible visual mode before calling the model - M._H.feedkeys("", "xn") + M.helpers.feedkeys("", "xn") local cursor = true if not M.config.command_auto_select_response then @@ -3141,43 +1885,49 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) -- delete selection vim.api.nvim_buf_set_lines(buf, start_line - 1, end_line - 1, false, {}) -- prepare handler - handler = M.create_handler(buf, win, start_line - 1, true, prefix, cursor) + handler = M.dispatcher.create_handler(buf, win, start_line - 1, true, prefix, cursor) elseif target == M.Target.append then -- move cursor to the end of the selection vim.api.nvim_win_set_cursor(0, { end_line, 0 }) -- put newline after selection vim.api.nvim_put({ "" }, "l", true, true) -- prepare handler - handler = M.create_handler(buf, win, end_line, true, prefix, cursor) + handler = M.dispatcher.create_handler(buf, win, end_line, true, prefix, cursor) elseif target == M.Target.prepend then -- move cursor to the start of the selection vim.api.nvim_win_set_cursor(0, { start_line, 0 }) -- put newline before selection vim.api.nvim_put({ "" }, "l", false, true) -- prepare handler - handler = M.create_handler(buf, win, start_line - 1, true, prefix, cursor) + handler = M.dispatcher.create_handler(buf, win, start_line - 1, true, prefix, cursor) elseif target == M.Target.popup then M._toggle_close(M._toggle_kind.popup) -- create a new buffer local popup_close = nil - buf, win, popup_close, _ = M._H.create_popup(nil, M._Name .. " popup (close with /)", function(w, h) - local top = M.config.style_popup_margin_top or 2 - local bottom = M.config.style_popup_margin_bottom or 8 - local left = M.config.style_popup_margin_left or 1 - local right = M.config.style_popup_margin_right or 1 - local max_width = M.config.style_popup_max_width or 160 - local ww = math.min(w - (left + right), max_width) - local wh = h - (top + bottom) - return ww, wh, top, (w - ww) / 2 - end, { on_leave = true, escape = true }, { border = M.config.style_popup_border or "single" }) + buf, win, popup_close, _ = M.render.popup( + nil, + M._Name .. " popup (close with /)", + function(w, h) + local top = M.config.style_popup_margin_top or 2 + local bottom = M.config.style_popup_margin_bottom or 8 + local left = M.config.style_popup_margin_left or 1 + local right = M.config.style_popup_margin_right or 1 + local max_width = M.config.style_popup_max_width or 160 + local ww = math.min(w - (left + right), max_width) + local wh = h - (top + bottom) + return ww, wh, top, (w - ww) / 2 + end, + { on_leave = true, escape = true }, + { border = M.config.style_popup_border or "single", zindex = M.config.zindex } + ) -- set the created buffer as the current buffer vim.api.nvim_set_current_buf(buf) -- set the filetype to markdown - vim.api.nvim_buf_set_option(buf, "filetype", "markdown") + vim.api.nvim_set_option_value("filetype", "markdown", { buf = buf }) -- better text wrapping vim.api.nvim_command("setlocal wrap linebreak") -- prepare handler - handler = M.create_handler(buf, win, 0, false, "", false) + handler = M.dispatcher.create_handler(buf, win, 0, false, "", false) M._toggle_add(M._toggle_kind.popup, { win = win, buf = buf, close = popup_close }) elseif type(target) == "table" then if target.type == M.Target.new().type then @@ -3194,12 +1944,12 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) buf = vim.api.nvim_create_buf(true, true) vim.api.nvim_set_current_buf(buf) - local group = M._H.create_augroup("GpScratchSave" .. _H.uuid(), { clear = true }) + local group = M.helpers.create_augroup("GpScratchSave" .. M.helpers.uuid(), { clear = true }) vim.api.nvim_create_autocmd({ "BufWritePre" }, { buffer = buf, group = group, callback = function(ctx) - vim.api.nvim_buf_set_option(ctx.buf, "buftype", "") + vim.api.nvim_set_option_value("buftype", "", { buf = ctx.buf }) vim.api.nvim_buf_set_name(ctx.buf, ctx.file) vim.api.nvim_command("w!") vim.api.nvim_del_augroup_by_id(ctx.group) @@ -3207,16 +1957,16 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) }) local ft = target.filetype or filetype - vim.api.nvim_buf_set_option(buf, "filetype", ft) + vim.api.nvim_set_option_value("filetype", ft, { buf = buf }) - handler = M.create_handler(buf, win, 0, false, "", cursor) + handler = M.dispatcher.create_handler(buf, win, 0, false, "", cursor) end -- call the model and write the response - M.query( + M.dispatcher.query( buf, agent.provider, - M.prepare_payload(messages, agent.model, agent.provider), + M.dispatcher.prepare_payload(messages, agent.model, agent.provider), handler, vim.schedule_wrap(function(qid) on_exit(qid) @@ -3249,434 +1999,4 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) end) end ----@param callback function # callback function(text) -M.Whisper = function(language, callback) - -- make sure sox is installed - if vim.fn.executable("sox") == 0 then - M.logger.error("sox is not installed") - return - end - - local rec_file = M.config.whisper_dir .. "/rec.wav" - local rec_options = { - sox = { - cmd = "sox", - opts = { - "-c", - "1", - "--buffer", - "32", - "-d", - "rec.wav", - "trim", - "0", - "3600", - }, - exit_code = 0, - }, - arecord = { - cmd = "arecord", - opts = { - "-c", - "1", - "-f", - "S16_LE", - "-r", - "48000", - "-d", - 3600, - "rec.wav", - }, - exit_code = 1, - }, - ffmpeg = { - cmd = "ffmpeg", - opts = { - "-y", - "-f", - "avfoundation", - "-i", - ":0", - "-t", - "3600", - "rec.wav", - }, - exit_code = 255, - }, - } - - if not M.valid_api_key() then - return - end - - local gid = M._H.create_augroup("GpWhisper", { clear = true }) - - -- create popup - local buf, _, close_popup, _ = M._H.create_popup( - nil, - M._Name .. " Whisper", - function(w, h) - return 60, 12, (h - 12) * 0.4, (w - 60) * 0.5 - end, - { gid = gid, on_leave = false, escape = false, persist = false }, - { border = M.config.style_popup_border or "single" } - ) - - -- animated instructions in the popup - local counter = 0 - local timer = vim.loop.new_timer() - timer:start( - 0, - 200, - vim.schedule_wrap(function() - if vim.api.nvim_buf_is_valid(buf) then - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { - " ", - " Speak πŸ‘„ loudly πŸ“£ into the microphone 🎀: ", - " " .. string.rep("πŸ‘‚", counter), - " ", - " Pressing starts the transcription.", - " ", - " Cancel the recording with / or :GpStop.", - " ", - " The last recording is in /tmp/gp_whisper/.", - }) - end - counter = counter + 1 - if counter % 22 == 0 then - counter = 0 - end - end) - ) - - local close = _H.once(function() - if timer then - timer:stop() - timer:close() - end - close_popup() - vim.api.nvim_del_augroup_by_id(gid) - M.cmd.Stop() - end) - - _H.set_keymap({ buf }, { "n", "i", "v" }, "", function() - M.cmd.Stop() - end) - - _H.set_keymap({ buf }, { "n", "i", "v" }, "", function() - M.cmd.Stop() - end) - - local continue = false - _H.set_keymap({ buf }, { "n", "i", "v" }, "", function() - continue = true - vim.defer_fn(function() - M.cmd.Stop() - end, 300) - end) - - -- cleanup on buffer exit - _H.autocmd({ "BufWipeout", "BufHidden", "BufDelete" }, { buf }, close, gid) - - local curl_params = M.config.curl_params or {} - local curl = "curl" .. " " .. table.concat(curl_params, " ") - - -- transcribe the recording - local transcribe = function() - local cmd = "cd " - .. M.config.whisper_dir - .. " && " - .. "export LC_NUMERIC='C' && " - -- normalize volume to -3dB - .. "sox --norm=-3 rec.wav norm.wav && " - -- get RMS level dB * silence threshold - .. "t=$(sox 'norm.wav' -n channels 1 stats 2>&1 | grep 'RMS lev dB' " - .. " | sed -e 's/.* //' | awk '{print $1*" - .. M.config.whisper_silence - .. "}') && " - -- remove silence, speed up, pad and convert to mp3 - .. "sox -q norm.wav -C 196.5 final.mp3 silence -l 1 0.05 $t'dB' -1 1.0 $t'dB'" - .. " pad 0.1 0.1 tempo " - .. M.config.whisper_tempo - .. " && " - -- call openai - .. curl - .. " --max-time 20 " - .. M.config.whisper_api_endpoint - .. ' -s -H "Authorization: Bearer ' - .. M.config.openai_api_key - .. '" -H "Content-Type: multipart/form-data" ' - .. '-F model="whisper-1" -F language="' - .. language - .. '" -F file="@final.mp3" ' - .. '-F response_format="json"' - - M._H.process(nil, "bash", { "-c", cmd }, function(code, signal, stdout, _) - if code ~= 0 then - M.logger.error(string.format("Whisper query exited: %d, %d", code, signal)) - return - end - - if not stdout or stdout == "" or #stdout < 11 then - M.logger.error("Whisper query, no stdout: " .. vim.inspect(stdout)) - return - end - local text = vim.json.decode(stdout).text - if not text then - M.logger.error("Whisper query, no text: " .. vim.inspect(stdout)) - return - end - - text = table.concat(vim.split(text, "\n"), " ") - text = text:gsub("%s+$", "") - - if callback and stdout then - callback(text) - end - end) - end - - local cmd = {} - - local rec_cmd = M.config.whisper_rec_cmd - -- if rec_cmd not set explicitly, try to autodetect - if not rec_cmd then - rec_cmd = "sox" - if vim.fn.executable("ffmpeg") == 1 then - local devices = vim.fn.system("ffmpeg -devices -v quiet | grep -i avfoundation | wc -l") - devices = string.gsub(devices, "^%s*(.-)%s*$", "%1") - if devices == "1" then - rec_cmd = "ffmpeg" - end - end - if vim.fn.executable("arecord") == 1 then - rec_cmd = "arecord" - end - end - - if type(rec_cmd) == "table" and rec_cmd[1] and rec_options[rec_cmd[1]] then - rec_cmd = vim.deepcopy(rec_cmd) - cmd.cmd = table.remove(rec_cmd, 1) - cmd.exit_code = rec_options[cmd.cmd].exit_code - cmd.opts = rec_cmd - elseif type(rec_cmd) == "string" and rec_options[rec_cmd] then - cmd = rec_options[rec_cmd] - else - M.logger.error(string.format("Whisper got invalid recording command: %s", rec_cmd)) - close() - return - end - for i, v in ipairs(cmd.opts) do - if v == "rec.wav" then - cmd.opts[i] = rec_file - end - end - - M._H.process(nil, cmd.cmd, cmd.opts, function(code, signal, stdout, stderr) - close() - - if code and code ~= cmd.exit_code then - M.logger.error( - cmd.cmd - .. " exited with code and signal:\ncode: " - .. code - .. ", signal: " - .. signal - .. "\nstdout: " - .. vim.inspect(stdout) - .. "\nstderr: " - .. vim.inspect(stderr) - ) - return - end - - if not continue then - return - end - - vim.schedule(function() - transcribe() - end) - end) -end - -M.cmd.Whisper = function(params) - local buf = vim.api.nvim_get_current_buf() - local start_line = vim.api.nvim_win_get_cursor(0)[1] - local end_line = start_line - - if params.range == 2 then - start_line = params.line1 - end_line = params.line2 - end - - local args = vim.split(params.args, " ") - - local language = config.whisper_language - if args[1] ~= "" then - language = args[1] - end - - M.Whisper(language, function(text) - if not vim.api.nvim_buf_is_valid(buf) then - return - end - - if text then - vim.api.nvim_buf_set_lines(buf, start_line - 1, end_line, false, { text }) - end - end) -end - -M.cmd.ImageAgent = function(params) - local agent_name = string.gsub(params.args, "^%s*(.-)%s*$", "%1") - if agent_name == "" then - M.logger.info("Image agent: " .. (M._state.image_agent or "none")) - return - end - - if not M.image_agents[agent_name] then - M.logger.warning("Unknown image agent: " .. agent_name) - return - end - - M._state.image_agent = agent_name - M.logger.info("Image agent: " .. M._state.image_agent) - - M.refresh_state() -end - ----@return table # { cmd_prefix, name, model, quality, style, size } -M.get_image_agent = function() - local template = M.config.image_prompt_prefix_template - local cmd_prefix = M._H.template_render(template, { ["{{agent}}"] = M._state.image_agent }) - local name = M._state.image_agent - local model = M.image_agents[name].model - local quality = M.image_agents[name].quality - local style = M.image_agents[name].style - local size = M.image_agents[name].size - return { cmd_prefix = cmd_prefix, name = name, model = model, quality = quality, style = style, size = size } -end - -M.cmd.Image = function(params) - local prompt = params.args - local agent = M.get_image_agent() - if prompt == "" then - vim.ui.input({ prompt = agent.cmd_prefix }, function(input) - prompt = input - if not prompt then - return - end - M.generate_image(prompt, agent.model, agent.quality, agent.style, agent.size) - end) - else - M.generate_image(prompt, agent.model, agent.quality, agent.style, agent.size) - end -end - -function M.generate_image(prompt, model, quality, style, size) - if not M.valid_api_key() then - return - end - - local cmd = "curl" - local payload = { - model = model, - prompt = prompt, - n = 1, - size = size, - style = style, - quality = quality, - } - local args = { - "-s", - "-H", - "Content-Type: application/json", - "-H", - "Authorization: Bearer " .. M.config.openai_api_key, - "-d", - vim.json.encode(payload), - "https://api.openai.com/v1/images/generations", - } - - local qid = M._H.uuid() - M._queries[qid] = { - timestamp = os.time(), - payload = payload, - raw_response = "", - error = "", - url = "", - prompt = "", - save_path = "", - save_raw_response = "", - save_error = "", - } - local query = M._queries[qid] - - M.spinner.start_spinner("Generating image...") - - _H.process(nil, cmd, args, function(code, signal, stdout_data, stderr_data) - M.spinner.stop_spinner() - query.raw_response = stdout_data - query.error = stderr_data - if code ~= 0 then - M.logger.error( - "Image generation exited: code: " - .. code - .. " signal: " - .. signal - .. " stdout: " - .. stdout_data - .. " stderr: " - .. stderr_data - ) - return - end - local result = vim.json.decode(stdout_data) - query.parsed_response = vim.inspect(result) - if result and result.data and result.data[1] and result.data[1].url then - local image_url = result.data[1].url - query.url = image_url - -- query.prompt = result.data[1].prompt - vim.ui.input( - { prompt = M.config.image_prompt_save, completion = "file", default = M.config.image_dir }, - function(save_path) - if not save_path or save_path == "" then - M.logger.info("Image URL: " .. image_url) - return - end - query.save_path = save_path - M.spinner.start_spinner("Saving image...") - _H.process( - nil, - "curl", - { "-s", "-o", save_path, image_url }, - function(save_code, save_signal, save_stdout_data, save_stderr_data) - M.spinner.stop_spinner() - query.save_raw_response = save_stdout_data - query.save_error = save_stderr_data - if save_code == 0 then - M.logger.info("Image saved to: " .. save_path) - else - M.logger.error( - "Failed to save image: path: " - .. save_path - .. " code: " - .. save_code - .. " signal: " - .. save_signal - .. " stderr: " - .. save_stderr_data - ) - end - end - ) - end - ) - else - M.logger.error("Image generation failed: " .. vim.inspect(stdout_data)) - end - end) -end - return M diff --git a/lua/gp/logger.lua b/lua/gp/logger.lua index 4a56912d..82509411 100644 --- a/lua/gp/logger.lua +++ b/lua/gp/logger.lua @@ -1,13 +1,32 @@ +-------------------------------------------------------------------------------- +-- Logger module +-------------------------------------------------------------------------------- + +local uv = vim.uv or vim.loop + local M = {} local file = "/dev/null" local uuid = "" +local store_sensitive = false M._log_history = {} -M.level = vim.log.levels.INFO + +---@return string # formatted time with milliseconds +M.now = function() + local time = os.date("%Y-%m-%d.%H-%M-%S") + local stamp = tostring(math.floor(uv.hrtime() / 1000000) % 1000) + -- make sure stamp is 3 digits + while #stamp < 3 do + stamp = stamp .. "0" + end + return time .. "." .. stamp +end ---@param path string # path to log file -M.set_log_file = function(path) +---@param sensitive boolean | nil # whether to store sensitive data in logs +M.setup = function(path, sensitive) + store_sensitive = sensitive or false uuid = string.format("%x", math.random(0, 0xFFFF)) .. string.format("%x", os.time() % 0xFFFF) M.debug("New neovim instance [" .. uuid .. "] started, setting log file to " .. path) local dir = vim.fn.fnamemodify(path, ":h") @@ -16,6 +35,27 @@ M.set_log_file = function(path) end file = path + -- truncate log file if it's too big + if uv.fs_stat(file) then + local content = {} + for line in io.lines(file) do + table.insert(content, line) + end + + if #content > 20000 then + local truncated_file = io.open(file, "w") + if truncated_file then + for i, line in ipairs(content) do + if #content - i < 10000 then + truncated_file:write(line .. "\n") + end + end + truncated_file:close() + M.debug("Log file " .. file .. " truncated to last 10K lines") + end + end + end + local log_file = io.open(file, "a") if log_file then for _, line in ipairs(M._log_history) do @@ -27,11 +67,22 @@ end ---@param msg string # message to log ---@param level integer # log level -local log = function(msg, level) - local raw = string.format("[%s] [%s] %s: %s", os.date("%Y-%m-%d %H:%M:%S"), uuid, vim.lsp.log_levels[level], msg) +---@param slevel string # log level as string +---@param sensitive boolean | nil # sensitive log +local log = function(msg, level, slevel, sensitive) + local raw = msg + if sensitive then + if not store_sensitive then + raw = "REDACTED" + end + raw = raw:gsub("([^\n]+)", "[SENSITIVE DATA] %1") + end + raw = string.format("[%s] [%s] %s: %s", M.now(), uuid, slevel, raw) - M._log_history[#M._log_history + 1] = raw - if #M._log_history > 100 then + if not sensitive then + M._log_history[#M._log_history + 1] = raw + end + if #M._log_history > 20 then table.remove(M._log_history, 1) end @@ -41,36 +92,43 @@ local log = function(msg, level) log_file:close() end - if level >= M.level then - vim.schedule(function() - vim.notify(msg, level, { title = "gp.nvim" }) - end) + if level <= vim.log.levels.DEBUG then + return end + + vim.schedule(function() + vim.notify("Gp.nvim: " .. msg, level, { title = "Gp.nvim" }) + end) end ---@param msg string # error message -M.error = function(msg) - log(msg, vim.log.levels.ERROR) +---@param sensitive boolean | nil # sensitive log +M.error = function(msg, sensitive) + log(msg, vim.log.levels.ERROR, "ERROR", sensitive) end ---@param msg string # warning message -M.warning = function(msg) - log(msg, vim.log.levels.WARN) +---@param sensitive boolean | nil # sensitive log +M.warning = function(msg, sensitive) + log(msg, vim.log.levels.WARN, "WARNING", sensitive) end ---@param msg string # plain message -M.info = function(msg) - log(msg, vim.log.levels.INFO) +---@param sensitive boolean | nil # sensitive log +M.info = function(msg, sensitive) + log(msg, vim.log.levels.INFO, "INFO", sensitive) end ---@param msg string # debug message -M.debug = function(msg) - log(msg, vim.log.levels.DEBUG) +---@param sensitive boolean | nil # sensitive log +M.debug = function(msg, sensitive) + log(msg, vim.log.levels.DEBUG, "DEBUG", sensitive) end ---@param msg string # trace message -M.trace = function(msg) - log(msg, vim.log.levels.TRACE) +---@param sensitive boolean | nil # sensitive log +M.trace = function(msg, sensitive) + log(msg, vim.log.levels.TRACE, "TRACE", sensitive) end return M diff --git a/lua/gp/render.lua b/lua/gp/render.lua new file mode 100644 index 00000000..d2d4b943 --- /dev/null +++ b/lua/gp/render.lua @@ -0,0 +1,200 @@ +-------------------------------------------------------------------------------- +-- Render module for logic related to visualization +-------------------------------------------------------------------------------- + +local logger = require("gp.logger") +local helpers = require("gp.helper") +local tasker = require("gp.tasker") + +local M = {} + +---@param template string # template string +---@param key string # key to replace +---@param value string | table | nil # value to replace key with (nil => "") +---@return string # returns rendered template with specified key replaced by value +M.template_replace = function(template, key, value) + value = value or "" + + if type(value) == "table" then + value = table.concat(value, "\n") + end + + value = value:gsub("%%", "%%%%") + template = template:gsub(key, value) + template = template:gsub("%%%%", "%%") + return template +end + +---@param template string # template string +---@param key_value_pairs table # table with key value pairs +---@return string # returns rendered template with keys replaced by values from key_value_pairs +M.template = function(template, key_value_pairs) + for key, value in pairs(key_value_pairs) do + template = M.template_replace(template, key, value) + end + + return template +end + +---@param template string # template string +---@param command string | nil # command +---@param selection string | nil # selection +---@param filetype string | nil # filetype +---@param filename string | nil # filename +M.prompt_template = function(template, command, selection, filetype, filename) + local git_root = helpers.find_git_root(filename) + if git_root ~= "" then + local git_root_plus_one = vim.fn.fnamemodify(git_root, ":h") + if git_root_plus_one ~= "" then + filename = filename or "" + filename = filename:sub(#git_root_plus_one + 2) + end + end + + local key_value_pairs = { + ["{{command}}"] = command, + ["{{selection}}"] = selection, + ["{{filetype}}"] = filetype, + ["{{filename}}"] = filename, + } + return M.template(template, key_value_pairs) +end + +---@param params table # table with command args +---@param origin_buf number # selection origin buffer +---@param target_buf number # selection target buffer +---@param template string # template to render +M.append_selection = function(params, origin_buf, target_buf, template) + -- prepare selection + local lines = vim.api.nvim_buf_get_lines(origin_buf, params.line1 - 1, params.line2, false) + local selection = table.concat(lines, "\n") + if selection ~= "" then + local filetype = helpers.get_filetype(origin_buf) + local fname = vim.api.nvim_buf_get_name(origin_buf) + local rendered = M.prompt_template(template, "", selection, filetype, fname) + if rendered then + selection = rendered + end + end + + -- delete whitespace lines at the end of the file + local last_content_line = helpers.last_content_line(target_buf) + vim.api.nvim_buf_set_lines(target_buf, last_content_line, -1, false, {}) + + -- insert selection lines + lines = vim.split("\n" .. selection, "\n") + vim.api.nvim_buf_set_lines(target_buf, last_content_line, -1, false, lines) +end + +---@param buf number | nil # buffer number +---@param title string # title of the popup +---@param size_func function # size_func(editor_width, editor_height) -> width, height, row, col +---@param opts table # options - gid=nul, on_leave=false, persist=false +---@param style table # style - border="single" +---returns table with buffer, window, close function, resize function +M.popup = function(buf, title, size_func, opts, style) + opts = opts or {} + style = style or {} + local border = style.border or "single" + local zindex = style.zindex or 49 + + -- create buffer + buf = buf or vim.api.nvim_create_buf(false, not opts.persist) + + -- setting to the middle of the editor + local options = { + relative = "editor", + -- dummy values gets resized later + width = 10, + height = 10, + row = 10, + col = 10, + style = "minimal", + border = border, + title = title, + title_pos = "center", + zindex = zindex, + } + + -- open the window and return the buffer + local win = vim.api.nvim_open_win(buf, true, options) + + local resize = function() + -- get editor dimensions + local ew = vim.api.nvim_get_option_value("columns", {}) + local eh = vim.api.nvim_get_option_value("lines", {}) + + local w, h, r, c = size_func(ew, eh) + + -- setting to the middle of the editor + local o = { + relative = "editor", + -- half of the editor width + width = math.floor(w), + -- half of the editor height + height = math.floor(h), + -- center of the editor + row = math.floor(r), + -- center of the editor + col = math.floor(c), + } + if o.width <= 0 or o.height <= 0 then + logger.error("Invalid popup size (window too small to render)") + return + end + vim.api.nvim_win_set_config(win, o) + end + + local pgid = opts.gid or helpers.create_augroup("GpPopup", { clear = true }) + + -- cleanup on exit + local close = tasker.once(function() + vim.schedule(function() + -- delete only internal augroups + if not opts.gid then + vim.api.nvim_del_augroup_by_id(pgid) + end + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + if opts.persist then + return + end + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_delete(buf, { force = true }) + end + end) + end) + + -- resize on vim resize + helpers.autocmd("VimResized", { buf }, resize, pgid) + + -- cleanup on buffer exit + helpers.autocmd({ "BufWipeout", "BufHidden", "BufDelete" }, { buf }, close, pgid) + + -- optional cleanup on buffer leave + if opts.on_leave then + -- close when entering non-popup buffer + helpers.autocmd({ "BufEnter" }, nil, function(event) + local b = event.buf + if b ~= buf then + close() + -- make sure to set current buffer after close + vim.schedule(vim.schedule_wrap(function() + vim.api.nvim_set_current_buf(b) + end)) + end + end, pgid) + end + + -- cleanup on escape exit + if opts.escape then + helpers.set_keymap({ buf }, "n", "", close, title .. " close on escape") + helpers.set_keymap({ buf }, { "n", "v", "i" }, "", close, title .. " close on escape") + end + + resize() + return buf, win, close, resize +end + +return M diff --git a/lua/gp/spinner.lua b/lua/gp/spinner.lua index c69d4c35..ff03db19 100644 --- a/lua/gp/spinner.lua +++ b/lua/gp/spinner.lua @@ -1,4 +1,5 @@ local M = {} + M._spinner_frames = { "01010010", "01101111", @@ -33,7 +34,7 @@ function M.start_spinner(msg) M._display_spinner(M._msg) if not M._spinner_timer then - M._spinner_timer = vim.loop.new_timer() + M._spinner_timer = (vim.uv or vim.loop).new_timer() M._spinner_timer:start( 0, 100, diff --git a/lua/gp/tasker.lua b/lua/gp/tasker.lua new file mode 100644 index 00000000..df630e3c --- /dev/null +++ b/lua/gp/tasker.lua @@ -0,0 +1,230 @@ +-------------------------------------------------------------------------------- +-- Task managmenet module +-------------------------------------------------------------------------------- + +local logger = require("gp.logger") + +local uv = vim.uv or vim.loop + +local M = {} +M._handles = {} +M._queries = {} -- table of latest queries + +---@param fn function # function to wrap so it only gets called once +M.once = function(fn) + local once = false + return function(...) + if once then + return + end + once = true + fn(...) + end +end + +---@param N number # number of queries to keep +---@param age number # age of queries to keep in seconds +M.cleanup_old_queries = function(N, age) + local current_time = os.time() + + local query_count = 0 + for _ in pairs(M._queries) do + query_count = query_count + 1 + end + + if query_count <= N then + return + end + + for qid, query_data in pairs(M._queries) do + if current_time - query_data.timestamp > age then + M._queries[qid] = nil + end + end +end + +---@param qid string # query id +---@return table | nil # query data +M.get_query = function(qid) + if not M._queries[qid] then + M.logger.error("query with ID " .. tostring(qid) .. " not found.") + return nil + end + return M._queries[qid] +end + +---@param qid string # query id +---@param payload table # query payload +M.set_query = function(qid, payload) + M._queries[qid] = payload + M.cleanup_old_queries(10, 60) +end + +-- add a process handle and its corresponding pid to the _handles table +---@param handle userdata | nil # the Lua uv handle +---@param pid number | string # the process id +---@param buf number | nil # buffer number +M.add_handle = function(handle, pid, buf) + table.insert(M._handles, { handle = handle, pid = pid, buf = buf }) +end + +-- remove a process handle from the _handles table using its pid +---@param pid number | string # the process id to find the corresponding handle +M.remove_handle = function(pid) + for i, h in ipairs(M._handles) do + if h.pid == pid then + table.remove(M._handles, i) + return + end + end +end + +--- check if there is some pid running for the given buffer +---@param buf number | nil # buffer number +---@return boolean +M.is_busy = function(buf) + if buf == nil then + return false + end + for _, h in ipairs(M._handles) do + if h.buf == buf then + logger.warning("Another Gp process [" .. h.pid .. "] is already running for buffer " .. buf) + return true + end + end + return false +end + +-- stop receiving gpt responses for all processes and clean the handles +---@param signal number | nil # signal to send to the process +M.stop = function(signal) + if M._handles == {} then + return + end + + for _, h in ipairs(M._handles) do + if h.handle ~= nil and not h.handle:is_closing() then + uv.kill(h.pid, signal or 15) + end + end + + M._handles = {} +end + +---@param buf number | nil # buffer number +---@param cmd string # command to execute +---@param args table # arguments for command +---@param callback function | nil # exit callback function(code, signal, stdout_data, stderr_data) +---@param out_reader function | nil # stdout reader function(err, data) +---@param err_reader function | nil # stderr reader function(err, data) +M.run = function(buf, cmd, args, callback, out_reader, err_reader) + logger.debug("run command: " .. cmd .. " " .. table.concat(args, " "), true) + + local handle, pid + local stdout = uv.new_pipe(false) + local stderr = uv.new_pipe(false) + local stdout_data = "" + local stderr_data = "" + + if M.is_busy(buf) then + return + end + + local on_exit = M.once(vim.schedule_wrap(function(code, signal) + stdout:read_stop() + stderr:read_stop() + stdout:close() + stderr:close() + if handle and not handle:is_closing() then + handle:close() + end + if callback then + callback(code, signal, stdout_data, stderr_data) + end + M.remove_handle(pid) + end)) + + handle, pid = uv.spawn(cmd, { + args = args, + stdio = { nil, stdout, stderr }, + hide = true, + detach = true, + }, on_exit) + + logger.debug(cmd .. " command started with pid: " .. pid, true) + + M.add_handle(handle, pid, buf) + + uv.read_start(stdout, function(err, data) + if err then + logger.error("Error reading stdout: " .. vim.inspect(err)) + end + if data then + stdout_data = stdout_data .. data + end + if out_reader then + out_reader(err, data) + end + end) + + uv.read_start(stderr, function(err, data) + if err then + logger.error("Error reading stderr: " .. vim.inspect(err)) + end + if data then + stderr_data = stderr_data .. data + end + if err_reader then + err_reader(err, data) + end + end) +end + +---@param buf number | nil # buffer number +---@param directory string # directory to search in +---@param pattern string # pattern to search for +---@param callback function # callback function(results, regex) +-- results: table of elements with file, lnum and line +-- regex: string - final regex used for search +M.grep_directory = function(buf, directory, pattern, callback) + pattern = pattern or "" + -- replace spaces with wildcards + pattern = pattern:gsub("%s+", ".*") + -- strip leading and trailing non alphanumeric characters + local re = pattern:gsub("^%W*(.-)%W*$", "%1") + + M.run(buf, "grep", { "-irEn", "--null", pattern, directory }, function(c, _, stdout, _) + local results = {} + if c ~= 0 then + callback(results, re) + return + end + for _, line in ipairs(vim.split(stdout, "\n")) do + line = line:gsub("^%s*(.-)%s*$", "%1") + -- line contains non whitespace characters + if line:match("%S") then + -- extract file path (until zero byte) + local file = line:match("^(.-)%z") + -- substract dir from file + local filename = vim.fn.fnamemodify(file, ":t") + local line_number = line:match("%z(%d+):") + local line_text = line:match("%z%d+:(.*)") + table.insert(results, { + file = filename, + lnum = line_number, + line = line_text, + }) + end + end + table.sort(results, function(a, b) + if a.file == b.file then + return a.lnum < b.lnum + else + return a.file > b.file + end + end) + callback(results, re) + end) +end + +return M diff --git a/lua/gp/utils.lua b/lua/gp/utils.lua index 6b9a226e..4cfdd2e8 100644 --- a/lua/gp/utils.lua +++ b/lua/gp/utils.lua @@ -127,7 +127,7 @@ end --- Locates the git_root using the cwd function Utils.git_root_from_cwd() - return require("gp")._H.find_git_root(vim.fn.getcwd()) + return require("gp.helper").find_git_root(vim.fn.getcwd()) end -- If the given path is a relative path, turn it into a fullpath diff --git a/lua/gp/vault.lua b/lua/gp/vault.lua new file mode 100644 index 00000000..d1cfef38 --- /dev/null +++ b/lua/gp/vault.lua @@ -0,0 +1,212 @@ +-------------------------------------------------------------------------------- +-- Vault module for managing secrets +-------------------------------------------------------------------------------- + +local logger = require("gp.logger") +local tasker = require("gp.tasker") +local helpers = require("gp.helper") +local default_config = require("gp.config") + +local V = { + _obfuscated_secrets = {}, + _state = {}, + config = {}, +} + +local secrets = {} -- private secretes accessible only via vault.get_secret + +-- backwards compatibility +local alias = { + openai = "openai_api_key", +} + +---@param opts table # user config +V.setup = function(opts) + logger.debug("vault setup started\n" .. vim.inspect(opts), true) + + V.config.curl_params = opts.curl_params or default_config.curl_params + V.config.state_dir = opts.state_dir or default_config.state_dir + + helpers.prepare_dir(V.config.state_dir, "vault state") + + logger.debug("vault setup finished\n" .. vim.inspect(V), true) +end + +---@param name string # provider name +---@param secret string | table | nil # secret or command to retrieve it +V.add_secret = function(name, secret) + local s = { secret = secret } + s = vim.deepcopy(s) + name = alias[name] or name + secrets[name] = s.secret + logger.debug("vault adding secret " .. name .. ": " .. vim.inspect(s.secret), true) +end + +---@param name string # secret name +---@return string | nil # secret or nil if not found +V.get_secret = function(name) + name = alias[name] or name + + local secret = secrets[name] + logger.debug("vault get_secret:" .. name .. ": " .. vim.inspect(secret), true) + + if not secret then + logger.warning("vault secret " .. name .. " not found", true) + return nil + end + + if type(secret) == "table" then + logger.warning("vault secret " .. name .. " is still an unresolved command: " .. vim.inspect(secret), true) + return nil + end + return secret +end + +---@param name string # provider name +---@param secret string | table | nil # secret or command to retrieve it +---@param callback function | nil # callback to run after secret is resolved +V.resolve_secret = function(name, secret, callback) + logger.debug("vault resolver started for " .. name .. ": " .. vim.inspect(secret), true) + name = alias[name] or name + callback = callback or function() end + if secrets[name] and type(secrets[name]) ~= "table" then + logger.debug("vault resolver secret " .. name .. " already resolved", true) + callback() + return + end + + local post_process = function() + local s = secrets[name] + if s and type(s) == "string" then + secrets[name] = s:gsub("^%s*(.-)%s*$", "%1") + end + logger.debug("vault resolver finished for " .. name .. ": " .. vim.inspect(secrets[name]), true) + + V._obfuscated_secrets[name] = s:sub(1, 3) .. string.rep("*", #s - 6) .. s:sub(-3) + + callback() + end + + if not secret then + logger.warning("vault resolver for " .. name .. " got empty secret", true) + return + end + + if type(secret) == "table" then + local copy = vim.deepcopy(secret) + local cmd = table.remove(copy, 1) + local args = copy + tasker.run(nil, cmd, args, function(code, signal, stdout_data, stderr_data) + if code == 0 then + local content = stdout_data:match("^%s*(.-)%s*$") + if not string.match(content, "%S") then + logger.warning("vault resolver got empty response for " .. name .. " secret command " .. vim.inspect(secret)) + return + end + secrets[name] = content + post_process() + else + logger.warning( + "vault resolver for " + .. name + .. "secret command " + .. vim.inspect(secret) + .. " failed:\ncode: " + .. code + .. ", signal: " + .. signal + .. "\nstdout: " + .. stdout_data + .. "\nstderr: " + .. stderr_data + ) + end + end) + else + secrets[name] = secret + post_process() + end +end + +V.refresh_copilot_bearer = function(callback) + local secret = secrets.copilot + if not secret or type(secret) == "table" then + return + end + logger.debug("vault refresh_copilot_bearer: started", true) + + callback = callback or function() end + + local state_file = V.config.state_dir .. "/vault_state.json" + + local state = {} + if vim.fn.filereadable(state_file) ~= 0 then + state = helpers.file_to_table(state_file) or {} + end + + local bearer = V._state.copilot_bearer or state.copilot_bearer or {} + if bearer.token and bearer.expires_at and bearer.expires_at > os.time() then + secrets.copilot_bearer = bearer.token + logger.debug("vault refresh_copilot_bearer: token still valid, running callback", true) + callback() + return + end + + local curl_params = vim.deepcopy(V.config.curl_params or {}) + local args = { + "-s", + "-v", + "https://api.github.com/copilot_internal/v2/token", + "-H", + "Content-Type: application/json", + "-H", + "accept: */*", + "-H", + "authorization: token " .. secret, + "-H", + "editor-version: vscode/1.90.2", + "-H", + "editor-plugin-version: copilot-chat/0.17.2024062801", + "-H", + "user-agent: GitHubCopilotChat/0.17.2024062801", + } + + for _, arg in ipairs(args) do + table.insert(curl_params, arg) + end + + tasker.run(nil, "curl", curl_params, function(code, signal, stdout, stderr) + if code ~= 0 then + logger.error(string.format("copilot bearer resolve failed: %d, %d", code, signal, stderr)) + return + end + + V._state.copilot_bearer = vim.json.decode(stdout) + secrets.copilot_bearer = V._state.copilot_bearer.token + helpers.table_to_file(V._state, state_file) + + logger.debug("vault refresh_copilot_bearer: token resolved, running callback", true) + callback() + end, nil, nil) +end + +---@param name string # secret name +---@param callback function # function to run after secret is resolved +V.run_with_secret = function(name, callback) + name = alias[name] or name + if not secrets[name] then + logger.warning("vault secret " .. name .. " not found", true) + return + end + if type(secrets[name]) == "table" then + V.resolve_secret(name, secrets[name], function() + logger.debug("vault run_with_secret: " .. name .. " resolved, running callback", true) + callback() + end) + else + logger.debug("vault run_with_secret: " .. name .. " already resolved, running callback", true) + callback() + end +end + +return V diff --git a/lua/gp/whisper.lua b/lua/gp/whisper.lua new file mode 100644 index 00000000..329d8997 --- /dev/null +++ b/lua/gp/whisper.lua @@ -0,0 +1,364 @@ +-------------------------------------------------------------------------------- +-- Whisper module for transcribing speech +-------------------------------------------------------------------------------- + +local uv = vim.uv or vim.loop + +local logger = require("gp.logger") +local tasker = require("gp.tasker") +local render = require("gp.render") +local helpers = require("gp.helper") +local vault = require("gp.vault") + +local default_config = require("gp.config") + +local W = { + config = {}, + cmd = {}, + disabled = false, +} + +---@param opts table # user config +W.setup = function(opts) + logger.debug("whisper setup started\n" .. vim.inspect(opts)) + + W.config = vim.deepcopy(default_config.whisper) + + if opts.disable then + W.disabled = true + logger.debug("whisper is disabled") + return + end + + for k, v in pairs(opts) do + W.config[k] = v + end + + W.config.store_dir = helpers.prepare_dir(W.config.store_dir, "whisper store") + + for cmd, _ in pairs(W.cmd) do + helpers.create_user_command(W.config.cmd_prefix .. cmd, W.cmd[cmd]) + end + logger.debug("whisper setup finished") +end + +---@param callback function # callback function(text) +---@param language string | nil # language code +local whisper = function(callback, language) + language = language or W.config.language + -- make sure sox is installed + if vim.fn.executable("sox") == 0 then + logger.error("sox is not installed") + return + end + + local bearer = vault.get_secret("openai_api_key") + if not bearer then + logger.error("OpenAI API key not found") + return + end + + local rec_file = W.config.store_dir .. "/rec.wav" + local rec_options = { + sox = { + cmd = "sox", + opts = { + "-c", + "1", + "--buffer", + "32", + "-d", + "rec.wav", + "trim", + "0", + "3600", + }, + exit_code = 0, + }, + arecord = { + cmd = "arecord", + opts = { + "-c", + "1", + "-f", + "S16_LE", + "-r", + "48000", + "-d", + 3600, + "rec.wav", + }, + exit_code = 1, + }, + ffmpeg = { + cmd = "ffmpeg", + opts = { + "-y", + "-f", + "avfoundation", + "-i", + ":0", + "-t", + "3600", + "rec.wav", + }, + exit_code = 255, + }, + } + + local gid = helpers.create_augroup("GpWhisper", { clear = true }) + + -- create popup + local buf, _, close_popup, _ = render.popup( + nil, + W.config.cmd_prefix .. " Whisper", + function(w, h) + return 60, 12, (h - 12) * 0.4, (w - 60) * 0.5 + end, + { gid = gid, on_leave = false, escape = false, persist = false }, + { border = W.config.style_popup_border or "single" } + ) + + -- animated instructions in the popup + local counter = 0 + local timer = uv.new_timer() + timer:start( + 0, + 200, + vim.schedule_wrap(function() + if vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + " ", + " Speak πŸ‘„ loudly πŸ“£ into the microphone 🎀: ", + " " .. string.rep("πŸ‘‚", counter), + " ", + " Pressing starts the transcription.", + " ", + " Cancel the recording with / or :GpStop.", + " ", + " The last recording is in /tmp/gp_whisper/.", + }) + end + counter = counter + 1 + if counter % 22 == 0 then + counter = 0 + end + end) + ) + + local close = tasker.once(function() + if timer then + timer:stop() + timer:close() + end + close_popup() + vim.api.nvim_del_augroup_by_id(gid) + tasker.stop() + end) + + helpers.set_keymap({ buf }, { "n", "i", "v" }, "", function() + tasker.stop() + end) + + helpers.set_keymap({ buf }, { "n", "i", "v" }, "", function() + tasker.stop() + end) + + local continue = false + helpers.set_keymap({ buf }, { "n", "i", "v" }, "", function() + continue = true + vim.defer_fn(function() + tasker.stop() + end, 300) + end) + + -- cleanup on buffer exit + helpers.autocmd({ "BufWipeout", "BufHidden", "BufDelete" }, { buf }, close, gid) + + local curl_params = W.config.curl_params or {} + local curl = "curl" .. " " .. table.concat(curl_params, " ") + + -- transcribe the recording + local transcribe = function() + local cmd = "cd " + .. W.config.store_dir + .. " && " + .. "export LC_NUMERIC='C' && " + -- normalize volume to -3dB + .. "sox --norm=-3 rec.wav norm.wav && " + -- get RMS level dB * silence threshold + .. "t=$(sox 'norm.wav' -n channels 1 stats 2>&1 | grep 'RMS lev dB' " + .. " | sed -e 's/.* //' | awk '{print $1*" + .. W.config.silence + .. "}') && " + -- remove silence, speed up, pad and convert to mp3 + .. "sox -q norm.wav -C 196.5 final.mp3 silence -l 1 0.05 $t'dB' -1 1.0 $t'dB'" + .. " pad 0.1 0.1 tempo " + .. W.config.tempo + .. " && " + -- call openai + .. curl + .. " --max-time 20 " + .. W.config.endpoint + .. ' -s -H "Authorization: Bearer ' + .. bearer + .. '" -H "Content-Type: multipart/form-data" ' + .. '-F model="whisper-1" -F language="' + .. language + .. '" -F file="@final.mp3" ' + .. '-F response_format="json"' + + tasker.run(nil, "bash", { "-c", cmd }, function(code, signal, stdout, _) + if code ~= 0 then + logger.error(string.format("Whisper query exited: %d, %d", code, signal)) + return + end + + if not stdout or stdout == "" or #stdout < 11 then + logger.error("Whisper query, no stdout: " .. vim.inspect(stdout)) + return + end + local text = vim.json.decode(stdout).text + if not text then + logger.error("Whisper query, no text: " .. vim.inspect(stdout)) + return + end + + text = table.concat(vim.split(text, "\n"), " ") + text = text:gsub("%s+$", "") + + if callback and stdout then + callback(text) + end + end) + end + + local cmd = {} + + local rec_cmd = W.config.rec_cmd + -- if rec_cmd not set explicitly, try to autodetect + if not rec_cmd then + rec_cmd = "sox" + if vim.fn.executable("ffmpeg") == 1 then + local devices = vim.fn.system("ffmpeg -devices -v quiet | grep -i avfoundation | wc -l") + devices = string.gsub(devices, "^%s*(.-)%s*$", "%1") + if devices == "1" then + rec_cmd = "ffmpeg" + end + end + if vim.fn.executable("arecord") == 1 then + rec_cmd = "arecord" + end + end + + if type(rec_cmd) == "table" and rec_cmd[1] and rec_options[rec_cmd[1]] then + rec_cmd = vim.deepcopy(rec_cmd) + cmd.cmd = table.remove(rec_cmd, 1) + cmd.exit_code = rec_options[cmd.cmd].exit_code + cmd.opts = rec_cmd + elseif type(rec_cmd) == "string" and rec_options[rec_cmd] then + cmd = rec_options[rec_cmd] + else + logger.error(string.format("Whisper got invalid recording command: %s", rec_cmd)) + close() + return + end + for i, v in ipairs(cmd.opts) do + if v == "rec.wav" then + cmd.opts[i] = rec_file + end + end + + tasker.run(nil, cmd.cmd, cmd.opts, function(code, signal, stdout, stderr) + close() + + if code and code ~= cmd.exit_code then + logger.error( + cmd.cmd + .. " exited with code and signal:\ncode: " + .. code + .. ", signal: " + .. signal + .. "\nstdout: " + .. vim.inspect(stdout) + .. "\nstderr: " + .. vim.inspect(stderr) + ) + return + end + + if not continue then + return + end + + vim.schedule(function() + transcribe() + end) + end) +end + +---@param callback function # callback function(text) +---@param language string | nil # language code +W.Whisper = function(callback, language) + vault.run_with_secret("openai_api_key", function() + whisper(callback, language) + end) +end + +W.cmd.Whisper = function(params) + local buf = vim.api.nvim_get_current_buf() + local start_line = vim.api.nvim_win_get_cursor(0)[1] + local end_line = start_line + + if params.range == 2 then + start_line = params.line1 + end_line = params.line2 + end + + local args = vim.split(params.args, " ") + + local language = W.config.language + if args[1] ~= "" then + language = args[1] + end + + W.Whisper(function(text) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + + if text then + vim.api.nvim_buf_set_lines(buf, start_line - 1, end_line, false, { text }) + end + end, language) +end + +W.check_health = function() + if W.disabled then + vim.health.warn("whisper is disabled") + return + end + if vim.fn.executable("sox") == 1 then + vim.health.ok("sox is installed") + local output = vim.fn.system("sox -h | grep -i mp3 | wc -l 2>/dev/null") + if output:sub(1, 1) == "0" then + vim.health.error("sox is not compiled with mp3 support" .. "\n on debian/ubuntu install libsox-fmt-mp3") + else + vim.health.ok("sox is compiled with mp3 support") + end + else + vim.health.warn("sox is not installed") + end + + if vim.fn.executable("arecord") == 1 then + vim.health.ok("arecord found - will be used for recording (sox for post-processing)") + elseif vim.fn.executable("ffmpeg") == 1 then + local devices = vim.fn.system("ffmpeg -devices -v quiet | grep -i avfoundation | wc -l") + devices = string.gsub(devices, "^%s*(.-)%s*$", "%1") + if devices == "1" then + vim.health.ok("ffmpeg with avfoundation found - will be used for recording (sox for post-processing)") + end + end +end + +return W