From 0708c356fe2e3c0cbd47937959dadde49a62bb9f Mon Sep 17 00:00:00 2001 From: disrupted Date: Tue, 3 Feb 2026 23:08:33 +0100 Subject: [PATCH 1/6] fix(ui): prevent duplicate windows --- lua/opencode/ui/autocmds.lua | 7 +++-- lua/opencode/ui/output_window.lua | 1 + lua/opencode/ui/ui.lua | 44 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index a3301654..6b4d3dd7 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -75,15 +75,18 @@ function M.setup_autocmds(windows) end end +---@param windows OpencodeWindowState? function M.setup_resize_handler(windows) local resize_group = vim.api.nvim_create_augroup('OpencodeResize', { clear = true }) vim.api.nvim_create_autocmd('VimResized', { group = resize_group, callback = function() + require('opencode.ui.ui').reconcile_windows(windows, 'input') + require('opencode.ui.ui').reconcile_windows(windows, 'output') require('opencode.ui.topbar').render() require('opencode.ui.footer').update_window(windows) - require('opencode.ui.input_window').update_dimensions(windows) - require('opencode.ui.output_window').update_dimensions(windows) + input_window.update_dimensions(windows) + output_window.update_dimensions(windows) end, }) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index b0d8bb4d..b9cfd90e 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -23,6 +23,7 @@ function M._build_output_win_config() } end +---@param windows OpencodeWindowState? function M.mounted(windows) windows = windows or state.windows return windows and windows.output_buf and windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 6ddc0482..bf3c4251 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -296,4 +296,48 @@ function M.toggle_zoom() end end +---Reconcile window state when duplicate windows may have been created +---This can happen when external commands like 'tabdo' manipulate window layouts +---@param windows OpencodeWindowState? +---@param key string +function M.reconcile_windows(windows, key) + local buf_key = key .. '_buf' + if not windows or not windows[buf_key] then + return + end + + local wins = vim.fn.win_findbuf(windows[buf_key]) + if type(wins) ~= 'table' or #wins == 0 then + return + end + + local valid_wins = {} + for _, win in ipairs(wins) do + if vim.api.nvim_win_is_valid(win) then + table.insert(valid_wins, win) + end + end + if #valid_wins == 0 then + return + end + + local current_tab = vim.api.nvim_get_current_tabpage() + local preferred = nil + for _, win in ipairs(valid_wins) do + if vim.api.nvim_win_get_tabpage(win) == current_tab then + preferred = win + break + end + end + preferred = preferred or valid_wins[1] + + windows[key .. '_win'] = preferred + + for _, win in ipairs(valid_wins) do + if win ~= preferred and vim.api.nvim_win_get_tabpage(win) == current_tab then + pcall(vim.api.nvim_win_close, win, false) + end + end +end + return M From 574c536ebd9e621741d01b6eacda751102f21568 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 5 Feb 2026 08:24:56 -0500 Subject: [PATCH 2/6] fix: prevent duplicate windows by implementing buffer-to-window binding This should fix #144 --- lua/opencode/ui/autocmds.lua | 3 - lua/opencode/ui/buf_fix_win.lua | 67 +++++ lua/opencode/ui/input_window.lua | 9 + lua/opencode/ui/output_window.lua | 10 + lua/opencode/ui/ui.lua | 44 --- tests/unit/buf_fix_win_spec.lua | 464 ++++++++++++++++++++++++++++++ 6 files changed, 550 insertions(+), 47 deletions(-) create mode 100644 lua/opencode/ui/buf_fix_win.lua create mode 100644 tests/unit/buf_fix_win_spec.lua diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 6b4d3dd7..7c780eef 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -81,15 +81,12 @@ function M.setup_resize_handler(windows) vim.api.nvim_create_autocmd('VimResized', { group = resize_group, callback = function() - require('opencode.ui.ui').reconcile_windows(windows, 'input') - require('opencode.ui.ui').reconcile_windows(windows, 'output') require('opencode.ui.topbar').render() require('opencode.ui.footer').update_window(windows) input_window.update_dimensions(windows) output_window.update_dimensions(windows) end, }) - vim.api.nvim_create_autocmd('WinResized', { group = resize_group, callback = function(args) diff --git a/lua/opencode/ui/buf_fix_win.lua b/lua/opencode/ui/buf_fix_win.lua new file mode 100644 index 00000000..a7e69293 --- /dev/null +++ b/lua/opencode/ui/buf_fix_win.lua @@ -0,0 +1,67 @@ +--- Prevents a buffer from appearing in multiple windows (opposite of 'winfixbuf') +--- +--- This module solves the problem where buffers can appear in multiple windows +--- despite having 'winfixbuf' set. The 'winfixbuf' option prevents a window from +--- changing buffers, but doesn't prevent a buffer from appearing in multiple windows. +--- +local M = {} +local buff_to_win_map = {} +local global_autocmd_setup = false + +local function close_duplicates(buf, get_win) + local intended = get_win() + if not intended or not vim.api.nvim_win_is_valid(intended) then + return + end + + local wins = vim.fn.win_findbuf(buf) + if #wins <= 1 then + return + end + + for _, win in ipairs(wins) do + if win ~= intended and vim.api.nvim_win_is_valid(win) then + vim.schedule(function() + pcall(vim.api.nvim_win_close, win, true) + end) + end + end +end + +local check_all_buffers = vim.schedule_wrap(function() + for buf, get_win in pairs(buff_to_win_map) do + if vim.api.nvim_buf_is_valid(buf) then + close_duplicates(buf, get_win) + else + buff_to_win_map[buf] = nil + end + end +end) + +local function setup() + if global_autocmd_setup then + return + end + global_autocmd_setup = true + + vim.api.nvim_create_autocmd({ 'WinNew', 'VimResized' }, { + callback = check_all_buffers, + }) +end + +--- Protect a buffer from appearing in multiple windows +---@param buf integer Buffer number +---@param get_intended_window fun(): integer? Function returning intended window ID +function M.fix_to_win(buf, get_intended_window) + setup() + + buff_to_win_map[buf] = get_intended_window + vim.api.nvim_create_autocmd('BufWinEnter', { + buffer = buf, + callback = function() + close_duplicates(buf, get_intended_window) + end, + }) +end + +return M diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 0818110c..a8722b98 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -62,6 +62,13 @@ end function M.create_buf() local input_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_option_value('filetype', 'opencode', { buf = input_buf }) + + local buffixwin = require('opencode.ui.buf_fix_win') + buffixwin.fix_to_win(input_buf, function() + local state = require('opencode.state') + return state.windows and state.windows.input_win + end) + return input_buf end @@ -261,6 +268,8 @@ function M.setup(windows) set_win_option('number', false, windows) set_win_option('relativenumber', false, windows) set_buf_option('buftype', 'nofile', windows) + set_buf_option('bufhidden', 'hide', windows) + set_buf_option('buflisted', false, windows) set_buf_option('swapfile', false, windows) if config.ui.position ~= 'current' then diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index b9cfd90e..61dd1af2 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -8,6 +8,13 @@ M.viewport_at_bottom = true function M.create_buf() local output_buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_option_value('filetype', 'opencode_output', { buf = output_buf }) + + local buffixwin = require('opencode.ui.buf_fix_win') + buffixwin.fix_to_win(output_buf, function() + local state = require('opencode.state') + return state.windows and state.windows.output_win + end) + return output_buf end @@ -97,7 +104,10 @@ function M.setup(windows) set_win_option('relativenumber', false, windows.output_win) set_buf_option('modifiable', false, windows.output_buf) set_buf_option('buftype', 'nofile', windows.output_buf) + set_buf_option('bufhidden', 'hide', windows.output_buf) + 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 diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index bf3c4251..6ddc0482 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -296,48 +296,4 @@ function M.toggle_zoom() end end ----Reconcile window state when duplicate windows may have been created ----This can happen when external commands like 'tabdo' manipulate window layouts ----@param windows OpencodeWindowState? ----@param key string -function M.reconcile_windows(windows, key) - local buf_key = key .. '_buf' - if not windows or not windows[buf_key] then - return - end - - local wins = vim.fn.win_findbuf(windows[buf_key]) - if type(wins) ~= 'table' or #wins == 0 then - return - end - - local valid_wins = {} - for _, win in ipairs(wins) do - if vim.api.nvim_win_is_valid(win) then - table.insert(valid_wins, win) - end - end - if #valid_wins == 0 then - return - end - - local current_tab = vim.api.nvim_get_current_tabpage() - local preferred = nil - for _, win in ipairs(valid_wins) do - if vim.api.nvim_win_get_tabpage(win) == current_tab then - preferred = win - break - end - end - preferred = preferred or valid_wins[1] - - windows[key .. '_win'] = preferred - - for _, win in ipairs(valid_wins) do - if win ~= preferred and vim.api.nvim_win_get_tabpage(win) == current_tab then - pcall(vim.api.nvim_win_close, win, false) - end - end -end - return M diff --git a/tests/unit/buf_fix_win_spec.lua b/tests/unit/buf_fix_win_spec.lua new file mode 100644 index 00000000..8c14b1f2 --- /dev/null +++ b/tests/unit/buf_fix_win_spec.lua @@ -0,0 +1,464 @@ +local buf_fix_win = require('opencode.ui.buf_fix_win') + +describe('buf_fix_win module', function() + local test_bufs = {} + local test_wins = {} + + local function create_test_buffer() + local buf = vim.api.nvim_create_buf(false, true) + table.insert(test_bufs, buf) + return buf + end + + local function create_test_window(buf) + vim.cmd('split') + local win = vim.api.nvim_get_current_win() + if buf then + vim.api.nvim_win_set_buf(win, buf) + end + table.insert(test_wins, win) + return win + end + + local function cleanup_test_windows() + for _, win in ipairs(test_wins) do + if vim.api.nvim_win_is_valid(win) then + pcall(vim.api.nvim_win_close, win, true) + end + end + test_wins = {} + end + + local function cleanup_test_buffers() + for _, buf in ipairs(test_bufs) do + if vim.api.nvim_buf_is_valid(buf) then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end + end + test_bufs = {} + end + + after_each(function() + cleanup_test_windows() + cleanup_test_buffers() + vim.cmd('only') + end) + + describe('fix_to_win', function() + it('should prevent buffer from appearing in multiple windows', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + local win2 = create_test_window() + vim.api.nvim_win_set_buf(win2, buf) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) + end) + + assert.is_false(vim.api.nvim_win_is_valid(win2)) + assert.is_true(vim.api.nvim_win_is_valid(win1)) + end) + + it('should keep buffer in intended window', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + assert.equal(vim.api.nvim_win_get_buf(win1), buf) + end) + + it('should handle nil intended window gracefully', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return nil + end + + assert.has_no.errors(function() + buf_fix_win.fix_to_win(buf, get_intended_window) + + local other_window = create_test_window() + vim.api.nvim_win_set_buf(other_window, buf) + + -- Give scheduled tasks time to run + vim.wait(100, function() + return false + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(other_window)) + end) + end) + + it('should close duplicates when buffer appears in multiple windows', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window(buf) + local win3 = create_test_window(buf) + + assert.equal(vim.api.nvim_win_get_buf(win1), buf) + assert.equal(vim.api.nvim_win_get_buf(win2), buf) + assert.equal(vim.api.nvim_win_get_buf(win3), buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) and not vim.api.nvim_win_is_valid(win3) + end) + + -- Only win1 should remain + assert.is_true(vim.api.nvim_win_is_valid(win1)) + + assert.is_false(vim.api.nvim_win_is_valid(win2)) + assert.is_false(vim.api.nvim_win_is_valid(win3)) + end) + + it('should work when buffer has only one window', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + assert.has_no.errors(function() + buf_fix_win.fix_to_win(buf, get_intended_window) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.equal(vim.api.nvim_win_get_buf(win1), buf) + end) + + it('should handle dynamic intended window changes', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window() + + local intended_window = win1 + local get_intended_window = function() + return intended_window + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + -- Change intended window + intended_window = win2 + vim.api.nvim_win_set_buf(win2, buf) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win1) + end) + + -- Win1 should be closed, win2 should remain + assert.is_false(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(win2)) + end) + + it('should handle WinNew autocmd for new windows', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + -- Create a new window and try to switch to the buffer + vim.cmd('split') + local new_win = vim.api.nvim_get_current_win() + table.insert(test_wins, new_win) + + vim.api.nvim_win_set_buf(new_win, buf) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(new_win) + end) + + -- New window should be closed + assert.is_false(vim.api.nvim_win_is_valid(new_win)) + assert.is_true(vim.api.nvim_win_is_valid(win1)) + end) + + it('should not close intended window even if it is a duplicate', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window(buf) + + local get_intended_window = function() + return win1 + end + + buf_fix_win.fix_to_win(buf, get_intended_window) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_false(vim.api.nvim_win_is_valid(win2)) + end) + + it('should handle multiple buffers with different intended windows', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + -- Try to swap buffers + local win3 = create_test_window() + local win4 = create_test_window() + + vim.api.nvim_win_set_buf(win3, buf1) + vim.api.nvim_win_set_buf(win4, buf2) + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win3) and not vim.api.nvim_win_is_valid(win4) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(win2)) + assert.is_false(vim.api.nvim_win_is_valid(win3)) + assert.is_false(vim.api.nvim_win_is_valid(win4)) + end) + + it('should handle invalid window from get_intended_window', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + local get_intended_window = function() + return 99999 -- Invalid window ID + end + + assert.has_no.errors(function() + buf_fix_win.fix_to_win(buf, get_intended_window) + + -- Try to open in another window + local win2 = create_test_window() + vim.api.nvim_win_set_buf(win2, buf) + + vim.wait(100, function() + return false + end) + end) + end) + end) + + describe('autocmd setup behavior', function() + local fresh_buf_fix_win + + before_each(function() + -- Reload module to reset internal state for these tests only + package.loaded['opencode.ui.buf_fix_win'] = nil + fresh_buf_fix_win = require('opencode.ui.buf_fix_win') + end) + + after_each(function() + -- Restore the original module for other tests + package.loaded['opencode.ui.buf_fix_win'] = nil + buf_fix_win = require('opencode.ui.buf_fix_win') + end) + + it('should setup global WinNew autocmd when fix_to_win is first called', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial_autocmds = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + + local final_autocmds = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + assert.equal(initial_autocmds + 1, final_autocmds) + end) + + it('should not duplicate global WinNew autocmd on subsequent calls', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + fresh_buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + + local after_first_call = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + fresh_buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + local after_second_call = #vim.api.nvim_get_autocmds({ event = 'WinNew' }) + + assert.equal(after_first_call, after_second_call) + end) + + it('should create BufWinEnter autocmd for each buffer', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial_autocmds = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + + local final_autocmds = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + assert.equal(initial_autocmds + 1, final_autocmds) + end) + + it('should create separate BufWinEnter autocmds for different buffers', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + local initial_buf1 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf1 }) + local initial_buf2 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf2 }) + + fresh_buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + fresh_buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + local final_buf1 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf1 }) + local final_buf2 = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf2 }) + + assert.equal(initial_buf1 + 1, final_buf1) + assert.equal(initial_buf2 + 1, final_buf2) + end) + + it('should setup global VimResized autocmd when fix_to_win is first called', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial_autocmds = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + + local final_autocmds = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + assert.equal(initial_autocmds + 1, final_autocmds) + end) + + it('should not duplicate global VimResized autocmd on subsequent calls', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + + fresh_buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + + local after_first_call = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + fresh_buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + local after_second_call = #vim.api.nvim_get_autocmds({ event = 'VimResized' }) + + assert.equal(after_first_call, after_second_call) + end) + end) + + describe('VimResized event handling', function() + it('should close duplicate windows on VimResized', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + local win2 = create_test_window(buf) + + buf_fix_win.fix_to_win(buf, function() + return win1 + end) + + -- Simulate VimResized event + vim.cmd('doautocmd VimResized') + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win2) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_false(vim.api.nvim_win_is_valid(win2)) + end) + + it('should handle VimResized with multiple buffers', function() + local buf1 = create_test_buffer() + local buf2 = create_test_buffer() + + local win1 = create_test_window(buf1) + local win2 = create_test_window(buf2) + local win3 = create_test_window(buf1) -- Duplicate of buf1 + local win4 = create_test_window(buf2) -- Duplicate of buf2 + + buf_fix_win.fix_to_win(buf1, function() + return win1 + end) + buf_fix_win.fix_to_win(buf2, function() + return win2 + end) + + vim.cmd('doautocmd VimResized') + + vim.wait(100, function() + return not vim.api.nvim_win_is_valid(win3) and not vim.api.nvim_win_is_valid(win4) + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + assert.is_true(vim.api.nvim_win_is_valid(win2)) + assert.is_false(vim.api.nvim_win_is_valid(win3)) + assert.is_false(vim.api.nvim_win_is_valid(win4)) + end) + + it('should not close windows when no duplicates exist after VimResized', function() + local buf = create_test_buffer() + local win1 = create_test_window(buf) + + buf_fix_win.fix_to_win(buf, function() + return win1 + end) + + vim.cmd('doautocmd VimResized') + + vim.wait(100, function() + return false + end) + + assert.is_true(vim.api.nvim_win_is_valid(win1)) + end) + end) +end) From fccd818d07d2a0e696a5c10439e5839130b079d8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 5 Feb 2026 08:36:12 -0500 Subject: [PATCH 3/6] feat(ui): add toggle between tab pages --- lua/opencode/api.lua | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 8a03ae2e..ba219665 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -55,13 +55,29 @@ function M.paste_image() core.paste_image_from_clipboard() end +--- Check if opencode windows are in the current tab page +--- @return boolean +local function are_windows_in_current_tab() + if not state.windows or not state.windows.output_win then + return false + end + + local current_tab = vim.api.nvim_get_current_tabpage() + local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, state.windows.output_win) + return ok and win_tab == current_tab +end + M.toggle = Promise.async(function(new_session) -- When auto_hide input is enabled, always focus input; otherwise use last focused local focus = 'input' ---@cast focus 'input' | 'output' if not config.ui.input.auto_hide then focus = state.last_focused_opencode_window or 'input' end - if state.windows == nil then + + if state.windows == nil or not are_windows_in_current_tab() then + if state.windows then + M.close() + end core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await() else M.close() From ab6647af73cb750b1f0c175e5c5abef4ca65b1ff Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 5 Feb 2026 08:49:24 -0500 Subject: [PATCH 4/6] fix(ui): prevent duplicate windows by cleaning up autocmds --- lua/opencode/ui/buf_fix_win.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lua/opencode/ui/buf_fix_win.lua b/lua/opencode/ui/buf_fix_win.lua index a7e69293..d028345a 100644 --- a/lua/opencode/ui/buf_fix_win.lua +++ b/lua/opencode/ui/buf_fix_win.lua @@ -44,9 +44,18 @@ local function setup() end global_autocmd_setup = true + local augroup = vim.api.nvim_create_augroup('OpenCodeBufFixWin', { clear = false }) vim.api.nvim_create_autocmd({ 'WinNew', 'VimResized' }, { + group = augroup, callback = check_all_buffers, }) + vim.api.nvim_create_autocmd('BufDelete', { + callback = function(args) + if args and args.buf then + buff_to_win_map[args.buf] = nil + end + end, + }) end --- Protect a buffer from appearing in multiple windows @@ -56,7 +65,9 @@ function M.fix_to_win(buf, get_intended_window) setup() buff_to_win_map[buf] = get_intended_window + local augroup = vim.api.nvim_create_augroup('OpenCodeBufFixWin_' .. buf, { clear = true }) vim.api.nvim_create_autocmd('BufWinEnter', { + group = augroup, buffer = buf, callback = function() close_duplicates(buf, get_intended_window) From d98ee70f8a6304b7b4ec4572e7f757082682394a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 5 Feb 2026 08:56:49 -0500 Subject: [PATCH 5/6] fix(buf_fix_win): prevent duplicate BufWinEnter autocmds for the same buffer --- lua/opencode/ui/buf_fix_win.lua | 18 ++++++++---------- lua/opencode/ui/input_window.lua | 1 - lua/opencode/ui/output_window.lua | 1 - tests/unit/buf_fix_win_spec.lua | 19 +++++++++++++++++++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/lua/opencode/ui/buf_fix_win.lua b/lua/opencode/ui/buf_fix_win.lua index d028345a..74fdb0c7 100644 --- a/lua/opencode/ui/buf_fix_win.lua +++ b/lua/opencode/ui/buf_fix_win.lua @@ -15,16 +15,14 @@ local function close_duplicates(buf, get_win) end local wins = vim.fn.win_findbuf(buf) - if #wins <= 1 then - return - end - - for _, win in ipairs(wins) do - if win ~= intended and vim.api.nvim_win_is_valid(win) then - vim.schedule(function() - pcall(vim.api.nvim_win_close, win, true) - end) - end + if #wins > 1 then + vim.schedule(function() + for _, win in ipairs(wins) do + if win ~= intended and vim.api.nvim_win_is_valid(win) then + pcall(vim.api.nvim_win_close, win, true) + end + end + end) end end diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index a8722b98..2e7a1fb2 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -65,7 +65,6 @@ function M.create_buf() local buffixwin = require('opencode.ui.buf_fix_win') buffixwin.fix_to_win(input_buf, function() - local state = require('opencode.state') return state.windows and state.windows.input_win end) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 61dd1af2..09cfca23 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -11,7 +11,6 @@ function M.create_buf() local buffixwin = require('opencode.ui.buf_fix_win') buffixwin.fix_to_win(output_buf, function() - local state = require('opencode.state') return state.windows and state.windows.output_win end) diff --git a/tests/unit/buf_fix_win_spec.lua b/tests/unit/buf_fix_win_spec.lua index 8c14b1f2..60c64dd0 100644 --- a/tests/unit/buf_fix_win_spec.lua +++ b/tests/unit/buf_fix_win_spec.lua @@ -358,6 +358,25 @@ describe('buf_fix_win module', function() assert.equal(initial_buf2 + 1, final_buf2) end) + it('should not accumulate BufWinEnter autocmds when fix_to_win is called multiple times for same buffer', function() + local buf = create_test_buffer() + local win = create_test_window(buf) + + local initial = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + local after_first = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + fresh_buf_fix_win.fix_to_win(buf, function() + return win + end) + local after_second = #vim.api.nvim_get_autocmds({ event = 'BufWinEnter', buffer = buf }) + + assert.equal(initial + 1, after_first) + assert.equal(initial + 1, after_second) + end) + it('should setup global VimResized autocmd when fix_to_win is first called', function() local buf = create_test_buffer() local win = create_test_window(buf) From 7f1938ebb505e280d6ffb296cd3f06148086efb4 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 5 Feb 2026 09:12:31 -0500 Subject: [PATCH 6/6] fix(tests): increase timer test wait timeout to prevent flakiness --- tests/unit/timer_spec.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/timer_spec.lua b/tests/unit/timer_spec.lua index 934c9291..0873242c 100644 --- a/tests/unit/timer_spec.lua +++ b/tests/unit/timer_spec.lua @@ -64,7 +64,7 @@ describe('Timer', function() assert.is_true(timer:is_running()) -- Wait for multiple ticks - vim.wait(500, function() + vim.wait(1000, function() return tick_count >= 3 end)