Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ require('opencode').setup({
['<leader>oR'] = { 'rename_session' }, -- Rename current session
['<leader>op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list
['<leader>oz'] = { 'toggle_zoom' }, -- Zoom in/out on the Opencode windows
['<leader>ov'] = { 'paste_image'}, -- Paste image from clipboard into current session
['<leader>od'] = { 'diff_open' }, -- Opens a diff tab of a modified file since the last opencode prompt
['<leader>o]'] = { 'diff_next' }, -- Navigate to next file diff
['<leader>o['] = { 'diff_prev' }, -- Navigate to previous file diff
Expand All @@ -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)
['<M-v>'] = { 'paste_image', mode = 'i' }, -- Paste image from clipboard as attachment
['<C-i>'] = { 'focus_input', mode = { 'n', 'i' } }, -- Focus on input window and enter insert mode at the end of the input from the output window
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } }, -- Toggle between input and output panes
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } }, -- Navigate to previous prompt in history
Expand Down
9 changes: 9 additions & 0 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ M.defaults = {
['<leader>oR'] = { 'rename_session', desc = 'Rename session' },
['<leader>op'] = { 'configure_provider', desc = 'Configure provider' },
['<leader>oz'] = { 'toggle_zoom', desc = 'Toggle zoom' },
['<leader>ov'] = { 'paste_image', desc = 'Paste image from clipboard' },
['<leader>od'] = { 'diff_open', desc = 'Open diff view' },
['<leader>o]'] = { 'diff_next', desc = 'Next diff' },
['<leader>o['] = { 'diff_prev', desc = 'Previous diff' },
Expand Down Expand Up @@ -60,6 +61,7 @@ M.defaults = {
['@'] = { 'mention', mode = 'i' },
['/'] = { 'slash_commands', mode = 'i' },
['#'] = { 'context_items', mode = 'i' },
['<M-v>'] = { 'paste_image', mode = 'i' },
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } },
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' } },
Expand Down
26 changes: 24 additions & 2 deletions lua/opencode/context.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions lua/opencode/core.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
162 changes: 162 additions & 0 deletions lua/opencode/image_handler.lua
Original file line number Diff line number Diff line change
@@ -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
13 changes: 12 additions & 1 deletion lua/opencode/types.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions lua/opencode/util.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions tests/unit/context_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading