diff --git a/README.md b/README.md index dabc40e..06903b8 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,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/8dd99d85adfcfcb326f85a1f15bcd254f628df59/lua/gp/config.lua#L10-L627 +https://github.com/Robitx/gp.nvim/blob/1be358df28e39132f894871d387d9373ab3636fa/lua/gp/config.lua#L10-L630 # Usage diff --git a/after/ftplugin/gpchat.lua b/after/ftplugin/gpchat.lua new file mode 100644 index 0000000..3ad2a90 --- /dev/null +++ b/after/ftplugin/gpchat.lua @@ -0,0 +1,182 @@ +local M = require("gp") + +M.logger.debug("gpchat: loading ftplugin") + +vim.opt_local.swapfile = false +vim.opt_local.wrap = true +vim.opt_local.linebreak = true + +local buf = vim.api.nvim_get_current_buf() +local ns_id = vim.api.nvim_create_namespace("GpChatExt_" .. buf) + +-- ensure normal mode +vim.cmd.stopinsert() +M.helpers.feedkeys("", "xn") + +M.logger.debug("gpchat: ns_id " .. ns_id .. " for buffer " .. buf) + +if M.config.chat_prompt_buf_type then + 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 = "" }) + end) +end + +-- setup chat specific commands +local commands = { + { + command = "ChatRespond", + modes = M.config.chat_shortcut_respond.modes, + shortcut = M.config.chat_shortcut_respond.shortcut, + comment = "GPT prompt Chat Respond", + }, + { + command = "ChatNew", + modes = M.config.chat_shortcut_new.modes, + shortcut = M.config.chat_shortcut_new.shortcut, + comment = "GPT prompt Chat New", + }, + { + command = "ChatHelp", + modes = M.config.chat_shortcut_help.modes, + shortcut = M.config.chat_shortcut_help.shortcut, + comment = "GPT prompt Chat Help", + }, +} +for _, rc in ipairs(commands) do + local cmd = M.config.cmd_prefix .. rc.command .. "" + for _, mode in ipairs(rc.modes) do + if mode == "n" or mode == "i" then + 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.helpers.feedkeys("", "xn") + end, rc.comment) + else + M.helpers.set_keymap({ buf }, mode, rc.shortcut, ":'<,'>" .. cmd, rc.comment) + end + end +end + +local ds = M.config.chat_shortcut_delete +M.helpers.set_keymap({ buf }, ds.modes, ds.shortcut, M.cmd.ChatDelete, "GPT prompt Chat Delete") + +local ss = M.config.chat_shortcut_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 + vim.opt_local.conceallevel = 2 + vim.opt_local.concealcursor = "" + vim.fn.matchadd("Conceal", [[^- model: .*model.:.[^"]*\zs".*\ze]], 10, -1, { conceal = "…" }) + vim.fn.matchadd("Conceal", [[^- model: \zs.*model.:.\ze.*]], 10, -1, { conceal = "…" }) + vim.fn.matchadd("Conceal", [[^- role: .\{64,64\}\zs.*\ze]], 10, -1, { conceal = "…" }) + vim.fn.matchadd("Conceal", [[^- role: .[^\\]*\zs\\.*\ze]], 10, -1, { conceal = "…" }) +end + +vim.api.nvim_create_autocmd({ "BufEnter", "TextChanged", "InsertLeave" }, { + buffer = buf, + callback = function(event) + if M.helpers.deleted_invalid_autocmd(buf, event) then + return + end + + local filename = vim.api.nvim_buf_get_name(buf) + local dir = vim.fn.fnamemodify(filename, ":h") + + local name = vim.fn.fnamemodify(filename, ":t") + local _, _, prefix = name:find("^(.*)_[^_]*$") + name = prefix and name:sub(#prefix + 2) or name + + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local headers, _, _ = M.helpers.parse_headers(lines) + local topic = headers["topic"] or "" + topic = topic:gsub("[^%w%s]", ""):lower() + topic = topic:gsub("%s+", "_"):gsub("^_+", ""):gsub("_+$", "") + + if topic and topic ~= "" and topic ~= prefix then + local new_filename = dir .. "/" .. topic .. "_" .. name + M.logger.debug("gpchat: renaming buffer " .. buf .. " from " .. filename .. " to " .. new_filename) + vim.api.nvim_buf_set_name(buf, new_filename) + M.helpers.delete_file(filename) + end + + local context_dir = headers["contextDir"] or "?" + local new_context_dir = nil + if context_dir ~= "?" and context_dir ~= "" then + local full_path = vim.fn.fnamemodify(context_dir, ":p") + if vim.fn.isdirectory(full_path) == 1 then + new_context_dir = vim.fn.resolve(full_path) + else + M.logger.warning("gpchat: contextDir " .. full_path .. " is not a directory") + end + end + M.buffer_state.set(buf, "context_dir", new_context_dir) + + M.helpers.save_buffer(buf, "gpchat TextChanged InsertLeave autocmd") + end, +}) +vim.api.nvim_create_autocmd({ "User" }, { + callback = function(event) + if event.event == "User" and event.match ~= "GpRefresh" then + return + end + if M.helpers.deleted_invalid_autocmd(buf, event) then + return + end + + M.logger.debug("gpchat: refreshing buffer " .. buf .. " " .. vim.json.encode(event)) + + M.chat_header(buf) + + vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1) + + local msg = "Current Agent: [" .. M._state.chat_agent .. "]" + if not M._state.show_chat_help then + msg = "Toggle help: " .. M.config.chat_shortcut_help.shortcut .. " | " .. msg + end + + vim.api.nvim_buf_set_extmark(buf, ns_id, 0, 0, { + strict = false, + right_gravity = false, + virt_text_pos = "right_align", + virt_text = { + { msg, "DiagnosticHint" }, + }, + hl_mode = "combine", + }) + + M.helpers.save_buffer(buf, "gpchat User GpRefresh autocmd") + end, +}) + +local has_cmp, cmp = pcall(require, "cmp") +if not has_cmp then + M.logger.debug("gpchat: cmp not found, skipping cmp setup") + return +end + +M.macro.build_cmp_source("gpchat", { + require("gp.macros.with_file"), + require("gp.macros.with_repo_instructions"), +}) + +local sources = { + { name = "gpchat" }, +} +for _, source in pairs(cmp.get_config().sources) do + if source.name ~= "gpchat" and source.name ~= "buffer" then + table.insert(sources, source) + end +end + +M.logger.debug("gpchat: cmp sources " .. vim.inspect(sources)) + +cmp.setup.buffer({ + -- keyword_length = 1, + max_item_count = 100, + completion = { autocomplete = { require("cmp.types").cmp.TriggerEvent.TextChanged } }, + sources = sources, +}) diff --git a/after/ftplugin/gpmd.lua b/after/ftplugin/gpmd.lua new file mode 100644 index 0000000..97b1f75 --- /dev/null +++ b/after/ftplugin/gpmd.lua @@ -0,0 +1,24 @@ +local M = require("gp") + +M.logger.debug("gpmd: loading ftplugin") + +vim.opt_local.swapfile = false +vim.opt_local.wrap = true +vim.opt_local.linebreak = true + +local buf = vim.api.nvim_get_current_buf() + +vim.api.nvim_create_autocmd({ "TextChanged", "InsertLeave" }, { + buffer = buf, + callback = function(event) + if M.helpers.deleted_invalid_autocmd(buf, event) then + return + end + M.logger.debug("gpmd: saving buffer " .. buf .. " " .. vim.json.encode(event)) + M.helpers.save_buffer(buf, "gpmd TextChanged InsertLeave autocmd") + end, +}) + +-- ensure normal mode +vim.cmd.stopinsert() +M.helpers.feedkeys("", "xn") diff --git a/doc/gp.nvim.txt b/doc/gp.nvim.txt index 41805d1..653e9e5 100644 --- a/doc/gp.nvim.txt +++ b/doc/gp.nvim.txt @@ -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/8dd99d85adfcfcb326f85a1f15bcd254f628df59/lua/gp/config.lua#L10-L627 +https://github.com/Robitx/gp.nvim/blob/1be358df28e39132f894871d387d9373ab3636fa/lua/gp/config.lua#L10-L630 ============================================================================== diff --git a/lua/gp/buffer_state.lua b/lua/gp/buffer_state.lua new file mode 100644 index 0000000..3f3b960 --- /dev/null +++ b/lua/gp/buffer_state.lua @@ -0,0 +1,39 @@ +local logger = require("gp.logger") + +local M = {} + +local state = {} + +---@param buf number # buffer number +M.clear = function(buf) + logger.debug("buffer state[" .. buf .. "] clear: current state: " .. vim.inspect(state[buf])) + state[buf] = nil +end + +---@param buf number # buffer number +---@return table # buffer state +M.get = function(buf) + logger.debug("buffer state[" .. buf .. "]: get: " .. vim.inspect(state[buf])) + return state[buf] or {} +end + +---@param buf number # buffer number +---@param key string # key to get +---@return any # value of the key +M.get_key = function(buf, key) + local value = state[buf] and state[buf][key] or nil + logger.debug("buffer state[" .. buf .. "] get_key: key '" .. key .. "' value: " .. vim.inspect(value)) + return value +end + +---@param buf number # buffer number +---@param key string # key to set +---@param value any # value to set +M.set = function(buf, key, value) + logger.debug("buffer state[" .. buf .. "]: set: key '" .. key .. "' to value: " .. vim.inspect(value)) + state[buf] = state[buf] or {} + state[buf][key] = value + logger.debug("buffer state[" .. buf .. "]: set: updated state: " .. vim.inspect(state[buf])) +end + +return M diff --git a/lua/gp/config.lua b/lua/gp/config.lua index 1924e43..8bd9ace 100644 --- a/lua/gp/config.lua +++ b/lua/gp/config.lua @@ -331,6 +331,7 @@ local config = { chat_shortcut_delete = { modes = { "n", "i", "v", "x" }, shortcut = "d" }, chat_shortcut_stop = { modes = { "n", "i", "v", "x" }, shortcut = "s" }, chat_shortcut_new = { modes = { "n", "i", "v", "x" }, shortcut = "c" }, + chat_shortcut_help = { modes = { "n", "i", "v", "x" }, shortcut = "h" }, -- default search term when using :GpChatFinder chat_finder_pattern = "topic ", chat_finder_mappings = { @@ -377,18 +378,20 @@ local config = { command_auto_select_response = true, -- templates - template_selection = "I have the following from {{filename}}:" + template_selection = "I have the following primary snippet from {{filename}}:" .. "\n\n```{{filetype}}\n{{selection}}\n```\n\n{{command}}", - template_rewrite = "I have the following from {{filename}}:" + template_rewrite = "I have the following primary snippet from {{filename}}:" .. "\n\n```{{filetype}}\n{{selection}}\n```\n\n{{command}}" - .. "\n\nRespond exclusively with the snippet that should replace the selection above.", - template_append = "I have the following from {{filename}}:" + .. "\n\nRespond exclusively with the snippet that should replace the primary selection above.", + template_append = "I have the following primary snippet from {{filename}}:" .. "\n\n```{{filetype}}\n{{selection}}\n```\n\n{{command}}" - .. "\n\nRespond exclusively with the snippet that should be appended after the selection above.", - template_prepend = "I have the following from {{filename}}:" + .. "\n\nRespond exclusively with the snippet that should be appended after the primary selection above.", + template_prepend = "I have the following primary snippet from {{filename}}:" .. "\n\n```{{filetype}}\n{{selection}}\n```\n\n{{command}}" - .. "\n\nRespond exclusively with the snippet that should be prepended before the selection above.", + .. "\n\nRespond exclusively with the snippet that should be prepended before the primary selection above.", template_command = "{{command}}", + template_context_file = "\n\nHere is a file {{filename}} for additional context:" + .. "\n\n```\n{{content}}\n```\n\n", -- https://platform.openai.com/docs/guides/speech-to-text/quickstart -- Whisper costs $0.006 / minute (rounded to the nearest second) diff --git a/lua/gp/defaults.lua b/lua/gp/defaults.lua index 0aa1572..aaf3f70 100644 --- a/lua/gp/defaults.lua +++ b/lua/gp/defaults.lua @@ -13,15 +13,18 @@ M.code_system_prompt = "You are an AI working as a code editor.\n\n" .. "Please AVOID COMMENTARY OUTSIDE OF THE SNIPPET RESPONSE.\n" .. "START AND END YOUR ANSWER WITH:\n\n```" -M.chat_template = [[ -# topic: ? +M.chat_help = [[ +# Write your queries after {{user_prefix}}. Use `{{respond_shortcut}}` or :{{cmd_prefix}}ChatRespond to generate a response. +# Response generation can be terminated by using `{{stop_shortcut}}` or :{{cmd_prefix}}ChatStop command. +# Chats are saved automatically. To delete this chat, use `{{delete_shortcut}}` or :{{cmd_prefix}}ChatDelete. +# Be cautious of very long chats. Start a fresh chat by using `{{new_shortcut}}` or :{{cmd_prefix}}ChatNew. +# Add context macros by typing @ in the chat. Toggle this help by `{{help_shortcut}}` or :{{cmd_prefix}}ChatHelp.]] -- file: {{filename}} +M.chat_template = [[ +--- +topic: ? {{optional_headers}} -Write your queries after {{user_prefix}}. Use `{{respond_shortcut}}` or :{{cmd_prefix}}ChatRespond to generate a response. -Response generation can be terminated by using `{{stop_shortcut}}` or :{{cmd_prefix}}ChatStop command. -Chats are saved automatically. To delete this chat, use `{{delete_shortcut}}` or :{{cmd_prefix}}ChatDelete. -Be cautious of very long chats. Start a fresh chat by using `{{new_shortcut}}` or :{{cmd_prefix}}ChatNew. +]] .. M.chat_help .. [[ --- @@ -29,8 +32,8 @@ Be cautious of very long chats. Start a fresh chat by using `{{new_shortcut}}` o ]] M.short_chat_template = [[ -# topic: ? -- file: {{filename}} +--- +topic: ? --- {{user_prefix}} diff --git a/lua/gp/dispatcher.lua b/lua/gp/dispatcher.lua index d4214aa..976e450 100644 --- a/lua/gp/dispatcher.lua +++ b/lua/gp/dispatcher.lua @@ -2,6 +2,8 @@ -- Dispatcher handles the communication between the plugin and LLM providers. -------------------------------------------------------------------------------- +local uv = vim.uv or vim.loop + local logger = require("gp.logger") local tasker = require("gp.tasker") local vault = require("gp.vault") @@ -18,7 +20,7 @@ local D = { ---@param opts table # user config D.setup = function(opts) - logger.debug("dispatcher setup started\n" .. vim.inspect(opts)) + logger.debug("dispatcher: setup started\n" .. vim.inspect(opts)) D.config.curl_params = opts.curl_params or default_config.curl_params @@ -52,9 +54,26 @@ D.setup = function(opts) D.query_dir = helpers.prepare_dir(D.query_dir, "query store") - local files = vim.fn.glob(D.query_dir .. "/*.json", false, true) + local files = {} + local handle = uv.fs_scandir(D.query_dir) + if handle then + local name, type + while true do + name, type = uv.fs_scandir_next(handle) + if not name then + break + end + local path = D.query_dir .. "/" .. name + type = type or uv.fs_stat(path).type + if type == "file" and name:match("%.json$") then + table.insert(files, path) + end + end + end + + logger.debug("dispatcher: query files: " .. #files) if #files > 200 then - logger.debug("too many query files, truncating cache") + logger.debug("dispatcher: too many query files, truncating cache") table.sort(files, function(a, b) return a > b end) @@ -63,7 +82,7 @@ D.setup = function(opts) end end - logger.debug("dispatcher setup finished\n" .. vim.inspect(D)) + logger.debug("dispatcher: setup finished\n" .. vim.inspect(D)) end ---@param messages table diff --git a/lua/gp/helper.lua b/lua/gp/helper.lua index 1864bde..bace9c4 100644 --- a/lua/gp/helper.lua +++ b/lua/gp/helper.lua @@ -63,6 +63,18 @@ _H.autocmd = function(events, buffers, callback, gid) end end +---@param callback function # callback to schedule +---@param depth number # depth of nested scheduling +_H.schedule = function(callback, depth) + logger.debug("scheduling callback with depth: " .. depth) + if depth <= 0 then + return callback() + end + return vim.schedule(function() + _H.schedule(callback, depth - 1) + 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 @@ -272,12 +284,12 @@ _H.create_user_command = function(cmd_name, cmd_func, completion, desc) logger.debug( "completing user command: " .. cmd_name - .. "\narg_lead: " - .. arg_lead - .. "\ncmd_line: " - .. cmd_line - .. "\ncursor_pos: " + .. " cursor_pos: " .. cursor_pos + .. " arg_lead: " + .. vim.inspect(arg_lead) + .. " cmd_line: " + .. vim.inspect(cmd_line) ) if not completion then return {} @@ -293,4 +305,54 @@ _H.create_user_command = function(cmd_name, cmd_func, completion, desc) }) end +---@param lines string[] # array of lines +---@return table, table, number | nil, table # headers, indices, last header line, comments +_H.parse_headers = function(lines) + local headers = {} + local indices = {} + local comments = {} + + for i, line in ipairs(lines) do + if i > 1 and line:sub(1, 3) == "---" then + return headers, indices, i - 1, comments + end + + local key, value = line:match("^[-#%s]*(%w+):%s*(.*)%s*") + if key ~= nil then + headers[key] = value + indices[key] = i - 1 + elseif line:match("^# ") then + comments[line] = i - 1 + end + end + + return headers, indices, nil, comments +end + +---@param buf number # buffer number +---@param event table # event object +_H.deleted_invalid_autocmd = function(buf, event) + if not vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_del_autocmd(event.id) + logger.debug("deleting invalid autocmd: " .. event.id .. " for buffer: " .. buf) + return true + end + return false +end + +---@param buf number # buffer number +---@param caller string | nil # cause of the save +---@return boolean # true if successful, false otherwise +_H.save_buffer = function(buf, caller) + if not vim.api.nvim_buf_is_valid(buf) then + return false + end + local success = pcall(vim.api.nvim_buf_call, buf, function() + vim.cmd('silent! write') + end) + caller = caller or "unknown" + logger.debug("saving buffer: " .. buf .. " success: " .. vim.inspect(success) .. " caller: " .. vim.inspect(caller)) + return success +end + return _H diff --git a/lua/gp/init.lua b/lua/gp/init.lua index 128914c..c6b066e 100644 --- a/lua/gp/init.lua +++ b/lua/gp/init.lua @@ -6,6 +6,9 @@ -------------------------------------------------------------------------------- local config = require("gp.config") +local uv = vim.uv or vim.loop +table.unpack = table.unpack or unpack -- 5.1 compatibility + local M = { _Name = "Gp", -- plugin name _state = {}, -- table of state variables @@ -24,12 +27,21 @@ local M = { tasker = require("gp.tasker"), -- tasker module vault = require("gp.vault"), -- handles secrets whisper = require("gp.whisper"), -- whisper module + macro = require("gp.macro"), -- builder for macro completion + buffer_state = require("gp.buffer_state"), -- buffer state module } -------------------------------------------------------------------------------- -- Module helper functions and variables -------------------------------------------------------------------------------- +M.cmd.Do = function(params) + M.logger.info("Dummy Do command called:\n" .. vim.inspect(params)) + local result = M.command_parser(params.args, {}, {}) + result.template = M.render.template(result.template, result.artifacts) + M.logger.info("Dummy Do command result:\n" .. vim.inspect(result)) +end + local agent_completion = function() local buf = vim.api.nvim_get_current_buf() local file_name = vim.api.nvim_buf_get_name(buf) @@ -167,15 +179,10 @@ M.setup = function(opts) table.sort(M._chat_agents) table.sort(M._command_agents) - M.refresh_state() - - if M.config.default_command_agent then - M.refresh_state({ command_agent = M.config.default_command_agent }) - end - - if M.config.default_chat_agent then - M.refresh_state({ chat_agent = M.config.default_chat_agent }) - end + M.refresh_state({ + command_agent = M.config.default_command_agent, + chat_agent = M.config.default_chat_agent, + }) -- register user commands for hook, _ in pairs(M.hooks) do @@ -189,12 +196,74 @@ M.setup = function(opts) end) end + M.logger.debug("hook setup done") + + local ft_completion = M.macro.build_completion({ + require("gp.macros.agent"), + require("gp.macros.target_filename"), + require("gp.macros.target_filetype"), + require("gp.macros.with_current_buf"), + require("gp.macros.with_file"), + require("gp.macros.with_repo_instructions"), + }) + + local base_completion = M.macro.build_completion({ + require("gp.macros.agent"), + require("gp.macros.with_current_buf"), + require("gp.macros.with_file"), + require("gp.macros.with_repo_instructions"), + }) + + M.logger.debug("ft_completion done") + + local do_completion = M.macro.build_completion({ + require("gp.macros.agent"), + require("gp.macros.target"), + require("gp.macros.target_filename"), + require("gp.macros.target_filetype"), + require("gp.macros.with_current_buf"), + require("gp.macros.with_file"), + require("gp.macros.with_repo_instructions"), + }) + + M.logger.debug("do_completion done") + + M.command_parser = M.macro.build_parser({ + require("gp.macros.agent"), + require("gp.macros.target"), + require("gp.macros.target_filename"), + require("gp.macros.target_filetype"), + require("gp.macros.with_current_buf"), + require("gp.macros.with_file"), + require("gp.macros.with_repo_instructions"), + }) + + M.chat_parser = M.macro.build_parser({ + require("gp.macros.with_file"), + require("gp.macros.with_repo_instructions"), + }) + local completions = { ChatNew = { "popup", "split", "vsplit", "tabnew" }, ChatPaste = { "popup", "split", "vsplit", "tabnew" }, ChatToggle = { "popup", "split", "vsplit", "tabnew" }, Context = { "popup", "split", "vsplit", "tabnew" }, Agent = agent_completion, + Do = do_completion, + Enew = ft_completion, + New = ft_completion, + Vnew = ft_completion, + Tabnew = ft_completion, + Rewrite = base_completion, + Prepend = base_completion, + Append = base_completion, + Popup = base_completion, + } + + local updates = { + ChatHelp = function() + return { show_chat_help = not M._state.show_chat_help } + end, } -- register default commands @@ -202,13 +271,53 @@ M.setup = function(opts) if M.hooks[cmd] == nil then M.helpers.create_user_command(M.config.cmd_prefix .. cmd, function(params) M.logger.debug("running command: " .. cmd) - M.refresh_state() + M.refresh_state((updates[cmd] or function() end)()) M.cmd[cmd](params) end, completions[cmd]) end end - M.buf_handler() + vim.api.nvim_create_autocmd("BufEnter", { + pattern = "*.md", + callback = function(ev) + M.helpers.schedule(function() + local buf = ev.buf + local current_ft = vim.bo[buf].filetype + + if current_ft == "markdown.gpchat" then + vim.cmd("doautocmd User GpRefresh") + elseif current_ft ~= "markdown.gpmd" then + local path = ev.file + if M.helpers.ends_with(path, ".gp.md") then + vim.bo[buf].filetype = "markdown.gpmd" + elseif M.not_chat(buf, path) == nil then + vim.bo[buf].filetype = "markdown.gpchat" + vim.cmd("doautocmd User GpRefresh") + end + end + end, 1) + end, + }) + + vim.api.nvim_create_autocmd("BufEnter", { + callback = function(ev) + local buf = ev.buf + local context_dir = M.buffer_state.get_key(buf, "context_dir") + context_dir = context_dir or M.helpers.find_git_root() + if context_dir == "" then + context_dir = vim.fn.getcwd() + end + + local full_path = vim.fn.fnamemodify(context_dir, ":p") + if vim.fn.isdirectory(full_path) == 1 then + full_path = vim.fn.resolve(full_path) + M.buffer_state.set(buf, "context_dir", full_path) + end + + local filename = vim.api.nvim_buf_get_name(buf) + M.buffer_state.set(buf, "is_chat", M.not_chat(buf, filename) == nil) + end, + }) if vim.fn.executable("curl") == 0 then M.logger.error("curl is not installed, run :checkhealth gp") @@ -257,6 +366,10 @@ M.refresh_state = function(update) M._state.last_chat = nil end + if M._state.show_chat_help == nil then + M._state.show_chat_help = true + end + 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( @@ -275,9 +388,7 @@ M.refresh_state = function(update) M.prepare_commands() - local buf = vim.api.nvim_get_current_buf() - local file_name = vim.api.nvim_buf_get_name(buf) - M.display_chat_agent(buf, file_name) + vim.cmd("doautocmd User GpRefresh") end M.Target = { @@ -315,6 +426,32 @@ M.Target = { end, } +---@param target number | table # target to get name for +---@return string # name of the target +---@return string | nil # filetype of the target, if applicable +M.get_target_name = function(target) + local names = {} + for name, value in pairs(M.Target) do + if type(value) == "number" then + names[value] = name + elseif type(value) == "function" then + local result = value() + if type(result) == "table" and result.type then + names[result.type] = name + end + end + end + + if type(target) == "number" then + return names[target] or "unknown" + elseif type(target) == "table" and target.type then + return names[target.type] or "unknown", target.filetype + end + + M.logger.error("Invalid target type: " .. vim.inspect(target)) + return "unknown" +end + -- creates prompt commands for each target M.prepare_commands = function() for name, target in pairs(M.Target) do @@ -428,23 +565,6 @@ M._toggle_resolve = function(kind) return M._toggle_kind.unknown end ----@param buf number | nil # buffer number -M.prep_md = function(buf) - -- disable swapping for this buffer and set filetype to markdown - vim.api.nvim_command("setlocal noswapfile") - -- better text wrapping - vim.api.nvim_command("setlocal wrap linebreak") - -- auto save on TextChanged, InsertLeave - vim.api.nvim_command("autocmd TextChanged,InsertLeave silent! write") - - -- register shortcuts local to this buffer - buf = buf or vim.api.nvim_get_current_buf() - - -- ensure normal mode - vim.api.nvim_command("stopinsert") - M.helpers.feedkeys("", "xn") -end - ---@param buf number # buffer number ---@param file_name string # file name ---@return string | nil # reason for not being a chat or nil if it is a chat @@ -456,157 +576,27 @@ M.not_chat = function(buf, file_name) return "resolved file (" .. file_name .. ") not in chat dir (" .. chat_dir .. ")" end - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - if #lines < 5 then - return "file too short" + local extension = vim.fn.fnamemodify(file_name, ":e") + if extension ~= "md" then + return "file extension is not .md" end - if not lines[1]:match("^# ") then - return "missing topic header" - end + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - local header_found = nil - for i = 1, 10 do - if i < #lines and lines[i]:match("^- file: ") then - header_found = true + local header_break_found = false + for i = 2, 20 do + if i < #lines and lines[i]:match("^%-%-%-%s*$") then + header_break_found = true break end end - if not header_found then - return "missing file header" + if not header_break_found then + return "missing header break" end return nil end -M.display_chat_agent = function(buf, file_name) - if M.not_chat(buf, file_name) then - return - end - - if buf ~= vim.api.nvim_get_current_buf() then - return - end - - local ns_id = vim.api.nvim_create_namespace("GpChatExt_" .. file_name) - vim.api.nvim_buf_clear_namespace(buf, ns_id, 0, -1) - - vim.api.nvim_buf_set_extmark(buf, ns_id, 0, 0, { - strict = false, - right_gravity = true, - virt_text_pos = "right_align", - virt_text = { - { "Current Agent: [" .. M._state.chat_agent .. "]", "DiagnosticHint" }, - }, - hl_mode = "combine", - }) -end - -M._prepared_bufs = {} -M.prep_chat = function(buf, file_name) - if M.not_chat(buf, file_name) then - return - end - - if buf ~= vim.api.nvim_get_current_buf() then - 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_set_option_value("buftype", "prompt", { buf = buf }) - vim.fn.prompt_setprompt(buf, "") - vim.fn.prompt_setcallback(buf, function() - M.cmd.ChatRespond({ args = "" }) - end) - end - - -- setup chat specific commands - local range_commands = { - { - command = "ChatRespond", - modes = M.config.chat_shortcut_respond.modes, - shortcut = M.config.chat_shortcut_respond.shortcut, - comment = "GPT prompt Chat Respond", - }, - { - command = "ChatNew", - modes = M.config.chat_shortcut_new.modes, - shortcut = M.config.chat_shortcut_new.shortcut, - comment = "GPT prompt Chat New", - }, - } - for _, rc in ipairs(range_commands) do - local cmd = M.config.cmd_prefix .. rc.command .. "" - for _, mode in ipairs(rc.modes) do - if mode == "n" or mode == "i" then - 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.helpers.feedkeys("", "xn") - end, rc.comment) - else - M.helpers.set_keymap({ buf }, mode, rc.shortcut, ":'<,'>" .. cmd, rc.comment) - end - end - end - - local ds = M.config.chat_shortcut_delete - M.helpers.set_keymap({ buf }, ds.modes, ds.shortcut, M.cmd.ChatDelete, "GPT prompt Chat Delete") - - local ss = M.config.chat_shortcut_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 - vim.opt_local.conceallevel = 2 - vim.opt_local.concealcursor = "" - vim.fn.matchadd("Conceal", [[^- model: .*model.:.[^"]*\zs".*\ze]], 10, -1, { conceal = "…" }) - vim.fn.matchadd("Conceal", [[^- model: \zs.*model.:.\ze.*]], 10, -1, { conceal = "…" }) - vim.fn.matchadd("Conceal", [[^- role: .\{64,64\}\zs.*\ze]], 10, -1, { conceal = "…" }) - vim.fn.matchadd("Conceal", [[^- role: .[^\\]*\zs\\.*\ze]], 10, -1, { conceal = "…" }) - end -end - -M.buf_handler = function() - local gid = M.helpers.create_augroup("GpBufHandler", { clear = true }) - - M.helpers.autocmd({ "BufEnter" }, nil, function(event) - local buf = event.buf - - if not vim.api.nvim_buf_is_valid(buf) then - return - end - - local file_name = vim.api.nvim_buf_get_name(buf) - - M.prep_chat(buf, file_name) - M.display_chat_agent(buf, file_name) - M.prep_context(buf, file_name) - end, gid) - - M.helpers.autocmd({ "WinEnter" }, nil, function(event) - local buf = event.buf - - if not vim.api.nvim_buf_is_valid(buf) then - return - end - - local file_name = vim.api.nvim_buf_get_name(buf) - - M.display_chat_agent(buf, file_name) - end, gid) -end - M.BufTarget = { current = 0, -- current window popup = 1, -- popup window @@ -783,15 +773,23 @@ M.new_chat = function(params, toggle, system_prompt, agent) system_prompt = "" end + local context_dir = M.buffer_state.get_key(vim.api.nvim_get_current_buf(), "context_dir") + context_dir = context_dir or M.helpers.find_git_root() + if context_dir == "" then + context_dir = vim.fn.getcwd() + end + context_dir = "contextDir: " .. context_dir .. "\n" + 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, + ["{{optional_headers}}"] = model .. provider .. system_prompt .. context_dir, ["{{user_prefix}}"] = M.config.chat_user_prefix, ["{{respond_shortcut}}"] = M.config.chat_shortcut_respond.shortcut, ["{{cmd_prefix}}"] = M.config.cmd_prefix, ["{{stop_shortcut}}"] = M.config.chat_shortcut_stop.shortcut, ["{{delete_shortcut}}"] = M.config.chat_shortcut_delete.shortcut, ["{{new_shortcut}}"] = M.config.chat_shortcut_new.shortcut, + ["{{help_shortcut}}"] = M.config.chat_shortcut_help.shortcut, }) -- escape underscores (for markdown) @@ -802,8 +800,11 @@ M.new_chat = function(params, toggle, system_prompt, agent) -- strip leading and trailing newlines template = template:gsub("^%s*(.-)%s*$", "%1") .. "\n" + local lines = vim.split(template, "\n") + lines = M.chat_header_lines(lines) + -- create chat file - vim.fn.writefile(vim.split(template, "\n"), filename) + vim.fn.writefile(lines, filename) local target = M.resolve_buf_target(params) local buf = M.open_buf(filename, target, M._toggle_kind.chat, toggle) @@ -944,9 +945,6 @@ M.chat_respond = function(params) -- go to normal mode vim.cmd("stopinsert") - -- get all lines - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - -- check if file looks like a chat file local file_name = vim.api.nvim_buf_get_name(buf) local reason = M.not_chat(buf, file_name) @@ -955,26 +953,8 @@ M.chat_respond = function(params) return end - -- headers are fields before first --- - local headers = {} - local header_end = nil - local line_idx = 0 - ---parse headers - for _, line in ipairs(lines) do - -- first line starts with --- - if line:sub(1, 3) == "---" then - header_end = line_idx - break - end - -- parse header fields - local key, value = line:match("^[-#] (%w+): (.*)") - if key ~= nil then - headers[key] = value - end - - line_idx = line_idx + 1 - end - + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local headers, indices, header_end = M.helpers.parse_headers(lines) if header_end == nil then M.logger.error("Error while parsing headers: --- not found. Check your chat template.") return @@ -982,16 +962,19 @@ M.chat_respond = function(params) -- message needs role and content local messages = {} - local role = "" + local role = "user" local content = "" -- iterate over lines - local start_index = header_end + 1 + local start_index = header_end + 2 local end_index = #lines if params.range == 2 then start_index = math.max(start_index, params.line1) end_index = math.min(end_index, params.line2) end + if start_index > end_index then + start_index = end_index + end local agent = M.get_chat_agent() local agent_name = agent.name @@ -1045,14 +1028,13 @@ M.chat_respond = function(params) table.insert(messages, { role = role, content = content }) role = "assistant" content = "" - elseif role ~= "" then + else content = content .. "\n" .. line end end -- insert last message not handled in loop table.insert(messages, { role = role, content = content }) - -- replace first empty message with system prompt content = "" if headers.role and headers.role:match("%S") then content = headers.role @@ -1062,14 +1044,25 @@ M.chat_respond = function(params) if content:match("%S") then -- make it multiline again if it contains escaped newlines content = content:gsub("\\n", "\n") - messages[1] = { role = "system", content = content } + table.insert(messages, 1, { role = "system", content = content }) end - -- strip whitespace from ends of content + local state = M.buffer_state.get(buf) for _, message in ipairs(messages) do + local response = M.chat_parser(message.content, {}, state) + if response then + message.content = M.render.template(response.template, response.artifacts) + state = response.state + end message.content = message.content:gsub("^%s*(.-)%s*$", "%1") end + messages = vim.tbl_filter(function(message) + return not (message.content == "" and message.role == "user") + end, messages) + + M.logger.debug("messages: " .. vim.inspect(messages), true) + -- write assistant prompt 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, "" }) @@ -1105,8 +1098,7 @@ M.chat_respond = function(params) M.helpers.undojoin(buf) vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) - -- if topic is ?, then generate it - if headers.topic == "?" then + if headers.topic and headers.topic:gsub("[^%w]", "") == "" then -- insert last model response table.insert(messages, { role = "assistant", content = qt.response }) @@ -1124,23 +1116,18 @@ M.chat_respond = function(params) 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 local topic = vim.api.nvim_buf_get_lines(topic_buf, 0, -1, false)[1] - -- close invisible buffer vim.api.nvim_buf_delete(topic_buf, { force = true }) - -- strip whitespace from ends of topic topic = topic:gsub("^%s*(.-)%s*$", "%1") - -- strip dot from end of topic topic = topic:gsub("%.$", "") - -- if topic is empty do not replace it if topic == "" then return end - -- replace topic in current buffer + local i = indices.topic M.helpers.undojoin(buf) - vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "# topic: " .. topic }) + vim.api.nvim_buf_set_lines(buf, i, i + 1, false, { "topic: " .. topic }) end) ) end @@ -1153,6 +1140,92 @@ M.chat_respond = function(params) ) end +---@param lines table # array of lines to process +---@return table # updated array of lines +---@return number # original header end +---@return number # new header end +M.chat_header_lines = function(lines) + local _, _, header_end, comments = M.helpers.parse_headers(lines) + if header_end == nil then + M.logger.error("Error while parsing headers: --- not found. Check your chat template.") + return lines, 0, 0 + end + + if header_end + 1 >= #lines then + return lines, 0, 0 + end + + local help_template = M.render.template(M.defaults.chat_help, { + ["{{user_prefix}}"] = M.config.chat_user_prefix, + ["{{respond_shortcut}}"] = M.config.chat_shortcut_respond.shortcut, + ["{{cmd_prefix}}"] = M.config.cmd_prefix, + ["{{stop_shortcut}}"] = M.config.chat_shortcut_stop.shortcut, + ["{{delete_shortcut}}"] = M.config.chat_shortcut_delete.shortcut, + ["{{new_shortcut}}"] = M.config.chat_shortcut_new.shortcut, + ["{{help_shortcut}}"] = M.config.chat_shortcut_help.shortcut, + }) + + local help_lines = vim.split(help_template, "\n") + local help_map = {} + for _, line in ipairs(help_lines) do + help_map[line] = true + end + + local insert_help = true + local drop_lines = {} + for comment, index in pairs(comments) do + if help_map[comment] then + insert_help = false + table.insert(drop_lines, index) + end + end + + local new_header_end = header_end + + if M._state.show_chat_help and insert_help then + for i = #help_lines, 1, -1 do + table.insert(lines, new_header_end + 1, help_lines[i]) + end + new_header_end = new_header_end + #help_lines + elseif not M._state.show_chat_help and not insert_help then + table.sort(drop_lines, function(a, b) + return a > b + end) + for _, index in ipairs(drop_lines) do + table.remove(lines, index + 1) + end + new_header_end = new_header_end - #drop_lines + end + + local j = 1 + while j <= new_header_end do + if lines[j]:match("^%s*$") then + table.remove(lines, j) + new_header_end = new_header_end - 1 + else + j = j + 1 + end + end + + return lines, header_end, new_header_end +end + +---@param buf number +M.chat_header = function(buf) + local file_name = vim.api.nvim_buf_get_name(buf) + M.logger.debug("ChatHelp: buffer: " .. buf .. " file: " .. file_name) + local reason = M.not_chat(buf, file_name) + if reason then + M.logger.debug("File " .. vim.inspect(file_name) .. " does not look like a chat file: " .. vim.inspect(reason)) + return + end + + local lines, old_header_end, header_end = M.chat_header_lines(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) + vim.api.nvim_buf_set_lines(buf, 0, old_header_end + 1, false, vim.list_slice(lines, 0, header_end + 1)) +end + +M.cmd.ChatHelp = function() end + M.cmd.ChatRespond = function(params) if params.args == "" and vim.v.count == 0 then M.chat_respond(params) @@ -1330,13 +1403,22 @@ M.cmd.ChatFinder = function() return end + table.sort(results, function(a, b) + local af = a.file:sub(-24, -11) + local bf = b.file:sub(-24, -11) + if af == bf then + return a.lnum < b.lnum + end + return af > bf + end) + picker_files = {} preview_lines = {} local picker_lines = {} for _, f in ipairs(results) do if f.line:len() > 0 then table.insert(picker_files, dir .. "/" .. f.file) - local fline = string.format("%s:%s %s", f.file:sub(3, -11), f.lnum, f.line) + local fline = string.format("%s:%s %s", f.file:sub(-24, -11), f.lnum, f.line) table.insert(picker_lines, fline) table.insert(preview_lines, tonumber(f.lnum)) end @@ -1592,42 +1674,6 @@ 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 @@ -1812,6 +1858,7 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) finish = M._selection_last_line + ll - fl end + --TODO: this bugs out when GpEnew called from welcome screen -- select from first_line to last_line vim.api.nvim_win_set_cursor(0, { start + 1, 0 }) vim.api.nvim_command("normal! V") @@ -1823,15 +1870,19 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) local filetype = M.helpers.get_filetype(buf) local filename = vim.api.nvim_buf_get_name(buf) + local state = M.buffer_state.get(buf) + local response = M.command_parser(command, {}, state) + if response then + command = M.render.template(response.template, response.artifacts) + state = response.state + end + + agent = state.agent or agent + 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 }) - local repo_instructions = M.repo_instructions() - if repo_instructions ~= "" then - table.insert(messages, { role = "system", content = repo_instructions }) - end - local user_prompt = M.render.prompt_template(template, command, selection, filetype, filename) table.insert(messages, { role = "user", content = user_prompt }) @@ -1904,7 +1955,7 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) win = vim.api.nvim_get_current_win() end - buf = vim.api.nvim_create_buf(true, true) + buf = vim.api.nvim_create_buf(true, false) vim.api.nvim_set_current_buf(buf) local group = M.helpers.create_augroup("GpScratchSave" .. M.helpers.uuid(), { clear = true }) @@ -1919,8 +1970,13 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) end, }) - local ft = target.filetype or filetype + local ft = state.target_filetype or target.filetype or filetype vim.api.nvim_set_option_value("filetype", ft, { buf = buf }) + local name = state.target_filename + if name then + vim.api.nvim_buf_set_name(buf, name) + M.helpers.save_buffer(buf, "Prompt created buffer") + end handler = M.dispatcher.create_handler(buf, win, 0, false, "", cursor) end @@ -1952,13 +2008,22 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) return end - -- if prompt is provided, ask the user to enter the command - vim.ui.input({ prompt = prompt, default = whisper }, function(input) - if not input or input == "" then - return - end - cb(input) - end) + -- old shortcuts might produce stuff like `:GpRewrite` this + -- used to be handled by vim.ui.input, which has trouble with completion + local command = ":" .. start_line .. "," .. end_line .. "Gp" + local targetName, filetype = M.get_target_name(target) + targetName = targetName:gsub("^%l", string.upper) + command = command .. targetName + command = command .. " @agent " .. agent.name + filetype = filetype and " @target_filetype " .. filetype or "" + command = command .. filetype + whisper = whisper and " " .. whisper or "" + command = command .. whisper + command = command .. " @with_repo_instructions" + command = command .. " @with_current_buf" + command = command .. " " + + vim.api.nvim_feedkeys(command, "n", false) end) end diff --git a/lua/gp/logger.lua b/lua/gp/logger.lua index 8250941..bfac06f 100644 --- a/lua/gp/logger.lua +++ b/lua/gp/logger.lua @@ -33,10 +33,13 @@ M.setup = function(path, sensitive) if vim.fn.isdirectory(dir) == 0 then vim.fn.mkdir(dir, "p") end + + local file_stats = uv.fs_stat(path) + M.debug("Log file " .. file .. " has " .. (file_stats and file_stats.size or 0) .. " bytes") + file = path - -- truncate log file if it's too big - if uv.fs_stat(file) then + if file_stats and file_stats.size > 5 * 1024 * 1024 then local content = {} for line in io.lines(file) do table.insert(content, line) diff --git a/lua/gp/macro.lua b/lua/gp/macro.lua new file mode 100644 index 0000000..48a569c --- /dev/null +++ b/lua/gp/macro.lua @@ -0,0 +1,247 @@ +local logger = require("gp.logger") +local buffer_state = require("gp.buffer_state") + +---@class gp.Macro_cmd_params +---@field arg_lead string +---@field cmd_line string +---@field cursor_pos number +---@field cropped_line string +---@field state table + +---@class gp.Macro_parser_result +---@field template string +---@field artifacts table +---@field state table + +--- gp.Macro Interface +-- @field name string: Name of the macro. +-- @field description string: Description of the macro. +-- @field default string: Default value for the macro (optional). +-- @field max_occurrences number: Maximum number of occurrences for the macro (optional). +-- @field triggered function: Function that determines if the macro is triggered. +-- @field completion function: Function that provides completion options. +-- @field parser function: Function that processes the macro in the template. + +---@class gp.Macro +---@field name string +---@field description string +---@field default? string +---@field max_occurrences? number +---@field triggered fun(params: gp.Macro_cmd_params): boolean +---@field completion fun(params: gp.Macro_cmd_params): string[] +---@field parser fun(params: gp.Macro_parser_result): gp.Macro_parser_result + +---@param value string # string to hash +---@return string # returns hash of the string +local fnv1a_hash = function(value) + ---@type number + local hash = 2166136261 + for i = 1, #value do + hash = vim.fn.xor(hash, string.byte(value, i)) + hash = vim.fn["and"]((hash * 16777619), 0xFFFFFFFF) + end + return string.format("%08x", hash) -- return as an 8-character hex string +end + +local M = {} + +---@param prefix string # prefix for the placeholder +---@param value string # value to hash +---@return string # returns placeholder +M.generate_placeholder = function(prefix, value) + local hash_value = fnv1a_hash(value) + local placeholder = "{{" .. prefix .. "." .. hash_value .. "}}" + return placeholder +end + +---@param macros gp.Macro[] +---@return fun(template: string, artifacts: table, state: table): gp.Macro_parser_result +M.build_parser = function(macros) + ---@param template string + ---@param artifacts table + ---@param state table + ---@return {template: string, artifacts: table, state: table} + local function parser(template, artifacts, state) + template = template or "" + ---@type gp.Macro_parser_result + local result = { + template = " " .. template .. " ", + artifacts = artifacts or {}, + state = state or buffer_state.get(vim.api.nvim_get_current_buf()), + } + logger.debug("macro parser input: " .. vim.inspect(result)) + + for _, macro in pairs(macros) do + logger.debug("macro parser current macro: " .. vim.inspect(macro)) + result = macro.parser(result) + logger.debug("macro parser result: " .. vim.inspect(result)) + end + return result + end + + return parser +end + +---@param macros gp.Macro[] +---@param raw boolean | nil # which function to return (completion or raw_completion) +---@return fun(arg_lead: string, cmd_line: string, cursor_pos: number): string[], boolean | nil +M.build_completion = function(macros, raw) + ---@type table + local map = {} + for _, macro in pairs(macros) do + map[macro.name] = macro + end + + ---@param arg_lead string + ---@param cmd_line string + ---@param cursor_pos number + ---@return string[], boolean # returns suggestions and whether some macro was triggered + local function raw_completion(arg_lead, cmd_line, cursor_pos) + local cropped_line = cmd_line:sub(1, cursor_pos) + + ---@type gp.Macro_cmd_params + local params = { + arg_lead = arg_lead, + cmd_line = cmd_line, + cursor_pos = cursor_pos, + cropped_line = cropped_line, + state = buffer_state.get(vim.api.nvim_get_current_buf()), + } + + cropped_line = " " .. cropped_line + + local suggestions = {} + local triggered = false + + logger.debug("macro completion input: " .. vim.inspect({ + params = params, + })) + + ---@type table + local candidates = {} + local cand = nil + for c in cropped_line:gmatch("%s@(%S+)%s") do + candidates[c] = candidates[c] and candidates[c] + 1 or 1 + cand = c + end + logger.debug("macro completion candidates: " .. vim.inspect(candidates)) + + if cand and map[cand] and map[cand].triggered(params) then + suggestions = map[cand].completion(params) + triggered = true + elseif cropped_line:match("%s$") or cropped_line:match("%s@%S*$") then + for _, c in pairs(macros) do + if not candidates[c.name] or candidates[c.name] < c.max_occurrences then + table.insert(suggestions, "@" .. c.name) + end + end + end + + logger.debug("macro completion suggestions: " .. vim.inspect(suggestions)) + return vim.deepcopy(suggestions), triggered + end + + local completion = function(arg_lead, cmd_line, cursor_pos) + local suggestions, _ = raw_completion(arg_lead, cmd_line, cursor_pos) + return suggestions + end + + if raw then + return raw_completion + end + + return completion +end + +local registered_cmp_sources = {} +M.build_cmp_source = function(name, macros) + if registered_cmp_sources[name] then + logger.debug("cmp source " .. name .. " already registered") + return nil + end + local source = {} + + source.new = function() + return setmetatable({}, { __index = source }) + end + + source.get_trigger_characters = function() + return { "@", " " } + end + + local completion = M.build_completion(macros, true) + + source.complete = function(self, params, callback) + local ctx = params.context + local suggestions, triggered = completion(ctx.cursor_before_line:match("%S*$"), ctx.cursor_line, ctx.cursor.col) + + if not triggered and not ctx.cursor_before_line:match("%s*@%S*$") then + suggestions = {} + end + + logger.debug("macro completion suggestions: " .. vim.inspect(suggestions)) + + local items = {} + for _, suggestion in ipairs(suggestions) do + table.insert(items, { + label = suggestion, + kind = require("cmp").lsp.CompletionItemKind.Keyword, + documentation = name, + }) + end + logger.debug("macro cmp complete output: " .. vim.inspect(items)) + + callback(items) + end + + local has_cmp, cmp = pcall(require, "cmp") + if not has_cmp then + logger.warning("cmp not found, skipping cmp source registration") + return source + end + + cmp.register_source(name, source) + registered_cmp_sources[name] = true + + if true then + return source + end + + cmp.event:on("complete_done", function(event) + if not event or not event.entry or event.entry.source.name ~= name then + return + end + local ctx = event.entry.source.context + local suggestions, triggered = completion(ctx.cursor_before_line:match("%S*$"), ctx.cursor_line, ctx.cursor.col) + logger.debug( + "macro cmp complete_done suggestions: " .. vim.inspect(suggestions) .. " triggered: " .. vim.inspect(triggered) + ) + if not suggestions or not triggered then + return + end + + vim.schedule(function() + -- insert a space if not already present at the cursor + local cursor_col = vim.api.nvim_win_get_cursor(0)[2] + local line = vim.api.nvim_get_current_line() + logger.debug( + "macro cmp complete_done cursor_col: " + .. cursor_col + .. " line: " + .. line + .. " char: " + .. line:sub(cursor_col, cursor_col) + ) + if line:sub(cursor_col, cursor_col) ~= " " then + vim.api.nvim_put({ " " }, "c", false, true) + end + vim.schedule(function() + cmp.complete(suggestions) + end) + end) + end) + + return source +end + +return M diff --git a/lua/gp/macros/agent.lua b/lua/gp/macros/agent.lua new file mode 100644 index 0000000..18a6c75 --- /dev/null +++ b/lua/gp/macros/agent.lua @@ -0,0 +1,43 @@ +local macro = require("gp.macro") +local gp = require("gp") + +local M = {} + +---@type gp.Macro +M = { + name = "agent", + description = "handles agent selection for commands", + default = "", + max_occurrences = 1, + + triggered = function(params) + return params.cropped_line:match("@agent%s+%S*$") + end, + + completion = function(params) + if params.state.is_chat then + return gp._chat_agents + end + return gp._command_agents + end, + + parser = function(result) + local template = result.template + local s, e, value = template:find("@agent%s+(%S+)") + if not value then + return result + end + + local placeholder = macro.generate_placeholder(M.name, value) + result.template = template:sub(1, s - 2) .. placeholder .. template:sub(e + 1) + if result.state.is_chat then + result.state[M.name] = gp.get_chat_agent(value) + else + result.state[M.name] = gp.get_command_agent(value) + end + result.artifacts[placeholder] = "" + return result + end, +} + +return M diff --git a/lua/gp/macros/target.lua b/lua/gp/macros/target.lua new file mode 100644 index 0000000..5bceabb --- /dev/null +++ b/lua/gp/macros/target.lua @@ -0,0 +1,46 @@ +local macro = require("gp.macro") + +local values = { + "rewrite", + "append", + "prepend", + "popup", + "enew", + "new", + "vnew", + "tabnew", +} + +local M = {} + +---@type gp.Macro +M = { + name = "target", + description = "handles target for commands", + default = "rewrite", + max_occurrences = 1, + + triggered = function(params) + return params.cropped_line:match("@target%s+%S*$") + end, + + completion = function(params) + return values + end, + + parser = function(result) + local template = result.template + local s, e, value = template:find("@target%s+(%S+)") + if not value then + return result + end + + local placeholder = macro.generate_placeholder(M.name, value) + result.template = template:sub(1, s - 2) .. placeholder .. template:sub(e + 1) + result.state[M.name] = value + result.artifacts[placeholder] = "" + return result + end, +} + +return M diff --git a/lua/gp/macros/target_filename.lua b/lua/gp/macros/target_filename.lua new file mode 100644 index 0000000..e5d6d20 --- /dev/null +++ b/lua/gp/macros/target_filename.lua @@ -0,0 +1,50 @@ +local macro = require("gp.macro") +local gp = require("gp") + +local M = {} + +---@type gp.Macro +M = { + name = "target_filename`", + description = "handles target buffer filename for commands", + default = nil, + max_occurrences = 1, + + triggered = function(params) + local cropped_line = params.cropped_line + return cropped_line:match("@target_filename`[^`]*$") + end, + + completion = function(params) + local root_dir = params.state.context_dir or vim.fn.getcwd() + local files = vim.fn.globpath(root_dir, "**", false, true) + local root_dir_length = #root_dir + 2 + files = vim.tbl_map(function(file) + return file:sub(root_dir_length) .. " `" + end, files) + return files + end, + + parser = function(result) + local template = result.template + local s, e, value = template:find("@target_filename`([^`]*)`") + if not value then + return result + end + + value = value:match("^%s*(.-)%s*$") + local placeholder = macro.generate_placeholder(M.name, value) + + local full_path = value + if vim.fn.fnamemodify(full_path, ":p") ~= value then + full_path = vim.fn.fnamemodify(result.state.context_dir .. "/" .. value, ":p") + end + + result.artifacts[placeholder] = "" + result.template = template:sub(1, s - 1) .. placeholder .. template:sub(e + 1) + result.state[M.name:sub(1, -2)] = full_path + return result + end, +} + +return M diff --git a/lua/gp/macros/target_filetype.lua b/lua/gp/macros/target_filetype.lua new file mode 100644 index 0000000..91be8bc --- /dev/null +++ b/lua/gp/macros/target_filetype.lua @@ -0,0 +1,40 @@ +local macro = require("gp.macro") + +local values = nil + +local M = {} + +---@type gp.Macro +M = { + name = "target_filetype", + description = "handles target buffer filetype for commands like GpEnew", + default = "markdown", + max_occurrences = 1, + + triggered = function(params) + return params.cropped_line:match("@target_filetype%s+%S*$") + end, + + completion = function(params) + if not values then + values = vim.fn.getcompletion("", "filetype") + end + return values + end, + + parser = function(result) + local template = result.template + local s, e, value = template:find("@target_filetype%s+(%S+)") + if not value then + return result + end + + local placeholder = macro.generate_placeholder(M.name, value) + result.template = template:sub(1, s - 2) .. placeholder .. template:sub(e + 1) + result.state[M.name] = value + result.artifacts[placeholder] = "" + return result + end, +} + +return M diff --git a/lua/gp/macros/with_current_buf.lua b/lua/gp/macros/with_current_buf.lua new file mode 100644 index 0000000..7f1424b --- /dev/null +++ b/lua/gp/macros/with_current_buf.lua @@ -0,0 +1,48 @@ +local macro = require("gp.macro") +local gp = require("gp") + +local M = {} + +---@type gp.Macro +M = { + name = "with_current_buf", + description = "replaces the macro with the content of the current file", + default = nil, + max_occurrences = 1, + + triggered = function(_) + return false + end, + + completion = function(_) + return {} + end, + + parser = function(result) + local template = result.template + local macro_pattern = "@with_current_buf" + + local s, e = template:find(macro_pattern) + if not s then + return result + end + + local placeholder = macro.generate_placeholder(M.name, "") + + local current_buf = vim.api.nvim_get_current_buf() + local content = table.concat(vim.api.nvim_buf_get_lines(current_buf, 0, -1, false), "\n") + local full_path = vim.api.nvim_buf_get_name(current_buf) + + content = gp.render.template(gp.config.template_context_file, { + ["{{content}}"] = content, + ["{{filename}}"] = full_path, + }) + result.artifacts[placeholder] = content + + result.template = template:sub(1, s - 1) .. placeholder .. template:sub(e + 1) + + return result + end, +} + +return M diff --git a/lua/gp/macros/with_file.lua b/lua/gp/macros/with_file.lua new file mode 100644 index 0000000..046beef --- /dev/null +++ b/lua/gp/macros/with_file.lua @@ -0,0 +1,66 @@ +local macro = require("gp.macro") +local gp = require("gp") + +local M = {} + +---@type gp.Macro +M = { + name = "with_file`", + description = "replaces the macro with the content of the specified file", + default = nil, + max_occurrences = 100, + + triggered = function(params) + local cropped_line = params.cropped_line + return cropped_line:match("@with_file`[^`]*$") + end, + + completion = function(params) + local root_dir = params.state.context_dir or vim.fn.getcwd() + local files = vim.fn.globpath(root_dir, "**", false, true) + local root_dir_length = #root_dir + 2 + files = vim.tbl_map(function(file) + return file:sub(root_dir_length) .. " `" + end, files) + return files + end, + + parser = function(result) + local template = result.template + local macro_pattern = "@with_file`([^`]*)`" + + for _ = 1, M.max_occurrences do + local s, e, value = template:find(macro_pattern) + if not value then + break + end + + value = value:match("^%s*(.-)%s*$") + local placeholder = macro.generate_placeholder(M.name, value) + + local full_path = value + if vim.fn.fnamemodify(full_path, ":p") ~= value then + full_path = vim.fn.fnamemodify(result.state.context_dir .. "/" .. value, ":p") + end + + if vim.fn.filereadable(full_path) == 0 then + result.artifacts[placeholder] = "" + gp.logger.error("Context file not found: " .. full_path) + else + local content = table.concat(vim.fn.readfile(full_path), "\n") + content = gp.render.template(gp.config.template_context_file, { + ["{{content}}"] = content, + ["{{filename}}"] = full_path, + }) + result.artifacts[placeholder] = content + end + + template = template:sub(1, s - 1) .. placeholder .. template:sub(e + 1) + end + + result.template = template + return result + end, +} + +return M diff --git a/lua/gp/macros/with_repo_instructions.lua b/lua/gp/macros/with_repo_instructions.lua new file mode 100644 index 0000000..315f078 --- /dev/null +++ b/lua/gp/macros/with_repo_instructions.lua @@ -0,0 +1,62 @@ +local macro = require("gp.macro") +local gp = require("gp") + +---@param git_root? string # optional git root directory +---@return string # returns instructions from the .gp.md file +local repo_instructions = function(git_root) + git_root = git_root or gp.helpers.find_git_root() + + if git_root == "" then + return "" + end + + local instruct_file = (git_root:gsub("/$", "")) .. "/.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 + +local M = {} + +---@type gp.Macro +M = { + name = "with_repo_instructions", + description = "replaces the macro with the content of the .gp.md file in the git root", + default = nil, + max_occurrences = 1, + + triggered = function(_) + return false + end, + + completion = function(_) + return {} + end, + + parser = function(result) + local template = result.template + local macro_pattern = "@with_repo_instructions" + + local s, e = template:find(macro_pattern) + if not s then + return result + end + + local placeholder = macro.generate_placeholder(M.name, "") + + local instructions = repo_instructions(result.state.context_dir) + result.artifacts[placeholder] = gp.render.template(gp.config.template_context_file, { + ["{{content}}"] = instructions, + ["{{filename}}"] = ".repository_instructions.md", + }) + + result.template = template:sub(1, s - 1) .. placeholder .. template:sub(e + 1) + return result + end, +} + +return M diff --git a/lua/gp/tasker.lua b/lua/gp/tasker.lua index df630e3..94c30c5 100644 --- a/lua/gp/tasker.lua +++ b/lua/gp/tasker.lua @@ -216,13 +216,6 @@ M.grep_directory = function(buf, directory, pattern, callback) }) 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