Skip to content

feat: Window state persistence (persist_state) — toggle preserves buffers for instant restore #228

Closed

Description

@jensenojs

Problem

:Opencode toggle closes windows and deletes buffers every time. Reopening recreates the UI from scratch, re-renders all output, and loses input draft, scroll/cursor position, and focused pane.

Expected Behavior

When ui.persist_state = true (proposed default):

  1. Hide UI windows without deleting buffers
  2. Restore existing buffers into new split windows on next toggle
  3. Preserve: input draft, scroll/cursor position, focused pane, input_hidden state
  4. Respect user intent: if user clicks a line after restore, do NOT force-scroll to bottom

When ui.persist_state = false, behavior remains unchanged.

Design: Three-State Window Model

closed ──(open)──> visible ──(hide)──> hidden ──(restore)──> visible
   ^                  |                   |                     |
   +──(close)─────────+                   +──(close_hidden)─────+

State is derived — get_window_status() checks window validity + hidden buffer existence. A pure-function decision engine (resolve_toggle_decision) maps (status, persist_state) → action.

Performance

100 toggle cycles, same session with output content:

Metric persist_state=false persist_state=true
Mean 27.15 ms 15.09 ms
P50 ~22 ms ~12.4 ms
P95 ~37 ms ~24.6 ms

~44% mean improvement from skipping buffer recreation and full session re-render.

Pre-requisite Fixes Discovered

Issues found during implementation that should be fixed independently:

  • curl.lua: job.pid persists after exit — health check false positive. Fix: explicit is_running flag
  • topbar.lua: missing pcall + win_is_valid guard on async callbacks
  • loading_animation.lua: missing buf_is_valid guard on extmark operations
  • output_window.lua: is_at_bottom() viewport check always true in streaming — fix: cursor-based check
  • output_window.lua: update_dimensions errors on split windows — fix: detect floating vs split
  • input_window.lua: same floating/split issue in apply_dimensions
  • renderer.lua: render_full_session has no debounce — rapid toggles cause request storms
  • event_manager.lua: _poll_external_messages hidden→visible transition causes redundant render + scroll override
  • reference_picker.lua: normal! zz on jump causes visual noise — make configurable

Implementation Status

  • Full implementation on feat/hide-window-toggle (commit c894fd9)
  • 604-line test suite (persist_state_spec.lua)
  • Performance benchmark (scripts/bench_toggle.lua)
  • All existing tests pass
  • Split into individual PR branches (in progress, see comments)

