Skip to content

Commit 27860e0

Browse files
authored
feat(image): paste image from the clipboard (#126)
Add seamless support for pasting images directly from the system clipboard into your Opencode session. The feature works across macOS, Linux (Wayland/X11), Windows, and WSL, automatically detecting the platform and using the appropriate clipboard extraction method. If a system clipboard image is unavailable, it will also attempt to parse base64-encoded image data from the clipboard. ### Keymaps Added - **Normal mode:** - `<leader>ov` — Paste image from clipboard into the current session. - **Insert mode:** - `<M-v>` — Paste image from clipboard as an attachment. ### Command added `Opencode paste_image`
1 parent 7798150 commit 27860e0

File tree

11 files changed

+466
-4
lines changed

11 files changed

+466
-4
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ require('opencode').setup({
112112
['<leader>oR'] = { 'rename_session' }, -- Rename current session
113113
['<leader>op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list
114114
['<leader>oz'] = { 'toggle_zoom' }, -- Zoom in/out on the Opencode windows
115+
['<leader>ov'] = { 'paste_image'}, -- Paste image from clipboard into current session
115116
['<leader>od'] = { 'diff_open' }, -- Opens a diff tab of a modified file since the last opencode prompt
116117
['<leader>o]'] = { 'diff_next' }, -- Navigate to next file diff
117118
['<leader>o['] = { 'diff_prev' }, -- Navigate to previous file diff
@@ -135,6 +136,7 @@ require('opencode').setup({
135136
['@'] = { 'mention', mode = 'i' }, -- Insert mention (file/agent)
136137
['/'] = { 'slash_commands', mode = 'i' }, -- Pick a command to run in the input window
137138
['#'] = { 'context_items', mode = 'i' }, -- Manage context items (current file, selection, diagnostics, mentioned files)
139+
['<M-v>'] = { 'paste_image', mode = 'i' }, -- Paste image from clipboard as attachment
138140
['<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
139141
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } }, -- Toggle between input and output panes
140142
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } }, -- Navigate to previous prompt in history

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: 2 additions & 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' },
@@ -60,6 +61,7 @@ M.defaults = {
6061
['@'] = { 'mention', mode = 'i' },
6162
['/'] = { 'slash_commands', mode = 'i' },
6263
['#'] = { 'context_items', mode = 'i' },
64+
['<M-v>'] = { 'paste_image', mode = 'i' },
6365
['<tab>'] = { 'toggle_pane', mode = { 'n', 'i' } },
6466
['<up>'] = { 'prev_prompt_history', mode = { 'n', 'i' } },
6567
['<down>'] = { 'next_prompt_history', mode = { 'n', 'i' } },

lua/opencode/context.lua

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ function M.is_context_enabled(context_key)
6666
end
6767
end
6868

69+
---@return OpencodeDiagnostic[]|nil
6970
function M.get_diagnostics(buf)
7071
if not M.is_context_enabled('diagnostics') then
7172
return nil
@@ -146,11 +147,18 @@ function M.add_file(file)
146147
return
147148
end
148149

150+
if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then
151+
vim.notify('File not added to context. Must be inside current working directory.')
152+
return
153+
end
154+
149155
file = vim.fn.fnamemodify(file, ':p')
150156

151157
if not vim.tbl_contains(M.context.mentioned_files, file) then
152158
table.insert(M.context.mentioned_files, file)
153159
end
160+
161+
state.context_updated_at = vim.uv.now()
154162
end
155163

156164
function M.remove_file(file)
@@ -316,7 +324,21 @@ local function format_file_part(path, prompt)
316324
local pos = prompt and prompt:find(mention)
317325
pos = pos and pos - 1 or 0 -- convert to 0-based index
318326

319-
local file_part = { filename = rel_path, type = 'file', mime = 'text/plain', url = 'file://' .. path }
327+
local ext = vim.fn.fnamemodify(path, ':e'):lower()
328+
local mime_type = 'text/plain'
329+
if ext == 'png' then
330+
mime_type = 'image/png'
331+
elseif ext == 'jpg' or ext == 'jpeg' then
332+
mime_type = 'image/jpeg'
333+
elseif ext == 'gif' then
334+
mime_type = 'image/gif'
335+
elseif ext == 'webp' then
336+
mime_type = 'image/webp'
337+
elseif ext == 'svg' then
338+
mime_type = 'image/svg+xml'
339+
end
340+
341+
local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path }
320342
if prompt then
321343
file_part.source = {
322344
path = path,
@@ -343,7 +365,7 @@ local function format_selection_part(selection)
343365
}
344366
end
345367

346-
---@param diagnostics vim.Diagnostic[]
368+
---@param diagnostics OpencodeDiagnostic[]
347369
local function format_diagnostics_part(diagnostics)
348370
local diag_list = {}
349371
for _, diag in ipairs(diagnostics) do

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: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
--- Image pasting functionality from clipboard
2+
--- @see https://github.com/sst/opencode/blob/45180104fe84e2d0b9d29be0f9f8a5e52d18e102/packages/opencode/src/cli/cmd/tui/util/clipboard.ts
3+
local context = require('opencode.context')
4+
local state = require('opencode.state')
5+
6+
local M = {}
7+
8+
--- Check if a file exists and has content
9+
--- @param path string
10+
--- @return boolean
11+
local function is_valid_file(path)
12+
return vim.fn.getfsize(path) > 0
13+
end
14+
15+
--- Run shell or powershell command and return success
16+
--- @param cmd string|table
17+
--- @param opts table?
18+
--- @return boolean
19+
local function run_shell_cmd(cmd, opts)
20+
local sys_cmd
21+
if type(cmd) == 'string' then
22+
sys_cmd = { 'sh', '-c', cmd }
23+
else
24+
sys_cmd = cmd
25+
end
26+
return vim.system(sys_cmd, opts):wait().code == 0
27+
end
28+
29+
--- Save base64 data to file
30+
--- @param data string
31+
--- @param path string
32+
--- @return boolean
33+
local function save_base64(data, path)
34+
if vim.fn.has('win32') == 1 then
35+
local script =
36+
string.format('[System.IO.File]::WriteAllBytes("%s", [System.Convert]::FromBase64String("%s"))', path, data)
37+
return run_shell_cmd({ 'powershell.exe', '-command', '-' }, { stdin = script })
38+
else
39+
local decode_arg = vim.uv.os_uname().sysname == 'Darwin' and '-D' or '-d'
40+
return run_shell_cmd(string.format('base64 %s > "%s"', decode_arg, path), { stdin = data })
41+
end
42+
end
43+
44+
--- macOS clipboard image handler using osascript
45+
--- @param path string
46+
--- @return boolean
47+
local function handle_darwin_clipboard(path)
48+
if vim.fn.executable('osascript') ~= 1 then
49+
return false
50+
end
51+
local cmd = string.format(
52+
"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'",
53+
path
54+
)
55+
return run_shell_cmd(cmd)
56+
end
57+
58+
--- Linux clipboard image handler supporting Wayland and X11
59+
--- @param path string
60+
--- @return boolean
61+
local function handle_linux_clipboard(path)
62+
if vim.fn.executable('wl-paste') == 1 and run_shell_cmd(string.format('wl-paste -t image/png > "%s"', path)) then
63+
return true
64+
end
65+
return vim.fn.executable('xclip') == 1
66+
and run_shell_cmd(string.format('xclip -selection clipboard -t image/png -o > "%s"', path))
67+
end
68+
69+
--- Windows clipboard image handler using PowerShell
70+
--- @param path string
71+
--- @return boolean
72+
local function handle_windows_clipboard(path)
73+
if vim.fn.executable('powershell.exe') ~= 1 then
74+
return false
75+
end
76+
local script = string.format(
77+
[[
78+
Add-Type -AssemblyName System.Windows.Forms;
79+
$img = [System.Windows.Forms.Clipboard]::GetImage();
80+
if ($img) {
81+
$img.Save('%s', [System.Drawing.Imaging.ImageFormat]::Png);
82+
} else {
83+
exit 1
84+
}
85+
]],
86+
path
87+
)
88+
return run_shell_cmd({ 'powershell.exe', '-command', '-' }, { stdin = script })
89+
end
90+
91+
local handlers = {
92+
Darwin = handle_darwin_clipboard,
93+
Linux = handle_linux_clipboard,
94+
Windows_NT = handle_windows_clipboard,
95+
}
96+
97+
--- Try to get image from system clipboard
98+
--- @param image_path string
99+
--- @return boolean
100+
local function try_system_clipboard(image_path)
101+
local os_name = vim.uv.os_uname().sysname
102+
local handler = handlers[os_name]
103+
104+
-- WSL detection and override
105+
if vim.fn.exists('$WSL_DISTRO_NAME') == 1 then
106+
handler = handlers.Windows_NT
107+
end
108+
109+
return handler and handler(image_path) and is_valid_file(image_path) or false
110+
end
111+
112+
--- Try to parse base64 image data from clipboard
113+
--- @param temp_dir string
114+
--- @param timestamp string
115+
--- @return boolean, string?
116+
local function try_base64_clipboard(temp_dir, timestamp)
117+
local content = vim.fn.getreg('+')
118+
if not content or content == '' then
119+
return false
120+
end
121+
122+
local format, data = content:match('^data:image/([^;]+);base64,(.+)$')
123+
if not format or not data then
124+
return false
125+
end
126+
127+
local image_path = string.format('%s/pasted_image_%s.%s', temp_dir, timestamp, format)
128+
local success = save_base64(data, image_path) and is_valid_file(image_path)
129+
130+
return success, success and image_path or nil
131+
end
132+
133+
--- Handle clipboard image data by saving it to a file and adding it to context
134+
--- @return boolean success True if image was successfully handled
135+
function M.paste_image_from_clipboard()
136+
local temp_dir = vim.fn.tempname()
137+
vim.fn.mkdir(temp_dir, 'p')
138+
local timestamp = os.date('%Y%m%d_%H%M%S')
139+
local image_path = string.format('%s/pasted_image_%s.png', temp_dir, timestamp)
140+
141+
local success = try_system_clipboard(image_path)
142+
143+
if not success then
144+
local base64_success, base64_path = try_base64_clipboard(temp_dir, timestamp --[[@as string]])
145+
success = base64_success
146+
if base64_path then
147+
image_path = base64_path
148+
end
149+
end
150+
151+
if success then
152+
context.add_file(image_path)
153+
state.context_updated_at = os.time()
154+
vim.notify('Image saved and added to context: ' .. vim.fn.fnamemodify(image_path, ':t'), vim.log.levels.INFO)
155+
return true
156+
end
157+
158+
vim.notify('No image found in clipboard.', vim.log.levels.WARN)
159+
return false
160+
end
161+
162+
return M

lua/opencode/types.lua

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11

2+
---@class OpencodeDiagnostic
3+
---@field message string
4+
---@field severity number
5+
---@field lnum number
6+
---@field col number
7+
---@field end_lnum? number
8+
---@field end_col? number
9+
---@field source? string
10+
---@field code? string|number
11+
---@field user_data? any
12+
213
---@class OpencodeConfigFile
314
---@field theme string
415
---@field autoshare boolean
@@ -333,7 +344,7 @@
333344
---@field mentioned_files string[]|nil
334345
---@field mentioned_subagents string[]|nil
335346
---@field selections OpencodeContextSelection[]|nil
336-
---@field linter_errors vim.Diagnostic[]|nil
347+
---@field linter_errors OpencodeDiagnostic[]|nil
337348

338349
---@class OpencodeContextSelection
339350
---@field file OpencodeContextFile

lua/opencode/ui/formatter.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ function M._format_diagnostics_context(output, part)
379379
return
380380
end
381381
local start_line = output:get_line_count()
382-
local diagnostics = json.content --[[@as vim.Diagnostic[] ]]
382+
local diagnostics = json.content --[[@as OpencodeDiagnostic[] ]]
383383
if not diagnostics or type(diagnostics) ~= 'table' or #diagnostics == 0 then
384384
return
385385
end

lua/opencode/util.lua

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,32 @@ function M.pcall_trace(fn, ...)
382382
end, ...)
383383
end
384384

385+
function M.is_path_in_cwd(path)
386+
local cwd = vim.fn.getcwd()
387+
local abs_path = vim.fn.fnamemodify(path, ':p')
388+
return abs_path:sub(1, #cwd) == cwd
389+
end
390+
391+
--- Check if a given path is in the system temporary directory.
392+
--- Optionally match the filename against a pattern.
393+
--- @param path string File path to check
394+
--- @param pattern string|nil Optional Lua pattern to match the filename
395+
--- @return boolean is_temp
396+
function M.is_temp_path(path, pattern)
397+
local temp_dir = vim.fn.tempname()
398+
temp_dir = vim.fn.fnamemodify(temp_dir, ':h')
399+
400+
local abs_path = vim.fn.fnamemodify(path, ':p')
401+
if abs_path:sub(1, #temp_dir) ~= temp_dir then
402+
return false
403+
end
404+
405+
if pattern then
406+
local filename = vim.fn.fnamemodify(path, ':t')
407+
return filename:match(pattern) ~= nil
408+
end
409+
410+
return true
411+
end
412+
385413
return M

tests/unit/context_spec.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,16 @@ describe('add_file/add_selection/add_subagent', function()
120120
vim.fn.filereadable = function()
121121
return 1
122122
end
123+
local util = require('opencode.util')
124+
local original_is_path_in_cwd = util.is_path_in_cwd
125+
util.is_path_in_cwd = function()
126+
return true
127+
end
128+
123129
context.add_file('/tmp/foo.lua')
124130
assert.same({ '/tmp/foo.lua' }, context.context.mentioned_files)
131+
132+
util.is_path_in_cwd = original_is_path_in_cwd
125133
end)
126134
it('does not add file if not filereadable', function()
127135
vim.fn.filereadable = function()

0 commit comments

Comments
 (0)