From 88b35e5c9c04819b106543666f7d8390400ee7d2 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 17 Nov 2025 07:15:13 -0500 Subject: [PATCH 1/4] feat(image): paste image from the clipboard --- lua/opencode/api.lua | 9 ++ lua/opencode/config.lua | 1 + lua/opencode/context.lua | 17 +++- lua/opencode/core.lua | 7 ++ lua/opencode/image_handler.lua | 169 +++++++++++++++++++++++++++++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 lua/opencode/image_handler.lua 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..a7e0d7aa 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' }, diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 5206f8b2..e84fb10b 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -316,7 +316,22 @@ 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 } + -- Determine MIME type based on file extension + 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, 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..4f303d7e --- /dev/null +++ b/lua/opencode/image_handler.lua @@ -0,0 +1,169 @@ +local context = require('opencode.context') +local state = require('opencode.state') + +local M = {} + +--- Check if a file was successfully created and has content +--- @param path string File path to check +--- @return boolean success True if file exists and has content +local function is_valid_file(path) + return vim.fn.filereadable(path) == 1 and vim.fn.getfsize(path) > 0 +end + +--- Try to extract image from macOS clipboard +--- @param image_path string Path where to save the image +--- @return boolean success True if image was extracted successfully +local function try_macos_clipboard(image_path) + if vim.fn.executable('osascript') ~= 1 then + return false + end + + local osascript_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'", + image_path + ) + + local result = vim.system({ 'sh', '-c', osascript_cmd }):wait() + return result.code == 0 and is_valid_file(image_path) +end + +--- Try to extract image from Linux clipboard (Wayland) +--- @param image_path string Path where to save the image +--- @return boolean success True if image was extracted successfully +local function try_wayland_clipboard(image_path) + if vim.fn.executable('wl-paste') ~= 1 then + return false + end + + local cmd = string.format('wl-paste -t image/png > "%s"', image_path) + local result = vim.system({ 'sh', '-c', cmd }):wait() + return result.code == 0 and is_valid_file(image_path) +end + +--- Try to extract image from Linux clipboard (X11) +--- @param image_path string Path where to save the image +--- @return boolean success True if image was extracted successfully +local function try_x11_clipboard(image_path) + if vim.fn.executable('xclip') ~= 1 then + return false + end + + local cmd = string.format('xclip -selection clipboard -t image/png -o > "%s"', image_path) + local result = vim.system({ 'sh', '-c', cmd }):wait() + return result.code == 0 and is_valid_file(image_path) +end + +--- Try to extract image from Windows/WSL clipboard +--- @param image_path string Path where to save the image +--- @return boolean success True if image was extracted successfully +local function try_windows_clipboard(image_path) + if vim.fn.executable('powershell.exe') ~= 1 then + return false + end + + local powershell_script = [[ + Add-Type -AssemblyName System.Windows.Forms; + $img = [System.Windows.Forms.Clipboard]::GetImage(); + if ($img) { + $ms = New-Object System.IO.MemoryStream; + $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); + [System.Convert]::ToBase64String($ms.ToArray()) + } + ]] + + local result = vim.system({ 'powershell.exe', '-command', powershell_script }):wait() + if result.code ~= 0 or not result.stdout or result.stdout:gsub('%s', '') == '' then + return false + end + + local base64_data = result.stdout:gsub('%s', '') + local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path) + local decode_result = vim.system({ 'sh', '-c', decode_cmd }):wait() + return decode_result.code == 0 and is_valid_file(image_path) +end + +--- Try to extract image from clipboard as base64 text +--- @param temp_dir string Temporary directory +--- @param timestamp string Timestamp for filename +--- @return string|nil image_path Path to extracted image, or nil if failed +local function try_base64_clipboard(temp_dir, timestamp) + local clipboard_content = vim.fn.getreg('+') + if not clipboard_content or not clipboard_content:match('^data:image/[^;]+;base64,') then + return nil + end + + local format, base64_data = clipboard_content:match('^data:image/([^;]+);base64,(.+)$') + if not format or not base64_data then + return nil + end + + local image_path = temp_dir .. '/pasted_image_' .. timestamp .. '.' .. format + local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path) + local result = vim.system({ 'sh', '-c', decode_cmd }):wait() + + if result.code == 0 and is_valid_file(image_path) then + return image_path + end + return nil +end + +--- Get error message for missing clipboard tools +--- @param os_name string Operating system name +--- @return string error_message Error message with installation instructions +local function get_clipboard_error_message(os_name) + local install_msg = 'No image found in clipboard. Install clipboard tools: ' + if os_name == 'Linux' then + return install_msg .. 'xclip (X11) or wl-clipboard (Wayland)' + elseif os_name == 'Darwin' then + return install_msg .. 'system clipboard should work natively' + else + return install_msg .. 'PowerShell (Windows/WSL)' + end +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 = temp_dir .. '/pasted_image_' .. timestamp .. '.png' + + local os_name = vim.uv.os_uname().sysname + local success = false + + if os_name == 'Darwin' then + success = try_macos_clipboard(image_path) + elseif os_name == 'Windows_NT' or vim.fn.exists('$WSL_DISTRO_NAME') == 1 then + success = try_windows_clipboard(image_path) + elseif os_name == 'Linux' then + success = try_wayland_clipboard(image_path) or try_x11_clipboard(image_path) + end + + if not success then + local fallback_path = try_base64_clipboard(temp_dir, timestamp) + if fallback_path then + image_path = fallback_path + success = true + end + end + + if not success then + vim.notify(get_clipboard_error_message(os_name), vim.log.levels.WARN) + return false + end + + context.add_file(image_path) + state.context_updated_at = os.time() + + local filename = vim.fn.fnamemodify(image_path, ':t') + vim.notify(string.format('Image saved and added to context: %s', filename), vim.log.levels.INFO) + + return true +end + +return M From 65ee8f4d6c1fd77024144c419346e0a284858fa3 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 19 Nov 2025 10:02:16 -0500 Subject: [PATCH 2/4] chore(image-paste): code cleanup and tests --- lua/opencode/context.lua | 10 +- lua/opencode/image_handler.lua | 223 ++++++++++++++---------------- lua/opencode/types.lua | 13 +- lua/opencode/ui/formatter.lua | 2 +- lua/opencode/util.lua | 28 ++++ tests/unit/context_spec.lua | 8 ++ tests/unit/image_handler_spec.lua | 211 ++++++++++++++++++++++++++++ 7 files changed, 374 insertions(+), 121 deletions(-) create mode 100644 tests/unit/image_handler_spec.lua diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index e84fb10b..cb852a5e 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) @@ -358,7 +366,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/image_handler.lua b/lua/opencode/image_handler.lua index 4f303d7e..4d700111 100644 --- a/lua/opencode/image_handler.lua +++ b/lua/opencode/image_handler.lua @@ -1,128 +1,127 @@ +--- 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 was successfully created and has content ---- @param path string File path to check ---- @return boolean success True if file exists and has content +--- Check if a file exists and has content +--- @param path string +--- @return boolean local function is_valid_file(path) - return vim.fn.filereadable(path) == 1 and vim.fn.getfsize(path) > 0 + return vim.fn.getfsize(path) > 0 end ---- Try to extract image from macOS clipboard ---- @param image_path string Path where to save the image ---- @return boolean success True if image was extracted successfully -local function try_macos_clipboard(image_path) - if vim.fn.executable('osascript') ~= 1 then - return false - end - - local osascript_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'", - image_path - ) - - local result = vim.system({ 'sh', '-c', osascript_cmd }):wait() - return result.code == 0 and is_valid_file(image_path) +--- Run shell command and return success +--- @param cmd string +--- @return boolean +local function run_shell_cmd(cmd) + return vim.system({ 'sh', '-c', cmd }):wait().code == 0 end ---- Try to extract image from Linux clipboard (Wayland) ---- @param image_path string Path where to save the image ---- @return boolean success True if image was extracted successfully -local function try_wayland_clipboard(image_path) - if vim.fn.executable('wl-paste') ~= 1 then - return false +--- 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 vim.system({ 'powershell.exe', '-command', '-' }, { stdin = script }):wait().code == 0 + else + local decode_arg = vim.uv.os_uname().sysname == 'Darwin' and '-D' or '-d' + return vim.system({ 'sh', '-c', string.format('base64 %s > "%s"', decode_arg, path) }, { stdin = data }):wait().code + == 0 end - - local cmd = string.format('wl-paste -t image/png > "%s"', image_path) - local result = vim.system({ 'sh', '-c', cmd }):wait() - return result.code == 0 and is_valid_file(image_path) end ---- Try to extract image from Linux clipboard (X11) ---- @param image_path string Path where to save the image ---- @return boolean success True if image was extracted successfully -local function try_x11_clipboard(image_path) - if vim.fn.executable('xclip') ~= 1 then +--- 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 - local cmd = string.format('xclip -selection clipboard -t image/png -o > "%s"', image_path) - local result = vim.system({ 'sh', '-c', cmd }):wait() - return result.code == 0 and is_valid_file(image_path) +--- 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 ---- Try to extract image from Windows/WSL clipboard ---- @param image_path string Path where to save the image ---- @return boolean success True if image was extracted successfully -local function try_windows_clipboard(image_path) +--- 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 vim.system({ 'powershell.exe', '-command', '-' }, { stdin = script }):wait().code == 0 +end - local powershell_script = [[ - Add-Type -AssemblyName System.Windows.Forms; - $img = [System.Windows.Forms.Clipboard]::GetImage(); - if ($img) { - $ms = New-Object System.IO.MemoryStream; - $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); - [System.Convert]::ToBase64String($ms.ToArray()) - } - ]] - - local result = vim.system({ 'powershell.exe', '-command', powershell_script }):wait() - if result.code ~= 0 or not result.stdout or result.stdout:gsub('%s', '') == '' then - return false +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 - local base64_data = result.stdout:gsub('%s', '') - local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path) - local decode_result = vim.system({ 'sh', '-c', decode_cmd }):wait() - return decode_result.code == 0 and is_valid_file(image_path) + return handler and handler(image_path) and is_valid_file(image_path) or false end ---- Try to extract image from clipboard as base64 text ---- @param temp_dir string Temporary directory ---- @param timestamp string Timestamp for filename ---- @return string|nil image_path Path to extracted image, or nil if failed +--- 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 clipboard_content = vim.fn.getreg('+') - if not clipboard_content or not clipboard_content:match('^data:image/[^;]+;base64,') then - return nil + local content = vim.fn.getreg('+') + if not content or content == '' then + return false end - local format, base64_data = clipboard_content:match('^data:image/([^;]+);base64,(.+)$') - if not format or not base64_data then - return nil + local format, data = content:match('^data:image/([^;]+);base64,(.+)$') + if not format or not data then + return false end - local image_path = temp_dir .. '/pasted_image_' .. timestamp .. '.' .. format - local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path) - local result = vim.system({ 'sh', '-c', decode_cmd }):wait() - - if result.code == 0 and is_valid_file(image_path) then - return image_path - end - return nil -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) ---- Get error message for missing clipboard tools ---- @param os_name string Operating system name ---- @return string error_message Error message with installation instructions -local function get_clipboard_error_message(os_name) - local install_msg = 'No image found in clipboard. Install clipboard tools: ' - if os_name == 'Linux' then - return install_msg .. 'xclip (X11) or wl-clipboard (Wayland)' - elseif os_name == 'Darwin' then - return install_msg .. 'system clipboard should work natively' - else - return install_msg .. 'PowerShell (Windows/WSL)' - end + return success, success and image_path or nil end --- Handle clipboard image data by saving it to a file and adding it to context @@ -131,39 +130,27 @@ 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 = temp_dir .. '/pasted_image_' .. timestamp .. '.png' + local image_path = string.format('%s/pasted_image_%s.png', temp_dir, timestamp) - local os_name = vim.uv.os_uname().sysname - local success = false - - if os_name == 'Darwin' then - success = try_macos_clipboard(image_path) - elseif os_name == 'Windows_NT' or vim.fn.exists('$WSL_DISTRO_NAME') == 1 then - success = try_windows_clipboard(image_path) - elseif os_name == 'Linux' then - success = try_wayland_clipboard(image_path) or try_x11_clipboard(image_path) - end + local success = try_system_clipboard(image_path) if not success then - local fallback_path = try_base64_clipboard(temp_dir, timestamp) - if fallback_path then - image_path = fallback_path - success = true + 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 not success then - vim.notify(get_clipboard_error_message(os_name), vim.log.levels.WARN) - return false + 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 - context.add_file(image_path) - state.context_updated_at = os.time() - - local filename = vim.fn.fnamemodify(image_path, ':t') - vim.notify(string.format('Image saved and added to context: %s', filename), vim.log.levels.INFO) - - return true + 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) From b04053c8e051ad2927e35154e7ed10521b6876ae Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 19 Nov 2025 19:44:59 -0500 Subject: [PATCH 3/4] feat(paste_image): add keymap for pasting images from the clipboard --- README.md | 2 ++ lua/opencode/config.lua | 1 + lua/opencode/context.lua | 1 - 3 files changed, 3 insertions(+), 1 deletion(-) 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/config.lua b/lua/opencode/config.lua index a7e0d7aa..9d633a75 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -61,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 cb852a5e..bbe02008 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -324,7 +324,6 @@ 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 - -- Determine MIME type based on file extension local ext = vim.fn.fnamemodify(path, ':e'):lower() local mime_type = 'text/plain' if ext == 'png' then From 3e418dc308e0efba2edbd08b5b87cd22ff2ffa36 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 21 Nov 2025 07:25:28 -0500 Subject: [PATCH 4/4] refactor(image_handler): pass all commands to run_shell_cmd --- lua/opencode/image_handler.lua | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lua/opencode/image_handler.lua b/lua/opencode/image_handler.lua index 4d700111..d4dfd3fd 100644 --- a/lua/opencode/image_handler.lua +++ b/lua/opencode/image_handler.lua @@ -12,11 +12,18 @@ local function is_valid_file(path) return vim.fn.getfsize(path) > 0 end ---- Run shell command and return success ---- @param cmd string +--- Run shell or powershell command and return success +--- @param cmd string|table +--- @param opts table? --- @return boolean -local function run_shell_cmd(cmd) - return vim.system({ 'sh', '-c', cmd }):wait().code == 0 +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 @@ -27,11 +34,10 @@ 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 vim.system({ 'powershell.exe', '-command', '-' }, { stdin = script }):wait().code == 0 + return run_shell_cmd({ 'powershell.exe', '-command', '-' }, { stdin = script }) else local decode_arg = vim.uv.os_uname().sysname == 'Darwin' and '-D' or '-d' - return vim.system({ 'sh', '-c', string.format('base64 %s > "%s"', decode_arg, path) }, { stdin = data }):wait().code - == 0 + return run_shell_cmd(string.format('base64 %s > "%s"', decode_arg, path), { stdin = data }) end end @@ -79,7 +85,7 @@ local function handle_windows_clipboard(path) ]], path ) - return vim.system({ 'powershell.exe', '-command', '-' }, { stdin = script }):wait().code == 0 + return run_shell_cmd({ 'powershell.exe', '-command', '-' }, { stdin = script }) end local handlers = {