Activity

  1. changed the title [-]Keep toggle from destroying UI buffers/windows[/-] [+]Toggle should preserve buffers when keep_buffers_on_close enabled[/+] on Feb 3, 2026
  2. changed the title [-]Toggle should preserve buffers when keep_buffers_on_close enabled[/-] [+]Toggle should preserve buffers when keep_buffers_on_toggle enabled[/+] on Feb 3, 2026
  3. changed the title [-]Toggle should preserve buffers when keep_buffers_on_toggle enabled[/-] [+]feat: Window state persistence (persist_state) — toggle preserves buffers for instant restore[/+] on Feb 12, 2026
  4. jensenojs commented on Feb 12, 2026

    @jensenojs
    CollaboratorAuthor

    The effect achieved in the second version. If you want to try it, please use this branch. It contains a Lua test script for running benchmarks. @sudo-tee @disrupted

    iShot_2026-02-12_20.19.47.mp4
    -- Minimal configuration for testing opencode.nvim
    -- Usage: nvim -u minimal_init.lua [file/directory]
    --
    -- This config provides a minimal setup for testing opencode toggle functionality.
    -- Prerequisites:
    --   - plenary.nvim must be available in runtimepath
    --   - opencode.nvim must be available in runtimepath
    --
    -- Example usage from plugin root:
    --   nvim -u minimal_init.lua .
    --
    -- Keymaps:
    --   zl - split <-> code, dialog -> split
    --   zL - split -> dialog -> code -> dialog
    --
    -- Commands:
    --   :OpencodeToggle - Toggle opencode window
    
    -- Get the directory of this config file
    local config_dir = vim.fn.fnamemodify(vim.fn.expand('<sfile>'), ':p:h')
    
    -- Try to find plenary in common locations
    local function find_plenary()
      -- Check if already in runtimepath
      local rtp = vim.opt.runtimepath:get()
      for _, path in ipairs(rtp) do
        if vim.fn.filereadable(path .. '/lua/plenary/init.lua') == 1 then
          return true
        end
      end
    
      -- Try common locations
      local paths = {
        vim.fn.stdpath('data') .. '/lazy/plenary.nvim',
        vim.fn.stdpath('data') .. '/site/pack/packer/start/plenary.nvim',
        vim.fn.stdpath('data') .. '/site/pack/packer/opt/plenary.nvim',
        config_dir .. '/../plenary.nvim',
      }
    
      for _, path in ipairs(paths) do
        if vim.fn.isdirectory(path) == 1 then
          vim.opt.runtimepath:prepend(path)
          return true
        end
      end
    
      return false
    end
    
    -- Setup runtimepath
    if vim.fn.filereadable(config_dir .. '/lua/opencode/init.lua') == 1 then
      vim.opt.runtimepath:prepend(config_dir)
    end
    
    -- Check dependencies
    if not find_plenary() then
      vim.notify('plenary.nvim not found! Please install it first.', vim.log.levels.ERROR)
      vim.notify('Expected locations: ~/.local/share/nvim/lazy/plenary.nvim or similar', vim.log.levels.INFO)
      return
    end
    
    -- Basic settings
    vim.opt.number = true
    vim.opt.relativenumber = true
    vim.opt.termguicolors = true
    
    local input_height_when_blur = 0.10
    local input_height_when_focus = 0.22
    
    -- Load opencode
    local ok, opencode = pcall(require, 'opencode')
    if not ok then
      vim.notify('Failed to load opencode: ' .. tostring(opencode), vim.log.levels.ERROR)
      return
    end
    
    opencode.setup({
      ui = {
        position = 'right',
        persist_state = true,
        input = {
          min_height = input_height_when_blur,
          max_height = input_height_when_focus,
          auto_hide = false,
        },
      },
      debug = {
        capture_streamed_events = true,
      },
    })
    
    -- Keymaps
    local MODE = {
      code = 'code',
      split = 'split',
      dialog = 'dialog',
    }
    
    local TRANSITIONS = {
      zl = {
        [MODE.code] = MODE.split,
        [MODE.split] = MODE.code,
        [MODE.dialog] = MODE.split,
      },
      zL = {
        [MODE.code] = MODE.dialog,
        [MODE.split] = MODE.dialog,
        [MODE.dialog] = MODE.code,
      },
    }
    
    local function get_visual_mode(api)
      local ok_window_state, window_state = pcall(api.get_window_state)
      if not ok_window_state or not window_state or window_state.status ~= 'visible' then
        return MODE.code
      end
    
      local ok_state, op_state = pcall(require, 'opencode.state')
      if ok_state and op_state.pre_zoom_width then
        return MODE.dialog
      end
    
      return MODE.split
    end
    
    local function after_toggle(toggle_result, cb)
      if toggle_result and type(toggle_result.and_then) == 'function' then
        toggle_result:and_then(cb)
        return
      end
      vim.schedule(cb)
    end
    
    local function ensure_code(api)
      if get_visual_mode(api) ~= MODE.code then
        api.toggle(false)
      end
    end
    
    local function ensure_split(api)
      local mode = get_visual_mode(api)
      if mode == MODE.split then
        return
      end
    
      if mode == MODE.dialog then
        api.toggle_zoom()
        return
      end
    
      after_toggle(api.toggle(false), function()
        if get_visual_mode(api) == MODE.dialog then
          api.toggle_zoom()
        end
      end)
    end
    
    local function ensure_dialog(api)
      local mode = get_visual_mode(api)
      if mode == MODE.dialog then
        return
      end
    
      if mode == MODE.split then
        api.toggle_zoom()
        return
      end
    
      after_toggle(api.toggle(false), function()
        if get_visual_mode(api) ~= MODE.dialog then
          api.toggle_zoom()
        end
      end)
    end
    
    local APPLY_MODE = {
      [MODE.code] = ensure_code,
      [MODE.split] = ensure_split,
      [MODE.dialog] = ensure_dialog,
    }
    
    local function run_transition(trigger)
      local ok_api, api = pcall(require, 'opencode.api')
      if not ok_api then
        return
      end
    
      local current_mode = get_visual_mode(api)
      local next_mode = TRANSITIONS[trigger] and TRANSITIONS[trigger][current_mode]
      local apply = next_mode and APPLY_MODE[next_mode]
      if apply then
        apply(api)
      end
    end
    
    vim.keymap.set('n', 'zl', function()
      run_transition('zl')
    end, { desc = 'Toggle Opencode split/code', noremap = true, silent = true })
    
    vim.keymap.set('n', 'zL', function()
      run_transition('zL')
    end, { desc = 'Toggle Opencode dialog/code', noremap = true, silent = true })
    
    local function resize_input_by_focus(focused)
      local ok_state, op_state = pcall(require, 'opencode.state')
      if not ok_state or not op_state.windows then
        return
      end
    
      local windows = op_state.windows
      if not windows.input_win or not vim.api.nvim_win_is_valid(windows.input_win) then
        return
      end
    
      local ratio = focused and input_height_when_focus or input_height_when_blur
      local ok_config, op_config = pcall(require, 'opencode.config')
      if not ok_config then
        return
      end
    
      op_config.values.ui.input.min_height = ratio
      op_config.values.ui.input.max_height = ratio
      require('opencode.ui.input_window').update_dimensions(windows)
    end
    
    local dynamic_input_group = vim.api.nvim_create_augroup(
      'OpencodeMinimalInputFocusHeight',
      { clear = true }
    )
    
    vim.api.nvim_create_autocmd('WinEnter', {
      group = dynamic_input_group,
      pattern = '*',
      callback = function()
        local ok_state, op_state = pcall(require, 'opencode.state')
        if not ok_state or not op_state.windows then
          return
        end
    
        local input_win = op_state.windows.input_win
        if
          input_win
          and vim.api.nvim_win_is_valid(input_win)
          and vim.api.nvim_get_current_win() == input_win
        then
          resize_input_by_focus(true)
        end
      end,
    })
    
    vim.api.nvim_create_autocmd('WinLeave', {
      group = dynamic_input_group,
      pattern = '*',
      callback = function(args)
        local ok_state, op_state = pcall(require, 'opencode.state')
        if not ok_state or not op_state.windows then
          return
        end
    
        local input_win = op_state.windows.input_win
        local leaving_win = tonumber(args.match)
        if input_win and leaving_win == input_win then
          vim.schedule(function()
            resize_input_by_focus(false)
          end)
        end
      end,
    })
    
    -- Commands
    vim.api.nvim_create_user_command('OpencodeToggle', function()
      require('opencode.api').toggle()
    end, {})
    
    -- Status line
    _G.OpencodeStatus = function()
      local ok, api = pcall(require, 'opencode.api')
      if not ok then
        return ''
      end
      local ok2, state = pcall(api.get_window_state)
      if not ok2 then
        return ''
      end
      if state.status == 'visible' then
        return '[Opencode:ON]'
      elseif state.status == 'hidden' then
        return '[Opencode:HID]'
      end
      return ''
    end
    
    vim.opt.statusline = '%f %{v:lua.OpencodeStatus()} %=%l,%c'
    
    vim.notify('Minimal opencode config loaded. zl=split/code, zL=dialog/code.', vim.log.levels.INFO)
  5. jensenojs commented on Feb 12, 2026

    @jensenojs
    CollaboratorAuthor

    Progress Update: PR Split Strategy

    The large commit has been split into independent PRs for incremental review.

    Phase 1 (PRs open)

    PR Branch Scope Tests
    #264 fix/defensive-guards pcall guards for topbar/loading_animation, curl.is_running fix curl_spec.lua (+57 lines)
    #265 fix/window-cursor-handling is_at_bottom() cursor semantics, buffer_valid(), cursor persistence in state cursor_tracking_spec.lua (22 tests)
    #266 feat/reference-picker-center-config configurable zz centering on reference jump config-level change

    All based on upstream/main, independent of each other, pass all existing tests.

    Key fixes

    curl.is_running: job.pid persists after process exit — health check thinks SSE stream is still alive. Fixed with explicit boolean flag.

    is_at_bottom cursor semantics: Old viewport-based check (line('w$')) almost always returns true in streaming chat. Changed to cursor-based check — cursor is the only reliable user-intent signal.

    Phase 2 (local branch ready, PR pending)

    • Renderer debounce + event_manager health check + poll fix

    Phase 3 (deferred)

    • persist_state integration — waiting for Phase 1/2 to stabilize
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions