From 6feeea6351151481d715cabc030a692de87df852 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 21 Jul 2026 14:01:00 -0400 Subject: [PATCH 1/2] fix(ui): prevent window leaks across tabpages when rendering markdown --- lua/opencode/ui/renderer/flush.lua | 17 ++++++++++++---- tests/unit/services_session_runtime_spec.lua | 21 ++++++++++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index 1fa54ce5..7b96ed9a 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -462,15 +462,24 @@ local function do_trigger_on_data_rendered() if not state.windows or not state.windows.output_buf or not state.windows.output_win then return end - vim.b[state.windows.output_buf].opencode_markdown_namespace = output_window.markdown_namespace + local output_buf = state.windows.output_buf + local output_win = state.windows.output_win + if + not vim.api.nvim_buf_is_valid(output_buf) + or not vim.api.nvim_win_is_valid(output_win) + or vim.api.nvim_win_get_buf(output_win) ~= output_buf + then + return + end + vim.b[output_buf].opencode_markdown_namespace = output_window.markdown_namespace if cb_type == 'function' then - pcall(config.ui.output.rendering.on_data_rendered, state.windows.output_buf, state.windows.output_win) + pcall(config.ui.output.rendering.on_data_rendered, output_buf, output_win) elseif vim.fn.exists(':RenderMarkdown') > 0 then - vim.api.nvim_buf_call(state.windows.output_buf, function() + vim.api.nvim_win_call(output_win, function() vim.cmd(':RenderMarkdown buf_enable') end) elseif vim.fn.exists(':Markview') > 0 then - vim.cmd(':Markview render ' .. state.windows.output_buf) + vim.cmd(':Markview render ' .. output_buf) end end diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 9cebc615..2f6d740d 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -656,7 +656,7 @@ describe('opencode.services.session_runtime', function() end) describe('markdown rendering metadata', function() - it('stores the markdown namespace on the output buffer before rendering', function() + it('renders markdown in the output window without leaking into the current tab', function() local output_window = require('opencode.ui.output_window') local buf = vim.api.nvim_create_buf(false, true) local win = vim.api.nvim_open_win(buf, false, { @@ -667,10 +667,16 @@ describe('opencode.services.session_runtime', function() col = 0, style = 'minimal', }) + local output_tab = vim.api.nvim_win_get_tabpage(win) state.ui.set_windows({ output_buf = buf, output_win = win }) vim.api.nvim_buf_set_var(buf, 'opencode_markdown_namespace', 0) + vim.cmd('tabnew') + local current_tab = vim.api.nvim_get_current_tabpage() + local current_win = vim.api.nvim_get_current_win() + local current_tab_windows = vim.api.nvim_tabpage_list_wins(current_tab) + local defer_stub = stub(vim, 'defer_fn').invokes(function(cb) cb() return 1 @@ -682,17 +688,28 @@ describe('opencode.services.session_runtime', function() end return original_exists(name) end - local cmd_stub = stub(vim, 'cmd') + local rendered_tab + local rendered_win + local cmd_stub = stub(vim, 'cmd').invokes(function() + rendered_tab = vim.api.nvim_get_current_tabpage() + rendered_win = vim.api.nvim_get_current_win() + end) flush.trigger_on_data_rendered() assert.equals(output_window.markdown_namespace, vim.b[buf].opencode_markdown_namespace) assert.stub(cmd_stub).was_called_with(':RenderMarkdown buf_enable') + assert.equals(output_tab, rendered_tab) + assert.equals(win, rendered_win) + assert.equals(current_tab, vim.api.nvim_get_current_tabpage()) + assert.equals(current_win, vim.api.nvim_get_current_win()) + assert.same(current_tab_windows, vim.api.nvim_tabpage_list_wins(current_tab)) cmd_stub:revert() defer_stub:revert() vim.fn.exists = original_exists state.ui.set_windows(nil) + vim.cmd('tabclose') pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true }) end) From d4486b97c323348642e3724151fb1608a0255939 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 23 Jul 2026 09:15:53 -0400 Subject: [PATCH 2/2] fix(tabs): defer output window rendering when switching tabs during session --- lua/opencode/ui/output_window.lua | 9 +++ lua/opencode/ui/renderer.lua | 3 + lua/opencode/ui/renderer/flush.lua | 25 +++++++ tests/unit/services_session_runtime_spec.lua | 69 ++++++++++++++++---- 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5bda4196..8833584d 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -732,6 +732,15 @@ function M.setup_autocmds(windows, group) end, }) + vim.api.nvim_create_autocmd('TabEnter', { + group = group, + callback = function() + if state.ui.is_window_in_current_tab(windows.output_win) then + require('opencode.ui.renderer.flush').resume_deferred_rendering() + end + end, + }) + vim.api.nvim_create_autocmd('BufEnter', { group = group, buffer = windows.output_buf, diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 52fce068..bba36d58 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -551,6 +551,9 @@ function M.scroll_to_bottom(force) if not vim.api.nvim_win_is_valid(output_win) then return end + if not state.ui.is_window_in_current_tab(output_win) then + return + end if force or config.ui.output.always_scroll_to_bottom or output_window.is_at_bottom(output_win) then scroll.scroll_win_to_bottom(output_win, output_buf) diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index 7b96ed9a..bc5ee14c 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -12,6 +12,13 @@ local append = require('opencode.ui.renderer.append') local M = {} local warned_part_render_error = false +local function output_window_is_in_background_tab() + local output_win = state.windows and state.windows.output_win + return output_win + and vim.api.nvim_win_is_valid(output_win) + and not state.ui.is_window_in_current_tab(output_win) +end + ---@param part_id string ---@param message_id string|nil ---@param err any @@ -474,6 +481,9 @@ local function do_trigger_on_data_rendered() vim.b[output_buf].opencode_markdown_namespace = output_window.markdown_namespace if cb_type == 'function' then pcall(config.ui.output.rendering.on_data_rendered, output_buf, output_win) + elseif not state.ui.is_window_in_current_tab(output_win) then + ctx.markdown_render_scheduled = true + return elseif vim.fn.exists(':RenderMarkdown') > 0 then vim.api.nvim_win_call(output_win, function() vim.cmd(':RenderMarkdown buf_enable') @@ -518,6 +528,9 @@ function M.end_bulk_mode() if not ctx.bulk_mode then return end + if output_window_is_in_background_tab() then + return + end ctx.bulk_mode = false local lines = ctx.bulk_buffer_lines if #lines == 0 then @@ -563,6 +576,9 @@ end ---Flush all pending renderer changes to the output buffer. function M.flush() + if output_window_is_in_background_tab() then + return + end local pending = snapshot_pending() local applied = apply_pending(pending, new_formatter_context()) if applied and not ctx.bulk_mode then @@ -570,4 +586,13 @@ function M.flush() end end +---Apply renderer work deferred while the output window was in another tab. +function M.resume_deferred_rendering() + M.flush() + if ctx.bulk_mode then + M.end_bulk_mode() + end + M.flush_pending_on_data_rendered() +end + return M diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 2f6d740d..9df6066e 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -656,7 +656,7 @@ describe('opencode.services.session_runtime', function() end) describe('markdown rendering metadata', function() - it('renders markdown in the output window without leaking into the current tab', function() + it('defers markdown rendering until the output tab becomes current', function() local output_window = require('opencode.ui.output_window') local buf = vim.api.nvim_create_buf(false, true) local win = vim.api.nvim_open_win(buf, false, { @@ -667,19 +667,23 @@ describe('opencode.services.session_runtime', function() col = 0, style = 'minimal', }) - local output_tab = vim.api.nvim_win_get_tabpage(win) state.ui.set_windows({ output_buf = buf, output_win = win }) vim.api.nvim_buf_set_var(buf, 'opencode_markdown_namespace', 0) + local output_tab = vim.api.nvim_get_current_tabpage() vim.cmd('tabnew') local current_tab = vim.api.nvim_get_current_tabpage() local current_win = vim.api.nvim_get_current_win() - local current_tab_windows = vim.api.nvim_tabpage_list_wins(current_tab) local defer_stub = stub(vim, 'defer_fn').invokes(function(cb) cb() - return 1 + return { + is_closing = function() + return false + end, + close = function() end, + } end) local original_exists = vim.fn.exists vim.fn.exists = function(name) @@ -688,27 +692,64 @@ describe('opencode.services.session_runtime', function() end return original_exists(name) end - local rendered_tab - local rendered_win - local cmd_stub = stub(vim, 'cmd').invokes(function() - rendered_tab = vim.api.nvim_get_current_tabpage() - rendered_win = vim.api.nvim_get_current_win() - end) + local cmd_stub = stub(vim, 'cmd') flush.trigger_on_data_rendered() assert.equals(output_window.markdown_namespace, vim.b[buf].opencode_markdown_namespace) - assert.stub(cmd_stub).was_called_with(':RenderMarkdown buf_enable') - assert.equals(output_tab, rendered_tab) - assert.equals(win, rendered_win) + assert.stub(cmd_stub).was_not_called() assert.equals(current_tab, vim.api.nvim_get_current_tabpage()) assert.equals(current_win, vim.api.nvim_get_current_win()) - assert.same(current_tab_windows, vim.api.nvim_tabpage_list_wins(current_tab)) + + vim.api.nvim_set_current_tabpage(output_tab) + flush.flush_pending_on_data_rendered() + + assert.stub(cmd_stub).was_called_with(':RenderMarkdown buf_enable') cmd_stub:revert() defer_stub:revert() vim.fn.exists = original_exists state.ui.set_windows(nil) + vim.api.nvim_set_current_tabpage(current_tab) + vim.cmd('tabclose') + pcall(vim.api.nvim_win_close, win, true) + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + + it('defers output buffer writes while the output window is in another tab', function() + local ctx = require('opencode.ui.renderer.ctx') + local buf = vim.api.nvim_create_buf(false, true) + local win = vim.api.nvim_open_win(buf, false, { + relative = 'editor', + width = 20, + height = 5, + row = 0, + col = 0, + style = 'minimal', + }) + local output_tab = vim.api.nvim_get_current_tabpage() + + state.ui.set_windows({ output_buf = buf, output_win = win }) + ctx:reset() + flush.begin_bulk_mode() + ctx.bulk_buffer_lines = { 'deferred output' } + + vim.cmd('tabnew') + local background_tab = vim.api.nvim_get_current_tabpage() + flush.end_bulk_mode() + + assert.same({ '' }, vim.api.nvim_buf_get_lines(buf, 0, -1, false)) + assert.is_true(ctx.bulk_mode) + + vim.api.nvim_set_current_tabpage(output_tab) + flush.resume_deferred_rendering() + + assert.same({ 'deferred output', '' }, vim.api.nvim_buf_get_lines(buf, 0, -1, false)) + assert.is_false(ctx.bulk_mode) + + ctx:reset() + state.ui.set_windows(nil) + vim.api.nvim_set_current_tabpage(background_tab) vim.cmd('tabclose') pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true })