From 5fc81e319fe18a48698a220ba4a42e5b74c9a838 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Sat, 14 Mar 2026 19:06:01 +0100 Subject: [PATCH 1/8] feat(ui): add configurable input window options Allow users to customize window-local options for the input window via `ui.input.win_options` in config. Any valid Neovim window option can be set, such as signcolumn, cursorline, number, relativenumber, etc. This improves flexibility for user preferences and editor appearance. --- lua/opencode/config.lua | 10 ++++++++++ lua/opencode/types.lua | 17 +++++++++++++++++ lua/opencode/ui/input_window.lua | 11 +++++++---- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 85c02c57..5053848c 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -160,6 +160,16 @@ M.defaults = { }, -- Auto-hide input window when prompt is submitted or focus switches to output window auto_hide = false, + -- Window-local options applied to the input window. + -- Any valid Neovim window option can be added here. + -- Users can override these and add any extra option, e.g.: + -- win_options = { signcolumn = 'no', cursorline = true, conceallevel = 2 } + win_options = { + signcolumn = 'yes', + cursorline = false, + number = false, + relativenumber = false, + }, }, picker = { snacks_layout = nil, diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 6ad6aabf..9312af5c 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -142,11 +142,28 @@ ---@field highlights? OpencodeHighlightConfig ---@field picker OpencodeUIPickerConfig +---Window-local options applied to the input window. +---Any valid Neovim window-local option (`:h window-variable`) can be set here. +---Common examples: +--- signcolumn = 'no' +--- cursorline = true +--- number = true +--- relativenumber = true +--- foldcolumn = '0' +--- statuscolumn = '' +--- conceallevel = 2 +---@class OpencodeUIInputWinOptions : table +---@field signcolumn? string # Value for 'signcolumn' (e.g. 'yes', 'no', 'auto') +---@field cursorline? boolean +---@field number? boolean +---@field relativenumber? boolean + ---@class OpencodeUIInputConfig ---@field text { wrap: boolean } ---@field min_height number ---@field max_height number ---@field auto_hide boolean +---@field win_options? OpencodeUIInputWinOptions # Window-local options applied to the input window. Any valid Neovim window option is accepted. ---@class OpencodeHighlightConfig ---@field vertical_borders? { tool?: { fg?: string, bg?: string }, user?: { fg?: string, bg?: string }, assistant?: { fg?: string, bg?: string } } diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 903b61b3..9889081e 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -268,10 +268,13 @@ function M.setup(windows) set_buf_option('filetype', 'opencode', windows) set_win_option('winhighlight', config.ui.window_highlight, windows) - set_win_option('signcolumn', 'yes', windows) - set_win_option('cursorline', false, windows) - set_win_option('number', false, windows) - set_win_option('relativenumber', false, windows) + + -- Apply user-configurable window options + local win_opts = config.ui.input.win_options or {} + for opt, value in pairs(win_opts) do + pcall(set_win_option, opt, value, windows) + end + set_buf_option('buftype', 'nofile', windows) set_buf_option('bufhidden', 'hide', windows) set_buf_option('buflisted', false, windows) From 8f6725cf47743d0ec1bf0b893b2db61184dc93bf Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 10:40:52 +0100 Subject: [PATCH 2/8] feat(context): add inline visual selection insertion Adds a new command and API to insert the current visual selection as a formatted code block directly into the input buffer. This includes a new `add_visual_selection_inline` function, a corresponding keymap, and a helper to build the formatted text. Unit tests are provided to ensure correct formatting and error handling for edge cases. --- lua/opencode/api.lua | 28 +++++++ lua/opencode/config.lua | 1 + lua/opencode/context.lua | 42 ++++++++++ tests/unit/context_spec.lua | 155 ++++++++++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 212c1aa8..41be4dde 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -1085,6 +1085,29 @@ M.add_visual_selection = Promise.async( end ) +M.add_visual_selection_inline = Promise.async( + ---@param opts? {open_input?: boolean} + ---@param range OpencodeSelectionRange + function(opts, range) + opts = vim.tbl_extend('force', { open_input = true }, opts or {}) + local context = require('opencode.context') + local text = context.build_inline_selection_text(range) + + if not text then + return + end + + M.open_input():await() + local input_window = require('opencode.ui.input_window') + input_window._append_to_input(text) + vim.schedule(function() + if vim.fn.mode() ~= 'n' then + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'n', false) + end + end) + end +) + ---@type table M.commands = { open = { @@ -1453,6 +1476,11 @@ M.commands = { desc = 'Add current visual selection to context', fn = M.add_visual_selection, }, + + add_visual_selection_inline = { + desc = 'Insert visual selection as inline code block in the input buffer', + fn = M.add_visual_selection_inline, + }, } M.slash_commands_map = { diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 5053848c..1ae97d38 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -40,6 +40,7 @@ M.defaults = { ['op'] = { 'configure_provider', desc = 'Configure provider' }, ['oV'] = { 'configure_variant', desc = 'Configure model variant' }, ['oy'] = { 'add_visual_selection', mode = { 'v' }, desc = 'Add visual selection to context' }, + ['oY'] = { 'add_visual_selection_inline', mode = { 'v' }, desc = 'Insert visual selection inline into input' }, ['oz'] = { 'toggle_zoom', desc = 'Toggle zoom' }, ['ov'] = { 'paste_image', desc = 'Paste image from clipboard' }, ['od'] = { 'diff_open', desc = 'Open diff view' }, diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index d7804e6c..6f14a665 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -165,6 +165,48 @@ function M.add_visual_selection(range) return true end +--- Captures the current visual selection and returns the text to be inserted inline +--- into the opencode input buffer, in the form: +--- +--- Here is some code from : +--- +--- ``` +--- +--- ``` +--- +---@param range? OpencodeSelectionRange +---@return string|nil text The formatted text to insert, or nil on failure +function M.build_inline_selection_text(range) + local buf = vim.api.nvim_get_current_buf() + + if not util.is_buf_a_file(buf) then + vim.notify('Cannot add selection: not a file buffer', vim.log.levels.WARN) + return nil + end + + local current_selection = BaseContext.get_current_selection(nil, range) + if not current_selection then + vim.notify('No visual selection found', vim.log.levels.WARN) + return nil + end + + local file = BaseContext.get_current_file_for_selection(buf) + if not file then + vim.notify('Cannot determine file for selection', vim.log.levels.WARN) + return nil + end + + local filetype = vim.bo[buf].filetype or '' + local text = string.format( + 'Here is some code from %s:\n\n```%s\n%s\n```', + file.path, + filetype, + current_selection.text + ) + + return text +end + function M.add_file(file) local is_file = vim.fn.filereadable(file) == 1 local is_dir = vim.fn.isdirectory(file) == 1 diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 4da6c66d..905b49fd 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -1140,3 +1140,158 @@ describe('add_visual_selection API', function() vim.notify = original_notify end) end) + +describe('build_inline_selection_text', function() + local context + local BaseContext + local util + + before_each(function() + context = require('opencode.context') + BaseContext = require('opencode.context.base_context') + util = require('opencode.util') + end) + + it('should return formatted inline text for a visual selection', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_selection = BaseContext.get_current_selection + local original_get_current_file_for_selection = BaseContext.get_current_file_for_selection + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return true + end + BaseContext.get_current_selection = function() + return { text = 'function foo()\n return 42\nend', lines = '10, 12' } + end + BaseContext.get_current_file_for_selection = function() + return { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' } + end + vim.api.nvim_get_current_buf = function() + return 5 + end + + -- Mock vim.bo to return a filetype + local original_bo = vim.bo + vim.bo = setmetatable({}, { + __index = function(_, buf) + return { filetype = 'lua' } + end, + }) + + local text = context.build_inline_selection_text() + + assert.is_not_nil(text) + assert.is_not_nil(text:match('Here is some code from /tmp/test%.lua:')) + assert.is_not_nil(text:match('```lua')) + assert.is_not_nil(text:match('function foo%(%)')) + assert.is_not_nil(text:match('```$')) + + util.is_buf_a_file = original_is_buf_a_file + BaseContext.get_current_selection = original_get_current_selection + BaseContext.get_current_file_for_selection = original_get_current_file_for_selection + vim.api.nvim_get_current_buf = original_get_current_buf + vim.bo = original_bo + end) + + it('should return nil and notify when not a file buffer', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return false + end + vim.api.nvim_get_current_buf = function() + return 10 + end + + local original_notify = vim.notify + local notifications = {} + vim.notify = function(msg, level) + table.insert(notifications, { msg = msg, level = level }) + end + + local text = context.build_inline_selection_text() + + assert.is_nil(text) + assert.equal(1, #notifications) + assert.equal('Cannot add selection: not a file buffer', notifications[1].msg) + assert.equal(vim.log.levels.WARN, notifications[1].level) + + util.is_buf_a_file = original_is_buf_a_file + vim.api.nvim_get_current_buf = original_get_current_buf + vim.notify = original_notify + end) + + it('should return nil and notify when no visual selection found', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_selection = BaseContext.get_current_selection + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return true + end + BaseContext.get_current_selection = function() + return nil + end + vim.api.nvim_get_current_buf = function() + return 11 + end + + local original_notify = vim.notify + local notifications = {} + vim.notify = function(msg, level) + table.insert(notifications, { msg = msg, level = level }) + end + + local text = context.build_inline_selection_text() + + assert.is_nil(text) + assert.equal(1, #notifications) + assert.equal('No visual selection found', notifications[1].msg) + assert.equal(vim.log.levels.WARN, notifications[1].level) + + util.is_buf_a_file = original_is_buf_a_file + BaseContext.get_current_selection = original_get_current_selection + vim.api.nvim_get_current_buf = original_get_current_buf + vim.notify = original_notify + end) + + it('should include the filetype in the code fence', function() + local original_is_buf_a_file = util.is_buf_a_file + local original_get_current_selection = BaseContext.get_current_selection + local original_get_current_file_for_selection = BaseContext.get_current_file_for_selection + local original_get_current_buf = vim.api.nvim_get_current_buf + + util.is_buf_a_file = function() + return true + end + BaseContext.get_current_selection = function() + return { text = 'const x = 1', lines = '1, 1' } + end + BaseContext.get_current_file_for_selection = function() + return { path = '/tmp/app.ts', name = 'app.ts', extension = 'ts' } + end + vim.api.nvim_get_current_buf = function() + return 6 + end + + local original_bo = vim.bo + vim.bo = setmetatable({}, { + __index = function(_, buf) + return { filetype = 'typescript' } + end, + }) + + local text = context.build_inline_selection_text() + + assert.is_not_nil(text) + assert.is_not_nil(text:match('```typescript')) + + util.is_buf_a_file = original_is_buf_a_file + BaseContext.get_current_selection = original_get_current_selection + BaseContext.get_current_file_for_selection = original_get_current_file_for_selection + vim.api.nvim_get_current_buf = original_get_current_buf + vim.bo = original_bo + end) +end) From 0f7164794178a41825b78b80a532b612221a5a12 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 10:45:12 +0100 Subject: [PATCH 3/8] feat: make ctrl-w-o work --- lua/opencode/state.lua | 48 +++++++++++++++++++++++++++++++++++- lua/opencode/ui/autocmds.lua | 40 ++++++++++++++++++++++++++++++ lua/opencode/ui/renderer.lua | 1 - lua/opencode/ui/ui.lua | 32 ++++++++++++++++++------ 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 47561ef4..f87471f0 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -80,7 +80,8 @@ ---@field clear_hidden_window_state fun() ---@field has_hidden_buffers fun(): boolean ---@field consume_hidden_buffers fun(): OpencodeHiddenBuffers|nil ----@field resolve_toggle_decision fun(persist_state: boolean, has_display_route: boolean): OpencodeToggleDecision + ---@field are_opencode_only_windows fun(): boolean + ---@field resolve_toggle_decision fun(persist_state: boolean, has_display_route: boolean): OpencodeToggleDecision ---@field resolve_open_windows_action fun(): 'reuse_visible'|'restore_hidden'|'create_fresh' ---@field get_window_cursor fun(win_id: integer|nil): integer[]|nil @@ -264,6 +265,43 @@ function M.are_windows_in_current_tab() or M.is_window_in_current_tab(_state.windows.output_win) end +--- Returns true when every normal (non-floating) window in the current tab +--- belongs to opencode (i.e. there are no code windows open alongside it). +---@return boolean +function M.are_opencode_only_windows() + local w = _state.windows + if not w then + return false + end + + local opencode_wins = {} + if w.input_win and vim.api.nvim_win_is_valid(w.input_win) then + opencode_wins[w.input_win] = true + end + if w.output_win and vim.api.nvim_win_is_valid(w.output_win) then + opencode_wins[w.output_win] = true + end + if w.footer_win and vim.api.nvim_win_is_valid(w.footer_win) then + opencode_wins[w.footer_win] = true + end + + -- No opencode windows tracked → not an only-opencode situation + if vim.tbl_isempty(opencode_wins) then + return false + end + + local current_tab = vim.api.nvim_get_current_tabpage() + for _, win_id in ipairs(vim.api.nvim_tabpage_list_wins(current_tab)) do + local cfg = vim.api.nvim_win_get_config(win_id) + -- Skip floating windows + if cfg.relative == '' and not opencode_wins[win_id] then + return false + end + end + + return true +end + ---@return boolean function M.is_visible() return M.get_window_state().status == 'visible' @@ -274,6 +312,7 @@ end ---@field in_tab boolean ---@field persist_state boolean ---@field has_display_route boolean +---@field only_windows boolean ---@generic T ---@param rules T[] @@ -323,6 +362,12 @@ local TOGGLE_ACTION_RULES = { return ctx.status == 'visible' and ctx.in_tab and not ctx.persist_state end, }, + { + action = 'hide', + when = function(ctx) + return ctx.status == 'visible' and ctx.in_tab and ctx.only_windows and not ctx.has_display_route + end, + }, { action = 'hide', when = function(ctx) @@ -348,6 +393,7 @@ local function lookup_toggle_action(status, in_tab, persist_state, has_display_r in_tab = in_tab, persist_state = persist_state, has_display_route = has_display_route, + only_windows = M.are_opencode_only_windows(), } local matched_rule = first_matching_rule(TOGGLE_ACTION_RULES, function(rule) diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 4f6b9a11..303018e4 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -113,6 +113,46 @@ function M.setup_autocmds(windows) end, }) end + + M.setup_window_only_keymap(windows) +end + +--- Set o / on both opencode buffers to close all other +--- non-floating windows while keeping the opencode pair intact. +--- Also clears the saved width ratio so the next open uses the config default. +---@param windows OpencodeWindowState +function M.setup_window_only_keymap(windows) + local opencode_wins = function() + local t = {} + for _, w in ipairs({ windows.input_win, windows.output_win, windows.footer_win }) do + if w then + t[w] = true + end + end + return t + end + + local handler = function() + local keep = opencode_wins() + keep[vim.api.nvim_get_current_win()] = true + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if not keep[win] and vim.api.nvim_win_is_valid(win) then + local cfg = vim.api.nvim_win_get_config(win) + if cfg.relative == '' then + pcall(vim.api.nvim_win_close, win, false) + end + end + end + -- Don't remember the current opencode width; next open will use config default + require('opencode.state').last_window_width_ratio = nil + end + + for _, buf in ipairs({ windows.input_buf, windows.output_buf }) do + if buf and vim.api.nvim_buf_is_valid(buf) then + vim.keymap.set('n', 'o', handler, { buffer = buf, desc = 'Keep only opencode windows', nowait = true }) + vim.keymap.set('n', '', handler, { buffer = buf, desc = 'Keep only opencode windows', nowait = true }) + end + end end ---@param windows OpencodeWindowState? diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 1c1eb382..e4489b98 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -731,7 +731,6 @@ function M.on_part_updated(properties, revert_index) local rendered_message = M._render_state:get_message(part.messageID) if not rendered_message or not rendered_message.message then - vim.notify('Could not find message for part: ' .. vim.inspect(part), vim.log.levels.WARN) return end diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 92724e08..478f1219 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -122,6 +122,31 @@ local function close_or_restore_output_window(windows) return end + -- Count non-floating windows still open in the current tab. + -- If output_win is the only one left (e.g. after o + toggle), we cannot + -- close it (Neovim forbids closing the last window). Instead swap its buffer + -- to a new empty scratch buffer so the opencode buffer goes into hiding while + -- the window stays alive showing a blank slate. + if windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) then + local current_tab = vim.api.nvim_get_current_tabpage() + local normal_wins = 0 + for _, w in ipairs(vim.api.nvim_tabpage_list_wins(current_tab)) do + if vim.api.nvim_win_get_config(w).relative == '' then + normal_wins = normal_wins + 1 + end + end + + if normal_wins <= 1 then + -- Last window: swap buffer instead of closing + local scratch = vim.api.nvim_create_buf(false, true) + pcall(vim.api.nvim_set_option_value, 'winfixbuf', false, { win = windows.output_win }) + pcall(vim.api.nvim_set_option_value, 'winfixwidth', false, { win = windows.output_win }) + pcall(vim.api.nvim_set_option_value, 'winfixheight', false, { win = windows.output_win }) + pcall(vim.api.nvim_win_set_buf, windows.output_win, scratch) + return + end + end + pcall(vim.api.nvim_win_close, windows.output_win, true) end @@ -135,13 +160,6 @@ function M.hide_visible_windows(windows) local snapshot = capture_hidden_snapshot(windows) - -- Only save width ratio for split modes (not dialog/current mode) - if config.ui.position ~= 'current' then - local total_cols = vim.o.columns - local current_width = vim.api.nvim_win_get_width(windows.output_win) - state.last_window_width_ratio = current_width / total_cols - end - state.clear_hidden_window_state() prepare_window_close() From 6ca985590e8da79c057c5975c988b2dd44acb2ae Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 11:09:20 +0100 Subject: [PATCH 4/8] refactor(ui): centralize winfixbuf logic in buf_fix_win Move winfixbuf handling for input and output windows to a shared `buf_fix_win.fix_to_win` utility. This reduces duplication and ensures consistent buffer-to-window pinning logic. Also removes redundant winfixbuf resets and last-window buffer swap logic from ui.lua, as this is now handled in a single place. --- lua/opencode/ui/input_window.lua | 6 +++--- lua/opencode/ui/output_window.lua | 6 +++--- lua/opencode/ui/ui.lua | 30 ------------------------------ 3 files changed, 6 insertions(+), 36 deletions(-) diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 9889081e..99bbad8d 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -280,9 +280,9 @@ function M.setup(windows) set_buf_option('buflisted', false, windows) set_buf_option('swapfile', false, windows) - if config.ui.position ~= 'current' then - set_win_option('winfixbuf', true, windows) - end + require('opencode.ui.buf_fix_win').fix_to_win(windows.input_buf, function() + return state.windows and state.windows.input_win + end) set_win_option('winfixwidth', true, windows) M.update_dimensions(windows) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index a6e561db..ec31e14b 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -115,9 +115,9 @@ function M.setup(windows) set_buf_option('buflisted', false, windows.output_buf) set_buf_option('swapfile', false, windows.output_buf) - if config.ui.position ~= 'current' then - set_win_option('winfixbuf', true, windows.output_win) - end + require('opencode.ui.buf_fix_win').fix_to_win(windows.output_buf, function() + return state.windows and state.windows.output_win + end) set_win_option('winfixheight', true, windows.output_win) set_win_option('winfixwidth', true, windows.output_win) set_win_option('signcolumn', 'yes', windows.output_win) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 478f1219..bccddefd 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -108,7 +108,6 @@ end local function close_or_restore_output_window(windows) if config.ui.position == 'current' then if windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) then - pcall(vim.api.nvim_set_option_value, 'winfixbuf', false, { win = windows.output_win }) if state.current_code_buf and vim.api.nvim_buf_is_valid(state.current_code_buf) then pcall(vim.api.nvim_win_set_buf, windows.output_win, state.current_code_buf) end @@ -122,31 +121,6 @@ local function close_or_restore_output_window(windows) return end - -- Count non-floating windows still open in the current tab. - -- If output_win is the only one left (e.g. after o + toggle), we cannot - -- close it (Neovim forbids closing the last window). Instead swap its buffer - -- to a new empty scratch buffer so the opencode buffer goes into hiding while - -- the window stays alive showing a blank slate. - if windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) then - local current_tab = vim.api.nvim_get_current_tabpage() - local normal_wins = 0 - for _, w in ipairs(vim.api.nvim_tabpage_list_wins(current_tab)) do - if vim.api.nvim_win_get_config(w).relative == '' then - normal_wins = normal_wins + 1 - end - end - - if normal_wins <= 1 then - -- Last window: swap buffer instead of closing - local scratch = vim.api.nvim_create_buf(false, true) - pcall(vim.api.nvim_set_option_value, 'winfixbuf', false, { win = windows.output_win }) - pcall(vim.api.nvim_set_option_value, 'winfixwidth', false, { win = windows.output_win }) - pcall(vim.api.nvim_set_option_value, 'winfixheight', false, { win = windows.output_win }) - pcall(vim.api.nvim_win_set_buf, windows.output_win, scratch) - return - end - end - pcall(vim.api.nvim_win_close, windows.output_win, true) end @@ -347,10 +321,6 @@ function M.create_split_windows(input_buf, output_buf) local input_win = open_split(ui_conf.input_position, 'horizontal') local output_win = main_win - if ui_conf.position == 'current' then - pcall(vim.api.nvim_set_option_value, 'winfixbuf', false, { win = output_win }) - end - vim.api.nvim_win_set_buf(input_win, input_buf) vim.api.nvim_win_set_buf(output_win, output_buf) return { input_win = input_win, output_win = output_win } From 3cc37a3be990281eab2a37413df1a2ef2e6b6c52 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 13:19:59 +0100 Subject: [PATCH 5/8] feat(context): improve inline selection formatting Update the inline visual selection text to use bolded file path formatting (**`path/to/file`**) instead of the previous "Here is some code from" prefix. Adjust unit tests to match the new output format. This change makes the inserted context more visually distinct and concise. --- lua/opencode/context.lua | 4 ++-- tests/unit/context_spec.lua | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 6f14a665..4cdc73f1 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -168,7 +168,7 @@ end --- Captures the current visual selection and returns the text to be inserted inline --- into the opencode input buffer, in the form: --- ---- Here is some code from : +--- **`path/to/file`** --- --- ``` --- @@ -198,7 +198,7 @@ function M.build_inline_selection_text(range) local filetype = vim.bo[buf].filetype or '' local text = string.format( - 'Here is some code from %s:\n\n```%s\n%s\n```', + '**`%s`**\n\n```%s\n%s\n```', file.path, filetype, current_selection.text diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 905b49fd..7f12bb4e 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -1182,7 +1182,7 @@ describe('build_inline_selection_text', function() local text = context.build_inline_selection_text() assert.is_not_nil(text) - assert.is_not_nil(text:match('Here is some code from /tmp/test%.lua:')) + assert.is_not_nil(text:match('%*%*`/tmp/test%.lua`%*%*')) assert.is_not_nil(text:match('```lua')) assert.is_not_nil(text:match('function foo%(%)')) assert.is_not_nil(text:match('```$')) From 252f4af618b5c510d96bc1b71088638cf9c96c39 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 14:12:15 +0100 Subject: [PATCH 6/8] feat: add inline visual selection insertion action Add new action `add_visual_selection_inline` with keymap `oY` to insert visually selected code directly into the input buffer as a Markdown code block, prefixed with the file path. Updates documentation with usage details and example. Leaves cursor in normal mode for prompt editing. --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4bc8d086..1615f70a 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ require('opencode').setup({ ['op'] = { 'configure_provider' }, -- Quick provider and model switch from predefined list ['oV'] = { 'configure_variant' }, -- Switch model variant for the current model ['oy'] = { 'add_visual_selection', mode = {'v'} }, + ['oY'] = { 'add_visual_selection_inline', mode = {'v'} }, -- Insert visual selection as inline code block in the input buffer ['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 @@ -645,6 +646,7 @@ The plugin provides the following actions that can be triggered via keymaps, com | Toggle reasoning output (thinking steps) | `otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` | | Open a quick chat input with selection/current line context | `o/` | `:Opencode quick_chat` | `require('opencode.api').quick_chat()` | | Add visual selection to context | `oy` | `:Opencode add_visual_selection` | `require('opencode.api').add_visual_selection(opts?)` | +| Insert visual selection inline into input | `oY` | `:Opencode add_visual_selection_inline` | `require('opencode.api').add_visual_selection_inline(opts?)` | **add_visual_selection opts:** @@ -653,9 +655,21 @@ The plugin provides the following actions that can be triggered via keymaps, com Example keymap for silent add: ```lua -['oY'] = { 'add_visual_selection', { open_input = false }, mode = {'v'} } +['oy'] = { 'add_visual_selection', { open_input = false }, mode = {'v'} } ``` +**add_visual_selection_inline** inserts the visually selected code directly into the input buffer as a Markdown code block, prefixed with the file path: + +``` +**`path/to/file.lua`** + +```lua + +``` +``` + +The cursor is left in normal mode in the input buffer so you can type your prompt around the inserted snippet. + ### Run opts You can pass additional options when running a prompt via command or API: From c7ae945771ea271e898ab447c86647fdc22be117 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 20:15:36 +0100 Subject: [PATCH 7/8] fix(ui): warn when message for part is missing Add a warning notification when a part update references a message that cannot be found in the render state. This helps with debugging and improves visibility into potential state inconsistencies. --- lua/opencode/ui/renderer.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index e4489b98..1c1eb382 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -731,6 +731,7 @@ function M.on_part_updated(properties, revert_index) local rendered_message = M._render_state:get_message(part.messageID) if not rendered_message or not rendered_message.message then + vim.notify('Could not find message for part: ' .. vim.inspect(part), vim.log.levels.WARN) return end From a201bab6652e71d7adf1f43506e182c29a7cb262 Mon Sep 17 00:00:00 2001 From: Julio Garcia Date: Tue, 17 Mar 2026 20:24:22 +0100 Subject: [PATCH 8/8] feat(ui): track last window width ratio on hide Store the ratio of the output window's width to the total columns when hiding windows, unless the UI position is 'current'. This enables restoring window sizes more accurately. --- lua/opencode/ui/ui.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index bccddefd..0eecf22e 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -134,6 +134,12 @@ function M.hide_visible_windows(windows) local snapshot = capture_hidden_snapshot(windows) + if config.ui.position ~= 'current' then + local total_cols = vim.o.columns + local current_width = vim.api.nvim_win_get_width(windows.output_win) + state.last_window_width_ratio = current_width / total_cols + end + state.clear_hidden_window_state() prepare_window_close()