Skip to content

Commit 88b35e5

Browse files
committed
feat(image): paste image from the clipboard
1 parent 7798150 commit 88b35e5

File tree

5 files changed

+202
-1
lines changed

5 files changed

+202
-1
lines changed

lua/opencode/api.lua

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ function M.close()
4545
ui.close_windows(state.windows)
4646
end
4747

48+
function M.paste_image()
49+
core.paste_image_from_clipboard()
50+
end
51+
4852
function M.toggle(new_session)
4953
if state.windows == nil then
5054
local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output'
@@ -1207,6 +1211,10 @@ M.commands = {
12071211
desc = 'Toggle tool output visibility in the output window',
12081212
fn = M.toggle_tool_output,
12091213
},
1214+
paste_image = {
1215+
desc = 'Paste image from clipboard and add to context',
1216+
fn = M.paste_image,
1217+
},
12101218
}
12111219

12121220
M.slash_commands_map = {
@@ -1270,6 +1278,7 @@ M.legacy_command_map = {
12701278
OpencodePermissionAccept = 'permission accept',
12711279
OpencodePermissionAcceptAll = 'permission accept_all',
12721280
OpencodePermissionDeny = 'permission deny',
1281+
OpencodePasteImage = 'paste_image',
12731282
}
12741283

12751284
function M.route_command(opts)

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ M.defaults = {
2525
['<leader>oR'] = { 'rename_session', desc = 'Rename session' },
2626
['<leader>op'] = { 'configure_provider', desc = 'Configure provider' },
2727
['<leader>oz'] = { 'toggle_zoom', desc = 'Toggle zoom' },
28+
['<leader>ov'] = { 'paste_image', desc = 'Paste image from clipboard' },
2829
['<leader>od'] = { 'diff_open', desc = 'Open diff view' },
2930
['<leader>o]'] = { 'diff_next', desc = 'Next diff' },
3031
['<leader>o['] = { 'diff_prev', desc = 'Previous diff' },

lua/opencode/context.lua

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,22 @@ local function format_file_part(path, prompt)
316316
local pos = prompt and prompt:find(mention)
317317
pos = pos and pos - 1 or 0 -- convert to 0-based index
318318

319-
local file_part = { filename = rel_path, type = 'file', mime = 'text/plain', url = 'file://' .. path }
319+
-- Determine MIME type based on file extension
320+
local ext = vim.fn.fnamemodify(path, ':e'):lower()
321+
local mime_type = 'text/plain'
322+
if ext == 'png' then
323+
mime_type = 'image/png'
324+
elseif ext == 'jpg' or ext == 'jpeg' then
325+
mime_type = 'image/jpeg'
326+
elseif ext == 'gif' then
327+
mime_type = 'image/gif'
328+
elseif ext == 'webp' then
329+
mime_type = 'image/webp'
330+
elseif ext == 'svg' then
331+
mime_type = 'image/svg+xml'
332+
end
333+
334+
local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path }
320335
if prompt then
321336
file_part.source = {
322337
path = path,

lua/opencode/core.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ local server_job = require('opencode.server_job')
77
local input_window = require('opencode.ui.input_window')
88
local util = require('opencode.util')
99
local config = require('opencode.config')
10+
local image_handler = require('opencode.image_handler')
1011

1112
local M = {}
1213
M._abort_count = 0
@@ -366,6 +367,12 @@ function M.initialize_current_model()
366367
return state.current_model
367368
end
368369

370+
--- Handle clipboard image data by saving it to a file and adding it to context
371+
--- @return boolean success True if image was successfully handled
372+
function M.paste_image_from_clipboard()
373+
return image_handler.paste_image_from_clipboard()
374+
end
375+
369376
function M.setup()
370377
state.subscribe('opencode_server', on_opencode_server)
371378

lua/opencode/image_handler.lua

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
local context = require('opencode.context')
2+
local state = require('opencode.state')
3+
4+
local M = {}
5+
6+
--- Check if a file was successfully created and has content
7+
--- @param path string File path to check
8+
--- @return boolean success True if file exists and has content
9+
local function is_valid_file(path)
10+
return vim.fn.filereadable(path) == 1 and vim.fn.getfsize(path) > 0
11+
end
12+
13+
--- Try to extract image from macOS clipboard
14+
--- @param image_path string Path where to save the image
15+
--- @return boolean success True if image was extracted successfully
16+
local function try_macos_clipboard(image_path)
17+
if vim.fn.executable('osascript') ~= 1 then
18+
return false
19+
end
20+
21+
local osascript_cmd = string.format(
22+
'osascript -e \'set imageData to the clipboard as "PNGf"\' '
23+
.. '-e \'set fileRef to open for access POSIX file "%s" with write permission\' '
24+
.. "-e 'set eof fileRef to 0' "
25+
.. "-e 'write imageData to fileRef' "
26+
.. "-e 'close access fileRef'",
27+
image_path
28+
)
29+
30+
local result = vim.system({ 'sh', '-c', osascript_cmd }):wait()
31+
return result.code == 0 and is_valid_file(image_path)
32+
end
33+
34+
--- Try to extract image from Linux clipboard (Wayland)
35+
--- @param image_path string Path where to save the image
36+
--- @return boolean success True if image was extracted successfully
37+
local function try_wayland_clipboard(image_path)
38+
if vim.fn.executable('wl-paste') ~= 1 then
39+
return false
40+
end
41+
42+
local cmd = string.format('wl-paste -t image/png > "%s"', image_path)
43+
local result = vim.system({ 'sh', '-c', cmd }):wait()
44+
return result.code == 0 and is_valid_file(image_path)
45+
end
46+
47+
--- Try to extract image from Linux clipboard (X11)
48+
--- @param image_path string Path where to save the image
49+
--- @return boolean success True if image was extracted successfully
50+
local function try_x11_clipboard(image_path)
51+
if vim.fn.executable('xclip') ~= 1 then
52+
return false
53+
end
54+
55+
local cmd = string.format('xclip -selection clipboard -t image/png -o > "%s"', image_path)
56+
local result = vim.system({ 'sh', '-c', cmd }):wait()
57+
return result.code == 0 and is_valid_file(image_path)
58+
end
59+
60+
--- Try to extract image from Windows/WSL clipboard
61+
--- @param image_path string Path where to save the image
62+
--- @return boolean success True if image was extracted successfully
63+
local function try_windows_clipboard(image_path)
64+
if vim.fn.executable('powershell.exe') ~= 1 then
65+
return false
66+
end
67+
68+
local powershell_script = [[
69+
Add-Type -AssemblyName System.Windows.Forms;
70+
$img = [System.Windows.Forms.Clipboard]::GetImage();
71+
if ($img) {
72+
$ms = New-Object System.IO.MemoryStream;
73+
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);
74+
[System.Convert]::ToBase64String($ms.ToArray())
75+
}
76+
]]
77+
78+
local result = vim.system({ 'powershell.exe', '-command', powershell_script }):wait()
79+
if result.code ~= 0 or not result.stdout or result.stdout:gsub('%s', '') == '' then
80+
return false
81+
end
82+
83+
local base64_data = result.stdout:gsub('%s', '')
84+
local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path)
85+
local decode_result = vim.system({ 'sh', '-c', decode_cmd }):wait()
86+
return decode_result.code == 0 and is_valid_file(image_path)
87+
end
88+
89+
--- Try to extract image from clipboard as base64 text
90+
--- @param temp_dir string Temporary directory
91+
--- @param timestamp string Timestamp for filename
92+
--- @return string|nil image_path Path to extracted image, or nil if failed
93+
local function try_base64_clipboard(temp_dir, timestamp)
94+
local clipboard_content = vim.fn.getreg('+')
95+
if not clipboard_content or not clipboard_content:match('^data:image/[^;]+;base64,') then
96+
return nil
97+
end
98+
99+
local format, base64_data = clipboard_content:match('^data:image/([^;]+);base64,(.+)$')
100+
if not format or not base64_data then
101+
return nil
102+
end
103+
104+
local image_path = temp_dir .. '/pasted_image_' .. timestamp .. '.' .. format
105+
local decode_cmd = string.format('echo "%s" | base64 -d > "%s"', base64_data, image_path)
106+
local result = vim.system({ 'sh', '-c', decode_cmd }):wait()
107+
108+
if result.code == 0 and is_valid_file(image_path) then
109+
return image_path
110+
end
111+
return nil
112+
end
113+
114+
--- Get error message for missing clipboard tools
115+
--- @param os_name string Operating system name
116+
--- @return string error_message Error message with installation instructions
117+
local function get_clipboard_error_message(os_name)
118+
local install_msg = 'No image found in clipboard. Install clipboard tools: '
119+
if os_name == 'Linux' then
120+
return install_msg .. 'xclip (X11) or wl-clipboard (Wayland)'
121+
elseif os_name == 'Darwin' then
122+
return install_msg .. 'system clipboard should work natively'
123+
else
124+
return install_msg .. 'PowerShell (Windows/WSL)'
125+
end
126+
end
127+
128+
--- Handle clipboard image data by saving it to a file and adding it to context
129+
--- @return boolean success True if image was successfully handled
130+
function M.paste_image_from_clipboard()
131+
local temp_dir = vim.fn.tempname()
132+
vim.fn.mkdir(temp_dir, 'p')
133+
local timestamp = os.date('%Y%m%d_%H%M%S')
134+
local image_path = temp_dir .. '/pasted_image_' .. timestamp .. '.png'
135+
136+
local os_name = vim.uv.os_uname().sysname
137+
local success = false
138+
139+
if os_name == 'Darwin' then
140+
success = try_macos_clipboard(image_path)
141+
elseif os_name == 'Windows_NT' or vim.fn.exists('$WSL_DISTRO_NAME') == 1 then
142+
success = try_windows_clipboard(image_path)
143+
elseif os_name == 'Linux' then
144+
success = try_wayland_clipboard(image_path) or try_x11_clipboard(image_path)
145+
end
146+
147+
if not success then
148+
local fallback_path = try_base64_clipboard(temp_dir, timestamp)
149+
if fallback_path then
150+
image_path = fallback_path
151+
success = true
152+
end
153+
end
154+
155+
if not success then
156+
vim.notify(get_clipboard_error_message(os_name), vim.log.levels.WARN)
157+
return false
158+
end
159+
160+
context.add_file(image_path)
161+
state.context_updated_at = os.time()
162+
163+
local filename = vim.fn.fnamemodify(image_path, ':t')
164+
vim.notify(string.format('Image saved and added to context: %s', filename), vim.log.levels.INFO)
165+
166+
return true
167+
end
168+
169+
return M

0 commit comments

Comments
 (0)