diff --git a/README.md b/README.md index be33535f..c272b3de 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ require('opencode').setup({ ['oR'] = { 'rename_session' }, -- Rename current session ['op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list ['oz'] = { 'toggle_zoom' }, -- Zoom in/out on the Opencode windows + ['ov'] = { 'paste_image'}, -- Paste image from clipboard into current session ['od'] = { 'diff_open' }, -- Opens a diff tab of a modified file since the last opencode prompt ['o]'] = { 'diff_next' }, -- Navigate to next file diff ['o['] = { 'diff_prev' }, -- Navigate to previous file diff @@ -135,6 +136,7 @@ require('opencode').setup({ ['@'] = { 'mention', mode = 'i' }, -- Insert mention (file/agent) ['/'] = { 'slash_commands', mode = 'i' }, -- Pick a command to run in the input window ['#'] = { 'context_items', mode = 'i' }, -- Manage context items (current file, selection, diagnostics, mentioned files) + [''] = { 'paste_image', mode = 'i' }, -- Paste image from clipboard as attachment [''] = { 'focus_input', mode = { 'n', 'i' } }, -- Focus on input window and enter insert mode at the end of the input from the output window [''] = { 'toggle_pane', mode = { 'n', 'i' } }, -- Toggle between input and output panes [''] = { 'prev_prompt_history', mode = { 'n', 'i' } }, -- Navigate to previous prompt in history diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 26fb8a1c..6059f6f6 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -45,6 +45,10 @@ function M.close() ui.close_windows(state.windows) end +function M.paste_image() + core.paste_image_from_clipboard() +end + function M.toggle(new_session) if state.windows == nil then local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output' @@ -1207,6 +1211,10 @@ M.commands = { desc = 'Toggle tool output visibility in the output window', fn = M.toggle_tool_output, }, + paste_image = { + desc = 'Paste image from clipboard and add to context', + fn = M.paste_image, + }, } M.slash_commands_map = { @@ -1270,6 +1278,7 @@ M.legacy_command_map = { OpencodePermissionAccept = 'permission accept', OpencodePermissionAcceptAll = 'permission accept_all', OpencodePermissionDeny = 'permission deny', + OpencodePasteImage = 'paste_image', } function M.route_command(opts) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 8f2aa9bf..9d633a75 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -25,6 +25,7 @@ M.defaults = { ['oR'] = { 'rename_session', desc = 'Rename session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, ['oz'] = { 'toggle_zoom', desc = 'Toggle zoom' }, + ['ov'] = { 'paste_image', desc = 'Paste image from clipboard' }, ['od'] = { 'diff_open', desc = 'Open diff view' }, ['o]'] = { 'diff_next', desc = 'Next diff' }, ['o['] = { 'diff_prev', desc = 'Previous diff' }, @@ -60,6 +61,7 @@ M.defaults = { ['@'] = { 'mention', mode = 'i' }, ['/'] = { 'slash_commands', mode = 'i' }, ['#'] = { 'context_items', mode = 'i' }, + [''] = { 'paste_image', mode = 'i' }, [''] = { 'toggle_pane', mode = { 'n', 'i' } }, [''] = { 'prev_prompt_history', mode = { 'n', 'i' } }, [''] = { 'next_prompt_history', mode = { 'n', 'i' } }, diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 5206f8b2..bbe02008 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -66,6 +66,7 @@ function M.is_context_enabled(context_key) end end +---@return OpencodeDiagnostic[]|nil function M.get_diagnostics(buf) if not M.is_context_enabled('diagnostics') then return nil @@ -146,11 +147,18 @@ function M.add_file(file) return end + if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then + vim.notify('File not added to context. Must be inside current working directory.') + return + end + file = vim.fn.fnamemodify(file, ':p') if not vim.tbl_contains(M.context.mentioned_files, file) then table.insert(M.context.mentioned_files, file) end + + state.context_updated_at = vim.uv.now() end function M.remove_file(file) @@ -316,7 +324,21 @@ local function format_file_part(path, prompt) local pos = prompt and prompt:find(mention) pos = pos and pos - 1 or 0 -- convert to 0-based index - local file_part = { filename = rel_path, type = 'file', mime = 'text/plain', url = 'file://' .. path } + local ext = vim.fn.fnamemodify(path, ':e'):lower() + local mime_type = 'text/plain' + if ext == 'png' then + mime_type = 'image/png' + elseif ext == 'jpg' or ext == 'jpeg' then + mime_type = 'image/jpeg' + elseif ext == 'gif' then + mime_type = 'image/gif' + elseif ext == 'webp' then + mime_type = 'image/webp' + elseif ext == 'svg' then + mime_type = 'image/svg+xml' + end + + local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path } if prompt then file_part.source = { path = path, @@ -343,7 +365,7 @@ local function format_selection_part(selection) } end ----@param diagnostics vim.Diagnostic[] +---@param diagnostics OpencodeDiagnostic[] local function format_diagnostics_part(diagnostics) local diag_list = {} for _, diag in ipairs(diagnostics) do diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 749893cc..b9100867 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -7,6 +7,7 @@ local server_job = require('opencode.server_job') local input_window = require('opencode.ui.input_window') local util = require('opencode.util') local config = require('opencode.config') +local image_handler = require('opencode.image_handler') local M = {} M._abort_count = 0 @@ -366,6 +367,12 @@ function M.initialize_current_model() return state.current_model end +--- Handle clipboard image data by saving it to a file and adding it to context +--- @return boolean success True if image was successfully handled +function M.paste_image_from_clipboard() + return image_handler.paste_image_from_clipboard() +end + function M.setup() state.subscribe('opencode_server', on_opencode_server) diff --git a/lua/opencode/image_handler.lua b/lua/opencode/image_handler.lua new file mode 100644 index 00000000..d4dfd3fd --- /dev/null +++ b/lua/opencode/image_handler.lua @@ -0,0 +1,162 @@ +--- Image pasting functionality from clipboard +--- @see https://github.com/sst/opencode/blob/45180104fe84e2d0b9d29be0f9f8a5e52d18e102/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +local context = require('opencode.context') +local state = require('opencode.state') + +local M = {} + +--- Check if a file exists and has content +--- @param path string +--- @return boolean +local function is_valid_file(path) + return vim.fn.getfsize(path) > 0 +end + +--- Run shell or powershell command and return success +--- @param cmd string|table +--- @param opts table? +--- @return boolean +local function run_shell_cmd(cmd, opts) + local sys_cmd + if type(cmd) == 'string' then + sys_cmd = { 'sh', '-c', cmd } + else + sys_cmd = cmd + end + return vim.system(sys_cmd, opts):wait().code == 0 +end + +--- Save base64 data to file +--- @param data string +--- @param path string +--- @return boolean +local function save_base64(data, path) + if vim.fn.has('win32') == 1 then + local script = + string.format('[System.IO.File]::WriteAllBytes("%s", [System.Convert]::FromBase64String("%s"))', path, data) + return run_shell_cmd({ 'powershell.exe', '-command', '-' }, { stdin = script }) + else + local decode_arg = vim.uv.os_uname().sysname == 'Darwin' and '-D' or '-d' + return run_shell_cmd(string.format('base64 %s > "%s"', decode_arg, path), { stdin = data }) + end +end + +--- macOS clipboard image handler using osascript +--- @param path string +--- @return boolean +local function handle_darwin_clipboard(path) + if vim.fn.executable('osascript') ~= 1 then + return false + end + local cmd = string.format( + "osascript -e 'set imageData to the clipboard as \"PNGf\"' -e 'set fileRef to open for access POSIX file \"%s\" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'", + path + ) + return run_shell_cmd(cmd) +end + +--- Linux clipboard image handler supporting Wayland and X11 +--- @param path string +--- @return boolean +local function handle_linux_clipboard(path) + if vim.fn.executable('wl-paste') == 1 and run_shell_cmd(string.format('wl-paste -t image/png > "%s"', path)) then + return true + end + return vim.fn.executable('xclip') == 1 + and run_shell_cmd(string.format('xclip -selection clipboard -t image/png -o > "%s"', path)) +end + +--- Windows clipboard image handler using PowerShell +--- @param path string +--- @return boolean +local function handle_windows_clipboard(path) + if vim.fn.executable('powershell.exe') ~= 1 then + return false + end + local script = string.format( + [[ + Add-Type -AssemblyName System.Windows.Forms; + $img = [System.Windows.Forms.Clipboard]::GetImage(); + if ($img) { + $img.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png); + } else { + exit 1 + } + ]], + path + ) + return run_shell_cmd({ 'powershell.exe', '-command', '-' }, { stdin = script }) +end + +local handlers = { + Darwin = handle_darwin_clipboard, + Linux = handle_linux_clipboard, + Windows_NT = handle_windows_clipboard, +} + +--- Try to get image from system clipboard +--- @param image_path string +--- @return boolean +local function try_system_clipboard(image_path) + local os_name = vim.uv.os_uname().sysname + local handler = handlers[os_name] + + -- WSL detection and override + if vim.fn.exists('$WSL_DISTRO_NAME') == 1 then + handler = handlers.Windows_NT + end + + return handler and handler(image_path) and is_valid_file(image_path) or false +end + +--- Try to parse base64 image data from clipboard +--- @param temp_dir string +--- @param timestamp string +--- @return boolean, string? +local function try_base64_clipboard(temp_dir, timestamp) + local content = vim.fn.getreg('+') + if not content or content == '' then + return false + end + + local format, data = content:match('^data:image/([^;]+);base64,(.+)$') + if not format or not data then + return false + end + + local image_path = string.format('%s/pasted_image_%s.%s', temp_dir, timestamp, format) + local success = save_base64(data, image_path) and is_valid_file(image_path) + + return success, success and image_path or nil +end + +--- Handle clipboard image data by saving it to a file and adding it to context +--- @return boolean success True if image was successfully handled +function M.paste_image_from_clipboard() + local temp_dir = vim.fn.tempname() + vim.fn.mkdir(temp_dir, 'p') + local timestamp = os.date('%Y%m%d_%H%M%S') + local image_path = string.format('%s/pasted_image_%s.png', temp_dir, timestamp) + + local success = try_system_clipboard(image_path) + + if not success then + local base64_success, base64_path = try_base64_clipboard(temp_dir, timestamp --[[@as string]]) + success = base64_success + if base64_path then + image_path = base64_path + end + end + + if success then + context.add_file(image_path) + state.context_updated_at = os.time() + vim.notify('Image saved and added to context: ' .. vim.fn.fnamemodify(image_path, ':t'), vim.log.levels.INFO) + return true + end + + vim.notify('No image found in clipboard.', vim.log.levels.WARN) + return false +end + +return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 4b9d0bc1..7b76e9df 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -1,4 +1,15 @@ +---@class OpencodeDiagnostic +---@field message string +---@field severity number +---@field lnum number +---@field col number +---@field end_lnum? number +---@field end_col? number +---@field source? string +---@field code? string|number +---@field user_data? any + ---@class OpencodeConfigFile ---@field theme string ---@field autoshare boolean @@ -333,7 +344,7 @@ ---@field mentioned_files string[]|nil ---@field mentioned_subagents string[]|nil ---@field selections OpencodeContextSelection[]|nil ----@field linter_errors vim.Diagnostic[]|nil +---@field linter_errors OpencodeDiagnostic[]|nil ---@class OpencodeContextSelection ---@field file OpencodeContextFile diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 6a946f15..8ed8c056 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -379,7 +379,7 @@ function M._format_diagnostics_context(output, part) return end local start_line = output:get_line_count() - local diagnostics = json.content --[[@as vim.Diagnostic[] ]] + local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]] if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then return end diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua index 64b69cd2..de7d1424 100644 --- a/lua/opencode/util.lua +++ b/lua/opencode/util.lua @@ -382,4 +382,32 @@ function M.pcall_trace(fn, ...) end, ...) end +function M.is_path_in_cwd(path) + local cwd = vim.fn.getcwd() + local abs_path = vim.fn.fnamemodify(path, ':p') + return abs_path:sub(1, #cwd) == cwd +end + +--- Check if a given path is in the system temporary directory. +--- Optionally match the filename against a pattern. +--- @param path string File path to check +--- @param pattern string|nil Optional Lua pattern to match the filename +--- @return boolean is_temp +function M.is_temp_path(path, pattern) + local temp_dir = vim.fn.tempname() + temp_dir = vim.fn.fnamemodify(temp_dir, ':h') + + local abs_path = vim.fn.fnamemodify(path, ':p') + if abs_path:sub(1, #temp_dir) ~= temp_dir then + return false + end + + if pattern then + local filename = vim.fn.fnamemodify(path, ':t') + return filename:match(pattern) ~= nil + end + + return true +end + return M diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 2af57014..de0204ee 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -120,8 +120,16 @@ describe('add_file/add_selection/add_subagent', function() vim.fn.filereadable = function() return 1 end + local util = require('opencode.util') + local original_is_path_in_cwd = util.is_path_in_cwd + util.is_path_in_cwd = function() + return true + end + context.add_file('/tmp/foo.lua') assert.same({ '/tmp/foo.lua' }, context.context.mentioned_files) + + util.is_path_in_cwd = original_is_path_in_cwd end) it('does not add file if not filereadable', function() vim.fn.filereadable = function() diff --git a/tests/unit/image_handler_spec.lua b/tests/unit/image_handler_spec.lua new file mode 100644 index 00000000..7044fc22 --- /dev/null +++ b/tests/unit/image_handler_spec.lua @@ -0,0 +1,211 @@ +local image_handler = require('opencode.image_handler') +local context = require('opencode.context') + +describe('image_handler', function() + local original_fn = vim.fn + local original_system = vim.system + local original_uv = vim.uv + local original_context_add_file = context.add_file + local original_notify = vim.notify + local original_os_date = os.date + + local mocks = { + executable = {}, + system_calls = {}, + clipboard_content = nil, + temp_dir = '/tmp/test_dir', + os_name = 'Darwin', + wsl_distro = 0, + added_files = {}, + notifications = {}, + } + + before_each(function() + mocks = { + executable = {}, + system_calls = {}, + clipboard_content = nil, + temp_dir = '/tmp/test_dir', + os_name = 'Darwin', + wsl_distro = 0, + added_files = {}, + notifications = {}, + } + + vim.fn = setmetatable({ + executable = function(cmd) + return mocks.executable[cmd] or 0 + end, + tempname = function() + return mocks.temp_dir + end, + mkdir = function() end, + getfsize = function(_) + return 100 -- Simulating non-empty file + end, + has = function(feature) + if feature == 'win32' then + return mocks.os_name == 'Windows_NT' and 1 or 0 + end + return 0 + end, + getreg = function(reg) + if reg == '+' then + return mocks.clipboard_content + end + return '' + end, + exists = function(var) + if var == '$WSL_DISTRO_NAME' then + return mocks.wsl_distro + end + return 0 + end, + fnamemodify = function(path, _) + return path + end, + }, { + __index = original_fn, + }) + + vim.system = function(cmd, opts) + table.insert(mocks.system_calls, { cmd = cmd, opts = opts }) + return { + wait = function() + return { code = 0 } + end, + } + end + + vim.uv = { + os_uname = function() + return { sysname = mocks.os_name } + end, + } + + context.add_file = function(path) + table.insert(mocks.added_files, path) + end + + vim.notify = function(msg, level) + table.insert(mocks.notifications, { msg = msg, level = level }) + end + + os.date = function(fmt) + if fmt == '%Y%m%d_%H%M%S' then + return '20240101_120000' + end + return original_os_date(fmt) + end + end) + + after_each(function() + vim.fn = original_fn + vim.system = original_system + vim.uv = original_uv + context.add_file = original_context_add_file + vim.notify = original_notify + os.date = original_os_date + end) + + it('handles Darwin clipboard with osascript', function() + mocks.os_name = 'Darwin' + mocks.executable['osascript'] = 1 + + local success = image_handler.paste_image_from_clipboard() + + assert.is_true(success) + assert.equals(1, #mocks.added_files) + assert.equals('/tmp/test_dir/pasted_image_20240101_120000.png', mocks.added_files[1]) + assert.is_true(#mocks.system_calls > 0) + local cmd = mocks.system_calls[1].cmd + assert.matches('osascript', cmd[3]) + end) + + it('handles Linux clipboard with wl-paste', function() + mocks.os_name = 'Linux' + mocks.executable['wl-paste'] = 1 + mocks.executable['xclip'] = 0 + + local success = image_handler.paste_image_from_clipboard() + + assert.is_true(success) + assert.equals(1, #mocks.added_files) + assert.matches('wl%-paste', mocks.system_calls[1].cmd[3]) + end) + + it('handles Linux clipboard with xclip', function() + mocks.os_name = 'Linux' + mocks.executable['wl-paste'] = 0 + mocks.executable['xclip'] = 1 + + local success = image_handler.paste_image_from_clipboard() + + assert.is_true(success) + assert.equals(1, #mocks.added_files) + assert.matches('xclip', mocks.system_calls[1].cmd[3]) + end) + + it('handles Windows clipboard', function() + mocks.os_name = 'Windows_NT' + mocks.executable['powershell.exe'] = 1 + + local success = image_handler.paste_image_from_clipboard() + + assert.is_true(success) + assert.equals(1, #mocks.added_files) + local cmd_args = mocks.system_calls[1].cmd + assert.equals('powershell.exe', cmd_args[1]) + end) + + it('handles WSL clipboard as Windows', function() + mocks.os_name = 'Linux' + mocks.wsl_distro = 1 + mocks.executable['powershell.exe'] = 1 + + local success = image_handler.paste_image_from_clipboard() + + assert.is_true(success) + assert.equals(1, #mocks.added_files) + local cmd_args = mocks.system_calls[1].cmd + assert.equals('powershell.exe', cmd_args[1]) + end) + + it('falls back to base64 clipboard if system command fails', function() + mocks.os_name = 'Darwin' + mocks.executable['osascript'] = 0 -- Force failure of system tool + mocks.clipboard_content = 'data:image/png;base64,fakebasedata' + + local success = image_handler.paste_image_from_clipboard() + + assert.is_true(success) + assert.equals(1, #mocks.added_files) + assert.equals('/tmp/test_dir/pasted_image_20240101_120000.png', mocks.added_files[1]) + local cmd_info = mocks.system_calls[1] + assert.matches('base64', cmd_info.cmd[3]) + end) + + it('fails gracefully when no image is found', function() + mocks.os_name = 'Darwin' + mocks.executable['osascript'] = 0 + mocks.clipboard_content = '' + + local success = image_handler.paste_image_from_clipboard() + + assert.is_false(success) + assert.equals(0, #mocks.added_files) + assert.equals(1, #mocks.notifications) + assert.equals('No image found in clipboard.', mocks.notifications[1].msg) + end) + + it('fails gracefully when base64 data is invalid', function() + mocks.os_name = 'Darwin' + mocks.executable['osascript'] = 0 + mocks.clipboard_content = 'invalid data' + + local success = image_handler.paste_image_from_clipboard() + + assert.is_false(success) + assert.equals(0, #mocks.added_files) + end) +end)