diff --git a/README.md b/README.md index 159513a..1734986 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,21 @@ Install `avante.nvim` using [lazy.nvim](https://github.com/folke/lazy.nvim): "MunifTanjim/nui.nvim", --- The below dependencies are optional, "nvim-tree/nvim-web-devicons", -- or echasnovski/mini.icons + { + -- support for image pasting + "HakonHarnes/img-clip.nvim", + event = "VeryLazy", + opts = { + -- recommended settings + default = { + embed_image_as_base64 = false, + prompt_for_file_name = false, + drag_and_drop = { + insert_mode = true, + }, + }, + }, + }, { -- Make sure to setup it properly if you have lazy=true 'MeanderingProgrammer/render-markdown.nvim', @@ -241,7 +256,7 @@ We would like to express our heartfelt gratitude to the contributors of the foll | --- | --- | --- | --- | | [git-conflict.nvim](https://github.com/akinsho/git-conflict.nvim) | No License | Diff comparison functionality | https://github.com/yetone/avante.nvim/blob/main/lua/avante/diff.lua | | [ChatGPT.nvim](https://github.com/jackMort/ChatGPT.nvim) | Apache 2.0 License | Calculation of tokens count | https://github.com/yetone/avante.nvim/blob/main/lua/avante/utils/tokens.lua | -| [img-clip.nvim](https://github.com/HakonHarnes/img-clip.nvim) | MIT License | Clipboard image support | https://github.com/yetone/avante.nvim/blob/main/lua/avante/clipboard/init.lua | +| [img-clip.nvim](https://github.com/HakonHarnes/img-clip.nvim) | MIT License | Clipboard image support | https://github.com/yetone/avante.nvim/blob/main/lua/avante/clipboard.lua | The high quality and ingenuity of these projects' source code have been immensely beneficial throughout our development process. We extend our sincere thanks and respect to the authors and contributors of these projects. It is the selfless dedication of the open-source community that drives projects like avante.nvim forward. diff --git a/after/plugin/avante.lua b/after/plugin/avante.lua new file mode 100644 index 0000000..9c35176 --- /dev/null +++ b/after/plugin/avante.lua @@ -0,0 +1,26 @@ +--- NOTE: We will override vim.paste if img-clip.nvim is available to work with avante.nvim internal logic paste + +local Clipboard = require("avante.clipboard") +local Config = require("avante.config") + +if Config.support_paste_image() then + vim.paste = (function(overriden) + ---@param lines string[] + ---@param phase -1|1|2|3 + return function(lines, phase) + local bufnr = vim.api.nvim_get_current_buf() + local filetype = vim.api.nvim_get_option_value("filetype", { buf = bufnr }) + if filetype ~= "AvanteInput" then + return overriden(lines, phase) + end + + ---@type string + local line = lines[1] + + local ok = Clipboard.paste_image(line) + if not ok then + return overriden(lines, phase) + end + end + end)(vim.paste) +end diff --git a/lua/avante/clipboard.lua b/lua/avante/clipboard.lua new file mode 100644 index 0000000..4ee649b --- /dev/null +++ b/lua/avante/clipboard.lua @@ -0,0 +1,74 @@ +---NOTE: this module is inspired by https://github.com/HakonHarnes/img-clip.nvim/tree/main +---@see https://github.com/ekickx/clipboard-image.nvim/blob/main/lua/clipboard-image/paste.lua + +local Path = require("plenary.path") +local Utils = require("avante.utils") +local Config = require("avante.config") +---@module "img-clip" +local ImgClip = nil + +---@class AvanteClipboard +---@field get_base64_content fun(filepath: string): string | nil +--- +---@class avante.Clipboard: AvanteClipboard +local M = {} + +---@type Path +local paste_directory = nil + +---@return Path +local function get_paste_directory() + if paste_directory then + return paste_directory + end + paste_directory = Path:new(Config.history.storage_path):joinpath("pasted_images") + return paste_directory +end + +M.support_paste_image = Config.support_paste_image + +M.setup = function() + get_paste_directory() + + if not paste_directory:exists() then + paste_directory:mkdir({ parent = true }) + end + + if M.support_paste_image() and ImgClip == nil then + ImgClip = require("img-clip") + end +end + +---@param line string +M.paste_image = function(line) + if not Config.support_paste_image() then + return false + end + + return ImgClip.paste_image({ + dir_path = paste_directory:absolute(), + prompt_for_file_name = false, + filetypes = { + AvanteInput = { url_encode_path = true, template = "\nimage: $FILE_PATH\n" }, + }, + }, line) +end + +---@param filepath string +M.get_base64_content = function(filepath) + local os_mapping = Utils.get_os_name() + ---@type vim.SystemCompleted + local output + if os_mapping == "darwin" or os_mapping == "linux" then + output = Utils.shell_run(("cat %s | base64 | tr -d '\n'"):format(filepath)) + if output.code == 0 then + return output.stdout + else + error("Failed to convert image to base64") + end + else + Utils.warn("Windows is not supported yet", { title = "Avante" }) + end +end + +return M diff --git a/lua/avante/clipboard/darwin.lua b/lua/avante/clipboard/darwin.lua deleted file mode 100644 index 0d81e51..0000000 --- a/lua/avante/clipboard/darwin.lua +++ /dev/null @@ -1,85 +0,0 @@ -local Utils = require("avante.utils") - ----@class AvanteClipboard -local M = {} - ----@alias DarwinClipboardCommand "pngpaste" | "osascript" -M.clip_cmd = nil - ----@return DarwinClipboardCommand -M.get_clip_cmd = function() - if M.clip_cmd then - return M.clip_cmd - end - if vim.fn.executable("pngpaste") == 1 then - M.clip_cmd = "pngpaste" - elseif vim.fn.executable("osascript") == 1 then - M.clip_cmd = "osascript" - end - return M.clip_cmd -end - -M.has_content = function() - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "pngpaste" then - output = Utils.shell_run("pngpaste -") - return output.code == 0 - elseif cmd == "osascript" then - output = Utils.shell_run("osascript -e 'clipboard info'") - return output.code == 0 and output.stdout ~= nil and output.stdout:find("class PNGf") ~= nil - end - - Utils.warn("Failed to validate clipboard content", { title = "Avante" }) - return false -end - -M.save_content = function(filepath) - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "pngpaste" then - output = Utils.shell_run(('pngpaste - > "%s"'):format(filepath)) - return output.code == 0 - elseif cmd == "osascript" then - output = Utils.shell_run( - string.format( - [[osascript -e 'set theFile to (open for access POSIX file "%s" with write permission)' ]] - .. [[-e 'try' -e 'write (the clipboard as «class PNGf») to theFile' -e 'end try' ]] - .. [[-e 'close access theFile' -e 'do shell script "cat %s > %s"']], - filepath, - filepath, - filepath - ) - ) - return output.code == 0 - end - return false -end - -M.get_base64_content = function() - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "pngpaste" then - output = Utils.shell_run("pngpaste - | base64 | tr -d '\n'") - if output.code == 0 then - return output.stdout - end - elseif cmd == "osascript" then - output = Utils.shell_run( - [[osascript -e 'set theFile to (open for access POSIX file "/tmp/image.png" with write permission)' -e 'try' -e 'write (the clipboard as «class PNGf») to theFile' -e 'end try' -e 'close access theFile'; ]] - .. [[cat /tmp/image.png | base64 | tr -d '\n']] - ) - if output.code == 0 then - return output.stdout - end - end - error("Failed to get clipboard content") -end - -return M diff --git a/lua/avante/clipboard/init.lua b/lua/avante/clipboard/init.lua deleted file mode 100644 index ada3ca0..0000000 --- a/lua/avante/clipboard/init.lua +++ /dev/null @@ -1,48 +0,0 @@ ----NOTE: this module is inspired by https://github.com/HakonHarnes/img-clip.nvim/tree/main ----@see https://github.com/ekickx/clipboard-image.nvim/blob/main/lua/clipboard-image/paste.lua - -local Path = require("plenary.path") -local Utils = require("avante.utils") -local Config = require("avante.config") - ----@class AvanteClipboard ----@field clip_cmd string ----@field get_clip_cmd fun(): string ----@field has_content fun(): boolean ----@field get_base64_content fun(): string ----@field save_content fun(filename: string): boolean ---- ----@class avante.Clipboard: AvanteClipboard -local M = {} - ----@type Path -local paste_directory = nil - ----@return Path -local function get_paste_directory() - if paste_directory then - return paste_directory - end - paste_directory = Path:new(Config.history.storage_path):joinpath("pasted_images") - return paste_directory -end - -M.setup = function() - get_paste_directory() - - if not paste_directory:exists() then - paste_directory:mkdir({ parent = true }) - end -end - -return setmetatable(M, { - __index = function(t, k) - local os_mapping = Utils.get_os_name() - ---@type AvanteClipboard - local impl = require("avante.clipboard." .. os_mapping) - if impl[k] ~= nil then - return impl[k] - end - return t[k] - end, -}) diff --git a/lua/avante/clipboard/linux.lua b/lua/avante/clipboard/linux.lua deleted file mode 100644 index e0cab2a..0000000 --- a/lua/avante/clipboard/linux.lua +++ /dev/null @@ -1,74 +0,0 @@ -local Utils = require("avante.utils") - ----@class AvanteClipboard -local M = {} - -M.clip_cmd = nil - -M.get_clip_cmd = function() - if M.clip_cmd then - return M.clip_cmd - end - -- Wayland - if os.getenv("WAYLAND_DISPLAY") ~= nil and vim.fn.executable("wl-paste") == 1 then - M.clip_cmd = "wl-paste" - -- X11 - elseif os.getenv("DISPLAY") ~= nil and vim.fn.executable("xclip") == 1 then - M.clip_cmd = "xclip" - end - return M.clip_cmd -end - -M.has_content = function() - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - -- X11 - if cmd == "xclip" then - output = Utils.shell_run("xclip -selection clipboard -t TARGETS -o") - return output.code == 0 and output.stdout:find("image/png") ~= nil - elseif cmd == "wl-paste" then - output = Utils.shell_run("wl-paste --list-types") - return output.code == 0 and output.stdout:find("image/png") ~= nil - end - - Utils.warn("Failed to validate clipboard content", { title = "Avante" }) - return false -end - -M.save_content = function(filepath) - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "xclip" then - output = Utils.shell_run(('xclip -selection clipboard -o -t image/png > "%s"'):format(filepath)) - return output.code == 0 - elseif cmd == "wl-paste" then - output = Utils.shell_run(('wl-paste --type image/png > "%s"'):format(filepath)) - return output.code == 0 - end - return false -end - -M.get_base64_content = function() - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "xclip" then - output = Utils.shell_run("xclip -selection clipboard -o -t image/png | base64 | tr -d '\n'") - if output.code == 0 then - return output.stdout - end - elseif cmd == "osascript" then - output = Utils.shell_run("wl-paste --type image/png | base64 | tr -d '\n'") - if output.code == 0 then - return output.stdout - end - end - error("Failed to get clipboard content") -end - -return M diff --git a/lua/avante/clipboard/windows.lua b/lua/avante/clipboard/windows.lua deleted file mode 100644 index 8a233a5..0000000 --- a/lua/avante/clipboard/windows.lua +++ /dev/null @@ -1,67 +0,0 @@ -local Utils = require("avante.utils") - ----@class AvanteClipboard -local M = {} - -M.clip_cmd = nil - -M.get_clip_cmd = function() - if M.clip_cmd then - return M.clip_cmd - end - if (vim.fn.has("win32") > 0 or vim.fn.has("wsl") > 0) and vim.fn.executable("powershell.exe") then - M.clip_cmd = "powershell.exe" - end - return M.clip_cmd -end - -M.has_content = function() - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "powershell.exe" then - output = - Utils.shell_run("Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::GetImage()") - return output.code == 0 and output.stdout:find("Width") ~= nil - end - - Utils.warn("Failed to validate clipboard content", { title = "Avante" }) - return false -end - -M.save_content = function(filepath) - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "powershell.exe" then - output = Utils.shell_run( - ("Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::GetImage().Save('%s')"):format( - filepath - ) - ) - return output.code == 0 - end - return false -end - -M.get_base64_content = function() - local cmd = M.get_clip_cmd() - ---@type vim.SystemCompleted - local output - - if cmd == "powershell.exe" then - output = Utils.shell_run( - [[Add-Type -AssemblyName System.Windows.Forms; $ms = New-Object System.IO.MemoryStream;]] - .. [[ [System.Windows.Forms.Clipboard]::GetImage().Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);]] - .. [[ [System.Convert]::ToBase64String($ms.ToArray())]] - ) - if output.code == 0 then - return output.stdout:gsub("\r\n", ""):gsub("\n", ""):gsub("\r", "") - end - end - error("Failed to get clipboard content") -end - -return M diff --git a/lua/avante/config.lua b/lua/avante/config.lua index 07eeade..e60f576 100644 --- a/lua/avante/config.lua +++ b/lua/avante/config.lua @@ -1,6 +1,8 @@ ---NOTE: user will be merged with defaults and ---we add a default var_accessor for this table to config values. ---- + +local Utils = require("avante.utils") + ---@class avante.CoreConfig: avante.Config local M = {} @@ -65,7 +67,7 @@ M.defaults = { ---1. auto_apply_diff_after_generation: Whether to automatically apply diff after LLM response. --- This would simulate similar behaviour to cursor. Default to false. ---2. auto_set_highlight_group: Whether to automatically set the highlight group for the current line. Default to true. - ---3. support_paste_from_clipboard: Whether to support pasting image from clipboard. Note that we will override vim.paste for this. Default to false. + ---3. support_paste_from_clipboard: Whether to support pasting image from clipboard. This will be determined automatically based whether img-clip is available or not. behaviour = { auto_set_highlight_group = true, auto_apply_diff_after_generation = false, @@ -151,7 +153,17 @@ M.hints = {} ---@param opts? avante.Config function M.setup(opts) - M.options = vim.tbl_deep_extend("force", M.defaults, opts or {}) + M.options = vim.tbl_deep_extend( + "force", + M.defaults, + opts or {}, + ---@type avante.Config + { + behaviour = { + support_paste_from_clipboard = M.support_paste_image(), + }, + } + ) M.diff = vim.tbl_deep_extend( "force", @@ -168,6 +180,14 @@ function M.setup(opts) end end +M.support_paste_image = function() + local supported = Utils.has("img-clip.nvim") + if not supported then + Utils.warn("img-clip.nvim is not installed. Pasting image will be disabled.", { once = true }) + end + return supported +end + ---@param opts? avante.Config function M.override(opts) opts = opts or {} diff --git a/lua/avante/llm.lua b/lua/avante/llm.lua index 9e51be8..e6f6abd 100644 --- a/lua/avante/llm.lua +++ b/lua/avante/llm.lua @@ -96,11 +96,27 @@ M.stream = function(question, code_lang, code_content, selected_content_content, mode = mode or "planning" local provider = Config.provider + -- Check if the question contains an image path + local image_path = nil + local original_question = question + if question:match("image: ") then + local lines = vim.split(question, "\n") + for i, line in ipairs(lines) do + if line:match("^image: ") then + image_path = line:gsub("^image: ", "") + table.remove(lines, i) + original_question = table.concat(lines, "\n") + break + end + end + end + ---@type AvantePromptOptions local code_opts = { base_prompt = mode == "planning" and planning_mode_prompt or editing_mode_prompt, system_prompt = system_prompt, - question = question, + question = original_question, + image_path = image_path, code_lang = code_lang, code_content = code_content, selected_code_content = selected_content_content, @@ -188,13 +204,6 @@ M.stream = function(question, code_lang, code_content, selected_content_content, on_complete("API request failed with status " .. result.status .. ". Body: " .. vim.inspect(result.body)) end end) - else - vim.schedule(function() - if not completed then - completed = true - on_complete(nil) - end - end) end active_job = nil end, diff --git a/lua/avante/providers/claude.lua b/lua/avante/providers/claude.lua index 00a8a6c..ceb858e 100644 --- a/lua/avante/providers/claude.lua +++ b/lua/avante/providers/claude.lua @@ -39,22 +39,22 @@ M.parse_message = function(opts) table.insert(message_content, selected_code_obj) end - table.insert(message_content, { - type = "text", - text = string.format("%s", opts.question), - }) - - if Config.behaviour.support_paste_from_clipboard and Clipboard.has_content() then + if Clipboard.support_paste_image() and opts.image_path then table.insert(message_content, { type = "image", source = { type = "base64", media_type = "image/png", - data = Clipboard.get_base64_content(), + data = Clipboard.get_base64_content(opts.image_path), }, }) end + table.insert(message_content, { + type = "text", + text = string.format("%s", opts.question), + }) + local user_prompt = opts.base_prompt local user_prompt_obj = { diff --git a/lua/avante/providers/init.lua b/lua/avante/providers/init.lua index d456b2a..2449140 100644 --- a/lua/avante/providers/init.lua +++ b/lua/avante/providers/init.lua @@ -12,6 +12,7 @@ local Dressing = require("avante.ui.dressing") ---@field base_prompt AvanteBasePrompt ---@field system_prompt AvanteSystemPrompt ---@field question string +---@field image_path? string ---@field code_lang string ---@field code_content string ---@field selected_code_content? string diff --git a/lua/avante/providers/openai.lua b/lua/avante/providers/openai.lua index d363b95..cfbe516 100644 --- a/lua/avante/providers/openai.lua +++ b/lua/avante/providers/openai.lua @@ -63,12 +63,12 @@ end M.parse_message = function(opts) ---@type string | OpenAIMessage[] local user_content - if Config.behaviour.support_paste_from_clipboard and Clipboard.has_content() then + if Config.behaviour.support_paste_from_clipboard and opts.image_path then user_content = {} table.insert(user_content, { type = "image_url", image_url = { - url = "data:image/png;base64," .. Clipboard.get_base64_content(), + url = "data:image/png;base64," .. Clipboard.get_base64_content(opts.image_path), }, }) table.insert(user_content, { type = "text", text = M.get_user_message(opts) })