From ec6711e1cb23057b90bc07ea5bda720a90ba81f8 Mon Sep 17 00:00:00 2001 From: oujinsai Date: Tue, 3 Feb 2026 15:48:18 +0800 Subject: [PATCH 1/2] feat(ui): add persist_state for preserving buffers on toggle Add `persist_state` configuration option (default: true) that preserves buffers and cursor positions when toggling the UI off. This enables faster restore and maintains scroll position across toggle operations. --- README.md | 1 + lua/opencode/api.lua | 69 ++++++++- lua/opencode/config.lua | 1 + lua/opencode/core.lua | 11 +- lua/opencode/state.lua | 88 +++++++++++ lua/opencode/types.lua | 1 + lua/opencode/ui/input_window.lua | 9 +- lua/opencode/ui/output_window.lua | 9 ++ lua/opencode/ui/renderer.lua | 2 + lua/opencode/ui/ui.lua | 94 +++++++++--- tests/unit/core_spec.lua | 6 +- tests/unit/persist_state_spec.lua | 238 ++++++++++++++++++++++++++++++ 12 files changed, 502 insertions(+), 27 deletions(-) create mode 100644 tests/unit/persist_state_spec.lua diff --git a/README.md b/README.md index a865ff4e..c89fb082 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ require('opencode').setup({ position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output. input_position = 'bottom', -- 'bottom' (default) or 'top'. Position of the input window window_width = 0.40, -- Width as percentage of editor width + persist_state = true, -- Preserve buffers and view state when toggling UI off (default: true). When true, buffers and scroll positions persist for faster restore. zoom_width = 0.8, -- Zoom width as percentage of editor width display_model = true, -- Display model name on top winbar display_context_size = true, -- Display context size in the footer diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index ba219665..7efaa518 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -15,6 +15,37 @@ local Promise = require('opencode.promise') local M = {} +---Get the current window state of opencode +---@return {status: 'closed'|'hidden'|'visible', visible: boolean, hidden: boolean, position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} +function M.get_window_state() + local windows = state.windows + local status = state.get_window_status() + + local cursor_positions = { + input = nil, + output = nil, + } + + -- If windows are visible, get current cursor positions + if status == 'visible' then + cursor_positions.input = state.save_cursor_position('input', windows.input_win) + cursor_positions.output = state.save_cursor_position('output', windows.output_win) + else + -- Return saved positions + cursor_positions.input = state.get_cursor_position('input') + cursor_positions.output = state.get_cursor_position('output') + end + + return { + status = status, + visible = status == 'visible', + hidden = status == 'hidden', + position = config.ui.position, + windows = windows, + cursor_positions = cursor_positions, + } +end + function M.swap_position() require('opencode.ui.ui').swap_position() end @@ -51,6 +82,11 @@ function M.close() ui.close_windows(state.windows) end +-- Hide the UI but keep buffers for fast restore. +function M.hide() + ui.close_windows(state.windows, true) +end + function M.paste_image() core.paste_image_from_clipboard() end @@ -74,13 +110,31 @@ M.toggle = Promise.async(function(new_session) focus = state.last_focused_opencode_window or 'input' end - if state.windows == nil or not are_windows_in_current_tab() then - if state.windows then - M.close() + -- Check if windows are actually open (not just if state.windows exists) + -- When hidden with persist_state, state.windows exists but window IDs are nil + local windows_open = state.windows ~= nil + and state.windows.input_win ~= nil + and vim.api.nvim_win_is_valid(state.windows.input_win) + and are_windows_in_current_tab() + + if not windows_open then + -- Windows closed (or hidden with preserved buffers), open/restore them + -- Note: When hidden with persist_state, state.windows exists but window IDs are nil. + -- We should not call M.close() here as it would delete the preserved buffers. + -- Just clear the stale window state reference; create_windows will handle hidden buffers. + if state.windows and not state.has_hidden_buffers() then + -- No hidden buffers, safe to clear the stale state + state.windows = nil end core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await() else - M.close() + -- Windows open, close/hide them + if config.ui.persist_state then + -- Keep buffers and avoid renderer teardown for faster restore. + M.hide() + else + M.close() + end end end) @@ -1037,6 +1091,13 @@ M.commands = { end, }, + hide = { + desc = 'Hide opencode windows (preserve buffers for fast restore)', + fn = function(args) + M.hide() + end, + }, + cancel = { desc = 'Cancel running request', fn = M.cancel, diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 14d131ff..7a4f3ae6 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -109,6 +109,7 @@ M.defaults = { position = 'right', input_position = 'bottom', window_width = 0.40, + persist_state = true, zoom_width = 0.8, picker_width = 100, display_model = true, diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index aea4b478..d58c3389 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -68,6 +68,9 @@ M.open = Promise.async(function(opts) end local are_windows_closed = state.windows == nil + or state.windows.input_win == nil + or not vim.api.nvim_win_is_valid(state.windows.input_win) + local restoring_hidden = are_windows_closed and ui.has_hidden_buffers() if are_windows_closed then -- Check if whether prompting will be allowed local mentioned_files = context.get_context().mentioned_files or {} @@ -79,6 +82,12 @@ M.open = Promise.async(function(opts) state.windows = ui.create_windows() end + -- Restore cursor positions for both windows when reopening + -- This ensures output window scroll position is preserved even when focus is on input + if are_windows_closed then + ui.focus_output({ restore_position = true }) + end + if opts.focus == 'input' then ui.focus_input({ restore_position = are_windows_closed, start_insert = opts.start_insert == true }) elseif opts.focus == 'output' then @@ -117,7 +126,7 @@ M.open = Promise.async(function(opts) state.active_session = M.create_new_session():await() end else - if not state.display_route and are_windows_closed then + if not state.display_route and are_windows_closed and not restoring_hidden then -- We're not displaying /help or something like that but we have an active session -- and the windows were closed so we need to do a full refresh. This mostly happens -- when opening the window after having closed it since we're not currently clearing diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 0f43666f..a6854ac0 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -196,6 +196,94 @@ function M.is_running() return M.job_count > 0 end +---Get the current window status +---@return 'closed'|'hidden'|'visible' +function M.get_window_status() + local windows = _state.windows + if windows == nil then + return 'closed' + end + if windows.input_win ~= nil and vim.api.nvim_win_is_valid(windows.input_win) then + return 'visible' + end + -- Check if we have hidden buffers (persist_state feature) + if _state._hidden_buffers and _state._hidden_buffers.input_buf and vim.api.nvim_buf_is_valid(_state._hidden_buffers.input_buf) then + return 'hidden' + end + return 'closed' +end + +---Save cursor position for a window +---@param win_type 'input'|'output' +---@param win_id integer|nil +function M.save_cursor_position(win_type, win_id) + if not win_id or not vim.api.nvim_win_is_valid(win_id) then + return + end + local ok, pos = pcall(vim.api.nvim_win_get_cursor, win_id) + if ok then + if win_type == 'input' then + _state.last_input_window_position = pos + elseif win_type == 'output' then + _state.last_output_window_position = pos + end + end +end + +---Get saved cursor position +---@param win_type 'input'|'output' +---@return integer[]|nil +function M.get_cursor_position(win_type) + if win_type == 'input' then + return _state.last_input_window_position + elseif win_type == 'output' then + return _state.last_output_window_position + end + return nil +end + +---Store hidden buffers for persist_state feature +---@param input_buf integer|nil +---@param output_buf integer|nil +---@param input_was_visible boolean +function M.stash_hidden_buffers(input_buf, output_buf, input_was_visible) + _state._hidden_buffers = { + input_buf = input_buf, + output_buf = output_buf, + input_was_visible = input_was_visible, + } +end + +---Peek at hidden buffers without consuming them (check only) +---@return boolean +function M.has_hidden_buffers() + local hidden = _state._hidden_buffers + if not hidden then + return false + end + -- Validate buffers are still valid + local input_valid = hidden.input_buf and vim.api.nvim_buf_is_valid(hidden.input_buf) + local output_valid = hidden.output_buf and vim.api.nvim_buf_is_valid(hidden.output_buf) + return input_valid and output_valid +end + +---Consume hidden buffers (returns and clears them) +---@return {input_buf: integer|nil, output_buf: integer|nil, input_was_visible: boolean}|nil +function M.consume_hidden_buffers() + local hidden = _state._hidden_buffers + if not hidden then + return nil + end + -- Validate buffers are still valid + local input_valid = hidden.input_buf and vim.api.nvim_buf_is_valid(hidden.input_buf) + local output_valid = hidden.output_buf and vim.api.nvim_buf_is_valid(hidden.output_buf) + if not input_valid or not output_valid then + hidden = nil + end + _state._hidden_buffers = nil + return hidden +end + --- Observable state proxy. All reads/writes go through this table. --- Use `state.subscribe(key, cb)` to listen for changes. --- Use `state.unsubscribe(key, cb)` to remove listeners. diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 69067c13..9b43d963 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -115,6 +115,7 @@ ---@field position 'right'|'left'|'current' # Position of the UI (default: 'right') ---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom') ---@field window_width number +---@field persist_state boolean ---@field zoom_width number ---@field picker_width number|nil # Default width for all pickers (nil uses current window width) ---@field display_model boolean diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 2e7a1fb2..b085545e 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -280,7 +280,11 @@ function M.setup(windows) M.update_dimensions(windows) M.refresh_placeholder(windows) M.setup_keymaps(windows) - M.recover_input(windows) + -- Only recover input if buffer is empty (not restored from hidden state) + local lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false) + if #lines == 0 or (#lines == 1 and lines[1] == '') then + M.recover_input(windows) + end require('opencode.ui.context_bar').render(windows) end @@ -480,6 +484,9 @@ function M.setup_autocmds(windows, group) group = group, buffer = windows.input_buf, callback = function() + -- Save cursor position before leaving + state.save_cursor_position('input', windows.input_win) + -- Auto-hide input window when auto_hide is enabled and focus leaves -- Don't hide if displaying a route (slash command output like /help) -- Don't hide if input contains content diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 09cfca23..83dccab7 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -264,6 +264,15 @@ function M.setup_autocmds(windows, group) M.viewport_at_bottom = M.is_at_bottom(windows.output_win) end, }) + + -- Save cursor position when leaving output window + vim.api.nvim_create_autocmd('WinLeave', { + group = group, + buffer = windows.output_buf, + callback = function() + state.save_cursor_position('output', windows.output_win) + end, + }) end function M.clear() diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 6e16e010..73bf9e0a 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -339,6 +339,8 @@ function M.scroll_to_bottom(force) vim.cmd('normal! zb') end) output_window.viewport_at_bottom = true + -- Save cursor position for persist_state feature + state.save_cursor_position('output', state.windows.output_win) else -- User has scrolled up, don't scroll output_window.viewport_at_bottom = false diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 6ddc0482..097cd8e3 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -9,17 +9,34 @@ local topbar = require('opencode.ui.topbar') local M = {} ---@param windows OpencodeWindowState? -function M.close_windows(windows) +---@param preserve_buffers? boolean If true and persist_state is enabled, preserve buffers +function M.close_windows(windows, preserve_buffers) if not windows then return end + -- Save cursor positions and input visibility state before closing windows + state.save_cursor_position('input', windows.input_win) + state.save_cursor_position('output', windows.output_win) + -- Capture input visibility before closing windows (input_window.is_hidden() won't work after) + local input_was_visible = not input_window.is_hidden() + if M.is_opencode_focused() then M.return_to_last_code_win() end + if state.display_route then + state.display_route = nil + end + + local should_preserve = preserve_buffers == true and config.ui.persist_state + topbar.close() - renderer.teardown() + if should_preserve then + renderer.setup_subscriptions(false) + else + renderer.teardown() + end pcall(vim.api.nvim_del_augroup_by_name, 'OpencodeResize') pcall(vim.api.nvim_del_augroup_by_name, 'OpencodeWindows') @@ -27,27 +44,41 @@ function M.close_windows(windows) ---@cast windows { input_win: integer, output_win: integer, input_buf: integer, output_buf: integer } pcall(vim.api.nvim_win_close, windows.input_win, true) if config.ui.position == 'current' 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 - -- Restore original window options - if state.saved_window_options and vim.api.nvim_win_is_valid(windows.output_win) then - for opt, value in pairs(state.saved_window_options) do - pcall(vim.api.nvim_set_option_value, opt, value, { win = windows.output_win }) + -- Only try to restore if output window is still valid + 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 + -- Restore original window options + if state.saved_window_options then + for opt, value in pairs(state.saved_window_options) do + pcall(vim.api.nvim_set_option_value, opt, value, { win = windows.output_win }) + end + state.saved_window_options = nil end - state.saved_window_options = nil end else pcall(vim.api.nvim_win_close, windows.output_win, true) end - pcall(vim.api.nvim_buf_delete, windows.input_buf, { force = true }) - pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true }) - footer.close() - if state.windows == windows then - state.windows = nil + if should_preserve then + state.stash_hidden_buffers(windows.input_buf, windows.output_buf, input_was_visible) + -- Keep state.windows but clear window IDs since they're closed + if state.windows == windows then + state.windows.input_win = nil + state.windows.output_win = nil + end + else + input_window._hidden = false + pcall(vim.api.nvim_buf_delete, windows.input_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true }) + state.stash_hidden_buffers(nil, nil, true) -- Clear hidden buffers + if state.windows == windows then + state.windows = nil + end end + footer.close() end function M.return_to_last_code_win() @@ -76,8 +107,8 @@ local function open_split(direction, type) end function M.create_split_windows(input_buf, output_buf) - if state.windows then - M.close_windows(state.windows) + if state.windows and state.windows.input_win and vim.api.nvim_win_is_valid(state.windows.input_win) then + M.close_windows(state.windows, config.ui.persist_state) end local ui_conf = config.ui @@ -92,6 +123,11 @@ function M.create_split_windows(input_buf, output_buf) local input_win = open_split(ui_conf.input_position, 'horizontal') local output_win = main_win + -- Clear winfixbuf before setting buffer to avoid E1513 error + 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 } @@ -108,7 +144,18 @@ function M.create_windows() state.current_code_buf = vim.api.nvim_get_current_buf() end - local buffers = M.setup_buffers() + local buffers + local restored = state.consume_hidden_buffers() + if restored then + buffers = { + input_buf = restored.input_buf, + output_buf = restored.output_buf, + footer_buf = footer.create_buf(), + } + else + buffers = M.setup_buffers() + end + local windows = buffers local win_ids = M.create_split_windows(buffers.input_buf, buffers.output_buf) @@ -126,9 +173,18 @@ function M.create_windows() autocmds.setup_resize_handler(windows) require('opencode.ui.contextual_actions').setup_contextual_actions(windows) + -- Restore input window visibility snapshot; auto_hide continues to control later focus changes. + if restored and not restored.input_was_visible then + input_window._hide() + end + return windows end +function M.has_hidden_buffers() + return state.has_hidden_buffers() +end + function M.focus_input(opts) opts = opts or {} local windows = state.windows diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index 94e5f843..fbaaa457 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -159,7 +159,7 @@ describe('opencode.core', function() assert.truthy(state.active_session) end) - it('focuses the appropriate window', function() + it('focuses the appropriate window and restores cursor positions', function() state.windows = nil ui.focus_input:revert() ui.focus_output:revert() @@ -171,9 +171,11 @@ describe('opencode.core', function() output_focused = true end) + -- When focus is 'input', both windows should have cursor positions restored + -- output is restored first, then input is focused core.open({ new_session = false, focus = 'input' }):wait() assert.is_true(input_focused) - assert.is_false(output_focused) + assert.is_true(output_focused) input_focused, output_focused = false, false core.open({ new_session = false, focus = 'output' }):wait() diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua new file mode 100644 index 00000000..846e68ac --- /dev/null +++ b/tests/unit/persist_state_spec.lua @@ -0,0 +1,238 @@ +local state = require('opencode.state') +local config = require('opencode.config') +local ui = require('opencode.ui.ui') + +describe('persist_state', function() + local windows + local original_config + local code_buf + local code_win + local tmpfile + + before_each(function() + original_config = vim.deepcopy(config.values) + state.windows = nil + state.current_code_view = nil + state.current_code_buf = nil + state.last_code_win_before_opencode = nil + state.saved_window_options = nil + end) + + after_each(function() + if state.windows then + ui.close_windows(state.windows, false) + state.windows = nil + end + + if code_win and vim.api.nvim_win_is_valid(code_win) then + pcall(vim.api.nvim_win_close, code_win, true) + end + + if code_buf and vim.api.nvim_buf_is_valid(code_buf) then + pcall(vim.api.nvim_buf_delete, code_buf, { force = true }) + end + + if tmpfile then + vim.fn.delete(tmpfile) + tmpfile = nil + end + + config.values = original_config + state.current_code_view = nil + state.current_code_buf = nil + state.last_code_win_before_opencode = nil + state.saved_window_options = nil + end) + + local function create_code_file(lines) + tmpfile = vim.fn.tempname() .. '.lua' + vim.fn.writefile(lines or { 'line 1', 'line 2', 'line 3', 'line 4', 'line 5' }, tmpfile) + + code_buf = vim.fn.bufadd(tmpfile) + vim.fn.bufload(code_buf) + vim.bo[code_buf].buflisted = true + + code_win = vim.api.nvim_open_win(code_buf, true, { + relative = 'editor', + width = 80, + height = 20, + row = 0, + col = 0, + }) + + return code_win, code_buf + end + + local function cleanup_windows() + if state.windows then + ui.close_windows(state.windows, false) + state.windows = nil + end + end + + describe('configuration', function() + it('should have persist_state default to true', function() + config.setup({}) + assert.equals(true, config.values.ui.persist_state) + end) + + it('should allow persist_state to be set to false', function() + config.setup({ + ui = { persist_state = false }, + }) + assert.equals(false, config.values.ui.persist_state) + end) + end) + + describe('buffer preservation', function() + it('should preserve buffers when closing with persist_state=true', function() + config.setup({ + ui = { position = 'right', persist_state = true }, + }) + + create_code_file() + + windows = ui.create_windows() + local input_buf = windows.input_buf + local output_buf = windows.output_buf + + vim.api.nvim_buf_set_lines(input_buf, 0, -1, false, { 'test content' }) + + ui.close_windows(windows, true) + + assert.is_true(vim.api.nvim_buf_is_valid(input_buf), 'input buffer should be preserved') + assert.is_true(vim.api.nvim_buf_is_valid(output_buf), 'output buffer should be preserved') + assert.is_true(ui.has_hidden_buffers(), 'should have hidden buffers') + + pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) + state.windows = nil + end) + + it('should restore preserved buffers when reopening', function() + config.setup({ + ui = { position = 'right', persist_state = true }, + }) + + create_code_file() + + windows = ui.create_windows() + local input_buf = windows.input_buf + local output_buf = windows.output_buf + + vim.api.nvim_buf_set_lines(input_buf, 0, -1, false, { 'preserved content' }) + + ui.close_windows(windows, true) + assert.is_true(ui.has_hidden_buffers()) + + windows = ui.create_windows() + + assert.equals(input_buf, windows.input_buf, 'should restore same input buffer') + assert.equals(output_buf, windows.output_buf, 'should restore same output buffer') + + local lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false) + assert.equals('preserved content', lines[1], 'content should be preserved') + + cleanup_windows() + end) + end) + + describe('api.get_window_state()', function() + local api = require('opencode.api') + local original_api_client + + before_each(function() + original_api_client = state.api_client + state.api_client = { + create_message = function() return require('opencode.promise').new():resolve({}) end, + get_config = function() return require('opencode.promise').new():resolve({}) end, + list_sessions = function() return require('opencode.promise').new():resolve({}) end, + get_session = function() return require('opencode.promise').new():resolve({}) end, + create_session = function() return require('opencode.promise').new():resolve({}) end, + } + end) + + after_each(function() + state.api_client = original_api_client + end) + + it('should return correct state for closed, visible, and hidden', function() + config.setup({ ui = { position = 'right', persist_state = true } }) + + -- Test closed state + local window_state = api.get_window_state() + assert.equals('closed', window_state.status) + assert.is_false(window_state.visible) + assert.is_false(window_state.hidden) + + -- Test visible state + create_code_file() + api.toggle(false):wait() + window_state = api.get_window_state() + assert.equals('visible', window_state.status) + assert.is_true(window_state.visible) + assert.is_false(window_state.hidden) + + -- Test hidden state + api.toggle(false):wait() + window_state = api.get_window_state() + assert.equals('hidden', window_state.status) + assert.is_false(window_state.visible) + assert.is_true(window_state.hidden) + + cleanup_windows() + end) + end) + + describe('api.toggle() integration', function() + local api = require('opencode.api') + local original_api_client + + before_each(function() + original_api_client = state.api_client + state.api_client = { + create_message = function() return require('opencode.promise').new():resolve({}) end, + get_config = function() return require('opencode.promise').new():resolve({}) end, + list_sessions = function() return require('opencode.promise').new():resolve({}) end, + get_session = function() return require('opencode.promise').new():resolve({}) end, + create_session = function() return require('opencode.promise').new():resolve({}) end, + } + end) + + after_each(function() + state.api_client = original_api_client + end) + + it('should handle complete open-close-reopen cycle', function() + config.setup({ + ui = { position = 'right', persist_state = true }, + }) + + create_code_file({ 'line 1', 'line 2', 'line 3' }) + + -- Open + api.toggle(false):wait() + local window_state = api.get_window_state() + assert.equals('visible', window_state.status) + local first_input_buf = state.windows.input_buf + vim.api.nvim_buf_set_lines(first_input_buf, 0, -1, false, { 'test content' }) + + -- Hide + api.toggle(false):wait() + window_state = api.get_window_state() + assert.equals('hidden', window_state.status) + assert.is_true(vim.api.nvim_buf_is_valid(first_input_buf), 'buffer should be preserved') + + -- Reopen + api.toggle(false):wait() + window_state = api.get_window_state() + assert.equals('visible', window_state.status) + assert.equals(first_input_buf, state.windows.input_buf, 'should reuse same buffer') + + local lines = vim.api.nvim_buf_get_lines(state.windows.input_buf, 0, -1, false) + assert.equals('test content', lines[1], 'content should be preserved') + + cleanup_windows() + end) + end) +end) From 43b5468035ae1d04eb25bd2c255d375088e59839 Mon Sep 17 00:00:00 2001 From: oujinsai Date: Fri, 6 Feb 2026 11:15:36 +0800 Subject: [PATCH 2/2] clean code --- README.md | 2 +- lua/opencode/api.lua | 75 +++++++++++++++++---------- lua/opencode/core.lua | 24 +++++---- lua/opencode/state.lua | 85 +++++++++++++++++++++---------- lua/opencode/ui/ui.lua | 27 ++++++---- tests/unit/core_spec.lua | 38 ++++++++++++++ tests/unit/persist_state_spec.lua | 78 ++++++++++++++++++++++++++++ 7 files changed, 253 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index c89fb082..6b904a6d 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ require('opencode').setup({ position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output. input_position = 'bottom', -- 'bottom' (default) or 'top'. Position of the input window window_width = 0.40, -- Width as percentage of editor width - persist_state = true, -- Preserve buffers and view state when toggling UI off (default: true). When true, buffers and scroll positions persist for faster restore. + persist_state = true, -- Preserve buffers and view state when toggling UI off (default: true). Hidden state reopens at the old cursor unless output was at bottom (then it follows latest output). zoom_width = 0.8, -- Zoom width as percentage of editor width display_model = true, -- Display model name on top winbar display_context_size = true, -- Display context size in the footer diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 7efaa518..ea44195f 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -18,24 +18,38 @@ local M = {} ---Get the current window state of opencode ---@return {status: 'closed'|'hidden'|'visible', visible: boolean, hidden: boolean, position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} function M.get_window_state() + ---@type OpencodeWindowState|nil local windows = state.windows local status = state.get_window_status() - local cursor_positions = { - input = nil, - output = nil, - } + ---@param win_id integer|nil + ---@return integer[]|nil + local function get_window_cursor(win_id) + if not win_id or not vim.api.nvim_win_is_valid(win_id) then + return nil + end + local ok, pos = pcall(vim.api.nvim_win_get_cursor, win_id) + if ok then + return pos + end + return nil + end - -- If windows are visible, get current cursor positions - if status == 'visible' then - cursor_positions.input = state.save_cursor_position('input', windows.input_win) - cursor_positions.output = state.save_cursor_position('output', windows.output_win) - else - -- Return saved positions - cursor_positions.input = state.get_cursor_position('input') - cursor_positions.output = state.get_cursor_position('output') + local input_cursor = get_window_cursor(windows and windows.input_win) + if input_cursor == nil then + input_cursor = state.get_cursor_position('input') + end + + local output_cursor = get_window_cursor(windows and windows.output_win) + if output_cursor == nil then + output_cursor = state.get_cursor_position('output') end + local cursor_positions = { + input = input_cursor, + output = output_cursor, + } + return { status = status, visible = status == 'visible', @@ -94,43 +108,50 @@ 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 + if not state.windows 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 + + local function is_window_in_current_tab(win_id) + if not win_id or not vim.api.nvim_win_is_valid(win_id) then + return false + end + local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, win_id) + return ok and win_tab == current_tab + end + + return is_window_in_current_tab(state.windows.input_win) + or is_window_in_current_tab(state.windows.output_win) 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 - -- Check if windows are actually open (not just if state.windows exists) - -- When hidden with persist_state, state.windows exists but window IDs are nil + local function is_valid_win(win_id) + return win_id ~= nil and vim.api.nvim_win_is_valid(win_id) + end + local windows_open = state.windows ~= nil - and state.windows.input_win ~= nil - and vim.api.nvim_win_is_valid(state.windows.input_win) + and (is_valid_win(state.windows.input_win) or is_valid_win(state.windows.output_win)) and are_windows_in_current_tab() if not windows_open then - -- Windows closed (or hidden with preserved buffers), open/restore them - -- Note: When hidden with persist_state, state.windows exists but window IDs are nil. - -- We should not call M.close() here as it would delete the preserved buffers. - -- Just clear the stale window state reference; create_windows will handle hidden buffers. if state.windows and not state.has_hidden_buffers() then - -- No hidden buffers, safe to clear the stale state state.windows = nil end core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await() else - -- Windows open, close/hide them + if state.display_route then + M.close() + return + end + if config.ui.persist_state then - -- Keep buffers and avoid renderer teardown for faster restore. M.hide() else M.close() diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index d58c3389..f6e09427 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -67,10 +67,9 @@ M.open = Promise.async(function(opts) require('opencode.context').load() end - local are_windows_closed = state.windows == nil - or state.windows.input_win == nil - or not vim.api.nvim_win_is_valid(state.windows.input_win) - local restoring_hidden = are_windows_closed and ui.has_hidden_buffers() + local window_status = state.get_window_status() + local are_windows_closed = window_status ~= 'visible' + local restoring_hidden = window_status == 'hidden' and ui.has_hidden_buffers() if are_windows_closed then -- Check if whether prompting will be allowed local mentioned_files = context.get_context().mentioned_files or {} @@ -82,8 +81,12 @@ M.open = Promise.async(function(opts) state.windows = ui.create_windows() end - -- Restore cursor positions for both windows when reopening - -- This ensures output window scroll position is preserved even when focus is on input + local should_sync_on_restore = restoring_hidden + and state.windows + and state.windows.output_was_at_bottom == true + -- Preserve reading context when reopening from hidden state: + -- only resync output when the user was previously following the bottom. + if are_windows_closed then ui.focus_output({ restore_position = true }) end @@ -126,11 +129,10 @@ M.open = Promise.async(function(opts) state.active_session = M.create_new_session():await() end else - if not state.display_route and are_windows_closed and not restoring_hidden then - -- We're not displaying /help or something like that but we have an active session - -- and the windows were closed so we need to do a full refresh. This mostly happens - -- when opening the window after having closed it since we're not currently clearing - -- the session on api.close() + if not state.display_route + and are_windows_closed + and (not restoring_hidden or should_sync_on_restore) + then ui.render_output() end end diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index a6854ac0..f2b00bcc 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -5,6 +5,13 @@ ---@field footer_buf integer|nil ---@field input_buf integer|nil ---@field output_buf integer|nil +---@field output_was_at_bottom boolean|nil + +---@class OpencodeHiddenBuffers +---@field input_buf integer +---@field output_buf integer +---@field input_was_visible boolean +---@field output_was_at_bottom boolean|nil ---@class OpencodeState ---@field windows OpencodeWindowState|nil @@ -43,13 +50,11 @@ ---@field pre_zoom_width integer|nil ---@field required_version string ---@field opencode_cli_version string|nil ----@field append fun( key:string, value:any) ----@field remove fun( key:string, idx:number) ----@field subscribe fun( key:string|nil, cb:fun(key:string, new_val:any, old_val:any)) ----@field subscribe fun( key:string|string[]|nil, cb:fun(key:string, new_val:any, old_val:any)) ----@field unsubscribe fun( key:string|nil, cb:fun(key:string, new_val:any, old_val:any)) ----@field is_running fun():boolean +---@field _hidden_buffers OpencodeHiddenBuffers|nil + +---@class OpencodeStateModule: OpencodeState +---@type OpencodeStateModule local M = {} -- Internal raw state table @@ -199,17 +204,23 @@ end ---Get the current window status ---@return 'closed'|'hidden'|'visible' function M.get_window_status() - local windows = _state.windows - if windows == nil then - return 'closed' + -- Check hidden buffers first to avoid race condition during close_windows() + if M.has_hidden_buffers() then + return 'hidden' end - if windows.input_win ~= nil and vim.api.nvim_win_is_valid(windows.input_win) then + + local windows = _state.windows + local input_visible = windows + and windows.input_win ~= nil + and vim.api.nvim_win_is_valid(windows.input_win) + local output_visible = windows + and windows.output_win ~= nil + and vim.api.nvim_win_is_valid(windows.output_win) + + if input_visible or output_visible then return 'visible' end - -- Check if we have hidden buffers (persist_state feature) - if _state._hidden_buffers and _state._hidden_buffers.input_buf and vim.api.nvim_buf_is_valid(_state._hidden_buffers.input_buf) then - return 'hidden' - end + return 'closed' end @@ -227,6 +238,8 @@ function M.save_cursor_position(win_type, win_id) elseif win_type == 'output' then _state.last_output_window_position = pos end + else + vim.notify('Failed to save cursor position: ' .. tostring(pos), vim.log.levels.DEBUG) end end @@ -246,14 +259,21 @@ end ---@param input_buf integer|nil ---@param output_buf integer|nil ---@param input_was_visible boolean -function M.stash_hidden_buffers(input_buf, output_buf, input_was_visible) +---@param output_was_at_bottom? boolean +function M.stash_hidden_buffers(input_buf, output_buf, input_was_visible, output_was_at_bottom) _state._hidden_buffers = { input_buf = input_buf, output_buf = output_buf, input_was_visible = input_was_visible, + output_was_at_bottom = output_was_at_bottom, } end +---Clear hidden buffer snapshot +function M.clear_hidden_buffers() + _state._hidden_buffers = nil +end + ---Peek at hidden buffers without consuming them (check only) ---@return boolean function M.has_hidden_buffers() @@ -261,26 +281,39 @@ function M.has_hidden_buffers() if not hidden then return false end - -- Validate buffers are still valid - local input_valid = hidden.input_buf and vim.api.nvim_buf_is_valid(hidden.input_buf) - local output_valid = hidden.output_buf and vim.api.nvim_buf_is_valid(hidden.output_buf) - return input_valid and output_valid + + if type(hidden.input_buf) ~= 'number' or type(hidden.output_buf) ~= 'number' then + _state._hidden_buffers = nil + return false + end + + return vim.api.nvim_buf_is_valid(hidden.input_buf) + and vim.api.nvim_buf_is_valid(hidden.output_buf) end ---Consume hidden buffers (returns and clears them) ----@return {input_buf: integer|nil, output_buf: integer|nil, input_was_visible: boolean}|nil +---@return {input_buf: integer|nil, output_buf: integer|nil, input_was_visible: boolean, output_was_at_bottom: boolean|nil}|nil function M.consume_hidden_buffers() local hidden = _state._hidden_buffers if not hidden then return nil end - -- Validate buffers are still valid - local input_valid = hidden.input_buf and vim.api.nvim_buf_is_valid(hidden.input_buf) - local output_valid = hidden.output_buf and vim.api.nvim_buf_is_valid(hidden.output_buf) + + -- Consume: clear state immediately + _state._hidden_buffers = nil + + -- Validate types + if type(hidden.input_buf) ~= 'number' or type(hidden.output_buf) ~= 'number' then + return nil + end + + -- Validate buffer validity + local input_valid = vim.api.nvim_buf_is_valid(hidden.input_buf) + local output_valid = vim.api.nvim_buf_is_valid(hidden.output_buf) if not input_valid or not output_valid then - hidden = nil + return nil end - _state._hidden_buffers = nil + return hidden end @@ -308,4 +341,4 @@ return setmetatable(M, { __ipairs = function() return ipairs(_state) end, -}) --[[@as OpencodeState]] +}) --[[@as OpencodeStateModule]] diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 097cd8e3..a79d1a54 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -1,4 +1,6 @@ +---@type OpencodeConfig & OpencodeConfigModule local config = require('opencode.config') +---@type OpencodeStateModule local state = require('opencode.state') local renderer = require('opencode.ui.renderer') local output_window = require('opencode.ui.output_window') @@ -15,11 +17,10 @@ function M.close_windows(windows, preserve_buffers) return end - -- Save cursor positions and input visibility state before closing windows state.save_cursor_position('input', windows.input_win) state.save_cursor_position('output', windows.output_win) - -- Capture input visibility before closing windows (input_window.is_hidden() won't work after) - local input_was_visible = not input_window.is_hidden() + local input_was_hidden = input_window.is_hidden() + local output_was_at_bottom = output_window.viewport_at_bottom if M.is_opencode_focused() then M.return_to_last_code_win() @@ -50,7 +51,6 @@ function M.close_windows(windows, preserve_buffers) 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 - -- Restore original window options if state.saved_window_options then for opt, value in pairs(state.saved_window_options) do pcall(vim.api.nvim_set_option_value, opt, value, { win = windows.output_win }) @@ -63,8 +63,12 @@ function M.close_windows(windows, preserve_buffers) end if should_preserve then - state.stash_hidden_buffers(windows.input_buf, windows.output_buf, input_was_visible) - -- Keep state.windows but clear window IDs since they're closed + state.stash_hidden_buffers( + windows.input_buf, + windows.output_buf, + not input_was_hidden, + output_was_at_bottom + ) if state.windows == windows then state.windows.input_win = nil state.windows.output_win = nil @@ -73,7 +77,7 @@ function M.close_windows(windows, preserve_buffers) input_window._hidden = false pcall(vim.api.nvim_buf_delete, windows.input_buf, { force = true }) pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true }) - state.stash_hidden_buffers(nil, nil, true) -- Clear hidden buffers + state.clear_hidden_buffers() if state.windows == windows then state.windows = nil end @@ -116,14 +120,15 @@ function M.create_split_windows(input_buf, output_buf) if ui_conf.position == 'current' then main_win = vim.api.nvim_get_current_win() else - main_win = open_split(ui_conf.position, 'vertical') + ---@type 'left'|'right' + local split_direction = ui_conf.position == 'left' and 'left' or 'right' + main_win = open_split(split_direction, 'vertical') end vim.api.nvim_set_current_win(main_win) local input_win = open_split(ui_conf.input_position, 'horizontal') local output_win = main_win - -- Clear winfixbuf before setting buffer to avoid E1513 error if ui_conf.position == 'current' then pcall(vim.api.nvim_set_option_value, 'winfixbuf', false, { win = output_win }) end @@ -161,6 +166,7 @@ function M.create_windows() windows.input_win = win_ids.input_win windows.output_win = win_ids.output_win + windows.output_was_at_bottom = restored and restored.output_was_at_bottom == true input_window.setup(windows) output_window.setup(windows) @@ -173,7 +179,6 @@ function M.create_windows() autocmds.setup_resize_handler(windows) require('opencode.ui.contextual_actions').setup_contextual_actions(windows) - -- Restore input window visibility snapshot; auto_hide continues to control later focus changes. if restored and not restored.input_was_visible then input_window._hide() end @@ -318,7 +323,7 @@ end function M.swap_position() local ui_conf = config.ui local new_pos = (ui_conf.position == 'left') and 'right' or 'left' - config.values.ui.position = new_pos + config.ui.position = new_pos if state.windows then M.close_windows(state.windows) diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index fbaaa457..b315c3e2 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -183,6 +183,44 @@ describe('opencode.core', function() assert.is_true(output_focused) end) + it('does not force full render when restoring hidden buffers', function() + state.active_session = { id = 'test-session' } + + local status_stub = stub(state, 'get_window_status').returns('hidden') + local hidden_stub = stub(ui, 'has_hidden_buffers').returns(true) + + core.open({ new_session = false, focus = 'input' }):wait() + + assert.stub(ui.render_output).was_not_called() + + hidden_stub:revert() + status_stub:revert() + end) + + it('forces full render when restoring hidden buffers from bottom', function() + state.active_session = { id = 'test-session' } + + ui.create_windows:revert() + stub(ui, 'create_windows').returns({ + mock = 'windows', + input_buf = 1, + output_buf = 2, + input_win = 3, + output_win = 4, + output_was_at_bottom = true, + }) + + local status_stub = stub(state, 'get_window_status').returns('hidden') + local hidden_stub = stub(ui, 'has_hidden_buffers').returns(true) + + core.open({ new_session = false, focus = 'input' }):wait() + + assert.stub(ui.render_output).was_called() + + hidden_stub:revert() + status_stub:revert() + end) + it('creates a new session when no active session and no last session exists', function() state.windows = nil state.active_session = nil diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index 846e68ac..80486553 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -1,6 +1,22 @@ local state = require('opencode.state') local config = require('opencode.config') local ui = require('opencode.ui.ui') +local input_window = require('opencode.ui.input_window') + +-- persist_state coverage matrix +-- +------------------+------------------------------------+-----------------------------------------------+-------------------------------+ +-- | Area | Scenario | Expected behavior | Note | +-- +------------------+------------------------------------+-----------------------------------------------+-------------------------------+ +-- | Config default | no user override | ui.persist_state defaults to true | | +-- | Config opt-out | ui.persist_state = false | toggle fully closes; no hidden state retained | compatibility path | +-- | Preserve close | close_windows(..., true) | input/output buffers remain valid | | +-- | Reopen restore | create_windows() after hidden | same buffer ids reused; input text unchanged | | +-- | State machine | closed -> visible -> hidden | status/visible/hidden flags are consistent | | +-- | Getter purity | get_window_state() while visible | does not call save_cursor_position() | read API must be side-effect free | +-- | Toggle E2E | open -> hide -> reopen | transitions valid; buffer content preserved | | +-- | Output-only view | input auto-hidden, output visible | still treated as visible for toggle decisions | prevents false-closed path | +-- | Non-preserve E2E | persist_state = false, then toggle | final status is closed; hidden buffers absent | prevents snapshot leakage | +-- +------------------+------------------------------------+-----------------------------------------------+-------------------------------+ describe('persist_state', function() local windows @@ -182,6 +198,29 @@ describe('persist_state', function() cleanup_windows() end) + + it('should not mutate cursor state when reading window state', function() + config.setup({ ui = { position = 'right', persist_state = true } }) + + create_code_file() + api.toggle(false):wait() + + local original_save_cursor = state.save_cursor_position + local save_calls = 0 + state.save_cursor_position = function(...) + save_calls = save_calls + 1 + return original_save_cursor(...) + end + + local window_state = api.get_window_state() + + state.save_cursor_position = original_save_cursor + + assert.equals('visible', window_state.status) + assert.equals(0, save_calls) + + cleanup_windows() + end) end) describe('api.toggle() integration', function() @@ -234,5 +273,44 @@ describe('persist_state', function() cleanup_windows() end) + + it('should treat output-only view as visible when toggling', function() + config.setup({ + ui = { + position = 'right', + persist_state = true, + input = { auto_hide = true }, + }, + }) + + create_code_file({ 'line 1', 'line 2' }) + + api.toggle(false):wait() + input_window._hide() + + assert.equals('visible', state.get_window_status()) + + api.toggle(false):wait() + local window_state = api.get_window_state() + assert.equals('hidden', window_state.status) + + cleanup_windows() + end) + + it('should fully close when persist_state is disabled', function() + config.setup({ + ui = { position = 'right', persist_state = false }, + }) + + create_code_file({ 'line 1', 'line 2' }) + + api.toggle(false):wait() + assert.equals('visible', state.get_window_status()) + + api.toggle(false):wait() + local window_state = api.get_window_state() + assert.equals('closed', window_state.status) + assert.is_false(ui.has_hidden_buffers()) + end) end) end)