diff --git a/lua/avante/clipboard/darwin.lua b/lua/avante/clipboard/darwin.lua new file mode 100644 index 000000000..c9741d6a4 --- /dev/null +++ b/lua/avante/clipboard/darwin.lua @@ -0,0 +1,59 @@ +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.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.get_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 new file mode 100644 index 000000000..bd88881a3 --- /dev/null +++ b/lua/avante/clipboard/init.lua @@ -0,0 +1,27 @@ +---NOTE: this module is inspired by https://github.com/HakonHarnes/img-clip.nvim/tree/main + +local Utils = require("avante.utils") + +---@class AvanteClipboard +---@field clip_cmd string +---@field get_clip_cmd fun(): string +---@field has_content fun(): boolean +---@field get_content fun(): string +--- +---@class avante.Clipboard: AvanteClipboard +local M = {} + +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] + elseif t[k] ~= nil then + return t[k] + else + error("Failed to find clipboard implementation for " .. os_mapping) + end + end, +}) diff --git a/lua/avante/clipboard/linux.lua b/lua/avante/clipboard/linux.lua new file mode 100644 index 000000000..d65519e37 --- /dev/null +++ b/lua/avante/clipboard/linux.lua @@ -0,0 +1,59 @@ +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.get_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 new file mode 100644 index 000000000..8ee3b2f40 --- /dev/null +++ b/lua/avante/clipboard/windows.lua @@ -0,0 +1,51 @@ +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.get_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 62131bace..26d20a398 100644 --- a/lua/avante/config.lua +++ b/lua/avante/config.lua @@ -75,9 +75,11 @@ 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. behaviour = { auto_set_highlight_group = true, auto_apply_diff_after_generation = false, + support_paste_from_clipboard = false, }, history = { storage_path = vim.fn.stdpath("state") .. "/avante", diff --git a/lua/avante/llm.lua b/lua/avante/llm.lua index d230a1544..3962db4ca 100644 --- a/lua/avante/llm.lua +++ b/lua/avante/llm.lua @@ -27,7 +27,8 @@ Your primary task is to suggest code modifications with precise line number rang 2. When suggesting modifications: a. Use the language in the question to reply. If there are non-English parts in the question, use the language of those parts. b. Explain why the change is necessary or beneficial. - c. Provide the exact code snippet to be replaced using this format: + c. If an image is provided, make sure to use the image in conjunction with the code snippet. + d. Provide the exact code snippet to be replaced using this format: Replace lines: {{start_line}}-{{end_line}} ```{{language}} @@ -96,6 +97,8 @@ M.stream = function(question, code_lang, code_content, selected_content_content, ---@type AvanteCurlOutput local spec = Provider.parse_curl_args(Provider, code_opts) + Utils.debug(spec) + ---@param line string local function parse_stream_data(line) local event = line:match("^event: (.+)$") diff --git a/lua/avante/providers/claude.lua b/lua/avante/providers/claude.lua index efce12360..be9714a5c 100644 --- a/lua/avante/providers/claude.lua +++ b/lua/avante/providers/claude.lua @@ -1,5 +1,6 @@ +local Config = require("avante.config") local Utils = require("avante.utils") -local Tokens = require("avante.utils.tokens") +local Clipboard = require("avante.clipboard") local P = require("avante.providers") ---@class AvanteProviderFunctor @@ -13,7 +14,7 @@ M.parse_message = function(opts) text = string.format("```%s\n%s```", opts.code_lang, opts.code_content), } - if Tokens.calculate_tokens(code_prompt_obj.text) > 1024 then + if Utils.tokens.calculate_tokens(code_prompt_obj.text) > 1024 then code_prompt_obj.cache_control = { type = "ephemeral" } end @@ -31,7 +32,7 @@ M.parse_message = function(opts) text = string.format("```%s\n%s```", opts.code_lang, opts.selected_code_content), } - if Tokens.calculate_tokens(selected_code_obj.text) > 1024 then + if Utils.tokens.calculate_tokens(selected_code_obj.text) > 1024 then selected_code_obj.cache_control = { type = "ephemeral" } end @@ -43,6 +44,17 @@ M.parse_message = function(opts) text = string.format("%s", opts.question), }) + if Config.behaviour.support_paste_from_clipboard and Clipboard.has_content() then + table.insert(message_content, { + type = "image", + source = { + type = "base64", + media_type = "image/png", + data = Clipboard.get_content(), + }, + }) + end + local user_prompt = opts.base_prompt local user_prompt_obj = { @@ -50,7 +62,7 @@ M.parse_message = function(opts) text = user_prompt, } - if Tokens.calculate_tokens(user_prompt_obj.text) > 1024 then + if Utils.tokens.calculate_tokens(user_prompt_obj.text) > 1024 then user_prompt_obj.cache_control = { type = "ephemeral" } end diff --git a/lua/avante/providers/init.lua b/lua/avante/providers/init.lua index 9448488d0..be6c538a5 100644 --- a/lua/avante/providers/init.lua +++ b/lua/avante/providers/init.lua @@ -63,7 +63,7 @@ local Dressing = require("avante.ui.dressing") ---@field parse_response_data AvanteResponseParser ---@field parse_curl_args? AvanteCurlArgsParser ---@field parse_stream_data? AvanteStreamParser ----@field parse_api_key fun(): string | nil +---@field parse_api_key? fun(): string | nil --- ---@class AvanteProviderFunctor ---@field parse_message AvanteMessageParser diff --git a/lua/avante/utils.lua b/lua/avante/utils/init.lua similarity index 93% rename from lua/avante/utils.lua rename to lua/avante/utils/init.lua index 3de277ba8..33ad7baa7 100644 --- a/lua/avante/utils.lua +++ b/lua/avante/utils/init.lua @@ -1,6 +1,7 @@ local api = vim.api ---@class avante.utils: LazyUtilCore +---@field tokens avante.utils.tokens local M = {} setmetatable(M, { @@ -43,11 +44,29 @@ end --- This function will run given shell command synchronously. ---@param input_cmd string ----@return integer, string?, string? +---@return vim.SystemCompleted M.shell_run = function(input_cmd) - local output = - vim.system(vim.split("sh -c " .. vim.fn.shellescape(input_cmd), " ", { trimempty = true }), { text = true }):wait() - return output.code, output.stderr, output.stdout + local shell = vim.o.shell:lower() + ---@type string + local cmd + + -- powershell then we can just run the cmd + if shell:match("powershell") or shell:match("pwsh") then + cmd = input_cmd + elseif vim.fn.has("wsl") > 0 then + -- wsl: powershell.exe -Command 'command "/path"' + cmd = "powershell.exe -NoProfile -Command '" .. input_cmd:gsub("'", '"') .. "'" + elseif vim.fn.has("win32") > 0 then + cmd = 'powershell.exe -NoProfile -Command "' .. input_cmd:gsub('"', "'") .. '"' + else + -- linux and macos we wil just do sh -c + cmd = "sh -c " .. vim.fn.shellescape(input_cmd) + end + + local output = vim.fn.system(cmd) + local code = vim.v.shell_error + + return { stdout = output, code = code } end ---@alias _ToggleSet fun(state: boolean): nil diff --git a/lua/avante/utils/tokens.lua b/lua/avante/utils/tokens.lua index 0c6ac11b8..20fd7b305 100644 --- a/lua/avante/utils/tokens.lua +++ b/lua/avante/utils/tokens.lua @@ -1,5 +1,7 @@ --Taken from https://github.com/jackMort/ChatGPT.nvim/blob/main/lua/chatgpt/flows/chat/tokens.lua local Tiktoken = require("avante.tiktoken") + +---@class avante.utils.tokens local Tokens = {} ---@type table<[string], number>