From 1be50bb117932bb918b65e1df205bf56dffda6d5 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 9 Mar 2026 08:22:33 -0400 Subject: [PATCH 01/13] wip: state-management refactor --- lua/opencode/api.lua | 18 +- lua/opencode/api_client.lua | 2 +- lua/opencode/core.lua | 72 ++++--- lua/opencode/quick_chat.lua | 2 +- lua/opencode/server_job.lua | 10 +- lua/opencode/snapshot.lua | 4 +- lua/opencode/state.lua | 312 +++++++++++++++++++++++++++-- lua/opencode/ui/autocmds.lua | 6 +- lua/opencode/ui/input_window.lua | 2 +- lua/opencode/ui/output_window.lua | 4 +- lua/opencode/ui/renderer.lua | 6 +- lua/opencode/ui/session_picker.lua | 2 +- lua/opencode/ui/ui.lua | 10 +- lua/opencode/variant_picker.lua | 4 +- state_refactor.md | 150 ++++++++++++++ 15 files changed, 511 insertions(+), 93 deletions(-) create mode 100644 state_refactor.md diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 212c1aa8..a9c6c92d 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -41,7 +41,7 @@ end function M.close() if state.display_route then - state.display_route = nil + state.ui.clear_display_route() ui.clear_output() -- need to trigger a re-render here to re-display the session ui.render_output() @@ -390,7 +390,7 @@ M.submit_input_prompt = Promise.async(function() if state.display_route then -- we're displaying /help or something similar, need to clear that and refresh -- the session data before sending the command - state.display_route = nil + state.ui.clear_display_route() ui.render_output(true) end @@ -485,7 +485,7 @@ M.initialize = Promise.async(function() vim.notify('Invalid model format: ' .. tostring(state.current_model), vim.log.levels.ERROR) return end - state.active_session = new_session + state.session.set_active(new_session) M.open_input() state.api_client:init_session(state.active_session.id, { providerID = providerId, @@ -533,7 +533,7 @@ end) function M.with_header(lines, show_welcome) show_welcome = show_welcome or false - state.display_route = '/header' + state.ui.set_display_route('/header') local msg = { '## Opencode.nvim', @@ -558,7 +558,7 @@ function M.with_header(lines, show_welcome) end function M.help() - state.display_route = '/help' + state.ui.set_display_route('/help') M.open_input() local msg = M.with_header({ '### Available Commands', @@ -611,7 +611,7 @@ M.commands_list = Promise.async(function() return end - state.display_route = '/commands' + state.ui.set_display_route('/commands') M.open_input() local msg = M.with_header({ @@ -859,7 +859,7 @@ M.rename_session = Promise.async(function(current_session, new_title) local session_obj = session.get_by_id(current_session.id):await() if session_obj then session_obj.title = title - state.active_session = vim.deepcopy(session_obj) + state.session.set_active(vim.deepcopy(session_obj)) end end promise:resolve(current_session) @@ -1056,7 +1056,7 @@ M.review = Promise.async(function(args) vim.notify('Invalid model format: ' .. tostring(state.current_model), vim.log.levels.ERROR) return end - state.active_session = new_session + state.session.set_active(new_session) M.open_input():await() state.api_client :send_command(state.active_session.id, { @@ -1181,7 +1181,7 @@ M.commands = { vim.notify('Failed to create new session', vim.log.levels.ERROR) return end - state.active_session = new_session + state.session.set_active(new_session) M.open_input() else M.open_input_new_session() diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index e3d3e454..8849192b 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -34,7 +34,7 @@ function OpencodeApiClient:_ensure_base_url() if not state.opencode_server then -- this is last resort - try to start the server and could be blocking - state.opencode_server = server_job.ensure_server():wait() --[[@as OpencodeServer]] + state.jobs.set_server(server_job.ensure_server():wait() --[[@as OpencodeServer]]) -- shouldn't normally happen but prevents error in replay tester if not state.opencode_server then return false diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 30388075..2e78a6d9 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -37,12 +37,12 @@ end) M.switch_session = Promise.async(function(session_id) local selected_session = session.get_by_id(session_id):await() - state.current_model = nil - state.current_mode = nil + state.model.clear_model() + state.model.clear_mode() M.ensure_current_mode():await() - state.active_session = selected_session - state.restore_points = {} + state.session.set_active(selected_session) + state.session.reset_restore_points() if state.is_visible() then ui.focus_input() else @@ -73,7 +73,7 @@ M.check_cwd = function() { current_cwd = state.current_cwd, new_cwd = vim.fn.getcwd() } ) state.current_cwd = vim.fn.getcwd() - state.active_session = nil + state.session.clear_active() context.unload_attachments() end end @@ -82,7 +82,7 @@ end M.open = Promise.async(function(opts) opts = opts or { focus = 'input', new_session = false } - state.is_opening = true + state.ui.set_opening(true) if not require('opencode.ui.ui').is_opencode_focused() then require('opencode.context').load() @@ -94,8 +94,8 @@ M.open = Promise.async(function(opts) if are_windows_closed then if not ui.is_opencode_focused() then - state.last_code_win_before_opencode = vim.api.nvim_get_current_win() - state.current_code_buf = vim.api.nvim_get_current_buf() + state.ui.set_last_code_window(vim.api.nvim_get_current_win()) + state.ui.set_current_code_buf(vim.api.nvim_get_current_buf()) end M.is_prompting_allowed() @@ -105,10 +105,10 @@ M.open = Promise.async(function(opts) if not restored then state.clear_hidden_window_state() restoring_hidden = false - state.windows = ui.create_windows() + state.ui.set_windows(ui.create_windows()) end else - state.windows = ui.create_windows() + state.ui.set_windows(ui.create_windows()) end end @@ -121,7 +121,7 @@ M.open = Promise.async(function(opts) local server = server_job.ensure_server():await() if not server then - state.is_opening = false + state.ui.set_opening(false) return Promise.new():reject('Server failed to start') end @@ -129,21 +129,21 @@ M.open = Promise.async(function(opts) local ok, err = pcall(function() if opts.new_session then - state.active_session = nil + state.session.clear_active() state.last_sent_context = nil context.unload_attachments() M.ensure_current_mode():await() - state.active_session = M.create_new_session():await() + state.session.set_active(M.create_new_session():await()) log.debug('Created new session on open', { session = state.active_session.id }) else M.ensure_current_mode():await() if not state.active_session then - state.active_session = session.get_last_workspace_session():await() + state.session.set_active(session.get_last_workspace_session():await()) if not state.active_session then - state.active_session = M.create_new_session():await() + state.session.set_active(M.create_new_session():await()) end else if not state.display_route and are_windows_closed and not restoring_hidden then @@ -156,10 +156,10 @@ M.open = Promise.async(function(opts) end end - state.is_opencode_focused = true + state.ui.set_panel_focused(true) end) - state.is_opening = false + state.ui.set_opening(false) if not ok then vim.notify('Error opening panel: ' .. tostring(err), vim.log.levels.ERROR) @@ -197,17 +197,17 @@ M.send_message = Promise.async(function(prompt, opts) if opts.model then local provider, model = opts.model:match('^(.-)/(.+)$') params.model = { providerID = provider, modelID = model } - state.current_model = opts.model + state.model.set_model(opts.model) if opts.variant then params.variant = opts.variant - state.current_variant = opts.variant + state.model.set_variant(opts.variant) end end if opts.agent then params.agent = opts.agent - state.current_mode = opts.agent + state.model.set_mode(opts.agent) end params.parts = context.format_message(prompt, opts.context):await() @@ -292,12 +292,10 @@ function M.configure_provider() return end local model_str = string.format('%s/%s', selection.provider, selection.model) - state.current_model = model_str + state.model.set_model(model_str) if state.current_mode then - local mode_map = vim.deepcopy(state.user_mode_model_map) - mode_map[state.current_mode] = model_str - state.user_mode_model_map = mode_map + state.model.set_mode_model_override(state.current_mode, model_str) end if state.is_visible() then @@ -317,7 +315,7 @@ function M.configure_variant() return end - state.current_variant = selection.name + state.model.set_variant(selection.name) if state.is_visible() then ui.focus_input() @@ -377,7 +375,7 @@ M.cycle_variant = Promise.async(function() next_variant = variants[next_index] end - state.current_variant = next_variant + state.model.set_variant(next_variant) local model_state = require('opencode.model_state') model_state.set_variant(provider, model, next_variant) @@ -411,11 +409,11 @@ M.cancel = Promise.async(function() end -- start a new one - state.opencode_server = nil + state.jobs.clear_server() -- NOTE: start a new server here to make sure we're subscribed -- to server events before a user sends a message - state.opencode_server = server_job.ensure_server():await() --[[@as OpencodeServer]] + state.jobs.set_server(server_job.ensure_server():await() --[[@as OpencodeServer]]) end end @@ -485,18 +483,18 @@ M.switch_to_mode = Promise.async(function(mode) return false end - state.current_mode = mode + state.model.set_mode(mode) local opencode_config = config_file.get_opencode_config():await() --[[@as OpencodeConfigFile]] local agent_config = opencode_config and opencode_config.agent or {} local mode_config = agent_config[mode] or {} if state.user_mode_model_map[mode] then - state.current_model = state.user_mode_model_map[mode] + state.model.set_model(state.user_mode_model_map[mode]) elseif mode_config.model and mode_config.model ~= '' then - state.current_model = mode_config.model + state.model.set_model(mode_config.model) elseif opencode_config and opencode_config.model and opencode_config.model ~= '' then - state.current_model = opencode_config.model + state.model.set_model(opencode_config.model) end return true end) @@ -536,7 +534,7 @@ M.initialize_current_model = Promise.async(function() local cfg = require('opencode.config_file').get_opencode_config():await() if cfg and cfg.model and cfg.model ~= '' then - state.current_model = cfg.model + state.model.set_model(cfg.model) end return state.current_model @@ -582,11 +580,11 @@ M.handle_directory_change = Promise.async(function() log.debug('Working directory change %s', vim.inspect({ cwd = cwd })) vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) - state.active_session = nil + state.session.clear_active() state.last_sent_context = nil context.unload_attachments() - state.active_session = session.get_last_workspace_session():await() or M.create_new_session():await() + state.session.set_active(session.get_last_workspace_session():await() or M.create_new_session():await()) log.debug('Loaded session for new working dir ' .. vim.inspect({ session = state.active_session })) end) @@ -597,7 +595,7 @@ function M.setup() state.subscribe('pending_permissions', M._on_current_permission_change) state.subscribe('current_model', function(key, new_val, old_val) if new_val ~= old_val then - state.current_variant = nil + state.model.clear_variant() -- Load saved variant for the new model if new_val then @@ -606,7 +604,7 @@ function M.setup() local model_state = require('opencode.model_state') local saved_variant = model_state.get_variant(provider, model) if saved_variant then - state.current_variant = saved_variant + state.model.set_variant(saved_variant) end end end diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua index 966cf7d9..efb878fe 100644 --- a/lua/opencode/quick_chat.lua +++ b/lua/opencode/quick_chat.lua @@ -375,7 +375,7 @@ M.quick_chat = Promise.async(function(message, options, range) end if config.debug.quick_chat and config.debug.quick_chat.set_active_session then - state.active_session = quick_chat_session + state.session.set_active(quick_chat_session) end running_sessions[quick_chat_session.id] = { diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index 9328d4b7..d689b62d 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -75,7 +75,7 @@ end function M.call_api(url, method, body) local call_promise = Promise.new() - state.job_count = state.job_count + 1 + state.jobs.increment_count() local request_entry = { nil, call_promise } table.insert(M.requests, request_entry) @@ -87,7 +87,7 @@ function M.call_api(url, method, body) break end end - state.job_count = #M.requests + state.jobs.set_count(#M.requests) end local opts = { @@ -246,7 +246,7 @@ local function spawn_and_retry(base_url, custom_port, custom_url, promise, timeo retry_connect(base_url, timeout, 3, function(url) port_mapping.register(custom_port, vim.fn.getcwd(), true, 'custom', url, server_pid) - state.opencode_server = opencode_server.from_custom(url, custom_port, 'custom') + state.jobs.set_server(opencode_server.from_custom(url, custom_port, 'custom')) promise:resolve(state.opencode_server) end, function(_err) if config.server.port == 'auto' then @@ -264,7 +264,7 @@ function M.try_connect_to_custom_server(base_url, timeout, promise, custom_port, local existing_started_by_nvim = port_mapping.started_by_nvim(custom_port) local mode = config.server.spawn_command and 'custom' or 'attach' port_mapping.register(custom_port, vim.fn.getcwd(), existing_started_by_nvim, mode, url, nil) - state.opencode_server = opencode_server.from_custom(url, custom_port, mode) + state.jobs.set_server(opencode_server.from_custom(url, custom_port, mode)) log.notify( string.format('Connected to remote server at %s on port %d.', base_url, custom_port), vim.log.levels.INFO @@ -285,7 +285,7 @@ end --- @param port? number|string Optional custom port --- @param hostname? string Optional custom hostname function M.spawn_local_server(promise, port, hostname) - state.opencode_server = opencode_server.new() + state.jobs.set_server(opencode_server.new()) local spawn_opts = { on_ready = function(job, base_url) diff --git a/lua/opencode/snapshot.lua b/lua/opencode/snapshot.lua index 1bf3795f..00ba0948 100644 --- a/lua/opencode/snapshot.lua +++ b/lua/opencode/snapshot.lua @@ -103,7 +103,7 @@ end ---@return RestorePoint[] function M.get_restore_points() if not state.active_session then - state.restore_points = {} + state.session.reset_restore_points() return {} end local cache_path = session.get_cache_path(state.active_session.id) @@ -117,7 +117,7 @@ function M.get_restore_points() table.sort(restore_points, function(a, b) return a.created_at > b.created_at end) - state.restore_points = restore_points + state.session.set_restore_points(restore_points) return state.restore_points end diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 47561ef4..2e6c2959 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -23,6 +23,61 @@ ---@class OpencodeToggleDecision ---@field action 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' +---@class OpencodeSessionStateMutations +---@field set_active fun(session: Session|nil) +---@field clear_active fun() +---@field set_restore_points fun(points: RestorePoint[]) +---@field reset_restore_points fun() + +---@class OpencodeProtectedStateSetOptions +---@field source? 'helper'|'raw' + +---@alias OpencodeProtectedStateKey +---| 'active_session' +---| 'restore_points' +---| 'job_count' +---| 'opencode_server' +---| 'windows' +---| 'is_opening' +---| 'is_opencode_focused' +---| 'last_focused_opencode_window' +---| 'last_code_win_before_opencode' +---| 'current_code_buf' +---| 'display_route' +---| 'current_mode' +---| 'current_model' +---| 'current_model_info' +---| 'current_variant' +---| 'user_mode_model_map' + +---@class OpencodeJobStateMutations +---@field increment_count fun(delta?: integer) +---@field set_count fun(count: integer) +---@field set_server fun(server: OpencodeServer|nil) +---@field clear_server fun() + +---@class OpencodeUiStateMutations +---@field set_windows fun(windows: OpencodeWindowState|nil) +---@field clear_windows fun() +---@field set_opening fun(is_opening: boolean) +---@field set_panel_focused fun(is_focused: boolean) +---@field set_last_focused_window fun(win_type: 'input'|'output'|nil) +---@field set_display_route fun(route: any) +---@field clear_display_route fun() +---@field set_last_code_window fun(win_id: integer|nil) +---@field set_current_code_buf fun(bufnr: integer|nil) + +---@class OpencodeModelStateMutations +---@field set_mode fun(mode: string|nil) +---@field clear_mode fun() +---@field set_model fun(model: string|nil) +---@field clear_model fun() +---@field set_model_info fun(info: table|nil) +---@field set_variant fun(variant: string|nil) +---@field clear_variant fun() +---@field set_mode_model_map fun(mode_map: table) +---@field set_mode_model_override fun(mode: string, model: string) + ---@class OpencodeState ---@field windows OpencodeWindowState|nil ---@field is_opening boolean @@ -83,6 +138,10 @@ ---@field resolve_toggle_decision fun(persist_state: boolean, has_display_route: boolean): OpencodeToggleDecision ---@field resolve_open_windows_action fun(): 'reuse_visible'|'restore_hidden'|'create_fresh' ---@field get_window_cursor fun(win_id: integer|nil): integer[]|nil +---@field session OpencodeSessionStateMutations +---@field jobs OpencodeJobStateMutations +---@field ui OpencodeUiStateMutations +---@field model OpencodeModelStateMutations local M = {} @@ -140,6 +199,204 @@ local _state = { -- Listener registry: { [key] = {cb1, cb2, ...}, ['*'] = {cb1, ...} } local _listeners = {} +local PROTECTED_KEYS = { + active_session = true, + restore_points = true, + job_count = true, + opencode_server = true, + windows = true, + is_opening = true, + is_opencode_focused = true, + last_focused_opencode_window = true, + last_code_win_before_opencode = true, + current_code_buf = true, + display_route = true, + current_mode = true, + current_model = true, + current_model_info = true, + current_variant = true, + user_mode_model_map = true, +} + +local _protected_write_warnings = {} + +---@param key string +---@param value any +---@param opts? OpencodeProtectedStateSetOptions +local function set_state(key, value, opts) + local old = _state[key] + opts = opts or { source = 'helper' } + + if opts.source == 'raw' then + warn_on_protected_raw_write(key) + end + + _state[key] = value + if not vim.deep_equal(old, value) then + M.notify(key, value, old) + end +end + +---@generic T +---@param key string +---@param updater fun(current: T): T +---@return T +local function update_state(key, updater) + local next_value = updater(_state[key]) + set_state(key, next_value) + return next_value +end + +M.session = {} + +---@param session Session|nil +function M.session.set_active(session) + set_state('active_session', session) +end + +function M.session.clear_active() + set_state('active_session', nil) +end + +---@param points RestorePoint[] +function M.session.set_restore_points(points) + set_state('restore_points', points) +end + +function M.session.reset_restore_points() + set_state('restore_points', {}) +end + +M.jobs = {} + +---@param delta integer|nil +function M.jobs.increment_count(delta) + update_state('job_count', function(current) + return (current or 0) + (delta or 1) + end) +end + +---@param count integer +function M.jobs.set_count(count) + set_state('job_count', count) +end + +---@param server OpencodeServer|nil +function M.jobs.set_server(server) + set_state('opencode_server', server) +end + +function M.jobs.clear_server() + set_state('opencode_server', nil) +end + +M.ui = {} + +---@param windows OpencodeWindowState|nil +function M.ui.set_windows(windows) + set_state('windows', windows) +end + +function M.ui.clear_windows() + set_state('windows', nil) +end + +---@param is_opening boolean +function M.ui.set_opening(is_opening) + set_state('is_opening', is_opening) +end + +---@param is_focused boolean +function M.ui.set_panel_focused(is_focused) + set_state('is_opencode_focused', is_focused) +end + +---@param win_type 'input'|'output'|nil +function M.ui.set_last_focused_window(win_type) + set_state('last_focused_opencode_window', win_type) +end + +---@param route any +function M.ui.set_display_route(route) + set_state('display_route', route) +end + +function M.ui.clear_display_route() + set_state('display_route', nil) +end + +---@param win_id integer|nil +function M.ui.set_last_code_window(win_id) + set_state('last_code_win_before_opencode', win_id) +end + +---@param bufnr integer|nil +function M.ui.set_current_code_buf(bufnr) + set_state('current_code_buf', bufnr) +end + +M.model = {} + +---@param mode string|nil +function M.model.set_mode(mode) + set_state('current_mode', mode) +end + +function M.model.clear_mode() + set_state('current_mode', nil) +end + +---@param model string|nil +function M.model.set_model(model) + set_state('current_model', model) +end + +function M.model.clear_model() + set_state('current_model', nil) +end + +---@param info table|nil +function M.model.set_model_info(info) + set_state('current_model_info', info) +end + +---@param variant string|nil +function M.model.set_variant(variant) + set_state('current_variant', variant) +end + +function M.model.clear_variant() + set_state('current_variant', nil) +end + +---@param mode_map table +function M.model.set_mode_model_map(mode_map) + set_state('user_mode_model_map', mode_map) +end + +---@param mode string +---@param model string +function M.model.set_mode_model_override(mode, model) + local mode_map = vim.deepcopy(_state.user_mode_model_map) + mode_map[mode] = model + set_state('user_mode_model_map', mode_map) +end + +---@param key string +local function warn_on_protected_raw_write(key) + if not PROTECTED_KEYS[key] or _protected_write_warnings[key] then + return + end + + _protected_write_warnings[key] = true + vim.schedule(function() + vim.notify( + string.format('Direct write to protected state key `%s`; prefer state domain helpers', key), + vim.log.levels.WARN + ) + end) +end + --- Subscribe to changes for a key (or all keys with '*'). ---@param key string|string[]|nil If nil or '*', listens to all keys ---@param cb fun(key:string, new_val:any, old_val:any) @@ -186,7 +443,7 @@ function M.unsubscribe(key, cb) end -- Notify listeners -local function _notify(key, new_val, old_val) +function M.notify(key, new_val, old_val) -- schedule notification to make sure we're not in a fast event -- context vim.schedule(function() @@ -219,7 +476,7 @@ function M.append(key, value) local old = vim.deepcopy(_state[key] --[[@as table]]) table.insert(_state[key] --[[@as table]], value) - _notify(key, _state[key], old) + M.notify(key, _state[key], old) end function M.remove(key, idx) @@ -232,7 +489,7 @@ function M.remove(key, idx) local old = vim.deepcopy(_state[key] --[[@as table]]) table.remove(_state[key] --[[@as table]], idx) - _notify(key, _state[key], old) + M.notify(key, _state[key], old) end --- @@ -260,8 +517,7 @@ function M.are_windows_in_current_tab() return false end - return M.is_window_in_current_tab(_state.windows.input_win) - or M.is_window_in_current_tab(_state.windows.output_win) + return M.is_window_in_current_tab(_state.windows.input_win) or M.is_window_in_current_tab(_state.windows.output_win) end ---@return boolean @@ -341,7 +597,7 @@ local TOGGLE_ACTION_RULES = { ---@param in_tab boolean ---@param persist_state boolean ---@param has_display_route boolean ----@return string +---@return 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' local function lookup_toggle_action(status, in_tab, persist_state, has_display_route) local ctx = { status = status, @@ -440,11 +696,19 @@ end ---@param hidden OpencodeHiddenBuffers|nil ---@return OpencodeHiddenBuffers|nil local function normalize_hidden_buffers(hidden) - if type(hidden) ~= 'table' then return nil end + if type(hidden) ~= 'table' then + return nil + end - local function valid_buf(b) return type(b) == 'number' and vim.api.nvim_buf_is_valid(b) end - if not valid_buf(hidden.input_buf) or not valid_buf(hidden.output_buf) then return nil end - if type(hidden.input_hidden) ~= 'boolean' then return nil end + local function valid_buf(b) + return type(b) == 'number' and vim.api.nvim_buf_is_valid(b) + end + if not valid_buf(hidden.input_buf) or not valid_buf(hidden.output_buf) then + return nil + end + if type(hidden.input_hidden) ~= 'boolean' then + return nil + end local fw = hidden.focused_window return { @@ -538,28 +802,38 @@ local function is_visible_in_tab() end local input_valid = w.input_win and vim.api.nvim_win_is_valid(w.input_win) local output_valid = w.output_win and vim.api.nvim_win_is_valid(w.output_win) - return (input_valid or output_valid) and M.are_windows_in_current_tab() + return ((input_valid or output_valid) and M.are_windows_in_current_tab()) == true end -- STATUS_DETECTION rules for get_window_state (evaluated in order) local STATUS_DETECTION = { { name = 'hidden_snapshot', - test = function() return M.has_hidden_buffers() and M.is_hidden_snapshot_in_current_tab() end, + test = function() + return M.has_hidden_buffers() and M.is_hidden_snapshot_in_current_tab() + end, status = 'hidden', - get_windows = function() return nil end, + get_windows = function() + return nil + end, }, { name = 'visible_in_tab', test = is_visible_in_tab, status = 'visible', - get_windows = function() return _state.windows end, + get_windows = function() + return _state.windows + end, }, { name = 'closed', - test = function() return true end, + test = function() + return true + end, status = 'closed', - get_windows = function() return nil end, + get_windows = function() + return nil + end, }, } @@ -598,11 +872,7 @@ return setmetatable(M, { return _state[k] end, __newindex = function(_, k, v) - local old = _state[k] - _state[k] = v - if not vim.deep_equal(old, v) then - _notify(k, v, old) - end + set_state(k, v, { source = 'raw' }) end, __pairs = function() return pairs(_state) diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 4f6b9a11..90ef61ea 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -35,8 +35,8 @@ function M.setup_autocmds(windows) return end local state = require('opencode.state') - state.last_code_win_before_opencode = vim.api.nvim_get_current_win() - state.current_code_buf = vim.api.nvim_get_current_buf() + state.ui.set_last_code_window(vim.api.nvim_get_current_win()) + state.ui.set_current_code_buf(vim.api.nvim_get_current_buf()) end, }) @@ -44,7 +44,7 @@ function M.setup_autocmds(windows) group = group, pattern = '*', callback = function() - require('opencode.state').is_opencode_focused = require('opencode.ui.ui').is_opencode_focused() + require('opencode.state').ui.set_panel_focused(require('opencode.ui.ui').is_opencode_focused()) end, }) diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 903b61b3..008e98d3 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -479,7 +479,7 @@ function M.setup_autocmds(windows, group) buffer = windows.input_buf, callback = function() M.refresh_placeholder(windows) - state.last_focused_opencode_window = 'input' + state.ui.set_last_focused_window('input') require('opencode.ui.context_bar').render() end, }) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index a6e561db..5106628c 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -261,7 +261,7 @@ function M.setup_autocmds(windows, group) buffer = windows.output_buf, callback = function() local input_window = require('opencode.ui.input_window') - state.last_focused_opencode_window = 'output' + state.ui.set_last_focused_window('output') input_window.refresh_placeholder(state.windows) vim.cmd('stopinsert') @@ -273,7 +273,7 @@ function M.setup_autocmds(windows, group) buffer = windows.output_buf, callback = function() local input_window = require('opencode.ui.input_window') - state.last_focused_opencode_window = 'output' + state.ui.set_last_focused_window('output') input_window.refresh_placeholder(state.windows) vim.cmd('stopinsert') diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 1c1eb382..ce3f9ae5 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -300,9 +300,9 @@ function M._set_model_and_mode_from_messages() if message and message.info then if message.info.modelID and message.info.providerID then - state.current_model = message.info.providerID .. '/' .. message.info.modelID + state.model.set_model(message.info.providerID .. '/' .. message.info.modelID) if message.info.mode then - state.current_mode = message.info.mode + state.model.set_mode(message.info.mode) end return end @@ -1205,7 +1205,7 @@ end ---@param message OpencodeMessage function M._update_stats_from_message(message) if not state.current_model and message.info.providerID and message.info.providerID ~= '' then - state.current_model = message.info.providerID .. '/' .. message.info.modelID + state.model.set_model(message.info.providerID .. '/' .. message.info.modelID) end local tokens = message.info.tokens diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 0ad0d279..21f82ef1 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -59,7 +59,7 @@ function M.pick(sessions, callback) for _, session in ipairs(sessions_to_delete) do if state.active_session and state.active_session.id == session.id then vim.notify('deleting current session, creating new session') - state.active_session = require('opencode.core').create_new_session():await() + state.session.set_active(require('opencode.core').create_new_session():await()) end state.api_client:delete_session(session.id):catch(function(err) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 92724e08..ac42698b 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -95,7 +95,7 @@ local function prepare_window_close() M.return_to_last_code_win() end if state.display_route then - state.display_route = nil + state.ui.clear_display_route() end pcall(vim.api.nvim_del_augroup_by_name, 'OpencodeResize') @@ -184,7 +184,7 @@ function M.teardown_visible_windows(windows) pcall(vim.api.nvim_buf_delete, windows.input_buf, { force = true }) pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true }) if state.windows == windows then - state.windows = nil + state.ui.clear_windows() end state.clear_hidden_window_state() end @@ -226,7 +226,7 @@ function M.restore_hidden_windows() local windows = state.windows if not windows then windows = {} - state.windows = windows + state.ui.set_windows(windows) end windows.input_buf = hidden.input_buf windows.output_buf = hidden.output_buf @@ -347,8 +347,8 @@ function M.create_windows() local autocmds = require('opencode.ui.autocmds') if not require('opencode.ui.ui').is_opencode_focused() then - state.last_code_win_before_opencode = vim.api.nvim_get_current_win() - state.current_code_buf = vim.api.nvim_get_current_buf() + state.ui.set_last_code_window(vim.api.nvim_get_current_win()) + state.ui.set_current_code_buf(vim.api.nvim_get_current_buf()) end -- Create new windows from scratch diff --git a/lua/opencode/variant_picker.lua b/lua/opencode/variant_picker.lua index cd81deba..45833027 100644 --- a/lua/opencode/variant_picker.lua +++ b/lua/opencode/variant_picker.lua @@ -57,7 +57,7 @@ function M.select(callback) if provider and model then local saved_variant = model_state.get_variant(provider, model) if saved_variant then - state.current_variant = saved_variant + state.model.set_variant(saved_variant) end end end @@ -89,7 +89,7 @@ function M.select(callback) actions = {}, callback = function(selection) if selection and state.current_model then - state.current_variant = selection.name + state.model.set_variant(selection.name) -- Save variant to model state local provider, model = state.current_model:match('^(.-)/(.+)$') diff --git a/state_refactor.md b/state_refactor.md new file mode 100644 index 00000000..cf0cf5d9 --- /dev/null +++ b/state_refactor.md @@ -0,0 +1,150 @@ +```markdown +# State Refactor Plan + +## Goal + +Refactor the plugin global state into a small store + slice modules so writes are funneled through safe, domain-owned APIs while preserving existing read semantics and notification timing. + +## Recommended starting work (default) + +Implement the store primitive, add a test escape hatch to silence protected-write warnings, and extract the `session` and `jobs` slices. This gives immediate safety for the highest-value mutation domains and a clear migration path for the rest. + +## High-level roadmap + +1. store: implement `lua/opencode/state/store.lua` (get/set/update/subscribe/notify) preserving current scheduling semantics. +2. test escape hatch: add a way for tests to set raw state without warnings (e.g. `store.set_raw` or an env var), plus `lua/opencode/state/test_helpers.lua`. +3. slices: create `lua/opencode/state/session.lua` and `lua/opencode/state/jobs.lua` that use store APIs and expose the existing helper signatures. +4. facade: create `lua/opencode/state/init.lua` to re-export store and slices and to preserve backward-compatible read access for callers still requiring `require('opencode.state')`. +5. migrate callers in small batches (server/jobs first, then UI and model). +6. add small lifecycle helpers/state machines in `state/jobs.lua` and `state/ui.lua`. +7. tighten policy (warnings -> errors) and cleanup once tests and migration are complete. + +## Concrete tasks + +- store + - File: `lua/opencode/state/store.lua` + - API: + - `get(key)` + - `set(key, value, opts?)` where `opts.source` is `'helper'|'raw'` and controls warnings + - `update(key, fn, opts?)` + - `subscribe(key_or_pattern, cb)` (support key-based subscriptions; preserve scheduling semantics) + - `notify(...)` (optional internal) + - Behavior: + - Preserve current notification timing (use `vim.schedule` if current code does) + - Centralize `PROTECTED_KEYS` logic and warn once per key on raw writes + - Provide `set_raw` or `set(key, value, {silent=true})` for tests + +- test escape hatch + - File: `lua/opencode/state/test_helpers.lua` (or export functions from `store`) + - API: + - `silence_protected_writes()` / `allow_raw_writes_for_tests()` — minimal API for test suites + - Usage: + - Tests can call the helper in a setup block to avoid noisy warnings while they directly mutate state + +- session slice + - File: `lua/opencode/state/session.lua` + - Exported helpers (match existing names): + - `set_active(session, opts?)` + - `clear_active()` + - `set_restore_points(points)` + - `reset_restore_points()` + - any other session helpers already in  `lua/opencode/state.lua` + - Implementation: + - Use `store.set`/`update` and `store.subscribe` where necessary + - Keep call-site signatures unchanged + +- jobs slice + - File: `lua/opencode/state/jobs.lua` + - Exported helpers: + - `increment_count()` + - `decrement_count()` (if required) + - `set_count(n)` + - `set_server(server)` + - `clear_server()` + - small lifecycle helpers like `ensure_server()`/`on_server_start()` optionally + - Implementation: + - Use `store` primitives; centralize server lifecycle transitions + +- facade/init + - File: `lua/opencode/state/init.lua` (module returned by `require('opencode.state')`) + - Re-export: + - `store` or thin read-proxy for backward compatibility + - `session`, `jobs`, `ui`, `model` slices as they become available + - Behavior: + - Reads (e.g., `state.some_key`) should continue to work for existing code + - Writes should be routed through slices or still emit protected-write warnings if raw + +## Migration strategy + +- Do not change everything in one PR. Use multiple, small PRs: + 1. Add `store.lua`, `test_helpers.lua`, no callers changed. + 2. Add `state/session.lua` and `state/jobs.lua`. + 3. Add `state/init.lua` facade; update a small set of callers to require new slices or to call `state.session.*`. + 4. Migrate other call sites in batches (server/job, then UI, then model). + 5. Final cleanup and tighten warning policy. +- After each change: run  `./run_tests.sh` and fix failures. +- For tests that directly mutate `state.*`, either: + 1. Use the test helper to silence warnings, or + 2. Update tests to use the new slice helpers (preferred long term). + +## Files to create (initial) + +- `lua/opencode/state/store.lua` +- `lua/opencode/state/test_helpers.lua` +- `lua/opencode/state/session.lua` +- `lua/opencode/state/jobs.lua` +- `lua/opencode/state/init.lua` + +## Testing & verification + +- Run unit tests after each step:  `./run_tests.sh` +- Manually exercise UI/server flows for timing-sensitive behavior (notifications should be scheduled same as before) +- Verify that protected-key warnings are logged once per key and that tests can silence them via test helper +- Grep for raw writes: `rg "state\.[a-zA-Z_][a-zA-Z0-9_]*\s*="` and migrate the important ones first (session, jobs, ui, model) + +## Commit & PR guidance + +- Keep commits small and descriptive: + 1. "feat(state): add store primitive and test helpers" + 2. "feat(state/session, jobs): move session and jobs helpers to slices" + 3. "refactor(state): add facade and wire slice exports" + 4. "refactor: migrate server/job call sites to state.jobs API" +- Each PR should aim to be test-green and include a short migration note. +- Don’t amend commits; create new commits for fixes. + +## Timing & milestones (example) + +- Day 1: Implement `store.lua` + `test_helpers.lua` + unit tests for store behavior +- Day 2: Implement `session.lua` + `jobs.lua`, add tests for slices +- Day 3: Add `state/init.lua` facade and migrate a small set of callers +- Day 4: Migrate remaining high-value callers, run full test suite +- Day 5: Small cleanups, tighten warnings (optional) + +## Risks & mitigations + +- Risk: notification/timing changes introduce subtle bugs + - Mitigation: preserve `vim.schedule` usage and run integration-like tests +- Risk: tests fail due to direct state mutation + - Mitigation: provide test escape hatch and gradually update tests to use new helpers +- Risk: large PRs are hard to review + - Mitigation: split work into small PRs focused on one slice or behavior at a time + +## Open questions (pick one) + +1. Start now with the recommended default: implement `store + test escape hatch + session + jobs`? (Recommended) +2. Or would you prefer I extract a different slice first (e.g. `ui` or `model`)? + +If you pick (1), I will produce ready-to-apply code snippets for: + +- `lua/opencode/state/store.lua` +- `lua/opencode/state/test_helpers.lua` +- `lua/opencode/state/session.lua` +- `lua/opencode/state/jobs.lua` +- `lua/opencode/state/init.lua` + +You can then paste them into files or ask me to apply them to the repository. +``` + +## Next step + +- Reply with the option you want (1 = default store+tests+session+jobs, 2 = extract different slice first) or edit the markdown above and tell me which parts to change. From 0be71d96954d1ce2dcf13804a6eba7d19c7c255e Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 9 Mar 2026 13:43:05 -0400 Subject: [PATCH 02/13] feat(state): split state in smaller slices --- lua/opencode/api.lua | 2 +- lua/opencode/core.lua | 2 +- lua/opencode/state.lua | 884 +--------------------------- lua/opencode/state/init.lua | 184 ++++++ lua/opencode/state/jobs.lua | 38 ++ lua/opencode/state/model.lua | 51 ++ lua/opencode/state/session.lua | 27 + lua/opencode/state/store.lua | 256 ++++++++ lua/opencode/state/test_helpers.lua | 26 + lua/opencode/state/ui.lua | 401 +++++++++++++ lua/opencode/ui/input_window.lua | 4 +- lua/opencode/ui/output_window.lua | 4 +- lua/opencode/ui/ui.lua | 16 +- tests/minimal/init.lua | 5 + tests/unit/persist_state_spec.lua | 60 +- tests/unit/zoom_spec.lua | 2 +- 16 files changed, 1049 insertions(+), 913 deletions(-) create mode 100644 lua/opencode/state/init.lua create mode 100644 lua/opencode/state/jobs.lua create mode 100644 lua/opencode/state/model.lua create mode 100644 lua/opencode/state/session.lua create mode 100644 lua/opencode/state/store.lua create mode 100644 lua/opencode/state/test_helpers.lua create mode 100644 lua/opencode/state/ui.lua diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index a9c6c92d..360763fe 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -98,7 +98,7 @@ local function build_toggle_open_context(restore_hidden) end M.toggle = Promise.async(function(new_session) - local decision = state.resolve_toggle_decision(config.ui.persist_state, state.display_route ~= nil) + local decision = state.ui.resolve_toggle_decision(config.ui.persist_state, state.display_route ~= nil) local action = decision.action local is_new_session = new_session == true diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 2e78a6d9..dcf15bc2 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -103,7 +103,7 @@ M.open = Promise.async(function(opts) if restoring_hidden then local restored = ui.restore_hidden_windows() if not restored then - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() restoring_hidden = false state.ui.set_windows(ui.create_windows()) end diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 2e6c2959..f6727c9d 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -1,883 +1 @@ ----@class OpencodeWindowState ----@field input_win integer|nil ----@field output_win integer|nil ----@field footer_win integer|nil ----@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 footer_buf integer|nil ----@field output_was_at_bottom boolean ----@field input_hidden boolean ----@field input_cursor integer[]|nil ----@field output_cursor integer[]|nil ----@field output_view table|nil ----@field focused_window 'input'|'output'|nil ----@field position 'right'|'left'|'current'|nil ----@field owner_tab integer|nil - ----@class OpencodeToggleDecision ----@field action 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' - ----@class OpencodeSessionStateMutations ----@field set_active fun(session: Session|nil) ----@field clear_active fun() ----@field set_restore_points fun(points: RestorePoint[]) ----@field reset_restore_points fun() - ----@class OpencodeProtectedStateSetOptions ----@field source? 'helper'|'raw' - ----@alias OpencodeProtectedStateKey ----| 'active_session' ----| 'restore_points' ----| 'job_count' ----| 'opencode_server' ----| 'windows' ----| 'is_opening' ----| 'is_opencode_focused' ----| 'last_focused_opencode_window' ----| 'last_code_win_before_opencode' ----| 'current_code_buf' ----| 'display_route' ----| 'current_mode' ----| 'current_model' ----| 'current_model_info' ----| 'current_variant' ----| 'user_mode_model_map' - ----@class OpencodeJobStateMutations ----@field increment_count fun(delta?: integer) ----@field set_count fun(count: integer) ----@field set_server fun(server: OpencodeServer|nil) ----@field clear_server fun() - ----@class OpencodeUiStateMutations ----@field set_windows fun(windows: OpencodeWindowState|nil) ----@field clear_windows fun() ----@field set_opening fun(is_opening: boolean) ----@field set_panel_focused fun(is_focused: boolean) ----@field set_last_focused_window fun(win_type: 'input'|'output'|nil) ----@field set_display_route fun(route: any) ----@field clear_display_route fun() ----@field set_last_code_window fun(win_id: integer|nil) ----@field set_current_code_buf fun(bufnr: integer|nil) - ----@class OpencodeModelStateMutations ----@field set_mode fun(mode: string|nil) ----@field clear_mode fun() ----@field set_model fun(model: string|nil) ----@field clear_model fun() ----@field set_model_info fun(info: table|nil) ----@field set_variant fun(variant: string|nil) ----@field clear_variant fun() ----@field set_mode_model_map fun(mode_map: table) ----@field set_mode_model_override fun(mode: string, model: string) - ----@class OpencodeState ----@field windows OpencodeWindowState|nil ----@field is_opening boolean ----@field input_content table ----@field is_opencode_focused boolean ----@field last_focused_opencode_window string|nil ----@field last_input_window_position integer[]|nil ----@field last_output_window_position integer[]|nil ----@field last_code_win_before_opencode integer|nil ----@field current_code_buf number|nil ----@field saved_window_options table|nil ----@field display_route any|nil ----@field current_mode string ----@field last_output number ----@field last_sent_context OpencodeContext|nil ----@field current_context_config OpencodeContextConfig|nil ----@field context_updated_at number|nil ----@field active_session Session|nil ----@field restore_points RestorePoint[] ----@field current_model string|nil ----@field user_mode_model_map table ----@field current_model_info table|nil ----@field current_variant string|nil ----@field messages OpencodeMessage[]|nil ----@field current_message OpencodeMessage|nil ----@field last_user_message OpencodeMessage|nil ----@field pending_permissions OpencodePermission[] ----@field cost number ----@field tokens_count number ----@field job_count number ----@field user_message_count table ----@field opencode_server OpencodeServer|nil ----@field api_client OpencodeApiClient ----@field event_manager EventManager|nil ----@field pre_zoom_width integer|nil ----@field last_window_width_ratio number|nil ----@field required_version string ----@field opencode_cli_version string|nil ----@field current_cwd string|nil ----@field _hidden_buffers OpencodeHiddenBuffers|nil ----@field append fun( key:string, value:any) ----@field remove fun( key:string, idx:number) ----@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 get_window_state fun(): {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} ----@field is_window_in_current_tab fun(win_id: integer|nil): boolean ----@field are_windows_in_current_tab fun(): boolean ----@field get_window_cursor fun(win_id: integer|nil): integer[]|nil ----@field set_cursor_position fun(win_type: 'input'|'output', pos: integer[]|nil) ----@field get_cursor_position fun(win_type: 'input'|'output'): integer[]|nil ----@field stash_hidden_buffers fun(hidden: OpencodeHiddenBuffers|nil) ----@field inspect_hidden_buffers fun(): OpencodeHiddenBuffers|nil ----@field is_hidden_snapshot_in_current_tab fun(): boolean ----@field clear_hidden_window_state fun() ----@field has_hidden_buffers fun(): boolean ----@field consume_hidden_buffers fun(): OpencodeHiddenBuffers|nil ----@field resolve_toggle_decision fun(persist_state: boolean, has_display_route: boolean): OpencodeToggleDecision ----@field resolve_open_windows_action fun(): 'reuse_visible'|'restore_hidden'|'create_fresh' ----@field get_window_cursor fun(win_id: integer|nil): integer[]|nil ----@field session OpencodeSessionStateMutations ----@field jobs OpencodeJobStateMutations ----@field ui OpencodeUiStateMutations ----@field model OpencodeModelStateMutations - -local M = {} - --- Internal raw state table -local _state = { - -- ui - windows = nil, ---@type OpencodeWindowState|nil - is_opening = false, - input_content = {}, - is_opencode_focused = false, - last_focused_opencode_window = nil, - last_input_window_position = nil, - last_output_window_position = nil, - last_code_win_before_opencode = nil, - current_code_buf = nil, - saved_window_options = nil, - display_route = nil, - current_mode = nil, - last_output = 0, - pre_zoom_width = nil, - -- context - last_sent_context = nil, - current_context_config = {}, - context_updated_at = nil, - -- session - active_session = nil, - restore_points = {}, - current_model = nil, - user_mode_model_map = {}, - current_model_info = nil, - current_variant = nil, - -- messages - messages = nil, - current_message = nil, - last_user_message = nil, - pending_permissions = {}, - cost = 0, - tokens_count = 0, - -- job - job_count = 0, - user_message_count = {}, - opencode_server = nil, - api_client = nil, - event_manager = nil, - - -- versions - required_version = '0.6.3', - opencode_cli_version = nil, - current_cwd = vim.fn.getcwd(), - - -- persist_state snapshot - _hidden_buffers = nil, -} - --- Listener registry: { [key] = {cb1, cb2, ...}, ['*'] = {cb1, ...} } -local _listeners = {} - -local PROTECTED_KEYS = { - active_session = true, - restore_points = true, - job_count = true, - opencode_server = true, - windows = true, - is_opening = true, - is_opencode_focused = true, - last_focused_opencode_window = true, - last_code_win_before_opencode = true, - current_code_buf = true, - display_route = true, - current_mode = true, - current_model = true, - current_model_info = true, - current_variant = true, - user_mode_model_map = true, -} - -local _protected_write_warnings = {} - ----@param key string ----@param value any ----@param opts? OpencodeProtectedStateSetOptions -local function set_state(key, value, opts) - local old = _state[key] - opts = opts or { source = 'helper' } - - if opts.source == 'raw' then - warn_on_protected_raw_write(key) - end - - _state[key] = value - if not vim.deep_equal(old, value) then - M.notify(key, value, old) - end -end - ----@generic T ----@param key string ----@param updater fun(current: T): T ----@return T -local function update_state(key, updater) - local next_value = updater(_state[key]) - set_state(key, next_value) - return next_value -end - -M.session = {} - ----@param session Session|nil -function M.session.set_active(session) - set_state('active_session', session) -end - -function M.session.clear_active() - set_state('active_session', nil) -end - ----@param points RestorePoint[] -function M.session.set_restore_points(points) - set_state('restore_points', points) -end - -function M.session.reset_restore_points() - set_state('restore_points', {}) -end - -M.jobs = {} - ----@param delta integer|nil -function M.jobs.increment_count(delta) - update_state('job_count', function(current) - return (current or 0) + (delta or 1) - end) -end - ----@param count integer -function M.jobs.set_count(count) - set_state('job_count', count) -end - ----@param server OpencodeServer|nil -function M.jobs.set_server(server) - set_state('opencode_server', server) -end - -function M.jobs.clear_server() - set_state('opencode_server', nil) -end - -M.ui = {} - ----@param windows OpencodeWindowState|nil -function M.ui.set_windows(windows) - set_state('windows', windows) -end - -function M.ui.clear_windows() - set_state('windows', nil) -end - ----@param is_opening boolean -function M.ui.set_opening(is_opening) - set_state('is_opening', is_opening) -end - ----@param is_focused boolean -function M.ui.set_panel_focused(is_focused) - set_state('is_opencode_focused', is_focused) -end - ----@param win_type 'input'|'output'|nil -function M.ui.set_last_focused_window(win_type) - set_state('last_focused_opencode_window', win_type) -end - ----@param route any -function M.ui.set_display_route(route) - set_state('display_route', route) -end - -function M.ui.clear_display_route() - set_state('display_route', nil) -end - ----@param win_id integer|nil -function M.ui.set_last_code_window(win_id) - set_state('last_code_win_before_opencode', win_id) -end - ----@param bufnr integer|nil -function M.ui.set_current_code_buf(bufnr) - set_state('current_code_buf', bufnr) -end - -M.model = {} - ----@param mode string|nil -function M.model.set_mode(mode) - set_state('current_mode', mode) -end - -function M.model.clear_mode() - set_state('current_mode', nil) -end - ----@param model string|nil -function M.model.set_model(model) - set_state('current_model', model) -end - -function M.model.clear_model() - set_state('current_model', nil) -end - ----@param info table|nil -function M.model.set_model_info(info) - set_state('current_model_info', info) -end - ----@param variant string|nil -function M.model.set_variant(variant) - set_state('current_variant', variant) -end - -function M.model.clear_variant() - set_state('current_variant', nil) -end - ----@param mode_map table -function M.model.set_mode_model_map(mode_map) - set_state('user_mode_model_map', mode_map) -end - ----@param mode string ----@param model string -function M.model.set_mode_model_override(mode, model) - local mode_map = vim.deepcopy(_state.user_mode_model_map) - mode_map[mode] = model - set_state('user_mode_model_map', mode_map) -end - ----@param key string -local function warn_on_protected_raw_write(key) - if not PROTECTED_KEYS[key] or _protected_write_warnings[key] then - return - end - - _protected_write_warnings[key] = true - vim.schedule(function() - vim.notify( - string.format('Direct write to protected state key `%s`; prefer state domain helpers', key), - vim.log.levels.WARN - ) - end) -end - ---- Subscribe to changes for a key (or all keys with '*'). ----@param key string|string[]|nil If nil or '*', listens to all keys ----@param cb fun(key:string, new_val:any, old_val:any) ----@usage ---- state.subscribe('foo', function(key, new, old) ... end) ---- state.subscribe('*', function(key, new, old) ... end) -function M.subscribe(key, cb) - if type(key) == 'table' then - for _, k in ipairs(key) do - M.subscribe(k, cb) - end - return - end - key = key or '*' - if not _listeners[key] then - _listeners[key] = {} - end - - for _, fn in ipairs(_listeners[key]) do - if fn == cb then - return - end - end - - table.insert(_listeners[key], cb) -end - ---- Unsubscribe a callback for a key (or all keys) ----@param key string|nil ----@param cb fun(key:string, new_val:any, old_val:any) -function M.unsubscribe(key, cb) - key = key or '*' - local list = _listeners[key] - if not list then - return - end - - for i = #list, 1, -1 do - local fn = list[i] - if fn == cb then - table.remove(list, i) - end - end -end - --- Notify listeners -function M.notify(key, new_val, old_val) - -- schedule notification to make sure we're not in a fast event - -- context - vim.schedule(function() - if _listeners[key] then - for _, cb in ipairs(_listeners[key]) do - local ok, err = pcall(cb, key, new_val, old_val) - if not ok then - vim.notify(err --[[@as string]]) - end - end - end - if _listeners['*'] then - for _, cb in ipairs(_listeners['*']) do - pcall(cb, key, new_val, old_val) - end - end - end) -end - -function M.append(key, value) - if type(value) ~= 'table' then - error('Value must be a table to append') - end - if not _state[key] then - _state[key] = {} - end - if type(_state[key]) ~= 'table' then - error('State key is not a table: ' .. key) - end - - local old = vim.deepcopy(_state[key] --[[@as table]]) - table.insert(_state[key] --[[@as table]], value) - M.notify(key, _state[key], old) -end - -function M.remove(key, idx) - if not _state[key] then - return - end - if type(_state[key]) ~= 'table' then - error('State key is not a table: ' .. key) - end - - local old = vim.deepcopy(_state[key] --[[@as table]]) - table.remove(_state[key] --[[@as table]], idx) - M.notify(key, _state[key], old) -end - ---- ---- Returns true if any job (run or server) is running ---- -function M.is_running() - return M.job_count > 0 -end - ----@param win_id integer|nil ----@return boolean -function M.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 current_tab = vim.api.nvim_get_current_tabpage() - local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, win_id) - return ok and win_tab == current_tab -end - ----@return boolean -function M.are_windows_in_current_tab() - if not _state.windows then - return false - end - - return M.is_window_in_current_tab(_state.windows.input_win) or M.is_window_in_current_tab(_state.windows.output_win) -end - ----@return boolean -function M.is_visible() - return M.get_window_state().status == 'visible' -end - ----@class OpencodeToggleContext ----@field status 'closed'|'hidden'|'visible' ----@field in_tab boolean ----@field persist_state boolean ----@field has_display_route boolean - ----@generic T ----@param rules T[] ----@param match fun(rule: T): boolean ----@return T|nil -local function first_matching_rule(rules, match) - for _, rule in ipairs(rules) do - if match(rule) then - return rule - end - end - - return nil -end - ---- ORDER MATTERS: Rules are evaluated top-to-bottom; first match wins. ---- In particular, the has_display_route rule must precede the persist_state=true/hide rule, ---- otherwise toggling while viewing /help or /commands would hide instead of close. -local TOGGLE_ACTION_RULES = { - { - action = 'restore_hidden', - when = function(ctx) - return ctx.status == 'hidden' and ctx.persist_state - end, - }, - { - action = 'close_hidden', - when = function(ctx) - return ctx.status == 'hidden' and not ctx.persist_state - end, - }, - { - action = 'migrate', - when = function(ctx) - return ctx.status == 'visible' and not ctx.in_tab - end, - }, - { - action = 'close', - when = function(ctx) - return ctx.status == 'visible' and ctx.in_tab and ctx.has_display_route - end, - }, - { - action = 'close', - when = function(ctx) - return ctx.status == 'visible' and ctx.in_tab and not ctx.persist_state - end, - }, - { - action = 'hide', - when = function(ctx) - return ctx.status == 'visible' and ctx.in_tab and ctx.persist_state and not ctx.has_display_route - end, - }, - { - action = 'open', - when = function(ctx) - return ctx.status == 'closed' - end, - }, -} - ----@param status 'closed'|'hidden'|'visible' ----@param in_tab boolean ----@param persist_state boolean ----@param has_display_route boolean ----@return 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' -local function lookup_toggle_action(status, in_tab, persist_state, has_display_route) - local ctx = { - status = status, - in_tab = in_tab, - persist_state = persist_state, - has_display_route = has_display_route, - } - - local matched_rule = first_matching_rule(TOGGLE_ACTION_RULES, function(rule) - return rule.when(ctx) - end) - - return matched_rule and matched_rule.action or 'open' -end - ----@param persist_state boolean ----@param has_display_route boolean ----@return OpencodeToggleDecision -function M.resolve_toggle_decision(persist_state, has_display_route) - local status = M.get_window_state().status - local in_tab = M.are_windows_in_current_tab() - - local action = lookup_toggle_action(status, in_tab, persist_state, has_display_route) - return { action = action } -end - ----@return 'reuse_visible'|'restore_hidden'|'create_fresh' -function M.resolve_open_windows_action() - local status = M.get_window_state().status - if status == 'visible' then - return M.are_windows_in_current_tab() and 'reuse_visible' or 'create_fresh' - end - if status == 'hidden' then - return 'restore_hidden' - end - return 'create_fresh' -end - ----@param pos any ----@return integer[]|nil -local function normalize_cursor(pos) - if type(pos) ~= 'table' or #pos < 2 then - return nil - end - - local line = tonumber(pos[1]) - local col = tonumber(pos[2]) - if not line or not col then - return nil - end - - return { math.max(1, math.floor(line)), math.max(0, math.floor(col)) } -end - ----Get cursor position from a window (pure query, no side effects) ----@param win_id integer|nil ----@return integer[]|nil -function M.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 not ok then - return nil - end - - return normalize_cursor(pos) -end - ----Set saved cursor position ----@param win_type 'input'|'output' ----@param pos integer[]|nil -function M.set_cursor_position(win_type, pos) - local normalized = normalize_cursor(pos) - if win_type == 'input' then - _state.last_input_window_position = normalized - elseif win_type == 'output' then - _state.last_output_window_position = normalized - 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 normalize_cursor(_state.last_input_window_position) - end - if win_type == 'output' then - return normalize_cursor(_state.last_output_window_position) - end - return nil -end - ----@param hidden OpencodeHiddenBuffers|nil ----@return OpencodeHiddenBuffers|nil -local function normalize_hidden_buffers(hidden) - if type(hidden) ~= 'table' then - return nil - end - - local function valid_buf(b) - return type(b) == 'number' and vim.api.nvim_buf_is_valid(b) - end - if not valid_buf(hidden.input_buf) or not valid_buf(hidden.output_buf) then - return nil - end - if type(hidden.input_hidden) ~= 'boolean' then - return nil - end - - local fw = hidden.focused_window - return { - input_buf = hidden.input_buf, - output_buf = hidden.output_buf, - footer_buf = valid_buf(hidden.footer_buf) and hidden.footer_buf or nil, - output_was_at_bottom = hidden.output_was_at_bottom == true, - input_hidden = hidden.input_hidden, - input_cursor = normalize_cursor(hidden.input_cursor), - output_cursor = normalize_cursor(hidden.output_cursor), - output_view = type(hidden.output_view) == 'table' and vim.deepcopy(hidden.output_view) or nil, - focused_window = (fw == 'input' or fw == 'output') and fw or nil, - position = hidden.position, - owner_tab = type(hidden.owner_tab) == 'number' and hidden.owner_tab or nil, - } -end - ----@param copy boolean ----@return OpencodeHiddenBuffers|nil -local function read_hidden_buffers_snapshot(copy) - local normalized = normalize_hidden_buffers(_state._hidden_buffers) - if not normalized then - return nil - end - - if not copy then - return normalized - end - - return vim.deepcopy(normalized) -end - ----@return boolean -function M.is_hidden_snapshot_in_current_tab() - local hidden = read_hidden_buffers_snapshot(false) - if not hidden then - return false - end - - if type(hidden.owner_tab) ~= 'number' then - return true - end - - return hidden.owner_tab == vim.api.nvim_get_current_tabpage() -end - ----Store hidden buffers snapshot ----@param hidden OpencodeHiddenBuffers|nil -function M.stash_hidden_buffers(hidden) - if hidden == nil then - _state._hidden_buffers = nil - return - end - - _state._hidden_buffers = normalize_hidden_buffers(hidden) -end - ----Inspect hidden buffers snapshot without mutating state ----@return OpencodeHiddenBuffers|nil -function M.inspect_hidden_buffers() - return read_hidden_buffers_snapshot(true) -end - ----Clear hidden snapshot and drop empty window state -function M.clear_hidden_window_state() - _state._hidden_buffers = nil - if _state.windows and not _state.windows.input_win and not _state.windows.output_win then - _state.windows = nil - end -end - ----Check if hidden buffers snapshot is available ----@return boolean -function M.has_hidden_buffers() - return read_hidden_buffers_snapshot(false) ~= nil -end - ----Consume hidden buffers snapshot ----@return OpencodeHiddenBuffers|nil -function M.consume_hidden_buffers() - local hidden = M.inspect_hidden_buffers() - _state._hidden_buffers = nil - return hidden -end - ----@return boolean -local function is_visible_in_tab() - local w = _state.windows - if not w then - return false - end - local input_valid = w.input_win and vim.api.nvim_win_is_valid(w.input_win) - local output_valid = w.output_win and vim.api.nvim_win_is_valid(w.output_win) - return ((input_valid or output_valid) and M.are_windows_in_current_tab()) == true -end - --- STATUS_DETECTION rules for get_window_state (evaluated in order) -local STATUS_DETECTION = { - { - name = 'hidden_snapshot', - test = function() - return M.has_hidden_buffers() and M.is_hidden_snapshot_in_current_tab() - end, - status = 'hidden', - get_windows = function() - return nil - end, - }, - { - name = 'visible_in_tab', - test = is_visible_in_tab, - status = 'visible', - get_windows = function() - return _state.windows - end, - }, - { - name = 'closed', - test = function() - return true - end, - status = 'closed', - get_windows = function() - return nil - end, - }, -} - ----Get comprehensive window state for API consumers ----@return {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} -function M.get_window_state() - local config = require('opencode.config') - - local status_rule = first_matching_rule(STATUS_DETECTION, function(rule) - return rule.test() - end) - - local status = status_rule and status_rule.status or 'closed' - local current_windows = status_rule and status_rule.get_windows() or nil - - return { - status = status, - position = config.ui.position, - windows = current_windows and vim.deepcopy(current_windows) or nil, - cursor_positions = { - input = M.get_window_cursor(current_windows and current_windows.input_win) or M.get_cursor_position('input'), - output = M.get_window_cursor(current_windows and current_windows.output_win) or M.get_cursor_position('output'), - }, - } -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. ---- ---- Example: ---- state.subscribe('foo', function(key, new, old) print(key, new, old) end) ---- state.foo = 42 -- triggers callback -return setmetatable(M, { - __index = function(_, k) - return _state[k] - end, - __newindex = function(_, k, v) - set_state(k, v, { source = 'raw' }) - end, - __pairs = function() - return pairs(_state) - end, - __ipairs = function() - return ipairs(_state) - end, -}) --[[@as OpencodeState]] +return require('opencode.state.init') diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua new file mode 100644 index 00000000..69e8688b --- /dev/null +++ b/lua/opencode/state/init.lua @@ -0,0 +1,184 @@ +---@class OpencodeWindowState +---@field input_win integer|nil +---@field output_win integer|nil +---@field footer_win integer|nil +---@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 footer_buf integer|nil +---@field output_was_at_bottom boolean +---@field input_hidden boolean +---@field input_cursor integer[]|nil +---@field output_cursor integer[]|nil +---@field output_view table|nil +---@field focused_window 'input'|'output'|nil +---@field position 'right'|'left'|'current'|nil +---@field owner_tab integer|nil + +---@class OpencodeToggleDecision +---@field action 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' + +---@class OpencodeSessionStateMutations +---@field set_active fun(session: Session|nil, opts?: OpencodeProtectedStateSetOptions) +---@field clear_active fun(opts?: OpencodeProtectedStateSetOptions) +---@field set_restore_points fun(points: RestorePoint[], opts?: OpencodeProtectedStateSetOptions) +---@field reset_restore_points fun(opts?: OpencodeProtectedStateSetOptions) + +---@class OpencodeJobStateMutations +---@field increment_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) +---@field decrement_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) +---@field set_count fun(count: integer, opts?: OpencodeProtectedStateSetOptions) +---@field set_server fun(server: OpencodeServer|nil, opts?: OpencodeProtectedStateSetOptions) +---@field clear_server fun(opts?: OpencodeProtectedStateSetOptions) + +---@class OpencodeUiStateMutations +---@field set_windows fun(windows: OpencodeWindowState|nil) +---@field clear_windows fun() +---@field set_opening fun(is_opening: boolean) +---@field set_panel_focused fun(is_focused: boolean) +---@field set_last_focused_window fun(win_type: 'input'|'output'|nil) +---@field set_display_route fun(route: any) +---@field clear_display_route fun() +---@field set_last_code_window fun(win_id: integer|nil) +---@field set_current_code_buf fun(bufnr: integer|nil) +---@field set_last_window_width_ratio fun(ratio: number|nil) +---@field clear_last_window_width_ratio fun() + +---@class OpencodeModelStateMutations +---@field set_mode fun(mode: string|nil) +---@field clear_mode fun() +---@field set_model fun(model: string|nil) +---@field clear_model fun() +---@field set_model_info fun(info: table|nil) +---@field set_variant fun(variant: string|nil) +---@field clear_variant fun() +---@field set_mode_model_map fun(mode_map: table) +---@field set_mode_model_override fun(mode: string, model: string) + +---@class OpencodeState +---@field windows OpencodeWindowState|nil +---@field is_opening boolean +---@field input_content table +---@field is_opencode_focused boolean +---@field last_focused_opencode_window string|nil +---@field last_input_window_position integer[]|nil +---@field last_output_window_position integer[]|nil +---@field last_code_win_before_opencode integer|nil +---@field current_code_buf number|nil +---@field saved_window_options table|nil +---@field display_route any|nil +---@field current_mode string +---@field last_output number +---@field last_sent_context OpencodeContext|nil +---@field current_context_config OpencodeContextConfig|nil +---@field context_updated_at number|nil +---@field active_session Session|nil +---@field restore_points RestorePoint[] +---@field current_model string|nil +---@field user_mode_model_map table +---@field current_model_info table|nil +---@field current_variant string|nil +---@field messages OpencodeMessage[]|nil +---@field current_message OpencodeMessage|nil +---@field last_user_message OpencodeMessage|nil +---@field pending_permissions OpencodePermission[] +---@field cost number +---@field tokens_count number +---@field job_count number +---@field user_message_count table +---@field opencode_server OpencodeServer|nil +---@field api_client OpencodeApiClient +---@field event_manager EventManager|nil +---@field pre_zoom_width integer|nil +---@field last_window_width_ratio number|nil +---@field required_version string +---@field opencode_cli_version string|nil +---@field current_cwd string|nil +---@field _hidden_buffers OpencodeHiddenBuffers|nil +---@field append fun(key:string, value:any) +---@field remove fun(key:string, idx:number) +---@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 get_window_state fun(): {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} +---@field is_window_in_current_tab fun(win_id: integer|nil): boolean +---@field are_windows_in_current_tab fun(): boolean +---@field get_window_cursor fun(win_id: integer|nil): integer[]|nil +---@field set_cursor_position fun(win_type: 'input'|'output', pos: integer[]|nil) +---@field get_cursor_position fun(win_type: 'input'|'output'): integer[]|nil +---@field stash_hidden_buffers fun(hidden: OpencodeHiddenBuffers|nil) +---@field inspect_hidden_buffers fun(): OpencodeHiddenBuffers|nil +---@field is_hidden_snapshot_in_current_tab fun(): boolean +---@field clear_hidden_window_state fun() +---@field has_hidden_buffers fun(): boolean +---@field consume_hidden_buffers fun(): OpencodeHiddenBuffers|nil +---@field resolve_toggle_decision fun(persist_state: boolean, has_display_route: boolean): OpencodeToggleDecision +---@field resolve_open_windows_action fun(): 'reuse_visible'|'restore_hidden'|'create_fresh' +---@field get_window_cursor fun(win_id: integer|nil): integer[]|nil +---@field session OpencodeSessionStateMutations +---@field jobs OpencodeJobStateMutations +---@field ui OpencodeUiStateMutations +---@field model OpencodeModelStateMutations + +local store = require('opencode.state.store') +local session = require('opencode.state.session') +local jobs = require('opencode.state.jobs') +local ui = require('opencode.state.ui') +local model = require('opencode.state.model') +local test_helpers = require('opencode.state.test_helpers') + +local M = { + store = store, + session = session, + jobs = jobs, + ui = ui, + model = model, + test_helpers = test_helpers, + is_window_in_current_tab = ui.is_window_in_current_tab, + are_windows_in_current_tab = ui.are_windows_in_current_tab, + is_visible = ui.is_visible, + get_window_state = ui.get_window_state, + get_window_cursor = ui.get_window_cursor, + set_cursor_position = ui.set_cursor_position, + get_cursor_position = ui.get_cursor_position, + stash_hidden_buffers = ui.stash_hidden_buffers, + inspect_hidden_buffers = ui.inspect_hidden_buffers, + is_hidden_snapshot_in_current_tab = ui.is_hidden_snapshot_in_current_tab, + clear_hidden_window_state = ui.clear_hidden_window_state, + has_hidden_buffers = ui.has_hidden_buffers, + consume_hidden_buffers = ui.consume_hidden_buffers, + resolve_toggle_decision = ui.resolve_toggle_decision, + resolve_open_windows_action = ui.resolve_open_windows_action, + subscribe = store.subscribe, + unsubscribe = store.unsubscribe, + notify = store.notify, + append = store.append, + remove = store.remove, + set_raw = store.set_raw, + allow_raw_writes_for_tests = test_helpers.allow_raw_writes_for_tests, + silence_protected_writes = test_helpers.silence_protected_writes, +} + +function M.is_running() + return M.job_count > 0 +end + +return setmetatable(M, { + __index = function(_, key) + return store.get(key) + end, + __newindex = function(_, key, value) + store.set(key, value, { source = 'raw' }) + end, + __pairs = function() + return pairs(store.state()) + end, + __ipairs = function() + return ipairs(store.state()) + end, +}) --[[@as OpencodeState]] diff --git a/lua/opencode/state/jobs.lua b/lua/opencode/state/jobs.lua new file mode 100644 index 00000000..1b1f18e2 --- /dev/null +++ b/lua/opencode/state/jobs.lua @@ -0,0 +1,38 @@ +local store = require('opencode.state.store') + +local M = {} + +---@param delta integer|nil +---@param opts? OpencodeProtectedStateSetOptions +function M.increment_count(delta, opts) + return store.update('job_count', function(current) + return (current or 0) + (delta or 1) + end, opts) +end + +---@param delta integer|nil +---@param opts? OpencodeProtectedStateSetOptions +function M.decrement_count(delta, opts) + return store.update('job_count', function(current) + return math.max(0, (current or 0) - (delta or 1)) + end, opts) +end + +---@param count integer +---@param opts? OpencodeProtectedStateSetOptions +function M.set_count(count, opts) + return store.set('job_count', count, opts) +end + +---@param server OpencodeServer|nil +---@param opts? OpencodeProtectedStateSetOptions +function M.set_server(server, opts) + return store.set('opencode_server', server, opts) +end + +---@param opts? OpencodeProtectedStateSetOptions +function M.clear_server(opts) + return store.set('opencode_server', nil, opts) +end + +return M diff --git a/lua/opencode/state/model.lua b/lua/opencode/state/model.lua new file mode 100644 index 00000000..52ca63f4 --- /dev/null +++ b/lua/opencode/state/model.lua @@ -0,0 +1,51 @@ +local store = require('opencode.state.store') + +local M = {} + +---@param mode string|nil +function M.set_mode(mode) + return store.set('current_mode', mode) +end + +function M.clear_mode() + return store.set('current_mode', nil) +end + +---@param model string|nil +function M.set_model(model) + return store.set('current_model', model) +end + +function M.clear_model() + return store.set('current_model', nil) +end + +---@param info table|nil +function M.set_model_info(info) + return store.set('current_model_info', info) +end + +---@param variant string|nil +function M.set_variant(variant) + return store.set('current_variant', variant) +end + +function M.clear_variant() + return store.set('current_variant', nil) +end + +---@param mode_map table +function M.set_mode_model_map(mode_map) + return store.set('user_mode_model_map', mode_map) +end + +---@param mode string +---@param model string +function M.set_mode_model_override(mode, model) + local state = store.state() + local mode_map = vim.deepcopy(state.user_mode_model_map) + mode_map[mode] = model + return store.set('user_mode_model_map', mode_map) +end + +return M diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua new file mode 100644 index 00000000..bb64bc92 --- /dev/null +++ b/lua/opencode/state/session.lua @@ -0,0 +1,27 @@ +local store = require('opencode.state.store') + +local M = {} + +---@param session Session|nil +---@param opts? OpencodeProtectedStateSetOptions +function M.set_active(session, opts) + return store.set('active_session', session, opts) +end + +---@param opts? OpencodeProtectedStateSetOptions +function M.clear_active(opts) + return store.set('active_session', nil, opts) +end + +---@param points RestorePoint[] +---@param opts? OpencodeProtectedStateSetOptions +function M.set_restore_points(points, opts) + return store.set('restore_points', points, opts) +end + +---@param opts? OpencodeProtectedStateSetOptions +function M.reset_restore_points(opts) + return store.set('restore_points', {}, opts) +end + +return M diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua new file mode 100644 index 00000000..5c9a65a8 --- /dev/null +++ b/lua/opencode/state/store.lua @@ -0,0 +1,256 @@ +---@class OpencodeProtectedStateSetOptions +---@field source? 'helper'|'raw' +---@field silent? boolean + +local M = {} + +local _state = { + windows = nil, + is_opening = false, + input_content = {}, + is_opencode_focused = false, + last_focused_opencode_window = nil, + last_input_window_position = nil, + last_output_window_position = nil, + last_code_win_before_opencode = nil, + current_code_buf = nil, + saved_window_options = nil, + display_route = nil, + current_mode = nil, + last_output = 0, + pre_zoom_width = nil, + last_window_width_ratio = nil, + last_sent_context = nil, + current_context_config = {}, + context_updated_at = nil, + active_session = nil, + restore_points = {}, + current_model = nil, + user_mode_model_map = {}, + current_model_info = nil, + current_variant = nil, + messages = nil, + current_message = nil, + last_user_message = nil, + pending_permissions = {}, + cost = 0, + tokens_count = 0, + job_count = 0, + user_message_count = {}, + opencode_server = nil, + api_client = nil, + event_manager = nil, + required_version = '0.6.3', + opencode_cli_version = nil, + current_cwd = vim.fn.getcwd(), + _hidden_buffers = nil, +} + +local _listeners = {} + +local PROTECTED_KEYS = { + active_session = true, + restore_points = true, + job_count = true, + opencode_server = true, + windows = true, + is_opening = true, + is_opencode_focused = true, + last_focused_opencode_window = true, + last_code_win_before_opencode = true, + current_code_buf = true, + display_route = true, + last_window_width_ratio = true, + current_mode = true, + current_model = true, + current_model_info = true, + current_variant = true, + user_mode_model_map = true, +} + +local _protected_write_warnings = {} +local _silence_protected_writes = false + +---@param key string +---@param opts? OpencodeProtectedStateSetOptions +local function warn_on_protected_raw_write(key, opts) + if not PROTECTED_KEYS[key] or _protected_write_warnings[key] then + return + end + + if _silence_protected_writes or (opts and opts.silent) then + return + end + + _protected_write_warnings[key] = true + vim.schedule(function() + vim.notify( + string.format('Direct write to protected state key `%s`; prefer state domain helpers', key), + vim.log.levels.WARN + ) + end) +end + +function M.state() + return _state +end + +---@param key string +---@return any +function M.get(key) + return _state[key] +end + +---@param key string +---@param value any +---@param opts? OpencodeProtectedStateSetOptions +---@return any +function M.set(key, value, opts) + local old = _state[key] + opts = opts or { source = 'helper' } + + if opts.source == 'raw' then + warn_on_protected_raw_write(key, opts) + end + + _state[key] = value + if not vim.deep_equal(old, value) then + M.notify(key, value, old) + end + + return value +end + +---@param key string +---@param value any +---@param opts? OpencodeProtectedStateSetOptions +---@return any +function M.set_raw(key, value, opts) + local next_opts = vim.tbl_extend('force', { source = 'raw' }, opts or {}) + return M.set(key, value, next_opts) +end + +---@generic T +---@param key string +---@param updater fun(current: T): T +---@param opts? OpencodeProtectedStateSetOptions +---@return T +function M.update(key, updater, opts) + local next_value = updater(_state[key]) + M.set(key, next_value, opts) + return next_value +end + +---@param key string|string[]|nil +---@param cb fun(key:string, new_val:any, old_val:any) +function M.subscribe(key, cb) + if type(key) == 'table' then + for _, current_key in ipairs(key) do + M.subscribe(current_key, cb) + end + return + end + + key = key or '*' + if not _listeners[key] then + _listeners[key] = {} + end + + for _, fn in ipairs(_listeners[key]) do + if fn == cb then + return + end + end + + table.insert(_listeners[key], cb) +end + +---@param key string|nil +---@param cb fun(key:string, new_val:any, old_val:any) +function M.unsubscribe(key, cb) + key = key or '*' + local list = _listeners[key] + if not list then + return + end + + for i = #list, 1, -1 do + if list[i] == cb then + table.remove(list, i) + end + end +end + +function M.notify(key, new_val, old_val) + vim.schedule(function() + if _listeners[key] then + for _, cb in ipairs(_listeners[key]) do + local ok, err = pcall(cb, key, new_val, old_val) + if not ok then + vim.notify(err --[[@as string]]) + end + end + end + + if _listeners['*'] then + for _, cb in ipairs(_listeners['*']) do + pcall(cb, key, new_val, old_val) + end + end + end) +end + +---@param key string +---@param value any +function M.append(key, value) + if type(value) ~= 'table' then + error('Value must be a table to append') + end + if not _state[key] then + _state[key] = {} + end + if type(_state[key]) ~= 'table' then + error('State key is not a table: ' .. key) + end + + local old = vim.deepcopy(_state[key] --[[@as table]]) + table.insert(_state[key] --[[@as table]], value) + M.notify(key, _state[key], old) +end + +---@param key string +---@param idx integer +function M.remove(key, idx) + if not _state[key] then + return + end + if type(_state[key]) ~= 'table' then + error('State key is not a table: ' .. key) + end + + local old = vim.deepcopy(_state[key] --[[@as table]]) + table.remove(_state[key] --[[@as table]], idx) + M.notify(key, _state[key], old) +end + +---@param enabled boolean +function M.set_protected_writes_silenced(enabled) + _silence_protected_writes = enabled == true +end + +---@return boolean +function M.are_protected_writes_silenced() + return _silence_protected_writes +end + +function M.reset_protected_write_warnings() + _protected_write_warnings = {} +end + +---@param key string +---@return boolean +function M.is_protected_key(key) + return PROTECTED_KEYS[key] == true +end + +return M diff --git a/lua/opencode/state/test_helpers.lua b/lua/opencode/state/test_helpers.lua new file mode 100644 index 00000000..b54662c9 --- /dev/null +++ b/lua/opencode/state/test_helpers.lua @@ -0,0 +1,26 @@ +local store = require('opencode.state.store') + +local M = {} + +local function disable_warnings() + store.set_protected_writes_silenced(true) +end + +local function enable_warnings() + store.set_protected_writes_silenced(false) +end + +function M.allow_raw_writes_for_tests() + disable_warnings() + return enable_warnings +end + +function M.silence_protected_writes() + return M.allow_raw_writes_for_tests() +end + +function M.restore_protected_write_warnings() + enable_warnings() +end + +return M diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua new file mode 100644 index 00000000..ede930f9 --- /dev/null +++ b/lua/opencode/state/ui.lua @@ -0,0 +1,401 @@ +local store = require('opencode.state.store') + +local M = {} + +local _state = store.state() + +---@param windows OpencodeWindowState|nil +function M.set_windows(windows) + return store.set('windows', windows) +end + +function M.clear_windows() + return store.set('windows', nil) +end + +---@param is_opening boolean +function M.set_opening(is_opening) + return store.set('is_opening', is_opening) +end + +---@param is_focused boolean +function M.set_panel_focused(is_focused) + return store.set('is_opencode_focused', is_focused) +end + +---@param win_type 'input'|'output'|nil +function M.set_last_focused_window(win_type) + return store.set('last_focused_opencode_window', win_type) +end + +---@param route any +function M.set_display_route(route) + return store.set('display_route', route) +end + +function M.clear_display_route() + return store.set('display_route', nil) +end + +---@param win_id integer|nil +function M.set_last_code_window(win_id) + return store.set('last_code_win_before_opencode', win_id) +end + +---@param bufnr integer|nil +function M.set_current_code_buf(bufnr) + return store.set('current_code_buf', bufnr) +end + +---@param ratio number|nil +function M.set_last_window_width_ratio(ratio) + return store.set('last_window_width_ratio', ratio) +end + +function M.clear_last_window_width_ratio() + return store.set('last_window_width_ratio', nil) +end + +---@param win_id integer|nil +---@return boolean +function M.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 current_tab = vim.api.nvim_get_current_tabpage() + local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, win_id) + return ok and win_tab == current_tab +end + +---@return boolean +function M.are_windows_in_current_tab() + if not _state.windows then + return false + end + + return M.is_window_in_current_tab(_state.windows.input_win) or M.is_window_in_current_tab(_state.windows.output_win) +end + +---@generic T +---@param rules T[] +---@param match fun(rule: T): boolean +---@return T|nil +local function first_matching_rule(rules, match) + for _, rule in ipairs(rules) do + if match(rule) then + return rule + end + end + + return nil +end + +local TOGGLE_ACTION_RULES = { + { + action = 'restore_hidden', + when = function(ctx) + return ctx.status == 'hidden' and ctx.persist_state + end, + }, + { + action = 'close_hidden', + when = function(ctx) + return ctx.status == 'hidden' and not ctx.persist_state + end, + }, + { + action = 'migrate', + when = function(ctx) + return ctx.status == 'visible' and not ctx.in_tab + end, + }, + { + action = 'close', + when = function(ctx) + return ctx.status == 'visible' and ctx.in_tab and ctx.has_display_route + end, + }, + { + action = 'close', + when = function(ctx) + return ctx.status == 'visible' and ctx.in_tab and not ctx.persist_state + end, + }, + { + action = 'hide', + when = function(ctx) + return ctx.status == 'visible' and ctx.in_tab and ctx.persist_state and not ctx.has_display_route + end, + }, + { + action = 'open', + when = function(ctx) + return ctx.status == 'closed' + end, + }, +} + +---@param status 'closed'|'hidden'|'visible' +---@param in_tab boolean +---@param persist_state boolean +---@param has_display_route boolean +---@return 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' +local function lookup_toggle_action(status, in_tab, persist_state, has_display_route) + local ctx = { + status = status, + in_tab = in_tab, + persist_state = persist_state, + has_display_route = has_display_route, + } + + local matched_rule = first_matching_rule(TOGGLE_ACTION_RULES, function(rule) + return rule.when(ctx) + end) + + return matched_rule and matched_rule.action or 'open' +end + +---@param persist_state boolean +---@param has_display_route boolean +---@return OpencodeToggleDecision +function M.resolve_toggle_decision(persist_state, has_display_route) + local status = M.get_window_state().status + local in_tab = M.are_windows_in_current_tab() + local action = lookup_toggle_action(status, in_tab, persist_state, has_display_route) + return { action = action } +end + +---@return 'reuse_visible'|'restore_hidden'|'create_fresh' +function M.resolve_open_windows_action() + local status = M.get_window_state().status + if status == 'visible' then + return M.are_windows_in_current_tab() and 'reuse_visible' or 'create_fresh' + end + if status == 'hidden' then + return 'restore_hidden' + end + return 'create_fresh' +end + +---@param pos any +---@return integer[]|nil +local function normalize_cursor(pos) + if type(pos) ~= 'table' or #pos < 2 then + return nil + end + + local line = tonumber(pos[1]) + local col = tonumber(pos[2]) + if not line or not col then + return nil + end + + return { math.max(1, math.floor(line)), math.max(0, math.floor(col)) } +end + +---@param win_id integer|nil +---@return integer[]|nil +function M.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 not ok then + return nil + end + + return normalize_cursor(pos) +end + +---@param win_type 'input'|'output' +---@param pos integer[]|nil +function M.set_cursor_position(win_type, pos) + local normalized = normalize_cursor(pos) + if win_type == 'input' then + _state.last_input_window_position = normalized + elseif win_type == 'output' then + _state.last_output_window_position = normalized + end +end + +---@param win_type 'input'|'output' +---@return integer[]|nil +function M.get_cursor_position(win_type) + if win_type == 'input' then + return normalize_cursor(_state.last_input_window_position) + end + if win_type == 'output' then + return normalize_cursor(_state.last_output_window_position) + end + return nil +end + +---@param hidden OpencodeHiddenBuffers|nil +---@return OpencodeHiddenBuffers|nil +local function normalize_hidden_buffers(hidden) + if type(hidden) ~= 'table' then + return nil + end + + local function valid_buf(buf) + return type(buf) == 'number' and vim.api.nvim_buf_is_valid(buf) + end + + if not valid_buf(hidden.input_buf) or not valid_buf(hidden.output_buf) then + return nil + end + if type(hidden.input_hidden) ~= 'boolean' then + return nil + end + + local focused_window = hidden.focused_window + return { + input_buf = hidden.input_buf, + output_buf = hidden.output_buf, + footer_buf = valid_buf(hidden.footer_buf) and hidden.footer_buf or nil, + output_was_at_bottom = hidden.output_was_at_bottom == true, + input_hidden = hidden.input_hidden, + input_cursor = normalize_cursor(hidden.input_cursor), + output_cursor = normalize_cursor(hidden.output_cursor), + output_view = type(hidden.output_view) == 'table' and vim.deepcopy(hidden.output_view) or nil, + focused_window = (focused_window == 'input' or focused_window == 'output') and focused_window or nil, + position = hidden.position, + owner_tab = type(hidden.owner_tab) == 'number' and hidden.owner_tab or nil, + } +end + +---@param copy boolean +---@return OpencodeHiddenBuffers|nil +local function read_hidden_buffers_snapshot(copy) + local normalized = normalize_hidden_buffers(_state._hidden_buffers) + if not normalized then + return nil + end + + if not copy then + return normalized + end + + return vim.deepcopy(normalized) +end + +---@return boolean +function M.is_hidden_snapshot_in_current_tab() + local hidden = read_hidden_buffers_snapshot(false) + if not hidden then + return false + end + + if type(hidden.owner_tab) ~= 'number' then + return true + end + + return hidden.owner_tab == vim.api.nvim_get_current_tabpage() +end + +---@param hidden OpencodeHiddenBuffers|nil +function M.stash_hidden_buffers(hidden) + if hidden == nil then + _state._hidden_buffers = nil + return + end + + _state._hidden_buffers = normalize_hidden_buffers(hidden) +end + +---@return OpencodeHiddenBuffers|nil +function M.inspect_hidden_buffers() + return read_hidden_buffers_snapshot(true) +end + +function M.clear_hidden_window_state() + _state._hidden_buffers = nil + if _state.windows and not _state.windows.input_win and not _state.windows.output_win then + _state.windows = nil + end +end + +---@return boolean +function M.has_hidden_buffers() + return read_hidden_buffers_snapshot(false) ~= nil +end + +---@return OpencodeHiddenBuffers|nil +function M.consume_hidden_buffers() + local hidden = M.inspect_hidden_buffers() + _state._hidden_buffers = nil + return hidden +end + +---@return boolean +local function is_visible_in_tab() + local windows = _state.windows + if not windows then + return false + end + local input_valid = windows.input_win and vim.api.nvim_win_is_valid(windows.input_win) + local output_valid = windows.output_win and vim.api.nvim_win_is_valid(windows.output_win) + return ((input_valid or output_valid) and M.are_windows_in_current_tab()) == true +end + +local STATUS_DETECTION = { + { + name = 'hidden_snapshot', + test = function() + return M.has_hidden_buffers() and M.is_hidden_snapshot_in_current_tab() + end, + status = 'hidden', + get_windows = function() + return nil + end, + }, + { + name = 'visible_in_tab', + test = is_visible_in_tab, + status = 'visible', + get_windows = function() + return _state.windows + end, + }, + { + name = 'closed', + test = function() + return true + end, + status = 'closed', + get_windows = function() + return nil + end, + }, +} + +---@return boolean +function M.is_visible() + return M.get_window_state().status == 'visible' +end + +---@return {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} +function M.get_window_state() + local config = require('opencode.config') + + local status_rule = first_matching_rule(STATUS_DETECTION, function(rule) + return rule.test() + end) + + local status = status_rule and status_rule.status or 'closed' + local current_windows = status_rule and status_rule.get_windows() or nil + + return { + status = status, + position = config.ui.position, + windows = current_windows and vim.deepcopy(current_windows) or nil, + cursor_positions = { + input = M.get_window_cursor(current_windows and current_windows.input_win) or M.get_cursor_position('input'), + output = M.get_window_cursor(current_windows and current_windows.output_win) or M.get_cursor_position('output'), + }, + } +end + +return M diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 008e98d3..2c3812a5 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -522,9 +522,9 @@ function M.setup_autocmds(windows, group) group = group, buffer = windows.input_buf, callback = function() - local pos = state.get_window_cursor(windows.input_win) + local pos = state.ui.get_window_cursor(windows.input_win) if pos then - state.set_cursor_position('input', pos) + state.ui.set_cursor_position('input', pos) end end, }) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5106628c..14dd46f0 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -284,9 +284,9 @@ function M.setup_autocmds(windows, group) group = group, buffer = windows.output_buf, callback = function() - local pos = state.get_window_cursor(windows.output_win) + local pos = state.ui.get_window_cursor(windows.output_win) if pos then - state.set_cursor_position('output', pos) + state.ui.set_cursor_position('output', pos) end end, }) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index ac42698b..40a0e4e7 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -139,10 +139,10 @@ function M.hide_visible_windows(windows) if config.ui.position ~= 'current' then local total_cols = vim.o.columns local current_width = vim.api.nvim_win_get_width(windows.output_win) - state.last_window_width_ratio = current_width / total_cols + state.ui.set_last_window_width_ratio(current_width / total_cols) end - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() prepare_window_close() footer.close(true) @@ -186,7 +186,7 @@ function M.teardown_visible_windows(windows) if state.windows == windows then state.ui.clear_windows() end - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() end function M.drop_hidden_snapshot() @@ -202,7 +202,7 @@ function M.drop_hidden_snapshot() end input_window._hidden = false - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() end ---Restore windows using preserved buffers @@ -221,7 +221,7 @@ function M.restore_hidden_windows() local win_ids = M.create_split_windows(hidden.input_buf, hidden.output_buf) - state.consume_hidden_buffers() + state.ui.consume_hidden_buffers() local windows = state.windows if not windows then @@ -237,8 +237,8 @@ function M.restore_hidden_windows() windows.output_was_at_bottom = hidden.output_was_at_bottom == true windows.saved_width_ratio = state.last_window_width_ratio - state.set_cursor_position('input', hidden.input_cursor) - state.set_cursor_position('output', hidden.output_cursor) + state.ui.set_cursor_position('input', hidden.input_cursor) + state.ui.set_cursor_position('output', hidden.output_cursor) input_window.setup(windows) output_window.setup(windows) @@ -284,7 +284,7 @@ end ---Check if we have valid hidden buffers ---@return boolean function M.has_hidden_buffers() - return state.has_hidden_buffers() + return state.ui.has_hidden_buffers() end function M.return_to_last_code_win() diff --git a/tests/minimal/init.lua b/tests/minimal/init.lua index 440f6669..908ea43b 100644 --- a/tests/minimal/init.lua +++ b/tests/minimal/init.lua @@ -33,3 +33,8 @@ _G.test_plugin_root = plugin_root vim.opt.termguicolors = true require('opencode') +require('opencode.state.test_helpers').allow_raw_writes_for_tests() + +if vim.treesitter and vim.treesitter.start then + vim.treesitter.start = function() end +end diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index d52c1f92..ac5db987 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -39,12 +39,24 @@ local stub = require('luassert.stub') local function mock_api_client() return { - create_message = function() return Promise.new():resolve({}) end, - get_config = function() return Promise.new():resolve({}) end, - list_sessions = function() return Promise.new():resolve({}) end, - get_session = function() return Promise.new():resolve({}) end, - create_session = function() return Promise.new():resolve({}) end, - list_messages = function() return Promise.new():resolve({}) end, + create_message = function() + return Promise.new():resolve({}) + end, + get_config = function() + return Promise.new():resolve({}) + end, + list_sessions = function() + return Promise.new():resolve({}) + end, + get_session = function() + return Promise.new():resolve({}) + end, + create_session = function() + return Promise.new():resolve({}) + end, + list_messages = function() + return Promise.new():resolve({}) + end, } end @@ -131,7 +143,7 @@ describe('persist_state', function() end end - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() end local function cleanup_windows() @@ -169,7 +181,7 @@ describe('persist_state', function() state.api_client = mock_api_client() state.event_manager = EventManager.new() state.windows = nil - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() state.current_code_view = nil state.current_code_buf = nil state.last_code_win_before_opencode = nil @@ -181,11 +193,19 @@ describe('persist_state', function() original_opencode_server_new = opencode_server.new local mock_server = { url = 'http://127.0.0.1:4000', - is_running = function() return true end, + is_running = function() + return true + end, spawn = function() end, - shutdown = function() return Promise.new():resolve(true) end, - get_spawn_promise = function() return Promise.new():resolve(mock_server) end, - get_shutdown_promise = function() return Promise.new():resolve(true) end, + shutdown = function() + return Promise.new():resolve(true) + end, + get_spawn_promise = function() + return Promise.new():resolve(mock_server) + end, + get_shutdown_promise = function() + return Promise.new():resolve(true) + end, } opencode_server.new = function() return mock_server @@ -222,7 +242,7 @@ describe('persist_state', function() state.current_code_view = nil state.current_code_buf = nil state.last_code_win_before_opencode = nil - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() -- Restore mocked opencode_server if original_opencode_server_new then @@ -510,7 +530,12 @@ describe('persist_state', function() write_lines(state.windows.output_buf, output_lines) vim.api.nvim_set_current_win(state.windows.output_win) vim.api.nvim_win_set_cursor(state.windows.output_win, { 40, 0 }) - return { expected_win_fn = function() return state.windows.output_win end, expected_cursor = { 40, 0 } } + return { + expected_win_fn = function() + return state.windows.output_win + end, + expected_cursor = { 40, 0 }, + } end, assert_after = function(ctx) assert.equals(ctx.expected_win_fn(), vim.api.nvim_get_current_win()) @@ -529,7 +554,12 @@ describe('persist_state', function() vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { 'i1', 'i2', 'i3' }) vim.api.nvim_set_current_win(state.windows.input_win) vim.api.nvim_win_set_cursor(state.windows.input_win, { 2, 1 }) - return { expected_win_fn = function() return state.windows.input_win end, expected_cursor = { 2, 1 } } + return { + expected_win_fn = function() + return state.windows.input_win + end, + expected_cursor = { 2, 1 }, + } end, assert_after = function(ctx) assert.equals(ctx.expected_win_fn(), vim.api.nvim_get_current_win()) diff --git a/tests/unit/zoom_spec.lua b/tests/unit/zoom_spec.lua index 7282d760..b8d218b7 100644 --- a/tests/unit/zoom_spec.lua +++ b/tests/unit/zoom_spec.lua @@ -281,7 +281,7 @@ describe('ui zoom state', function() it('does not save width in dialog mode (position=current)', function() local original_position = config.ui.position config.ui.position = 'current' - state.last_window_width_ratio = nil + state.ui.clear_last_window_width_ratio() ui.hide_visible_windows(windows) assert.is_nil(state.last_window_width_ratio) From 62f8dd66299fa06d03f4c4a8d418de4af61ed816 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 13 Mar 2026 08:29:14 -0400 Subject: [PATCH 03/13] refactor(state): move UI/window APIs into state.ui Move window state, cursor, visibility, and hidden-buffer helpers into a dedicated state.ui table. Update callers across modules and tests to reference state.ui.*, improving separation between core store logic and UI utilities. --- lua/opencode/api.lua | 10 ++--- lua/opencode/core.lua | 18 ++++----- lua/opencode/state/init.lua | 30 -------------- lua/opencode/ui/input_window.lua | 4 +- lua/opencode/ui/output_window.lua | 4 +- lua/opencode/ui/ui.lua | 16 ++++---- tests/unit/cursor_tracking_spec.lua | 62 ++++++++++++++--------------- tests/unit/persist_state_spec.lua | 6 +-- 8 files changed, 60 insertions(+), 90 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 360763fe..13a04ee3 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -61,7 +61,7 @@ end ---@return {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} function M.get_window_state() - return state.get_window_state() + return state.ui.get_window_state() end ---@param hidden OpencodeHiddenBuffers|nil @@ -82,7 +82,7 @@ end ---@return {focus: 'input'|'output', open_action: 'reuse_visible'|'restore_hidden'|'create_fresh'} local function build_toggle_open_context(restore_hidden) if restore_hidden then - local hidden = state.inspect_hidden_buffers() + local hidden = state.ui.inspect_hidden_buffers() return { focus = resolve_hidden_focus(hidden), open_action = 'restore_hidden', @@ -329,7 +329,7 @@ function M.set_review_breakpoint() end function M.prev_history() - if not state.is_visible() then + if not state.ui.is_visible() then return end local prev_prompt = history.prev() @@ -340,7 +340,7 @@ function M.prev_history() end function M.next_history() - if not state.is_visible() then + if not state.ui.is_visible() then return end local next_prompt = history.next() @@ -575,7 +575,7 @@ function M.help() '|--------------|-------------|', }, false) - if not state.is_visible() or not state.windows.output_win then + if not state.ui.is_visible() or not state.windows.output_win then return end diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index dcf15bc2..4f054dc9 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -25,7 +25,7 @@ M.select_session = Promise.async(function(parent_id) ui.select_session(filtered_sessions, function(selected_session) if not selected_session then - if state.is_visible() then + if state.ui.is_visible() then ui.focus_input() end return @@ -43,7 +43,7 @@ M.switch_session = Promise.async(function(session_id) state.session.set_active(selected_session) state.session.reset_restore_points() - if state.is_visible() then + if state.ui.is_visible() then ui.focus_input() else M.open() @@ -52,7 +52,7 @@ end) ---@param opts? OpenOpts M.open_if_closed = Promise.async(function(opts) - if not state.is_visible() then + if not state.ui.is_visible() then M.open(opts):await() end end) @@ -88,7 +88,7 @@ M.open = Promise.async(function(opts) require('opencode.context').load() end - local open_windows_action = opts.open_action or state.resolve_open_windows_action() + local open_windows_action = opts.open_action or state.ui.resolve_open_windows_action() local are_windows_closed = open_windows_action ~= 'reuse_visible' local restoring_hidden = open_windows_action == 'restore_hidden' @@ -286,7 +286,7 @@ end function M.configure_provider() require('opencode.model_picker').select(function(selection) if not selection then - if state.is_visible() then + if state.ui.is_visible() then ui.focus_input() end return @@ -298,7 +298,7 @@ function M.configure_provider() state.model.set_mode_model_override(state.current_mode, model_str) end - if state.is_visible() then + if state.ui.is_visible() then ui.focus_input() else vim.notify('Changed provider to ' .. model_str, vim.log.levels.INFO) @@ -309,7 +309,7 @@ end function M.configure_variant() require('opencode.variant_picker').select(function(selection) if not selection then - if state.is_visible() then + if state.ui.is_visible() then ui.focus_input() end return @@ -317,7 +317,7 @@ function M.configure_variant() state.model.set_variant(selection.name) - if state.is_visible() then + if state.ui.is_visible() then ui.focus_input() else vim.notify('Changed variant to ' .. selection.name, vim.log.levels.INFO) @@ -417,7 +417,7 @@ M.cancel = Promise.async(function() end end - if state.is_visible() then + if state.ui.is_visible() then require('opencode.ui.footer').clear() input_window.set_content('') require('opencode.history').index = nil diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index 69e8688b..4c832ee1 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -105,21 +105,6 @@ ---@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 get_window_state fun(): {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}} ----@field is_window_in_current_tab fun(win_id: integer|nil): boolean ----@field are_windows_in_current_tab fun(): boolean ----@field get_window_cursor fun(win_id: integer|nil): integer[]|nil ----@field set_cursor_position fun(win_type: 'input'|'output', pos: integer[]|nil) ----@field get_cursor_position fun(win_type: 'input'|'output'): integer[]|nil ----@field stash_hidden_buffers fun(hidden: OpencodeHiddenBuffers|nil) ----@field inspect_hidden_buffers fun(): OpencodeHiddenBuffers|nil ----@field is_hidden_snapshot_in_current_tab fun(): boolean ----@field clear_hidden_window_state fun() ----@field has_hidden_buffers fun(): boolean ----@field consume_hidden_buffers fun(): OpencodeHiddenBuffers|nil ----@field resolve_toggle_decision fun(persist_state: boolean, has_display_route: boolean): OpencodeToggleDecision ----@field resolve_open_windows_action fun(): 'reuse_visible'|'restore_hidden'|'create_fresh' ----@field get_window_cursor fun(win_id: integer|nil): integer[]|nil ---@field session OpencodeSessionStateMutations ---@field jobs OpencodeJobStateMutations ---@field ui OpencodeUiStateMutations @@ -139,21 +124,6 @@ local M = { ui = ui, model = model, test_helpers = test_helpers, - is_window_in_current_tab = ui.is_window_in_current_tab, - are_windows_in_current_tab = ui.are_windows_in_current_tab, - is_visible = ui.is_visible, - get_window_state = ui.get_window_state, - get_window_cursor = ui.get_window_cursor, - set_cursor_position = ui.set_cursor_position, - get_cursor_position = ui.get_cursor_position, - stash_hidden_buffers = ui.stash_hidden_buffers, - inspect_hidden_buffers = ui.inspect_hidden_buffers, - is_hidden_snapshot_in_current_tab = ui.is_hidden_snapshot_in_current_tab, - clear_hidden_window_state = ui.clear_hidden_window_state, - has_hidden_buffers = ui.has_hidden_buffers, - consume_hidden_buffers = ui.consume_hidden_buffers, - resolve_toggle_decision = ui.resolve_toggle_decision, - resolve_open_windows_action = ui.resolve_open_windows_action, subscribe = store.subscribe, unsubscribe = store.unsubscribe, notify = store.notify, diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 2c3812a5..8cae5325 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -559,9 +559,9 @@ function M._hide() M._hidden = true M._toggling = true - local pos = state.get_window_cursor(windows.input_win) + local pos = state.ui.get_window_cursor(windows.input_win) if pos then - state.set_cursor_position('input', pos) + state.ui.set_cursor_position('input', pos) end pcall(vim.api.nvim_win_close, windows.input_win, false) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 14dd46f0..c760f134 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -318,9 +318,9 @@ function M.setup_autocmds(windows, group) if visible_bottom < line_count then pcall(vim.api.nvim_win_set_cursor, windows.output_win, { visible_bottom, 0 }) - local pos = state.get_window_cursor(windows.output_win) + local pos = state.ui.get_window_cursor(windows.output_win) if pos then - state.set_cursor_position('output', pos) + state.ui.set_cursor_position('output', pos) end end end diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 40a0e4e7..18fadd4c 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -13,8 +13,8 @@ local M = {} ---@return {input: integer[]|nil, output: integer[]|nil} local function capture_cursors_position(windows) return { - input = state.get_window_cursor(windows.input_win), - output = state.get_window_cursor(windows.output_win), + input = state.ui.get_window_cursor(windows.input_win), + output = state.ui.get_window_cursor(windows.output_win), } end @@ -76,7 +76,7 @@ local function capture_hidden_snapshot(windows) output_view = ok and type(view) == 'table' and view or nil, focused_window = focused, position = config.ui.position, - owner_tab = state.are_windows_in_current_tab() and vim.api.nvim_get_current_tabpage() or nil, + owner_tab = state.ui.are_windows_in_current_tab() and vim.api.nvim_get_current_tabpage() or nil, } end @@ -160,7 +160,7 @@ function M.hide_visible_windows(windows) state.input_content = lines end end - state.stash_hidden_buffers(snapshot) + state.ui.stash_hidden_buffers(snapshot) if state.windows == windows then state.windows.input_win = nil state.windows.output_win = nil @@ -192,7 +192,7 @@ end function M.drop_hidden_snapshot() renderer.teardown() - local hidden = state.inspect_hidden_buffers() + local hidden = state.ui.inspect_hidden_buffers() if hidden then for _, buf in ipairs({ hidden.input_buf, hidden.output_buf, hidden.footer_buf }) do if buf and vim.api.nvim_buf_is_valid(buf) then @@ -208,7 +208,7 @@ end ---Restore windows using preserved buffers ---@return boolean success function M.restore_hidden_windows() - local hidden = state.inspect_hidden_buffers() + local hidden = state.ui.inspect_hidden_buffers() if not hidden then return false end @@ -263,7 +263,7 @@ function M.restore_hidden_windows() if hidden.output_was_at_bottom then renderer.scroll_to_bottom(true) else - restore_window_cursor(w.output_win, w.output_buf, state.get_cursor_position('output')) + restore_window_cursor(w.output_win, w.output_buf, state.ui.get_cursor_position('output')) if type(hidden.output_view) == 'table' then pcall(vim.api.nvim_win_call, w.output_win, function() vim.fn.winrestview(hidden.output_view) @@ -272,7 +272,7 @@ function M.restore_hidden_windows() end if not hidden.input_hidden then - restore_window_cursor(w.input_win, w.input_buf, state.get_cursor_position('input')) + restore_window_cursor(w.input_win, w.input_buf, state.ui.get_cursor_position('input')) end end) diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 4a83e775..493138f1 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -4,8 +4,8 @@ local ui = require('opencode.ui.ui') describe('cursor persistence (state)', function() before_each(function() - state.set_cursor_position('input', nil) - state.set_cursor_position('output', nil) + state.ui.set_cursor_position('input', nil) + state.ui.set_cursor_position('output', nil) end) describe('renderer.scroll_to_bottom', function() @@ -93,73 +93,73 @@ describe('cursor persistence (state)', function() describe('set/get round-trip', function() it('stores and retrieves input cursor', function() - state.set_cursor_position('input', { 5, 3 }) - assert.same({ 5, 3 }, state.get_cursor_position('input')) + state.ui.set_cursor_position('input', { 5, 3 }) + assert.same({ 5, 3 }, state.ui.get_cursor_position('input')) end) it('stores and retrieves output cursor', function() - state.set_cursor_position('output', { 10, 0 }) - assert.same({ 10, 0 }, state.get_cursor_position('output')) + state.ui.set_cursor_position('output', { 10, 0 }) + assert.same({ 10, 0 }, state.ui.get_cursor_position('output')) end) it('input and output are independent', function() - state.set_cursor_position('input', { 1, 0 }) - state.set_cursor_position('output', { 99, 5 }) - assert.same({ 1, 0 }, state.get_cursor_position('input')) - assert.same({ 99, 5 }, state.get_cursor_position('output')) + state.ui.set_cursor_position('input', { 1, 0 }) + state.ui.set_cursor_position('output', { 99, 5 }) + assert.same({ 1, 0 }, state.ui.get_cursor_position('input')) + assert.same({ 99, 5 }, state.ui.get_cursor_position('output')) end) it('returns nil for unknown win_type', function() - assert.is_nil(state.get_cursor_position('footer')) + assert.is_nil(state.ui.get_cursor_position('footer')) end) end) describe('normalize_cursor edge cases', function() it('clamps negative line to 1', function() - state.set_cursor_position('input', { -5, 3 }) - local pos = state.get_cursor_position('input') + state.ui.set_cursor_position('input', { -5, 3 }) + local pos = state.ui.get_cursor_position('input') assert.equals(1, pos[1]) end) it('clamps negative col to 0', function() - state.set_cursor_position('input', { 1, -1 }) - local pos = state.get_cursor_position('input') + state.ui.set_cursor_position('input', { 1, -1 }) + local pos = state.ui.get_cursor_position('input') assert.equals(0, pos[2]) end) it('floors fractional values', function() - state.set_cursor_position('input', { 3.7, 2.9 }) - local pos = state.get_cursor_position('input') + state.ui.set_cursor_position('input', { 3.7, 2.9 }) + local pos = state.ui.get_cursor_position('input') assert.equals(3, pos[1]) assert.equals(2, pos[2]) end) it('rejects non-table input', function() - state.set_cursor_position('input', 'bad') - assert.is_nil(state.get_cursor_position('input')) + state.ui.set_cursor_position('input', 'bad') + assert.is_nil(state.ui.get_cursor_position('input')) end) it('rejects table with fewer than 2 elements', function() - state.set_cursor_position('input', { 1 }) - assert.is_nil(state.get_cursor_position('input')) + state.ui.set_cursor_position('input', { 1 }) + assert.is_nil(state.ui.get_cursor_position('input')) end) it('rejects non-numeric elements', function() - state.set_cursor_position('input', { 'a', 'b' }) - assert.is_nil(state.get_cursor_position('input')) + state.ui.set_cursor_position('input', { 'a', 'b' }) + assert.is_nil(state.ui.get_cursor_position('input')) end) it('clears position when set to nil', function() - state.set_cursor_position('input', { 5, 3 }) - state.set_cursor_position('input', nil) - assert.is_nil(state.get_cursor_position('input')) + state.ui.set_cursor_position('input', { 5, 3 }) + state.ui.set_cursor_position('input', nil) + assert.is_nil(state.ui.get_cursor_position('input')) end) end) describe('get_window_cursor', function() it('returns nil for invalid window', function() - assert.is_nil(state.get_window_cursor(nil)) - assert.is_nil(state.get_window_cursor(999999)) + assert.is_nil(state.ui.get_window_cursor(nil)) + assert.is_nil(state.ui.get_window_cursor(999999)) end) it('gets cursor from a real window', function() @@ -174,12 +174,12 @@ describe('cursor persistence (state)', function() }) vim.api.nvim_win_set_cursor(win, { 2, 3 }) - local pos = state.get_window_cursor(win) + local pos = state.ui.get_window_cursor(win) assert.same({ 2, 3 }, pos) -- Manually save to verify persistence path - state.set_cursor_position('output', pos) - assert.same({ 2, 3 }, state.get_cursor_position('output')) + state.ui.set_cursor_position('output', pos) + assert.same({ 2, 3 }, state.ui.get_cursor_position('output')) pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true }) diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index ac5db987..982b3d51 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -132,7 +132,7 @@ describe('persist_state', function() end local function cleanup_hidden_buffers() - local hb = state.inspect_hidden_buffers() + local hb = state.ui.inspect_hidden_buffers() if not hb then return end @@ -279,7 +279,7 @@ describe('persist_state', function() assert.is_function(ui.has_hidden_buffers) assert.is_true(ui.has_hidden_buffers()) - local hidden = state.inspect_hidden_buffers() + local hidden = state.ui.inspect_hidden_buffers() assert.is_not_nil(hidden) assert.equals(footer_buf, hidden.footer_buf) assert.is_true(vim.api.nvim_buf_is_valid(input_buf)) @@ -302,7 +302,7 @@ describe('persist_state', function() ui.close_windows(windows, true) assert.is_true(ui.has_hidden_buffers()) - local hidden = state.inspect_hidden_buffers() + local hidden = state.ui.inspect_hidden_buffers() local invalid_buf = hidden and hidden.input_buf if invalid_buf and vim.api.nvim_buf_is_valid(invalid_buf) then vim.api.nvim_buf_delete(invalid_buf, { force = true }) From 951df8ca7c4ab268b570dadbd1ac5c4934a87346 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 13 Mar 2026 09:12:35 -0400 Subject: [PATCH 04/13] refactor(state): centralize protected state mutations into domain setters --- lua/opencode/context.lua | 23 ++--- lua/opencode/context/chat_context.lua | 24 ++--- lua/opencode/core.lua | 16 ++-- lua/opencode/event_manager.lua | 5 +- lua/opencode/image_handler.lua | 2 +- lua/opencode/state/context.lua | 20 ++++ lua/opencode/state/init.lua | 36 +++++-- lua/opencode/state/jobs.lua | 15 +++ lua/opencode/state/renderer.lua | 35 +++++++ lua/opencode/state/session.lua | 10 ++ lua/opencode/state/store.lua | 61 +----------- lua/opencode/state/test_helpers.lua | 26 ------ lua/opencode/state/ui.lua | 29 ++++-- lua/opencode/ui/autocmds.lua | 2 +- lua/opencode/ui/input_window.lua | 2 +- lua/opencode/ui/output_window.lua | 2 +- lua/opencode/ui/renderer.lua | 24 ++--- lua/opencode/ui/ui.lua | 8 +- tests/helpers.lua | 4 +- tests/manual/renderer_replay.lua | 10 +- tests/minimal/init.lua | 1 - tests/replay/renderer_spec.lua | 18 ++-- tests/unit/api_client_spec.lua | 2 +- tests/unit/api_spec.lua | 18 ++-- tests/unit/config_file_spec.lua | 34 +++---- tests/unit/context_bar_spec.lua | 18 ++-- tests/unit/context_spec.lua | 16 ++-- tests/unit/core_spec.lua | 103 +++++++++++---------- tests/unit/cursor_tracking_spec.lua | 39 ++++---- tests/unit/dialog_spec.lua | 6 +- tests/unit/event_manager_spec.lua | 6 +- tests/unit/hooks_spec.lua | 34 ++++--- tests/unit/input_window_spec.lua | 60 ++++++------ tests/unit/loading_animation_spec.lua | 6 +- tests/unit/permission_integration_spec.lua | 40 ++++---- tests/unit/persist_state_spec.lua | 39 ++++---- tests/unit/render_state_spec.lua | 20 ++-- tests/unit/server_job_spec.lua | 4 +- tests/unit/session_spec.lua | 6 +- tests/unit/snapshot_spec.lua | 6 +- tests/unit/state_spec.lua | 58 +++++++----- tests/unit/zoom_spec.lua | 18 ++-- 42 files changed, 477 insertions(+), 429 deletions(-) create mode 100644 lua/opencode/state/context.lua create mode 100644 lua/opencode/state/renderer.lua delete mode 100644 lua/opencode/state/test_helpers.lua diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index d7804e6c..2019a8c0 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -22,11 +22,12 @@ local toggleable_context_keys = { ---@param context_key OpencodeToggleableContextKey ---@return table local function ensure_context_state(context_key) - state.current_context_config = state.current_context_config or {} - local current = state.current_context_config[context_key] + local current_config = state.current_context_config or {} + local current = current_config[context_key] local defaults = vim.tbl_get(config, 'context', context_key) or {} - state.current_context_config[context_key] = vim.tbl_deep_extend('force', {}, defaults, current or {}) - return state.current_context_config[context_key] + current_config[context_key] = vim.tbl_deep_extend('force', {}, defaults, current or {}) + state.context.set_current_context_config(current_config) + return current_config[context_key] end M.ChatContext = ChatContext @@ -117,12 +118,12 @@ end -- Delegate global state management to ChatContext function M.add_selection(selection) ChatContext.add_selection(selection) - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.remove_selection(selection) ChatContext.remove_selection(selection) - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.clear_selections() @@ -180,13 +181,13 @@ function M.add_file(file) file = vim.fn.fnamemodify(file, ':p') ChatContext.add_file(file) - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.remove_file(file) file = vim.fn.fnamemodify(file, ':p') ChatContext.remove_file(file) - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.clear_files() @@ -195,12 +196,12 @@ end function M.add_subagent(subagent) ChatContext.add_subagent(subagent) - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.remove_subagent(subagent) ChatContext.remove_subagent(subagent) - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.clear_subagents() @@ -213,7 +214,7 @@ end function M.load() ChatContext.load() - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end -- Context creation with delta logic (delegates to ChatContext) diff --git a/lua/opencode/context/chat_context.lua b/lua/opencode/context/chat_context.lua index c0cdcc36..687afe1b 100644 --- a/lua/opencode/context/chat_context.lua +++ b/lua/opencode/context/chat_context.lua @@ -175,7 +175,7 @@ function M.add_selection(selection) end table.insert(M.context.selections, selection) - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.remove_selection(selection) @@ -190,12 +190,12 @@ function M.remove_selection(selection) break end end - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.clear_selections() M.context.selections = {} - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.add_file(file) @@ -210,7 +210,7 @@ function M.add_file(file) if not vim.tbl_contains(M.context.mentioned_files, file) then table.insert(M.context.mentioned_files, file) end - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.remove_file(file) @@ -226,12 +226,12 @@ function M.remove_file(file) break end end - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.clear_files() M.context.mentioned_files = {} - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.add_subagent(subagent) @@ -243,7 +243,7 @@ function M.add_subagent(subagent) if not vim.tbl_contains(M.context.mentioned_subagents, subagent) then table.insert(M.context.mentioned_subagents, subagent) end - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.remove_subagent(subagent) @@ -258,18 +258,18 @@ function M.remove_subagent(subagent) break end end - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.clear_subagents() M.context.mentioned_subagents = {} - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.unload_attachments() M.context.mentioned_files = {} M.context.selections = {} - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end function M.get_mentioned_files() @@ -402,7 +402,7 @@ function M.load() or not vim.deep_equal(prev_cursor_data, M.context.cursor_data) or not vim.deep_equal(prev_linter_errors, M.context.linter_errors) then - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) end -- Handle current selection @@ -471,7 +471,7 @@ function M.delta_context(opts) end end - state.context_updated_at = vim.uv.now() + state.context.set_context_updated_at(vim.uv.now()) return ctx end diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 4f054dc9..804055a9 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -72,7 +72,7 @@ M.check_cwd = function() 'CWD changed since last check, resetting session and context', { current_cwd = state.current_cwd, new_cwd = vim.fn.getcwd() } ) - state.current_cwd = vim.fn.getcwd() + state.context.set_current_cwd(vim.fn.getcwd()) state.session.clear_active() context.unload_attachments() end @@ -130,7 +130,7 @@ M.open = Promise.async(function(opts) local ok, err = pcall(function() if opts.new_session then state.session.clear_active() - state.last_sent_context = nil + state.session.set_last_sent_context(nil) context.unload_attachments() M.ensure_current_mode():await() @@ -187,7 +187,7 @@ M.send_message = Promise.async(function(prompt, opts) opts = opts or {} opts.context = vim.tbl_deep_extend('force', state.current_context_config or {}, opts.context or {}) - state.current_context_config = opts.context + state.context.set_current_context_config(opts.context) context.load() opts.model = opts.model or M.initialize_current_model():await() opts.agent = opts.agent or state.current_mode or config.default_mode @@ -223,7 +223,7 @@ M.send_message = Promise.async(function(prompt, opts) local sent_message_count = vim.deepcopy(state.user_message_count) local new_value = (sent_message_count[session_id] or 0) + num sent_message_count[session_id] = new_value >= 0 and new_value or 0 - state.user_message_count = sent_message_count + state.session.set_user_message_count(sent_message_count) end update_sent_message_count(1) @@ -267,7 +267,7 @@ end) ---@param prompt string function M.after_run(prompt) context.unload_attachments() - state.last_sent_context = vim.deepcopy(context.get_context()) + state.session.set_last_sent_context(vim.deepcopy(context.get_context())) context.delta_context() require('opencode.history').write(prompt) M._abort_count = 0 @@ -437,7 +437,7 @@ M.opencode_ok = Promise.async(function() if not state.opencode_cli_version or state.opencode_cli_version == '' then local result = Promise.system({ config.opencode_executable, '--version' }):await() local out = (result and result.stdout or ''):gsub('%s+$', '') - state.opencode_cli_version = out:match('(%d+%%.%d+%%.%d+)') or out + state.jobs.set_opencode_cli_version(out:match('(%d+%%.%d+%%.%d+)') or out) end local required = state.required_version @@ -581,7 +581,7 @@ M.handle_directory_change = Promise.async(function() vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) state.session.clear_active() - state.last_sent_context = nil + state.session.set_last_sent_context(nil) context.unload_attachments() state.session.set_active(session.get_last_workspace_session():await() or M.create_new_session():await()) @@ -615,7 +615,7 @@ function M.setup() M.opencode_ok() end) local OpencodeApiClient = require('opencode.api_client') - state.api_client = OpencodeApiClient.create() + state.jobs.set_api_client(OpencodeApiClient.create()) end return M diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index c06fa224..b677cd90 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -593,8 +593,9 @@ function EventManager:get_subscriber_count(event_name) end function EventManager.setup() - state.event_manager = EventManager.new() - state.event_manager:start() + local manager = EventManager.new() + state.jobs.set_event_manager(manager) + manager:start() end return EventManager diff --git a/lua/opencode/image_handler.lua b/lua/opencode/image_handler.lua index d4dfd3fd..f19d4a15 100644 --- a/lua/opencode/image_handler.lua +++ b/lua/opencode/image_handler.lua @@ -150,7 +150,7 @@ function M.paste_image_from_clipboard() if success then context.add_file(image_path) - state.context_updated_at = os.time() + state.context.set_context_updated_at(os.time()) vim.notify('Image saved and added to context: ' .. vim.fn.fnamemodify(image_path, ':t'), vim.log.levels.INFO) return true end diff --git a/lua/opencode/state/context.lua b/lua/opencode/state/context.lua new file mode 100644 index 00000000..571fcf90 --- /dev/null +++ b/lua/opencode/state/context.lua @@ -0,0 +1,20 @@ +local store = require('opencode.state.store') + +local M = {} + +---@param config OpencodeContextConfig|nil +function M.set_current_context_config(config) + return store.set('current_context_config', config) +end + +---@param timestamp number|nil +function M.set_context_updated_at(timestamp) + return store.set('context_updated_at', timestamp) +end + +---@param cwd string|nil +function M.set_current_cwd(cwd) + return store.set('current_cwd', cwd) +end + +return M diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index 4c832ee1..d4e7e176 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -28,6 +28,8 @@ ---@field clear_active fun(opts?: OpencodeProtectedStateSetOptions) ---@field set_restore_points fun(points: RestorePoint[], opts?: OpencodeProtectedStateSetOptions) ---@field reset_restore_points fun(opts?: OpencodeProtectedStateSetOptions) +---@field set_last_sent_context fun(context: OpencodeContext|nil) +---@field set_user_message_count fun(count: table) ---@class OpencodeJobStateMutations ---@field increment_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) @@ -35,6 +37,9 @@ ---@field set_count fun(count: integer, opts?: OpencodeProtectedStateSetOptions) ---@field set_server fun(server: OpencodeServer|nil, opts?: OpencodeProtectedStateSetOptions) ---@field clear_server fun(opts?: OpencodeProtectedStateSetOptions) +---@field set_api_client fun(client: OpencodeApiClient|nil) +---@field set_event_manager fun(manager: EventManager|nil) +---@field set_opencode_cli_version fun(version: string|nil) ---@class OpencodeUiStateMutations ---@field set_windows fun(windows: OpencodeWindowState|nil) @@ -48,6 +53,9 @@ ---@field set_current_code_buf fun(bufnr: integer|nil) ---@field set_last_window_width_ratio fun(ratio: number|nil) ---@field clear_last_window_width_ratio fun() +---@field set_input_content fun(lines: table) +---@field set_saved_window_options fun(opts: table|nil) +---@field set_pre_zoom_width fun(width: integer|nil) ---@class OpencodeModelStateMutations ---@field set_mode fun(mode: string|nil) @@ -60,6 +68,19 @@ ---@field set_mode_model_map fun(mode_map: table) ---@field set_mode_model_override fun(mode: string, model: string) +---@class OpencodeRendererStateMutations +---@field set_messages fun(messages: OpencodeMessage[]|nil) +---@field set_current_message fun(message: OpencodeMessage|nil) +---@field set_last_user_message fun(message: OpencodeMessage|nil) +---@field set_pending_permissions fun(permissions: OpencodePermission[]) +---@field set_cost fun(cost: number) +---@field set_tokens_count fun(count: number) + +---@class OpencodeContextStateMutations +---@field set_current_context_config fun(config: OpencodeContextConfig|nil) +---@field set_context_updated_at fun(timestamp: number|nil) +---@field set_current_cwd fun(cwd: string|nil) + ---@class OpencodeState ---@field windows OpencodeWindowState|nil ---@field is_opening boolean @@ -109,13 +130,16 @@ ---@field jobs OpencodeJobStateMutations ---@field ui OpencodeUiStateMutations ---@field model OpencodeModelStateMutations +---@field renderer OpencodeRendererStateMutations +---@field context OpencodeContextStateMutations local store = require('opencode.state.store') local session = require('opencode.state.session') local jobs = require('opencode.state.jobs') local ui = require('opencode.state.ui') local model = require('opencode.state.model') -local test_helpers = require('opencode.state.test_helpers') +local renderer = require('opencode.state.renderer') +local context = require('opencode.state.context') local M = { store = store, @@ -123,15 +147,13 @@ local M = { jobs = jobs, ui = ui, model = model, - test_helpers = test_helpers, + renderer = renderer, + context = context, subscribe = store.subscribe, unsubscribe = store.unsubscribe, notify = store.notify, append = store.append, remove = store.remove, - set_raw = store.set_raw, - allow_raw_writes_for_tests = test_helpers.allow_raw_writes_for_tests, - silence_protected_writes = test_helpers.silence_protected_writes, } function M.is_running() @@ -142,8 +164,8 @@ return setmetatable(M, { __index = function(_, key) return store.get(key) end, - __newindex = function(_, key, value) - store.set(key, value, { source = 'raw' }) + __newindex = function(_, key, _value) + error(string.format('Direct write to state key `%s` is not allowed; use a state domain setter', key), 2) end, __pairs = function() return pairs(store.state()) diff --git a/lua/opencode/state/jobs.lua b/lua/opencode/state/jobs.lua index 1b1f18e2..7fbffe50 100644 --- a/lua/opencode/state/jobs.lua +++ b/lua/opencode/state/jobs.lua @@ -35,4 +35,19 @@ function M.clear_server(opts) return store.set('opencode_server', nil, opts) end +---@param client OpencodeApiClient|nil +function M.set_api_client(client) + return store.set('api_client', client) +end + +---@param manager EventManager|nil +function M.set_event_manager(manager) + return store.set('event_manager', manager) +end + +---@param version string|nil +function M.set_opencode_cli_version(version) + return store.set('opencode_cli_version', version) +end + return M diff --git a/lua/opencode/state/renderer.lua b/lua/opencode/state/renderer.lua new file mode 100644 index 00000000..883fd379 --- /dev/null +++ b/lua/opencode/state/renderer.lua @@ -0,0 +1,35 @@ +local store = require('opencode.state.store') + +local M = {} + +---@param messages OpencodeMessage[]|nil +function M.set_messages(messages) + return store.set('messages', messages) +end + +---@param message OpencodeMessage|nil +function M.set_current_message(message) + return store.set('current_message', message) +end + +---@param message OpencodeMessage|nil +function M.set_last_user_message(message) + return store.set('last_user_message', message) +end + +---@param permissions OpencodePermission[] +function M.set_pending_permissions(permissions) + return store.set('pending_permissions', permissions) +end + +---@param cost number +function M.set_cost(cost) + return store.set('cost', cost) +end + +---@param count number +function M.set_tokens_count(count) + return store.set('tokens_count', count) +end + +return M diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index bb64bc92..55c01956 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -24,4 +24,14 @@ function M.reset_restore_points(opts) return store.set('restore_points', {}, opts) end +---@param context OpencodeContext|nil +function M.set_last_sent_context(context) + return store.set('last_sent_context', context) +end + +---@param count table +function M.set_user_message_count(count) + return store.set('user_message_count', count) +end + return M diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index 5c9a65a8..fd8551dc 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -48,47 +48,14 @@ local _state = { local _listeners = {} -local PROTECTED_KEYS = { - active_session = true, - restore_points = true, - job_count = true, - opencode_server = true, - windows = true, - is_opening = true, - is_opencode_focused = true, - last_focused_opencode_window = true, - last_code_win_before_opencode = true, - current_code_buf = true, - display_route = true, - last_window_width_ratio = true, - current_mode = true, - current_model = true, - current_model_info = true, - current_variant = true, - user_mode_model_map = true, -} - -local _protected_write_warnings = {} -local _silence_protected_writes = false - ---@param key string ---@param opts? OpencodeProtectedStateSetOptions -local function warn_on_protected_raw_write(key, opts) - if not PROTECTED_KEYS[key] or _protected_write_warnings[key] then +local function error_on_raw_write(key, opts) + if opts and opts.silent then return end - if _silence_protected_writes or (opts and opts.silent) then - return - end - - _protected_write_warnings[key] = true - vim.schedule(function() - vim.notify( - string.format('Direct write to protected state key `%s`; prefer state domain helpers', key), - vim.log.levels.WARN - ) - end) + error(string.format('Direct write to state key `%s` is not allowed; use a state domain setter', key), 3) end function M.state() @@ -110,7 +77,7 @@ function M.set(key, value, opts) opts = opts or { source = 'helper' } if opts.source == 'raw' then - warn_on_protected_raw_write(key, opts) + error_on_raw_write(key, opts) end _state[key] = value @@ -233,24 +200,4 @@ function M.remove(key, idx) M.notify(key, _state[key], old) end ----@param enabled boolean -function M.set_protected_writes_silenced(enabled) - _silence_protected_writes = enabled == true -end - ----@return boolean -function M.are_protected_writes_silenced() - return _silence_protected_writes -end - -function M.reset_protected_write_warnings() - _protected_write_warnings = {} -end - ----@param key string ----@return boolean -function M.is_protected_key(key) - return PROTECTED_KEYS[key] == true -end - return M diff --git a/lua/opencode/state/test_helpers.lua b/lua/opencode/state/test_helpers.lua deleted file mode 100644 index b54662c9..00000000 --- a/lua/opencode/state/test_helpers.lua +++ /dev/null @@ -1,26 +0,0 @@ -local store = require('opencode.state.store') - -local M = {} - -local function disable_warnings() - store.set_protected_writes_silenced(true) -end - -local function enable_warnings() - store.set_protected_writes_silenced(false) -end - -function M.allow_raw_writes_for_tests() - disable_warnings() - return enable_warnings -end - -function M.silence_protected_writes() - return M.allow_raw_writes_for_tests() -end - -function M.restore_protected_write_warnings() - enable_warnings() -end - -return M diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index ede930f9..c52c212b 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -56,6 +56,21 @@ function M.clear_last_window_width_ratio() return store.set('last_window_width_ratio', nil) end +---@param lines table +function M.set_input_content(lines) + return store.set('input_content', lines) +end + +---@param opts table|nil +function M.set_saved_window_options(opts) + return store.set('saved_window_options', opts) +end + +---@param width integer|nil +function M.set_pre_zoom_width(width) + return store.set('pre_zoom_width', width) +end + ---@param win_id integer|nil ---@return boolean function M.is_window_in_current_tab(win_id) @@ -214,9 +229,9 @@ end function M.set_cursor_position(win_type, pos) local normalized = normalize_cursor(pos) if win_type == 'input' then - _state.last_input_window_position = normalized + store.set('last_input_window_position', normalized) elseif win_type == 'output' then - _state.last_output_window_position = normalized + store.set('last_output_window_position', normalized) end end @@ -298,11 +313,11 @@ end ---@param hidden OpencodeHiddenBuffers|nil function M.stash_hidden_buffers(hidden) if hidden == nil then - _state._hidden_buffers = nil + store.set('_hidden_buffers', nil) return end - _state._hidden_buffers = normalize_hidden_buffers(hidden) + store.set('_hidden_buffers', normalize_hidden_buffers(hidden)) end ---@return OpencodeHiddenBuffers|nil @@ -311,9 +326,9 @@ function M.inspect_hidden_buffers() end function M.clear_hidden_window_state() - _state._hidden_buffers = nil + store.set('_hidden_buffers', nil) if _state.windows and not _state.windows.input_win and not _state.windows.output_win then - _state.windows = nil + store.set('windows', nil) end end @@ -325,7 +340,7 @@ end ---@return OpencodeHiddenBuffers|nil function M.consume_hidden_buffers() local hidden = M.inspect_hidden_buffers() - _state._hidden_buffers = nil + store.set('_hidden_buffers', nil) return hidden end diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 90ef61ea..3b42e39d 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -82,7 +82,7 @@ function M.setup_autocmds(windows) end end - state.current_cwd = event.file + state.context.set_current_cwd(event.file) local core = require('opencode.core') core.handle_directory_change() end, diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index 8cae5325..5db5e97a 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -511,7 +511,7 @@ function M.setup_autocmds(windows, group) buffer = windows.input_buf, callback = function() local input_lines = vim.api.nvim_buf_get_lines(windows.input_buf, 0, -1, false) - state.input_content = input_lines + state.ui.set_input_content(input_lines) M.refresh_placeholder(windows, input_lines) require('opencode.ui.context_bar').render() M.schedule_resize(windows) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index c760f134..c4b98535 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -81,7 +81,7 @@ local function set_win_option(opt_name, value, win) -- Save original value if using position = 'current' if config.ui.position == 'current' then if not state.saved_window_options then - state.saved_window_options = {} + state.ui.set_saved_window_options({}) end -- Only save if not already saved (in case this function is called multiple times) if state.saved_window_options[opt_name] == nil then diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index ce3f9ae5..95275032 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -43,9 +43,9 @@ function M.reset() output_window.clear() - state.messages = {} - state.last_user_message = nil - state.tokens_count = 0 + state.renderer.set_messages({}) + state.renderer.set_last_user_message(nil) + state.renderer.set_tokens_count(0) local permissions = state.pending_permissions or {} if #permissions > 0 and state.api_client then @@ -54,7 +54,7 @@ function M.reset() end end permission_window.clear_all() - state.pending_permissions = {} + state.renderer.set_pending_permissions({}) trigger_on_data_rendered() end @@ -118,7 +118,7 @@ local function fetch_session() return Promise.new():resolve(nil) end - state.last_user_message = nil + state.renderer.set_last_user_message(nil) return require('opencode.session').get_messages(session) end @@ -697,9 +697,9 @@ function M.on_message_updated(message, revert_index) M._add_message_to_buffer(msg) - state.current_message = msg + state.renderer.set_current_message(msg) if message.info.role == 'user' then - state.last_user_message = msg + state.renderer.set_last_user_message(msg) end end @@ -964,7 +964,7 @@ function M.on_permission_updated(permission) -- Add permission to pending queue if not state.pending_permissions then - state.pending_permissions = {} + state.renderer.set_pending_permissions({}) end -- Check if permission already exists in queue @@ -982,7 +982,7 @@ function M.on_permission_updated(permission) else table.insert(permissions, permission) end - state.pending_permissions = permissions + state.renderer.set_pending_permissions(permissions) permission_window.add_permission(permission) @@ -1004,7 +1004,7 @@ function M.on_permission_replied(properties) if permission_id then permission_window.remove_permission(permission_id) - state.pending_permissions = vim.deepcopy(permission_window.get_all_permissions()) + state.renderer.set_pending_permissions(vim.deepcopy(permission_window.get_all_permissions())) if #state.pending_permissions == 0 then M._remove_part_from_buffer('permission-display-part') M._remove_message_from_buffer('permission-display-message') @@ -1210,11 +1210,11 @@ function M._update_stats_from_message(message) local tokens = message.info.tokens if tokens and tokens.input > 0 then - state.tokens_count = tokens.input + tokens.output + tokens.cache.read + tokens.cache.write + state.renderer.set_tokens_count(tokens.input + tokens.output + tokens.cache.read + tokens.cache.write) end if message.info.cost and type(message.info.cost) == 'number' then - state.cost = message.info.cost + state.renderer.set_cost(message.info.cost) end end diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 18fadd4c..27453b14 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -116,7 +116,7 @@ local function close_or_restore_output_window(windows) 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 + state.ui.set_saved_window_options(nil) end end return @@ -157,7 +157,7 @@ function M.hide_visible_windows(windows) if windows.input_buf and vim.api.nvim_buf_is_valid(windows.input_buf) then local ok, lines = pcall(vim.api.nvim_buf_get_lines, windows.input_buf, 0, -1, false) if ok then - state.input_content = lines + state.ui.set_input_content(lines) end end state.ui.stash_hidden_buffers(snapshot) @@ -531,9 +531,9 @@ function M.toggle_zoom() if state.pre_zoom_width then width = state.pre_zoom_width - state.pre_zoom_width = nil + state.ui.set_pre_zoom_width(nil) else - state.pre_zoom_width = vim.api.nvim_win_get_width(windows.output_win) + state.ui.set_pre_zoom_width(vim.api.nvim_win_get_width(windows.output_win)) width = math.floor(config.ui.zoom_width * vim.o.columns) end diff --git a/tests/helpers.lua b/tests/helpers.lua index ca331214..836d9480 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -22,12 +22,12 @@ function M.replay_setup() return nil end - state.current_mode = 'build' -- default mode for tests + state.model.set_mode('build') -- default mode for tests -- we use the event manager to dispatch events, have to setup before ui.create_windows require('opencode.event_manager').setup() - state.windows = ui.create_windows() + state.ui.set_windows(ui.create_windows()) -- disable fetching session and rendering it (we'll handle it at a lower level) renderer.render_full_session = function() diff --git a/tests/manual/renderer_replay.lua b/tests/manual/renderer_replay.lua index 327bf8a7..9f3852bd 100644 --- a/tests/manual/renderer_replay.lua +++ b/tests/manual/renderer_replay.lua @@ -37,7 +37,7 @@ function M.load_events(file_path) vim.notify('Loaded ' .. #M.events .. ' events from ' .. data_file, vim.log.levels.INFO) ---@diagnostic disable-next-line: missing-fields - state.active_session = helpers.get_session_from_events(M.events) + state.session.set_active(helpers.get_session_from_events(M.events)) return true end @@ -95,7 +95,7 @@ function M.replay_all(delay_ms) return end - state.job_count = 1 + state.jobs.set_count(1) -- This defer loop will fill the event manager throttling emitter and that -- emitter will drain the events through event manager, which @@ -103,7 +103,7 @@ function M.replay_all(delay_ms) local function tick() M.replay_next() if M.event_index >= #M.events or M.stop then - state.job_count = 0 + state.jobs.set_count(0) if M.headless_mode then M.dump_buffer_and_quit() @@ -222,11 +222,11 @@ function M.replay_full_session() return false end - state.active_session = helpers.get_session_from_events(M.events, true) + state.session.set_active(helpers.get_session_from_events(M.events, true)) local session_data = helpers.load_session_from_events(M.events) renderer._render_full_session_data(session_data) - state.job_count = 0 + state.jobs.set_count(0) vim.notify('Rendered full session from loaded events', vim.log.levels.INFO) return true diff --git a/tests/minimal/init.lua b/tests/minimal/init.lua index 908ea43b..d7e3293a 100644 --- a/tests/minimal/init.lua +++ b/tests/minimal/init.lua @@ -33,7 +33,6 @@ _G.test_plugin_root = plugin_root vim.opt.termguicolors = true require('opencode') -require('opencode.state.test_helpers').allow_raw_writes_for_tests() if vim.treesitter and vim.treesitter.start then vim.treesitter.start = function() end diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 0f469357..73adcd0f 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -150,11 +150,11 @@ describe('renderer unit tests', function() local renderer = require('opencode.ui.renderer') local topbar = require('opencode.ui.topbar') - state.active_session = { + state.session.set_active({ id = 'ses_123', title = 'New session - 2026-02-05T22:26:08.579Z', time = { created = 1, updated = 1 }, - } + }) local active_session_ref = state.active_session local topbar_render_stub = stub(topbar, 'render') @@ -176,13 +176,13 @@ describe('renderer unit tests', function() it('rerenders full session when revert changes', function() local renderer = require('opencode.ui.renderer') - state.messages = {} - state.active_session = { + state.renderer.set_messages({}) + state.session.set_active({ id = 'ses_123', title = 'Session', time = { created = 1, updated = 1 }, revert = { messageID = 'msg_1', snapshot = 'a', diff = '' }, - } + }) local render_stub = stub(renderer, '_render_full_session_data') @@ -202,11 +202,11 @@ describe('renderer unit tests', function() it('ignores session.updated for non-active session IDs', function() local renderer = require('opencode.ui.renderer') - state.active_session = { + state.session.set_active({ id = 'ses_123', title = 'Session', time = { created = 1, updated = 1 }, - } + }) local render_stub = stub(renderer, '_render_full_session_data') @@ -267,7 +267,7 @@ describe('renderer functional tests', function() .. ')', function() local events = helpers.load_test_data(filepath) - state.active_session = helpers.get_session_from_events(events) + state.session.set_active(helpers.get_session_from_events(events)) local expected = helpers.load_test_data(expected_path) helpers.replay_events(events) @@ -285,7 +285,7 @@ describe('renderer functional tests', function() it('replays ' .. name .. ' correctly (session)', function() local renderer = require('opencode.ui.renderer') local events = helpers.load_test_data(filepath) - state.active_session = helpers.get_session_from_events(events, true) + state.session.set_active(helpers.get_session_from_events(events, true)) local expected = helpers.load_test_data(expected_path) local session_data = helpers.load_session_from_events(events) diff --git a/tests/unit/api_client_spec.lua b/tests/unit/api_client_spec.lua index 31e45134..808d4fad 100644 --- a/tests/unit/api_client_spec.lua +++ b/tests/unit/api_client_spec.lua @@ -64,7 +64,7 @@ describe('api_client', function() local captured_calls = {} local original_cwd = vim.fn.getcwd local state = require('opencode.state') - state.current_cwd = '/current/directory' + state.context.set_current_cwd('/current/directory') vim.fn.getcwd = function() return '/current/directory' diff --git a/tests/unit/api_spec.lua b/tests/unit/api_spec.lua index d77babe9..a6a66963 100644 --- a/tests/unit/api_spec.lua +++ b/tests/unit/api_spec.lua @@ -377,11 +377,11 @@ describe('opencode.api', function() end local original_active_session = state.active_session - state.active_session = { id = 'test-session' } + state.session.set_active({ id = 'test-session' }) local original_api_client = state.api_client local send_command_calls = {} - state.api_client = { + state.jobs.set_api_client({ send_command = function(self, session_id, command_data) table.insert(send_command_calls, { session_id = session_id, command_data = command_data }) return { @@ -390,7 +390,7 @@ describe('opencode.api', function() end, } end, - } + }) local slash_commands = api.get_slash_commands():wait() @@ -427,8 +427,8 @@ describe('opencode.api', function() assert.equal('tester', send_command_calls[1].command_data.agent) config_file.get_user_commands = original_get_user_commands - state.active_session = original_active_session - state.api_client = original_api_client + state.session.set_active(original_active_session) + state.jobs.set_api_client(original_api_client) end) end) @@ -494,17 +494,17 @@ describe('opencode.api', function() describe('current_model', function() it('returns the current model from state', function() local original_model = state.current_model - state.current_model = 'testmodel' + state.model.set_model('testmodel') local model = api.current_model():wait() assert.equal('testmodel', model) - state.current_model = original_model + state.model.set_model(original_model) end) it('falls back to config file model when state.current_model is nil', function() local original_model = state.current_model - state.current_model = nil + state.model.clear_model() local config_file = require('opencode.config_file') local original_get_opencode_config = config_file.get_opencode_config @@ -520,7 +520,7 @@ describe('opencode.api', function() assert.equal('testmodel', model) config_file.get_opencode_config = original_get_opencode_config - state.current_model = original_model + state.model.set_model(original_model) end) end) diff --git a/tests/unit/config_file_spec.lua b/tests/unit/config_file_spec.lua index f52529b2..c25ca472 100644 --- a/tests/unit/config_file_spec.lua +++ b/tests/unit/config_file_spec.lua @@ -18,14 +18,14 @@ describe('config_file.setup', function() after_each(function() vim.schedule = original_schedule - state.api_client = original_api_client + state.jobs.set_api_client(original_api_client) end) it('lazily loads config when accessed', function() Promise.spawn(function() local get_config_called, get_project_called = false, false local cfg = { agent = { ['a1'] = { mode = 'primary' } } } - state.api_client = { + state.jobs.set_api_client({ get_config = function() get_config_called = true return Promise.new():resolve(cfg) @@ -34,7 +34,7 @@ describe('config_file.setup', function() get_project_called = true return Promise.new():resolve({ id = 'p1', name = 'P', path = '/tmp' }) end, - } + }) -- Promises should not be set up during setup (lazy loading) assert.falsy(config_file.config_promise) @@ -53,14 +53,14 @@ describe('config_file.setup', function() it('get_opencode_agents returns primary + defaults', function() Promise.spawn(function() - state.api_client = { + state.jobs.set_api_client({ get_config = function() return Promise.new():resolve({ agent = { ['custom'] = { mode = 'primary' } } }) end, get_current_project = function() return Promise.new():resolve({ id = 'p1' }) end, - } + }) local agents = config_file.get_opencode_agents():await() assert.True(vim.tbl_contains(agents, 'custom')) assert.True(vim.tbl_contains(agents, 'build')) @@ -70,14 +70,14 @@ describe('config_file.setup', function() it('get_opencode_agents respects disabled defaults', function() Promise.spawn(function() - state.api_client = { + state.jobs.set_api_client({ get_config = function() return Promise.new():resolve({ agent = { ['custom'] = { mode = 'primary' }, ['build'] = { disable = true }, ['plan'] = { disable = false } } }) end, get_current_project = function() return Promise.new():resolve({ id = 'p1' }) end, - } + }) local agents = config_file.get_opencode_agents():await() assert.True(vim.tbl_contains(agents, 'custom')) assert.False(vim.tbl_contains(agents, 'build')) @@ -87,7 +87,7 @@ describe('config_file.setup', function() it('get_opencode_agents filters out hidden agents', function() Promise.spawn(function() - state.api_client = { + state.jobs.set_api_client({ get_config = function() return Promise.new():resolve({ agent = { @@ -100,7 +100,7 @@ describe('config_file.setup', function() get_current_project = function() return Promise.new():resolve({ id = 'p1' }) end, - } + }) local agents = config_file.get_opencode_agents():await() assert.True(vim.tbl_contains(agents, 'custom')) assert.False(vim.tbl_contains(agents, 'compaction')) @@ -110,7 +110,7 @@ describe('config_file.setup', function() it('get_subagents filters out hidden agents', function() Promise.spawn(function() - state.api_client = { + state.jobs.set_api_client({ get_config = function() return Promise.new():resolve({ agent = { @@ -123,7 +123,7 @@ describe('config_file.setup', function() get_current_project = function() return Promise.new():resolve({ id = 'p1' }) end, - } + }) local agents = config_file.get_subagents():await() assert.True(vim.tbl_contains(agents, 'general')) assert.True(vim.tbl_contains(agents, 'explore')) @@ -134,7 +134,7 @@ describe('config_file.setup', function() it('get_subagents does not duplicate built-in agents when configured', function() Promise.spawn(function() - state.api_client = { + state.jobs.set_api_client({ get_config = function() return Promise.new():resolve({ agent = { @@ -147,7 +147,7 @@ describe('config_file.setup', function() get_current_project = function() return Promise.new():resolve({ id = 'p1' }) end, - } + }) local agents = config_file.get_subagents():await() -- Count occurrences of each agent @@ -170,7 +170,7 @@ describe('config_file.setup', function() it('get_subagents respects disabled built-in agents', function() Promise.spawn(function() - state.api_client = { + state.jobs.set_api_client({ get_config = function() return Promise.new():resolve({ agent = { @@ -182,7 +182,7 @@ describe('config_file.setup', function() get_current_project = function() return Promise.new():resolve({ id = 'p1' }) end, - } + }) local agents = config_file.get_subagents():await() assert.False(vim.tbl_contains(agents, 'general')) assert.False(vim.tbl_contains(agents, 'explore')) @@ -192,14 +192,14 @@ describe('config_file.setup', function() it('get_opencode_project returns project', function() Promise.spawn(function() local project = { id = 'p1', name = 'X' } - state.api_client = { + state.jobs.set_api_client({ get_config = function() return Promise.new():resolve({ agent = {} }) end, get_current_project = function() return Promise.new():resolve(project) end, - } + }) local proj = config_file.get_opencode_project():await() assert.same(project, proj) end):wait() diff --git a/tests/unit/context_bar_spec.lua b/tests/unit/context_bar_spec.lua index f724c4f9..38a89cb4 100644 --- a/tests/unit/context_bar_spec.lua +++ b/tests/unit/context_bar_spec.lua @@ -98,7 +98,7 @@ describe('opencode.ui.context_bar', function() vim.wo = {} -- Reset state - state.windows = nil + state.ui.set_windows(nil) end) after_each(function() @@ -120,7 +120,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2001 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) @@ -136,7 +136,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2002 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) @@ -153,7 +153,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2002 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) @@ -172,7 +172,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2003 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) @@ -195,7 +195,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2004 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) @@ -218,7 +218,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2004 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) @@ -240,7 +240,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2005 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) @@ -255,7 +255,7 @@ describe('opencode.ui.context_bar', function() local mock_input_win = 2006 local winbar_capture = create_mock_window(mock_input_win) - state.windows = { input_win = mock_input_win } + state.ui.set_windows({ input_win = mock_input_win }) context_bar.render() assert.is_string(winbar_capture.value) diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua index 4da6c66d..c11f22b1 100644 --- a/tests/unit/context_spec.lua +++ b/tests/unit/context_spec.lua @@ -214,7 +214,7 @@ describe('context update notifications', function() ChatContext.context.selections = { { file = { path = '/tmp/a.lua' }, lines = '1, 1', content = 'x' } } ChatContext.context.mentioned_subagents = { 'agent1' } - state.context_updated_at = 0 + state.context.set_context_updated_at(0) local tick = 0 original_now = vim.uv.now vim.uv.now = function() @@ -268,14 +268,14 @@ describe('delta_context', function() it('removes current_file if unchanged', function() local file = { name = 'foo.lua', path = '/tmp/foo.lua', extension = 'lua' } mock_context.current_file = vim.deepcopy(file) - state.last_sent_context = { current_file = mock_context.current_file } + state.session.set_last_sent_context({ current_file = mock_context.current_file }) local result = context.delta_context() assert.is_nil(result.current_file) end) it('removes mentioned_subagents if unchanged', function() local subagents = { 'a' } mock_context.mentioned_subagents = vim.deepcopy(subagents) - state.last_sent_context = { mentioned_subagents = vim.deepcopy(subagents) } + state.session.set_last_sent_context({ mentioned_subagents = vim.deepcopy(subagents) }) local result = context.delta_context() assert.is_nil(result.mentioned_subagents) end) @@ -417,7 +417,7 @@ describe('context toggle API', function() end) after_each(function() - state.current_context_config = original_context_config + state.context.set_current_context_config(original_context_config) context.load = original_load end) @@ -436,9 +436,9 @@ describe('context toggle API', function() it('toggle_context inverts the current value', function() context.load = function() end - state.current_context_config = { + state.context.set_current_context_config({ current_file = { enabled = false }, - } + }) local enabled = context.toggle_context('current_file') @@ -760,8 +760,8 @@ describe('ChatContext.load() preserves selections on file switch', function() } -- Mock state to indicate active session - state.active_session = true - state.is_opening = false + state.session.set_active(true) + state.ui.set_opening(false) end) it('should not clear selections when switching to a different file', function() diff --git a/tests/unit/core_spec.lua b/tests/unit/core_spec.lua index 532d2c9c..fbda0982 100644 --- a/tests/unit/core_spec.lua +++ b/tests/unit/core_spec.lua @@ -1,6 +1,7 @@ local core = require('opencode.core') local config_file = require('opencode.config_file') local state = require('opencode.state') +local store = require('opencode.state.store') local ui = require('opencode.ui.ui') local session = require('opencode.session') local Promise = require('opencode.promise') @@ -9,7 +10,7 @@ local assert = require('luassert') -- Provide a mock api_client for tests that need it local function mock_api_client() - state.api_client = { + state.jobs.set_api_client({ create_session = function(_, params) return Promise.new():resolve({ id = params and params.title or 'new-session' }) end, @@ -25,7 +26,7 @@ local function mock_api_client() get_config = function() return Promise.new():resolve({ model = 'gpt-4' }) end, - } + }) end describe('opencode.core', function() @@ -96,20 +97,20 @@ describe('opencode.core', function() mock_api_client() -- Mock server job to avoid trying to start real server - state.opencode_server = { + store.set('opencode_server', { is_running = function() return true end, shutdown = function() end, url = 'http://127.0.0.1:4000', - } + }) -- Config is now loaded lazily, so no need to pre-seed promises end) after_each(function() for k, v in pairs(original_state) do - state[k] = v + store.set(k, v) end vim.system = original_system vim.fn.executable = original_executable @@ -140,7 +141,7 @@ describe('opencode.core', function() describe('open', function() it("creates windows if they don't exist", function() - state.windows = nil + state.ui.set_windows(nil) core.open({ new_session = false, focus = 'input' }):wait() assert.truthy(state.windows) assert.same({ @@ -154,7 +155,7 @@ describe('opencode.core', function() it('ensure the current cwd is correct when opening', function() local cwd = vim.fn.getcwd() - state.current_cwd = nil + state.context.set_current_cwd(nil) core.open({ new_session = false, focus = 'input' }):wait() assert.equal(cwd, state.current_cwd) end) @@ -162,9 +163,9 @@ describe('opencode.core', function() it('reload the active_session if cwd has changed since last session', function() local original_getcwd = vim.fn.getcwd - state.windows = nil - state.active_session = { id = 'old-session' } - state.current_cwd = '/some/old/path' + state.ui.set_windows(nil) + state.session.set_active({ id = 'old-session' }) + state.context.set_current_cwd('/some/old/path') vim.fn.getcwd = function() return '/some/new/path' end @@ -184,14 +185,14 @@ describe('opencode.core', function() end) it('handles new session properly', function() - state.windows = nil - state.active_session = { id = 'old-session' } + state.ui.set_windows(nil) + state.session.set_active({ id = 'old-session' }) core.open({ new_session = true, focus = 'input' }):wait() assert.truthy(state.active_session) end) it('focuses the appropriate window', function() - state.windows = nil + state.ui.set_windows(nil) ui.focus_input:revert() ui.focus_output:revert() local input_focused, output_focused = false, false @@ -213,8 +214,8 @@ describe('opencode.core', function() end) it('creates a new session when no active session and no last session exists', function() - state.windows = nil - state.active_session = nil + state.ui.set_windows(nil) + state.session.set_active(nil) session.get_last_workspace_session:revert() stub(session, 'get_last_workspace_session').invokes(function() local p = Promise.new() @@ -229,8 +230,8 @@ describe('opencode.core', function() end) it('resets is_opening flag when error occurs', function() - state.windows = nil - state.is_opening = false + state.ui.set_windows(nil) + store.set('is_opening', false) -- Simply cause an error by stubbing a function that will be called local original_create_new_session = core.create_new_session @@ -282,7 +283,7 @@ describe('opencode.core', function() ui.render_output:revert() stub(ui, 'render_output') - state.windows = { input_buf = 1, output_buf = 2 } + state.ui.set_windows({ input_buf = 1, output_buf = 2 }) core.select_session(nil):wait() assert.equal(2, #passed) assert.equal('session3', passed[2].id) @@ -293,8 +294,8 @@ describe('opencode.core', function() describe('send_message', function() it('sends a message via api_client', function() - state.windows = { mock = 'windows' } - state.active_session = { id = 'sess1' } + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) local create_called = false local orig = state.api_client.create_message @@ -314,8 +315,8 @@ describe('opencode.core', function() end) it('creates new session when none active', function() - state.windows = { mock = 'windows' } - state.active_session = nil + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active(nil) local created_session local orig_session = state.api_client.create_session @@ -334,8 +335,8 @@ describe('opencode.core', function() it('persist options in state when sending message', function() local orig = state.api_client.create_message - state.windows = { mock = 'windows' } - state.active_session = { id = 'sess1' } + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) state.api_client.create_message = function(_, sid, params) create_called = true @@ -355,9 +356,9 @@ describe('opencode.core', function() end) it('increments and decrements user_message_count correctly', function() - state.windows = { mock = 'windows' } - state.active_session = { id = 'sess1' } - state.user_message_count = {} + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) + state.session.set_user_message_count({}) -- Capture the count at different stages local count_before = state.user_message_count['sess1'] or 0 @@ -392,9 +393,9 @@ describe('opencode.core', function() end) it('decrements user_message_count on error', function() - state.windows = { mock = 'windows' } - state.active_session = { id = 'sess1' } - state.user_message_count = {} + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'sess1' }) + state.session.set_user_message_count({}) -- Capture the count at different stages local count_before = state.user_message_count['sess1'] or 0 @@ -432,9 +433,9 @@ describe('opencode.core', function() describe('cancel', function() it('aborts running session even when ui is not visible', function() - state.windows = nil - state.active_session = { id = 'sess1' } - state.job_count = 1 + state.ui.set_windows(nil) + state.session.set_active({ id = 'sess1' }) + store.set('job_count', 1) local abort_stub = stub(state.api_client, 'abort_session').invokes(function() return Promise.new():resolve(true) @@ -478,7 +479,7 @@ describe('opencode.core', function() after_each(function() vim.system = original_system vim.fn.executable = original_executable - state.opencode_cli_version = saved_cli + state.jobs.set_opencode_cli_version(saved_cli) end) it('returns false when opencode executable is missing', function() @@ -493,8 +494,8 @@ describe('opencode.core', function() return 1 end vim.system = mock_vim_system({ stdout = 'opencode 0.4.1' }) - state.opencode_cli_version = nil - state.required_version = '0.4.2' + state.jobs.set_opencode_cli_version(nil) + store.set('required_version', '0.4.2') assert.is_false(core.opencode_ok():await()) end) @@ -503,8 +504,8 @@ describe('opencode.core', function() return 1 end vim.system = mock_vim_system({ stdout = 'opencode 0.4.2' }) - state.opencode_cli_version = nil - state.required_version = '0.4.2' + state.jobs.set_opencode_cli_version(nil) + store.set('required_version', '0.4.2') assert.is_true(core.opencode_ok():await()) end) @@ -513,8 +514,8 @@ describe('opencode.core', function() return 1 end vim.system = mock_vim_system({ stdout = 'opencode 0.5.0' }) - state.opencode_cli_version = nil - state.required_version = '0.4.2' + state.jobs.set_opencode_cli_version(nil) + store.set('required_version', '0.4.2') assert.is_true(core.opencode_ok():await()) end) end) @@ -535,8 +536,8 @@ describe('opencode.core', function() end) it('clears active session and context', function() - state.active_session = { id = 'old-session' } - state.last_sent_context = { some = 'context' } + state.session.set_active({ id = 'old-session' }) + state.session.set_last_sent_context({ some = 'context' }) core.handle_directory_change():wait() @@ -589,9 +590,9 @@ describe('opencode.core', function() stub(config_file, 'get_opencode_agents').returns(agents_promise) stub(config_file, 'get_opencode_config').returns(config_promise) - state.current_mode = nil - state.current_model = nil - state.user_mode_model_map = {} + store.set('current_mode', nil) + store.set('current_model', nil) + store.set('user_mode_model_map', {}) local promise = core.switch_to_mode('custom') local success = promise:wait() @@ -643,9 +644,9 @@ describe('opencode.core', function() stub(config_file, 'get_opencode_agents').returns(agents_promise) stub(config_file, 'get_opencode_config').returns(config_promise) - state.current_mode = nil - state.current_model = 'should-be-overridden' - state.user_mode_model_map = { plan = 'anthropic/claude-3-haiku' } + store.set('current_mode', nil) + store.set('current_model', 'should-be-overridden') + store.set('user_mode_model_map', { plan = 'anthropic/claude-3-haiku' }) local promise = core.switch_to_mode('plan') local success = promise:wait() @@ -670,9 +671,9 @@ describe('opencode.core', function() }) stub(config_file, 'get_opencode_agents').returns(agents_promise) stub(config_file, 'get_opencode_config').returns(config_promise) - state.current_mode = nil - state.current_model = 'old-model' - state.user_mode_model_map = {} + store.set('current_mode', nil) + store.set('current_model', 'old-model') + store.set('user_mode_model_map', {}) local promise = core.switch_to_mode('plan') local success = promise:wait() assert.is_true(success) diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index 493138f1..43793dd6 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -1,4 +1,5 @@ local state = require('opencode.state') +local store = require('opencode.state.store') local config = require('opencode.config') local ui = require('opencode.ui.ui') @@ -38,7 +39,7 @@ describe('cursor persistence (state)', function() col = 0, }) - state.windows = { output_win = win, output_buf = buf } + state.ui.set_windows({ output_win = win, output_buf = buf }) vim.api.nvim_set_current_win(win) vim.api.nvim_win_set_cursor(win, { 10, 0 }) end) @@ -47,7 +48,7 @@ describe('cursor persistence (state)', function() renderer.reset() pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true }) - state.windows = nil + state.ui.set_windows(nil) end) it('auto-scrolls when cursor was at previous bottom and buffer grows', function() @@ -208,13 +209,13 @@ describe('output_window.is_at_bottom', function() col = 0, }) - state.windows = { output_win = win, output_buf = buf } + state.ui.set_windows({ output_win = win, output_buf = buf }) end) after_each(function() pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true }) - state.windows = nil + state.ui.set_windows(nil) end) it('returns true when cursor is on last line', function() @@ -249,7 +250,7 @@ describe('output_window.is_at_bottom', function() end) it('returns true when no windows in state', function() - state.windows = nil + state.ui.set_windows(nil) assert.is_true(output_window.is_at_bottom(win)) end) @@ -262,7 +263,7 @@ describe('output_window.is_at_bottom', function() row = 0, col = 0, }) - state.windows = { output_win = empty_win, output_buf = empty_buf } + state.ui.set_windows({ output_win = empty_win, output_buf = empty_buf }) assert.is_true(output_window.is_at_bottom(empty_win)) @@ -307,14 +308,14 @@ describe('renderer.scroll_to_bottom', function() col = 0, }) - state.windows = { output_win = win, output_buf = buf } + state.ui.set_windows({ output_win = win, output_buf = buf }) renderer._prev_line_count = 50 end) after_each(function() pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true }) - state.windows = nil + state.ui.set_windows(nil) renderer._prev_line_count = 0 output_window.viewport_at_bottom = nil end) @@ -367,13 +368,13 @@ describe('ui.focus_input', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_win = input_win, output_win = output_win, input_buf = input_buf, output_buf = output_buf, - } - state.last_input_window_position = { 1, 4 } + }) + store.set('last_input_window_position', { 1, 4 }) end) after_each(function() @@ -381,8 +382,8 @@ describe('ui.focus_input', function() pcall(vim.api.nvim_win_close, output_win, true) pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) - state.windows = nil - state.last_input_window_position = nil + state.ui.set_windows(nil) + store.set('last_input_window_position', nil) end) it('does not restore cursor when already focused in input window', function() @@ -414,9 +415,9 @@ describe('renderer._add_message_to_buffer scrolling', function() col = 0, }) - state.windows = { output_win = win, output_buf = buf } - state.active_session = { id = 'test-session' } - state.messages = {} + state.ui.set_windows({ output_win = win, output_buf = buf }) + state.session.set_active({ id = 'test-session' }) + state.renderer.set_messages({}) renderer._prev_line_count = 1 renderer._render_state:reset() end) @@ -424,9 +425,9 @@ describe('renderer._add_message_to_buffer scrolling', function() after_each(function() pcall(vim.api.nvim_win_close, win, true) pcall(vim.api.nvim_buf_delete, buf, { force = true }) - state.windows = nil - state.active_session = nil - state.messages = nil + state.ui.set_windows(nil) + state.session.set_active(nil) + state.renderer.set_messages(nil) renderer._prev_line_count = 0 renderer._render_state:reset() end) diff --git a/tests/unit/dialog_spec.lua b/tests/unit/dialog_spec.lua index aac1c253..f853e4c2 100644 --- a/tests/unit/dialog_spec.lua +++ b/tests/unit/dialog_spec.lua @@ -28,12 +28,12 @@ describe('Dialog', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) -- Mock input_window module package.loaded['opencode.ui.input_window'] = nil @@ -49,7 +49,7 @@ describe('Dialog', function() pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() package.loaded['opencode.ui.input_window'] = nil end) diff --git a/tests/unit/event_manager_spec.lua b/tests/unit/event_manager_spec.lua index 3eb79074..b7df5c61 100644 --- a/tests/unit/event_manager_spec.lua +++ b/tests/unit/event_manager_spec.lua @@ -171,13 +171,13 @@ describe('EventManager', function() end, } - state.opencode_server = nil + state.jobs.clear_server() event_manager:start() event_manager:stop() event_manager:start() - state.opencode_server = fake_server + state.jobs.set_server(fake_server) vim.wait(200, function() return subscribe_calls > 0 @@ -185,7 +185,7 @@ describe('EventManager', function() assert.are.equal(1, subscribe_calls) - state.opencode_server = nil + state.jobs.clear_server() event_manager._subscribe_to_server_events = original_subscribe_to_server_events vim.defer_fn = original_defer_fn end) diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 5f4c3e10..75f6210a 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -77,7 +77,7 @@ describe('hooks', function() end local events = helpers.load_test_data('tests/data/simple-session.json') - state.active_session = helpers.get_session_from_events(events, true) + state.session.set_active(helpers.get_session_from_events(events, true)) local loaded_session = helpers.load_session_from_events(events) renderer._render_full_session_data(loaded_session) @@ -90,7 +90,7 @@ describe('hooks', function() config.hooks.on_session_loaded = nil local events = helpers.load_test_data('tests/data/simple-session.json') - state.active_session = helpers.get_session_from_events(events, true) + state.session.set_active(helpers.get_session_from_events(events, true)) local loaded_session = helpers.load_session_from_events(events) assert.has_no.errors(function() @@ -104,7 +104,7 @@ describe('hooks', function() end local events = helpers.load_test_data('tests/data/simple-session.json') - state.active_session = helpers.get_session_from_events(events, true) + state.session.set_active(helpers.get_session_from_events(events, true)) local loaded_session = helpers.load_session_from_events(events) assert.has_no.errors(function() @@ -135,9 +135,9 @@ describe('hooks', function() state.subscribe('user_message_count', core._on_user_message_count_change) -- Simulate job count change from 1 to 0 (done thinking) for a specific session - state.active_session = { id = 'test-session', title = 'Test' } - state.user_message_count = { ['test-session'] = 1 } - state.user_message_count = { ['test-session'] = 0 } + state.session.set_active({ id = 'test-session', title = 'Test' }) + state.session.set_user_message_count({ ['test-session'] = 1 }) + state.session.set_user_message_count({ ['test-session'] = 0 }) -- Wait for async notification vim.wait(100, function() @@ -154,10 +154,10 @@ describe('hooks', function() it('should not error when hook is nil', function() config.hooks.on_done_thinking = nil - state.active_session = { id = 'test-session', title = 'Test' } - state.user_message_count = { ['test-session'] = 1 } + state.session.set_active({ id = 'test-session', title = 'Test' }) + state.session.set_user_message_count({ ['test-session'] = 1 }) assert.has_no.errors(function() - state.user_message_count = { ['test-session'] = 0 } + state.session.set_user_message_count({ ['test-session'] = 0 }) end) end) @@ -166,10 +166,10 @@ describe('hooks', function() error('test error') end - state.active_session = { id = 'test-session', title = 'Test' } - state.user_message_count = { ['test-session'] = 1 } + state.session.set_active({ id = 'test-session', title = 'Test' }) + state.session.set_user_message_count({ ['test-session'] = 1 }) assert.has_no.errors(function() - state.user_message_count = { ['test-session'] = 0 } + state.session.set_user_message_count({ ['test-session'] = 0 }) end) end) end) @@ -197,8 +197,8 @@ describe('hooks', function() state.subscribe('pending_permissions', core._on_current_permission_change) -- Simulate permission change from nil to a value - state.active_session = { id = 'test-session', title = 'Test' } - state.pending_permissions = { { tool = 'test_tool', action = 'read' } } + state.session.set_active({ id = 'test-session', title = 'Test' }) + state.renderer.set_pending_permissions({ { tool = 'test_tool', action = 'read' } }) -- Wait for async notification vim.wait(100, function() @@ -215,9 +215,8 @@ describe('hooks', function() it('should not error when hook is nil', function() config.hooks.on_permission_requested = nil - state.pending_permissions = {} assert.has_no.errors(function() - state.current_permission = { { tool = 'test_tool', action = 'read' } } + state.renderer.set_pending_permissions({ { tool = 'test_tool', action = 'read' } }) end) end) @@ -226,9 +225,8 @@ describe('hooks', function() error('test error') end - state.pending_permissions = {} assert.has_no.errors(function() - state.current_permission = { { tool = 'test_tool', action = 'read' } } + state.renderer.set_pending_permissions({ { tool = 'test_tool', action = 'read' } }) end) end) end) diff --git a/tests/unit/input_window_spec.lua b/tests/unit/input_window_spec.lua index ffdff171..67c5f7d2 100644 --- a/tests/unit/input_window_spec.lua +++ b/tests/unit/input_window_spec.lua @@ -54,12 +54,12 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo test' }) @@ -71,7 +71,7 @@ describe('input_window', function() vim.api.nvim_win_close(output_win, true) vim.api.nvim_buf_delete(input_buf, { force = true }) vim.api.nvim_buf_delete(output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() end) it('should display command output in output window', function() @@ -113,12 +113,12 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo "hello world"' }) @@ -134,7 +134,7 @@ describe('input_window', function() vim.api.nvim_win_close(output_win, true) vim.api.nvim_buf_delete(input_buf, { force = true }) vim.api.nvim_buf_delete(output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() end) it('should prompt user to add output to input', function() @@ -171,12 +171,12 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!ls' }) @@ -189,7 +189,7 @@ describe('input_window', function() vim.api.nvim_win_close(output_win, true) vim.api.nvim_buf_delete(input_buf, { force = true }) vim.api.nvim_buf_delete(output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() end) it('should append formatted output to input when user selects Yes', function() @@ -220,12 +220,12 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo test' }) @@ -247,7 +247,7 @@ describe('input_window', function() vim.api.nvim_win_close(output_win, true) vim.api.nvim_buf_delete(input_buf, { force = true }) vim.api.nvim_buf_delete(output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() end) it('should clear output window when user selects No', function() @@ -278,12 +278,12 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!echo test' }) @@ -296,7 +296,7 @@ describe('input_window', function() vim.api.nvim_win_close(output_win, true) vim.api.nvim_buf_delete(input_buf, { force = true }) vim.api.nvim_buf_delete(output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() end) it('should handle command errors', function() @@ -336,12 +336,12 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) vim.api.nvim_buf_set_lines(state.windows.input_buf, 0, -1, false, { '!invalid_command' }) @@ -354,7 +354,7 @@ describe('input_window', function() vim.api.nvim_win_close(output_win, true) vim.api.nvim_buf_delete(input_buf, { force = true }) vim.api.nvim_buf_delete(output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() end) end) @@ -383,14 +383,14 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } - state.input_content = { '' } - state.display_route = false + }) + state.ui.set_input_content({ '' }) + state.ui.clear_display_route() config.ui.input.auto_hide = true end) @@ -403,9 +403,9 @@ describe('input_window', function() pcall(vim.api.nvim_win_close, output_win, true) pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) - state.windows = nil - state.input_content = nil - state.display_route = nil + state.ui.clear_windows() + state.ui.set_input_content(nil) + state.ui.clear_display_route() input_window._hidden = false end) @@ -445,7 +445,7 @@ describe('input_window', function() it('should NOT auto-hide when input has content', function() vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'User message', 'Assistant response' }) vim.api.nvim_buf_set_lines(input_buf, 0, -1, false, { 'user typing...' }) - state.input_content = { 'user typing...' } + state.ui.set_input_content({ 'user typing...' }) local group = vim.api.nvim_create_augroup('test_input_window_autohide', { clear = true }) input_window.setup_autocmds(state.windows, group) @@ -463,7 +463,7 @@ describe('input_window', function() it('should NOT auto-hide when display_route is active', function() vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'User message', 'Assistant response' }) - state.display_route = true + state.ui.set_display_route(true) local group = vim.api.nvim_create_augroup('test_input_window_autohide', { clear = true }) input_window.setup_autocmds(state.windows, group) @@ -501,12 +501,12 @@ describe('input_window', function() col = 0, }) - state.windows = { + state.ui.set_windows({ input_buf = input_buf, input_win = input_win, output_buf = output_buf, output_win = output_win, - } + }) end) after_each(function() @@ -514,7 +514,7 @@ describe('input_window', function() pcall(vim.api.nvim_win_close, output_win, true) pcall(vim.api.nvim_buf_delete, input_buf, { force = true }) pcall(vim.api.nvim_buf_delete, output_buf, { force = true }) - state.windows = nil + state.ui.clear_windows() input_window._hidden = false end) diff --git a/tests/unit/loading_animation_spec.lua b/tests/unit/loading_animation_spec.lua index 22d5db71..a3a68e38 100644 --- a/tests/unit/loading_animation_spec.lua +++ b/tests/unit/loading_animation_spec.lua @@ -7,13 +7,13 @@ describe('loading_animation status text', function() before_each(function() original_time = os.time loading_animation._animation.status_data = nil - state.active_session = nil + state.session.clear_active() end) after_each(function() os.time = original_time loading_animation._animation.status_data = nil - state.active_session = nil + state.session.clear_active() end) it('renders busy as thinking text', function() @@ -44,7 +44,7 @@ describe('loading_animation status text', function() end) it('ignores status updates for non-active sessions', function() - state.active_session = { id = 'ses_active' } + state.session.set_active({ id = "ses_active" }) loading_animation._animation.status_data = nil loading_animation.on_session_status({ diff --git a/tests/unit/permission_integration_spec.lua b/tests/unit/permission_integration_spec.lua index 3b5a7f65..7e060852 100644 --- a/tests/unit/permission_integration_spec.lua +++ b/tests/unit/permission_integration_spec.lua @@ -7,9 +7,9 @@ describe('permission_integration', function() local captured_calls before_each(function() - state.messages = {} - state.pending_permissions = {} - state.active_session = { id = 'session_123' } + state.renderer.set_messages({}) + state.renderer.set_pending_permissions({}) + state.session.set_active({ id = 'session_123' }) permission_window._permission_queue = {} permission_window._dialog = nil @@ -32,7 +32,7 @@ describe('permission_integration', function() describe('on_part_updated permission correlation', function() it('correlates part with pending permission by callID and messageID', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_test_123', permission = 'bash', @@ -41,7 +41,7 @@ describe('permission_integration', function() callID = 'call_xyz', }, }, - } + }) local message = { info = { id = 'msg_abc', sessionID = 'session_123' }, @@ -72,14 +72,14 @@ describe('permission_integration', function() end) it('supports backward compatibility with root-level callID/messageID', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_legacy_456', permission = 'bash', messageID = 'msg_legacy', callID = 'call_legacy', }, - } + }) local message = { info = { id = 'msg_legacy', sessionID = 'session_123' }, @@ -108,7 +108,7 @@ describe('permission_integration', function() end) it('does not call update_permission_from_part when callID does not match', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_test_123', permission = 'bash', @@ -117,7 +117,7 @@ describe('permission_integration', function() callID = 'call_xyz', }, }, - } + }) local message = { info = { id = 'msg_abc', sessionID = 'session_123' }, @@ -145,7 +145,7 @@ describe('permission_integration', function() end) it('does not call update_permission_from_part when messageID does not match', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_test_123', permission = 'bash', @@ -154,7 +154,7 @@ describe('permission_integration', function() callID = 'call_xyz', }, }, - } + }) local message = { info = { id = 'msg_different', sessionID = 'session_123' }, @@ -182,7 +182,7 @@ describe('permission_integration', function() end) it('skips correlation when part has no callID', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_test_123', permission = 'bash', @@ -191,7 +191,7 @@ describe('permission_integration', function() callID = 'call_xyz', }, }, - } + }) local message = { info = { id = 'msg_abc', sessionID = 'session_123' }, @@ -214,7 +214,7 @@ describe('permission_integration', function() end) it('skips iteration when no pending permissions', function() - state.pending_permissions = {} + state.renderer.set_pending_permissions({}) local message = { info = { id = 'msg_abc', sessionID = 'session_123' }, @@ -242,7 +242,7 @@ describe('permission_integration', function() end) it('matches correct permission when multiple pending permissions exist', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_first', permission = 'bash', @@ -267,7 +267,7 @@ describe('permission_integration', function() callID = 'call_third', }, }, - } + }) local message = { info = { id = 'msg_second', sessionID = 'session_123' }, @@ -296,7 +296,7 @@ describe('permission_integration', function() end) it('breaks after first match to avoid duplicate updates', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_first', permission = 'bash', @@ -313,7 +313,7 @@ describe('permission_integration', function() callID = 'call_xyz', }, }, - } + }) local message = { info = { id = 'msg_abc', sessionID = 'session_123' }, @@ -342,7 +342,7 @@ describe('permission_integration', function() end) it('prefers tool.callID over root callID when both present', function() - state.pending_permissions = { + state.renderer.set_pending_permissions({ { id = 'per_test_123', permission = 'bash', @@ -353,7 +353,7 @@ describe('permission_integration', function() callID = 'tool_call_id', }, }, - } + }) local message = { info = { id = 'tool_msg_id', sessionID = 'session_123' }, diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index 982b3d51..5ee51d1e 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -1,4 +1,5 @@ local state = require('opencode.state') +local store = require('opencode.state.store') local config = require('opencode.config') local api = require('opencode.api') local ui = require('opencode.ui.ui') @@ -149,7 +150,7 @@ describe('persist_state', function() local function cleanup_windows() if state.windows then ui.close_windows(state.windows, false) - state.windows = nil + state.ui.set_windows(nil) end end @@ -178,15 +179,15 @@ describe('persist_state', function() original_api_client = state.api_client original_event_manager = state.event_manager - state.api_client = mock_api_client() - state.event_manager = EventManager.new() - state.windows = nil + state.jobs.set_api_client(mock_api_client()) + state.jobs.set_event_manager(EventManager.new()) + state.ui.set_windows(nil) state.ui.clear_hidden_window_state() - state.current_code_view = nil - state.current_code_buf = nil - state.last_code_win_before_opencode = nil - state.active_session = nil - state.messages = {} + store.set('current_code_view', nil) + store.set('current_code_buf', nil) + store.set('last_code_win_before_opencode', nil) + state.session.set_active(nil) + state.renderer.set_messages({}) -- Mock opencode_server to prevent spawning real process in CI local opencode_server = require('opencode.opencode_server') @@ -211,7 +212,7 @@ describe('persist_state', function() return mock_server end -- Pre-set the server to skip ensure_server - state.opencode_server = mock_server + store.set('opencode_server', mock_server) end) after_each(function() @@ -236,12 +237,12 @@ describe('persist_state', function() end) end - state.event_manager = original_event_manager - state.api_client = original_api_client + state.jobs.set_event_manager(original_event_manager) + state.jobs.set_api_client(original_api_client) config.values = original_config - state.current_code_view = nil - state.current_code_buf = nil - state.last_code_win_before_opencode = nil + store.set('current_code_view', nil) + store.set('current_code_buf', nil) + store.set('last_code_win_before_opencode', nil) state.ui.clear_hidden_window_state() -- Restore mocked opencode_server @@ -431,7 +432,7 @@ describe('persist_state', function() name = 'invalid_state_settles', run = function() cleanup_windows() - state.windows = { input_win = 99999 } + state.ui.set_windows({ input_win = 99999 }) local settled = false local p = api.toggle(false) @@ -446,7 +447,7 @@ describe('persist_state', function() end, 50) assert.is_true(settled) - state.windows = nil + state.ui.set_windows(nil) end, }, } @@ -651,8 +652,8 @@ describe('persist_state', function() local event_manager = state.event_manager local output_buf = state.windows.output_buf - state.active_session = { id = 'test-session' } - state.messages = {} + state.session.set_active({ id = 'test-session' }) + state.renderer.set_messages({}) toggle_wait('hidden') assert.equals('test-session', state.active_session.id) diff --git a/tests/unit/render_state_spec.lua b/tests/unit/render_state_spec.lua index 947042d8..c9ec06ac 100644 --- a/tests/unit/render_state_spec.lua +++ b/tests/unit/render_state_spec.lua @@ -6,11 +6,11 @@ describe('RenderState', function() before_each(function() render_state = RenderState.new() - state.messages = {} + state.renderer.set_messages({}) end) after_each(function() - state.messages = {} + state.renderer.set_messages({}) end) describe('new and reset', function() @@ -251,7 +251,7 @@ describe('RenderState', function() describe('update_part_lines', function() before_each(function() - state.messages = { + state.renderer.set_messages({ { info = { id = 'msg1' }, parts = { @@ -259,7 +259,7 @@ describe('RenderState', function() { id = 'part2' }, }, }, - } + }) end) it('updates part line positions', function() @@ -308,7 +308,7 @@ describe('RenderState', function() describe('remove_part', function() before_each(function() - state.messages = { + state.renderer.set_messages({ { info = { id = 'msg1' }, parts = { @@ -316,7 +316,7 @@ describe('RenderState', function() { id = 'part2' }, }, }, - } + }) end) it('removes part and shifts subsequent content', function() @@ -372,14 +372,14 @@ describe('RenderState', function() describe('remove_message', function() before_each(function() - state.messages = { + state.renderer.set_messages({ { info = { id = 'msg1' }, }, { info = { id = 'msg2' }, }, - } + }) end) it('removes message and shifts subsequent content', function() @@ -416,7 +416,7 @@ describe('RenderState', function() describe('shift_all', function() before_each(function() - state.messages = { + state.renderer.set_messages({ { info = { id = 'msg1' }, parts = { @@ -424,7 +424,7 @@ describe('RenderState', function() { id = 'part2' }, }, }, - } + }) end) it('does nothing when delta is 0', function() diff --git a/tests/unit/server_job_spec.lua b/tests/unit/server_job_spec.lua index 7839e894..cbb4af93 100644 --- a/tests/unit/server_job_spec.lua +++ b/tests/unit/server_job_spec.lua @@ -138,14 +138,14 @@ describe('server_job', function() return false end - state.opencode_server = nil + state.jobs.clear_server() end) after_each(function() config.values.server.port = original_port config.values.server.url = original_url config.values.server.spawn_command = original_spawn_command - state.opencode_server = original_opencode_server + state.jobs.set_server(original_opencode_server) port_mapping.find_any_existing_port = original_find_any_existing_port port_mapping.find_port_for_directory = original_find_port_for_directory diff --git a/tests/unit/session_spec.lua b/tests/unit/session_spec.lua index 46d76cbb..ced1265b 100644 --- a/tests/unit/session_spec.lua +++ b/tests/unit/session_spec.lua @@ -145,7 +145,7 @@ describe('opencode.session', function() end -- Mock the api_client to return session data - state.api_client = { + state.jobs.set_api_client({ list_sessions = function() local sessions = {} local session_source = mock_data.session_list or session_list_mock @@ -191,7 +191,7 @@ describe('opencode.session', function() end return promise end, - } + }) end) -- Clean up after each test @@ -205,7 +205,7 @@ describe('opencode.session', function() vim.fn.json_decode = original_json_decode util.is_git_project = original_is_git_project config_file.get_opencode_project = original_get_opencode_project - state.api_client = original_api_client + state.jobs.set_api_client(original_api_client) mock_data = {} end) diff --git a/tests/unit/snapshot_spec.lua b/tests/unit/snapshot_spec.lua index 8390ea00..3fa944ea 100644 --- a/tests/unit/snapshot_spec.lua +++ b/tests/unit/snapshot_spec.lua @@ -43,7 +43,7 @@ describe('snapshot.restore', function() return p end - state.active_session = { snapshot_path = '/mock/gitdir' } + state.session.set_active({ snapshot_path = '/mock/gitdir' }) vim.g._last_notify = nil vim.g._last_system = nil end) @@ -53,7 +53,7 @@ describe('snapshot.restore', function() vim.system = orig_system vim.fn.getcwd = orig_getcwd config_file.get_workspace_snapshot_path = orig_get_workspace_snapshot_path - state.active_session = nil + state.session.clear_active() vim.g._last_notify = nil vim.g._last_system = nil system_calls = {} @@ -77,7 +77,7 @@ describe('snapshot.restore', function() end) it('notifies error if no active session', function() - state.active_session = nil + state.session.clear_active() -- When there's no active session, the promise still resolves but snapshot_git will fail config_file.get_workspace_snapshot_path = function() local p = Promise.new() diff --git a/tests/unit/state_spec.lua b/tests/unit/state_spec.lua index 2d8c9970..067389f7 100644 --- a/tests/unit/state_spec.lua +++ b/tests/unit/state_spec.lua @@ -2,27 +2,28 @@ -- Tests for the observable state module local state = require('opencode.state') +local store = require('opencode.state.store') describe('opencode.state (observable)', function() it('notifies listeners on key change', function() local called = false local changed_key, new_val, old_val - state.subscribe('test_key', function(key, newv, oldv) + state.subscribe('messages', function(key, newv, oldv) called = true changed_key = key new_val = newv old_val = oldv end) - state.test_key = 123 + state.renderer.set_messages({ { id = 'test' } }) vim.wait(50, function() return called == true end) assert.is_true(called) - assert.equals('test_key', changed_key) - assert.equals(123, new_val) - assert.is_nil(old_val) + assert.equals('messages', changed_key) + assert.same({ { id = 'test' } }, new_val) -- Clean up - state.test_key = nil + state.renderer.set_messages(nil) + state.unsubscribe('messages', nil) end) it('notifies wildcard listeners on any key change', function() @@ -34,16 +35,16 @@ describe('opencode.state (observable)', function() new_val = newv old_val = oldv end) - state.another_key = 'abc' + state.renderer.set_cost(99) vim.wait(50, function() return called == true end) assert.is_true(called) - assert.equals('another_key', changed_key) - assert.equals('abc', new_val) - assert.is_nil(old_val) + assert.equals('cost', changed_key) + assert.equals(99, new_val) -- Clean up - state.another_key = nil + state.renderer.set_cost(0) + state.unsubscribe('*', nil) end) it('can unregister listeners', function() @@ -51,17 +52,17 @@ describe('opencode.state (observable)', function() local cb = function() called = called + 1 end - state.subscribe('foo', cb) - state.foo = 1 + state.subscribe('tokens_count', cb) + state.renderer.set_tokens_count(1) vim.wait(50, function() return called == 1 end) - state.unsubscribe('foo', cb) - state.foo = 2 + state.unsubscribe('tokens_count', cb) + state.renderer.set_tokens_count(2) vim.wait(50) assert.equals(1, called) -- Clean up - state.foo = nil + state.renderer.set_tokens_count(0) end) it('does not register duplicate listeners for the same callback', function() @@ -70,34 +71,41 @@ describe('opencode.state (observable)', function() called = called + 1 end - state.subscribe('dup_key', cb) - state.subscribe('dup_key', cb) + state.subscribe('cost', cb) + state.subscribe('cost', cb) - state.dup_key = 'value' + state.renderer.set_cost(1) vim.wait(50, function() return called > 0 end) assert.equals(1, called) - state.unsubscribe('dup_key', cb) - state.dup_key = nil + state.unsubscribe('cost', cb) + state.renderer.set_cost(0) end) it('does not notify if value is unchanged', function() local called = false - state.subscribe('bar', function() + state.subscribe('tokens_count', function() called = true end) - state.bar = 42 + state.renderer.set_tokens_count(42) vim.wait(50, function() return called == true end) called = false - state.bar = 42 + state.renderer.set_tokens_count(42) vim.wait(50) assert.is_false(called) -- Clean up - state.bar = nil + state.renderer.set_tokens_count(0) + state.unsubscribe('tokens_count', nil) + end) + + it('errors on direct state write', function() + assert.has_error(function() + state.messages = {} + end) end) end) diff --git a/tests/unit/zoom_spec.lua b/tests/unit/zoom_spec.lua index b8d218b7..2ec50c6e 100644 --- a/tests/unit/zoom_spec.lua +++ b/tests/unit/zoom_spec.lua @@ -43,13 +43,13 @@ describe('ui zoom state', function() output_buf = output_buf, output_win = output_win, } - state.windows = windows - state.pre_zoom_width = nil + state.ui.set_windows(windows) + state.ui.set_pre_zoom_width(nil) end) after_each(function() vim.o.columns = original_columns - state.pre_zoom_width = nil + state.ui.set_pre_zoom_width(nil) if windows then pcall(vim.api.nvim_win_close, windows.input_win, true) @@ -57,7 +57,7 @@ describe('ui zoom state', function() pcall(vim.api.nvim_buf_delete, windows.input_buf, { force = true }) pcall(vim.api.nvim_buf_delete, windows.output_buf, { force = true }) end - state.windows = nil + state.ui.clear_windows() end) describe('toggle_zoom', function() @@ -109,7 +109,7 @@ describe('ui zoom state', function() end) it('does not change input window width when zoomed', function() - state.pre_zoom_width = 80 + state.ui.set_pre_zoom_width(80) local original_width = vim.api.nvim_win_get_width(windows.input_win) input_window.update_dimensions(windows) @@ -119,7 +119,7 @@ describe('ui zoom state', function() end) it('preserves zoom state after update_dimensions', function() - state.pre_zoom_width = 80 + state.ui.set_pre_zoom_width(80) input_window.update_dimensions(windows) @@ -211,7 +211,7 @@ describe('ui zoom state', function() end) it('uses zoom_width when zoomed', function() - state.pre_zoom_width = 80 + state.ui.set_pre_zoom_width(80) output_window.update_dimensions(windows) @@ -222,7 +222,7 @@ describe('ui zoom state', function() end) it('preserves zoom state after update_dimensions', function() - state.pre_zoom_width = 80 + state.ui.set_pre_zoom_width(80) output_window.update_dimensions(windows) @@ -302,7 +302,7 @@ describe('ui zoom state', function() end) it('prefers saved width over zoom width', function() - state.pre_zoom_width = 80 + state.ui.set_pre_zoom_width(80) local saved_ratio = 0.5 windows.saved_width_ratio = saved_ratio From 153c1a282d59af62e57ac5e57fd9066d8afa446a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 13 Mar 2026 09:20:57 -0400 Subject: [PATCH 05/13] refactor(state): add mutation type annotations for state modules --- lua/opencode/state/context.lua | 6 ++++ lua/opencode/state/init.lua | 58 --------------------------------- lua/opencode/state/jobs.lua | 10 ++++++ lua/opencode/state/renderer.lua | 9 +++++ lua/opencode/state/session.lua | 8 +++++ lua/opencode/state/ui.lua | 16 +++++++++ 6 files changed, 49 insertions(+), 58 deletions(-) diff --git a/lua/opencode/state/context.lua b/lua/opencode/state/context.lua index 571fcf90..692bbc4e 100644 --- a/lua/opencode/state/context.lua +++ b/lua/opencode/state/context.lua @@ -1,5 +1,11 @@ + local store = require('opencode.state.store') +---@class OpencodeContextStateMutations +---@field set_current_context_config fun(config: OpencodeContextConfig|nil) +---@field set_context_updated_at fun(timestamp: number|nil) +---@field set_current_cwd fun(cwd: string|nil) + local M = {} ---@param config OpencodeContextConfig|nil diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index d4e7e176..45e0ec0f 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -23,64 +23,6 @@ ---@class OpencodeToggleDecision ---@field action 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' ----@class OpencodeSessionStateMutations ----@field set_active fun(session: Session|nil, opts?: OpencodeProtectedStateSetOptions) ----@field clear_active fun(opts?: OpencodeProtectedStateSetOptions) ----@field set_restore_points fun(points: RestorePoint[], opts?: OpencodeProtectedStateSetOptions) ----@field reset_restore_points fun(opts?: OpencodeProtectedStateSetOptions) ----@field set_last_sent_context fun(context: OpencodeContext|nil) ----@field set_user_message_count fun(count: table) - ----@class OpencodeJobStateMutations ----@field increment_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) ----@field decrement_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) ----@field set_count fun(count: integer, opts?: OpencodeProtectedStateSetOptions) ----@field set_server fun(server: OpencodeServer|nil, opts?: OpencodeProtectedStateSetOptions) ----@field clear_server fun(opts?: OpencodeProtectedStateSetOptions) ----@field set_api_client fun(client: OpencodeApiClient|nil) ----@field set_event_manager fun(manager: EventManager|nil) ----@field set_opencode_cli_version fun(version: string|nil) - ----@class OpencodeUiStateMutations ----@field set_windows fun(windows: OpencodeWindowState|nil) ----@field clear_windows fun() ----@field set_opening fun(is_opening: boolean) ----@field set_panel_focused fun(is_focused: boolean) ----@field set_last_focused_window fun(win_type: 'input'|'output'|nil) ----@field set_display_route fun(route: any) ----@field clear_display_route fun() ----@field set_last_code_window fun(win_id: integer|nil) ----@field set_current_code_buf fun(bufnr: integer|nil) ----@field set_last_window_width_ratio fun(ratio: number|nil) ----@field clear_last_window_width_ratio fun() ----@field set_input_content fun(lines: table) ----@field set_saved_window_options fun(opts: table|nil) ----@field set_pre_zoom_width fun(width: integer|nil) - ----@class OpencodeModelStateMutations ----@field set_mode fun(mode: string|nil) ----@field clear_mode fun() ----@field set_model fun(model: string|nil) ----@field clear_model fun() ----@field set_model_info fun(info: table|nil) ----@field set_variant fun(variant: string|nil) ----@field clear_variant fun() ----@field set_mode_model_map fun(mode_map: table) ----@field set_mode_model_override fun(mode: string, model: string) - ----@class OpencodeRendererStateMutations ----@field set_messages fun(messages: OpencodeMessage[]|nil) ----@field set_current_message fun(message: OpencodeMessage|nil) ----@field set_last_user_message fun(message: OpencodeMessage|nil) ----@field set_pending_permissions fun(permissions: OpencodePermission[]) ----@field set_cost fun(cost: number) ----@field set_tokens_count fun(count: number) - ----@class OpencodeContextStateMutations ----@field set_current_context_config fun(config: OpencodeContextConfig|nil) ----@field set_context_updated_at fun(timestamp: number|nil) ----@field set_current_cwd fun(cwd: string|nil) - ---@class OpencodeState ---@field windows OpencodeWindowState|nil ---@field is_opening boolean diff --git a/lua/opencode/state/jobs.lua b/lua/opencode/state/jobs.lua index 7fbffe50..45b99898 100644 --- a/lua/opencode/state/jobs.lua +++ b/lua/opencode/state/jobs.lua @@ -1,5 +1,15 @@ local store = require('opencode.state.store') +---@class OpencodeJobStateMutations +---@field increment_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) +---@field decrement_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) +---@field set_count fun(count: integer, opts?: OpencodeProtectedStateSetOptions) +---@field set_server fun(server: OpencodeServer|nil, opts?: OpencodeProtectedStateSetOptions) +---@field clear_server fun(opts?: OpencodeProtectedStateSetOptions) +---@field set_api_client fun(client: OpencodeApiClient|nil) +---@field set_event_manager fun(manager: EventManager|nil) +---@field set_opencode_cli_version fun(version: string|nil) + local M = {} ---@param delta integer|nil diff --git a/lua/opencode/state/renderer.lua b/lua/opencode/state/renderer.lua index 883fd379..002bbff1 100644 --- a/lua/opencode/state/renderer.lua +++ b/lua/opencode/state/renderer.lua @@ -1,5 +1,14 @@ + local store = require('opencode.state.store') +---@class OpencodeRendererStateMutations +---@field set_messages fun(messages: OpencodeMessage[]|nil) +---@field set_current_message fun(message: OpencodeMessage|nil) +---@field set_last_user_message fun(message: OpencodeMessage|nil) +---@field set_pending_permissions fun(permissions: OpencodePermission[]) +---@field set_cost fun(cost: number) +---@field set_tokens_count fun(count: number) + local M = {} ---@param messages OpencodeMessage[]|nil diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index 55c01956..d889ed44 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -1,5 +1,13 @@ local store = require('opencode.state.store') +---@class OpencodeSessionStateMutations +---@field set_active fun(session: Session|nil, opts?: OpencodeProtectedStateSetOptions) +---@field clear_active fun(opts?: OpencodeProtectedStateSetOptions) +---@field set_restore_points fun(points: RestorePoint[], opts?: OpencodeProtectedStateSetOptions) +---@field reset_restore_points fun(opts?: OpencodeProtectedStateSetOptions) +---@field set_last_sent_context fun(context: OpencodeContext|nil) +---@field set_user_message_count fun(count: table) + local M = {} ---@param session Session|nil diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index c52c212b..3983c9e3 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -1,5 +1,21 @@ local store = require('opencode.state.store') +---@class OpencodeUiStateMutations +---@field set_windows fun(windows: OpencodeWindowState|nil) +---@field clear_windows fun() +---@field set_opening fun(is_opening: boolean) +---@field set_panel_focused fun(is_focused: boolean) +---@field set_last_focused_window fun(win_type: 'input'|'output'|nil) +---@field set_display_route fun(route: any) +---@field clear_display_route fun() +---@field set_last_code_window fun(win_id: integer|nil) +---@field set_current_code_buf fun(bufnr: integer|nil) +---@field set_last_window_width_ratio fun(ratio: number|nil) +---@field clear_last_window_width_ratio fun() +---@field set_input_content fun(lines: table) +---@field set_saved_window_options fun(opts: table|nil) +---@field set_pre_zoom_width fun(width: integer|nil) + local M = {} local _state = store.state() From 5c2fa45a70a8afbfd38aaea1e1f302299c2dc0a4 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 13 Mar 2026 09:23:23 -0400 Subject: [PATCH 06/13] refactor(state): rename notify to emit --- lua/opencode/state/init.lua | 2 +- lua/opencode/state/store.lua | 8 +- state_refactor.md | 150 ----------------------------------- 3 files changed, 5 insertions(+), 155 deletions(-) delete mode 100644 state_refactor.md diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index 45e0ec0f..ef7ea866 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -93,7 +93,7 @@ local M = { context = context, subscribe = store.subscribe, unsubscribe = store.unsubscribe, - notify = store.notify, + emit = store.emit, append = store.append, remove = store.remove, } diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index fd8551dc..10327445 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -82,7 +82,7 @@ function M.set(key, value, opts) _state[key] = value if not vim.deep_equal(old, value) then - M.notify(key, value, old) + M.emit(key, value, old) end return value @@ -148,7 +148,7 @@ function M.unsubscribe(key, cb) end end -function M.notify(key, new_val, old_val) +function M.emit(key, new_val, old_val) vim.schedule(function() if _listeners[key] then for _, cb in ipairs(_listeners[key]) do @@ -182,7 +182,7 @@ function M.append(key, value) local old = vim.deepcopy(_state[key] --[[@as table]]) table.insert(_state[key] --[[@as table]], value) - M.notify(key, _state[key], old) + M.emit(key, _state[key], old) end ---@param key string @@ -197,7 +197,7 @@ function M.remove(key, idx) local old = vim.deepcopy(_state[key] --[[@as table]]) table.remove(_state[key] --[[@as table]], idx) - M.notify(key, _state[key], old) + M.emit(key, _state[key], old) end return M diff --git a/state_refactor.md b/state_refactor.md deleted file mode 100644 index cf0cf5d9..00000000 --- a/state_refactor.md +++ /dev/null @@ -1,150 +0,0 @@ -```markdown -# State Refactor Plan - -## Goal - -Refactor the plugin global state into a small store + slice modules so writes are funneled through safe, domain-owned APIs while preserving existing read semantics and notification timing. - -## Recommended starting work (default) - -Implement the store primitive, add a test escape hatch to silence protected-write warnings, and extract the `session` and `jobs` slices. This gives immediate safety for the highest-value mutation domains and a clear migration path for the rest. - -## High-level roadmap - -1. store: implement `lua/opencode/state/store.lua` (get/set/update/subscribe/notify) preserving current scheduling semantics. -2. test escape hatch: add a way for tests to set raw state without warnings (e.g. `store.set_raw` or an env var), plus `lua/opencode/state/test_helpers.lua`. -3. slices: create `lua/opencode/state/session.lua` and `lua/opencode/state/jobs.lua` that use store APIs and expose the existing helper signatures. -4. facade: create `lua/opencode/state/init.lua` to re-export store and slices and to preserve backward-compatible read access for callers still requiring `require('opencode.state')`. -5. migrate callers in small batches (server/jobs first, then UI and model). -6. add small lifecycle helpers/state machines in `state/jobs.lua` and `state/ui.lua`. -7. tighten policy (warnings -> errors) and cleanup once tests and migration are complete. - -## Concrete tasks - -- store - - File: `lua/opencode/state/store.lua` - - API: - - `get(key)` - - `set(key, value, opts?)` where `opts.source` is `'helper'|'raw'` and controls warnings - - `update(key, fn, opts?)` - - `subscribe(key_or_pattern, cb)` (support key-based subscriptions; preserve scheduling semantics) - - `notify(...)` (optional internal) - - Behavior: - - Preserve current notification timing (use `vim.schedule` if current code does) - - Centralize `PROTECTED_KEYS` logic and warn once per key on raw writes - - Provide `set_raw` or `set(key, value, {silent=true})` for tests - -- test escape hatch - - File: `lua/opencode/state/test_helpers.lua` (or export functions from `store`) - - API: - - `silence_protected_writes()` / `allow_raw_writes_for_tests()` — minimal API for test suites - - Usage: - - Tests can call the helper in a setup block to avoid noisy warnings while they directly mutate state - -- session slice - - File: `lua/opencode/state/session.lua` - - Exported helpers (match existing names): - - `set_active(session, opts?)` - - `clear_active()` - - `set_restore_points(points)` - - `reset_restore_points()` - - any other session helpers already in  `lua/opencode/state.lua` - - Implementation: - - Use `store.set`/`update` and `store.subscribe` where necessary - - Keep call-site signatures unchanged - -- jobs slice - - File: `lua/opencode/state/jobs.lua` - - Exported helpers: - - `increment_count()` - - `decrement_count()` (if required) - - `set_count(n)` - - `set_server(server)` - - `clear_server()` - - small lifecycle helpers like `ensure_server()`/`on_server_start()` optionally - - Implementation: - - Use `store` primitives; centralize server lifecycle transitions - -- facade/init - - File: `lua/opencode/state/init.lua` (module returned by `require('opencode.state')`) - - Re-export: - - `store` or thin read-proxy for backward compatibility - - `session`, `jobs`, `ui`, `model` slices as they become available - - Behavior: - - Reads (e.g., `state.some_key`) should continue to work for existing code - - Writes should be routed through slices or still emit protected-write warnings if raw - -## Migration strategy - -- Do not change everything in one PR. Use multiple, small PRs: - 1. Add `store.lua`, `test_helpers.lua`, no callers changed. - 2. Add `state/session.lua` and `state/jobs.lua`. - 3. Add `state/init.lua` facade; update a small set of callers to require new slices or to call `state.session.*`. - 4. Migrate other call sites in batches (server/job, then UI, then model). - 5. Final cleanup and tighten warning policy. -- After each change: run  `./run_tests.sh` and fix failures. -- For tests that directly mutate `state.*`, either: - 1. Use the test helper to silence warnings, or - 2. Update tests to use the new slice helpers (preferred long term). - -## Files to create (initial) - -- `lua/opencode/state/store.lua` -- `lua/opencode/state/test_helpers.lua` -- `lua/opencode/state/session.lua` -- `lua/opencode/state/jobs.lua` -- `lua/opencode/state/init.lua` - -## Testing & verification - -- Run unit tests after each step:  `./run_tests.sh` -- Manually exercise UI/server flows for timing-sensitive behavior (notifications should be scheduled same as before) -- Verify that protected-key warnings are logged once per key and that tests can silence them via test helper -- Grep for raw writes: `rg "state\.[a-zA-Z_][a-zA-Z0-9_]*\s*="` and migrate the important ones first (session, jobs, ui, model) - -## Commit & PR guidance - -- Keep commits small and descriptive: - 1. "feat(state): add store primitive and test helpers" - 2. "feat(state/session, jobs): move session and jobs helpers to slices" - 3. "refactor(state): add facade and wire slice exports" - 4. "refactor: migrate server/job call sites to state.jobs API" -- Each PR should aim to be test-green and include a short migration note. -- Don’t amend commits; create new commits for fixes. - -## Timing & milestones (example) - -- Day 1: Implement `store.lua` + `test_helpers.lua` + unit tests for store behavior -- Day 2: Implement `session.lua` + `jobs.lua`, add tests for slices -- Day 3: Add `state/init.lua` facade and migrate a small set of callers -- Day 4: Migrate remaining high-value callers, run full test suite -- Day 5: Small cleanups, tighten warnings (optional) - -## Risks & mitigations - -- Risk: notification/timing changes introduce subtle bugs - - Mitigation: preserve `vim.schedule` usage and run integration-like tests -- Risk: tests fail due to direct state mutation - - Mitigation: provide test escape hatch and gradually update tests to use new helpers -- Risk: large PRs are hard to review - - Mitigation: split work into small PRs focused on one slice or behavior at a time - -## Open questions (pick one) - -1. Start now with the recommended default: implement `store + test escape hatch + session + jobs`? (Recommended) -2. Or would you prefer I extract a different slice first (e.g. `ui` or `model`)? - -If you pick (1), I will produce ready-to-apply code snippets for: - -- `lua/opencode/state/store.lua` -- `lua/opencode/state/test_helpers.lua` -- `lua/opencode/state/session.lua` -- `lua/opencode/state/jobs.lua` -- `lua/opencode/state/init.lua` - -You can then paste them into files or ask me to apply them to the repository. -``` - -## Next step - -- Reply with the option you want (1 = default store+tests+session+jobs, 2 = extract different slice first) or edit the markdown above and tell me which parts to change. From 25ad74275afd67911d9216671baddc6d74ca7900 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 13 Mar 2026 15:43:28 -0400 Subject: [PATCH 07/13] fix: unsubscribe in tests --- lua/opencode/state/store.lua | 54 +++++++++++++++++++++++------------- tests/unit/state_spec.lua | 22 ++++++++------- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index 10327445..0e91b4d3 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -2,8 +2,11 @@ ---@field source? 'helper'|'raw' ---@field silent? boolean +---@alias StateValue K extends keyof OpencodeState and StateValue or never + local M = {} +---@type OpencodeState local _state = { windows = nil, is_opening = false, @@ -62,16 +65,20 @@ function M.state() return _state end ----@param key string ----@return any +---@generic K extends keyof OpencodeState +---@param key K +---@return OpencodeState[K] function M.get(key) return _state[key] end ----@param key string ----@param value any +local c = M.get('user_message_counte') + +---@generic K extends keyof OpencodeState +---@param key K +---@param value StateValue ---@param opts? OpencodeProtectedStateSetOptions ----@return any +---@return StateValue function M.set(key, value, opts) local old = _state[key] opts = opts or { source = 'helper' } @@ -88,28 +95,30 @@ function M.set(key, value, opts) return value end ----@param key string ----@param value any +---@generic K extends keyof OpencodeState +---@param key K +---@param value StateValue ---@param opts? OpencodeProtectedStateSetOptions ----@return any +---@return StateValue function M.set_raw(key, value, opts) local next_opts = vim.tbl_extend('force', { source = 'raw' }, opts or {}) return M.set(key, value, next_opts) end ----@generic T ----@param key string ----@param updater fun(current: T): T +---@generic K extends keyof OpencodeState +---@param key K +---@param updater fun(current: StateValue): StateValue ---@param opts? OpencodeProtectedStateSetOptions ----@return T +---@return StateValue function M.update(key, updater, opts) local next_value = updater(_state[key]) M.set(key, next_value, opts) return next_value end ----@param key string|string[]|nil ----@param cb fun(key:string, new_val:any, old_val:any) +---@generic K extends keyof OpencodeState +---@param key K|K[]|nil +---@param cb fun(key:K, new_val:StateValue, old_val:StateValue) function M.subscribe(key, cb) if type(key) == 'table' then for _, current_key in ipairs(key) do @@ -132,8 +141,9 @@ function M.subscribe(key, cb) table.insert(_listeners[key], cb) end ----@param key string|nil ----@param cb fun(key:string, new_val:any, old_val:any) +---@generic K extends keyof OpencodeState +---@param key K|nil +---@param cb fun(key:K, new_val:StateValue, old_val:StateValue) function M.unsubscribe(key, cb) key = key or '*' local list = _listeners[key] @@ -148,6 +158,10 @@ function M.unsubscribe(key, cb) end end +---@generic K extends keyof OpencodeState +---@param key K +---@param new_val StateValue +---@param old_val StateValue function M.emit(key, new_val, old_val) vim.schedule(function() if _listeners[key] then @@ -167,8 +181,9 @@ function M.emit(key, new_val, old_val) end) end ----@param key string ----@param value any +---@generic K extends keyof OpencodeState +---@param key K +---@param value StateValue extends any[] and StateValue[integer] or never function M.append(key, value) if type(value) ~= 'table' then error('Value must be a table to append') @@ -185,7 +200,8 @@ function M.append(key, value) M.emit(key, _state[key], old) end ----@param key string +---@generic K extends keyof OpencodeState +---@param key K ---@param idx integer function M.remove(key, idx) if not _state[key] then diff --git a/tests/unit/state_spec.lua b/tests/unit/state_spec.lua index 067389f7..e9c4abde 100644 --- a/tests/unit/state_spec.lua +++ b/tests/unit/state_spec.lua @@ -2,18 +2,18 @@ -- Tests for the observable state module local state = require('opencode.state') -local store = require('opencode.state.store') describe('opencode.state (observable)', function() it('notifies listeners on key change', function() local called = false local changed_key, new_val, old_val - state.subscribe('messages', function(key, newv, oldv) + local cb = function(key, newv, oldv) called = true changed_key = key new_val = newv old_val = oldv - end) + end + state.subscribe('messages', cb) state.renderer.set_messages({ { id = 'test' } }) vim.wait(50, function() return called == true @@ -23,18 +23,19 @@ describe('opencode.state (observable)', function() assert.same({ { id = 'test' } }, new_val) -- Clean up state.renderer.set_messages(nil) - state.unsubscribe('messages', nil) + state.unsubscribe('messages', cb) end) it('notifies wildcard listeners on any key change', function() local called = false local changed_key, new_val, old_val - state.subscribe('*', function(key, newv, oldv) + local cb = function(key, newv, oldv) called = true changed_key = key new_val = newv old_val = oldv - end) + end + state.subscribe('*', cb) state.renderer.set_cost(99) vim.wait(50, function() return called == true @@ -44,7 +45,7 @@ describe('opencode.state (observable)', function() assert.equals(99, new_val) -- Clean up state.renderer.set_cost(0) - state.unsubscribe('*', nil) + state.unsubscribe('*', cb) end) it('can unregister listeners', function() @@ -87,9 +88,10 @@ describe('opencode.state (observable)', function() it('does not notify if value is unchanged', function() local called = false - state.subscribe('tokens_count', function() + local cb = function() called = true - end) + end + state.subscribe('tokens_count', cb) state.renderer.set_tokens_count(42) vim.wait(50, function() return called == true @@ -100,7 +102,7 @@ describe('opencode.state (observable)', function() assert.is_false(called) -- Clean up state.renderer.set_tokens_count(0) - state.unsubscribe('tokens_count', nil) + state.unsubscribe('tokens_count', cb) end) it('errors on direct state write', function() From ff2bf75d7aea1dde53432ff6f3208fc528c23487 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 13 Mar 2026 15:47:00 -0400 Subject: [PATCH 08/13] fix: context setting immutability --- lua/opencode/context.lua | 8 +++++--- lua/opencode/state/store.lua | 26 +++++++++++--------------- lua/opencode/state/ui.lua | 4 ++-- lua/opencode/ui/renderer.lua | 1 + 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 2019a8c0..444f744a 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -24,10 +24,12 @@ local toggleable_context_keys = { local function ensure_context_state(context_key) local current_config = state.current_context_config or {} local current = current_config[context_key] + local new_config = vim.deepcopy(current_config) local defaults = vim.tbl_get(config, 'context', context_key) or {} - current_config[context_key] = vim.tbl_deep_extend('force', {}, defaults, current or {}) - state.context.set_current_context_config(current_config) - return current_config[context_key] + + new_config[context_key] = vim.tbl_deep_extend('force', {}, defaults, current or {}) + state.context.set_current_context_config(new_config) + return new_config[context_key] end M.ChatContext = ChatContext diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index 0e91b4d3..838f3917 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -2,8 +2,6 @@ ---@field source? 'helper'|'raw' ---@field silent? boolean ----@alias StateValue K extends keyof OpencodeState and StateValue or never - local M = {} ---@type OpencodeState @@ -72,13 +70,11 @@ function M.get(key) return _state[key] end -local c = M.get('user_message_counte') - ---@generic K extends keyof OpencodeState ---@param key K ----@param value StateValue +---@param value OpencodeState[K] ---@param opts? OpencodeProtectedStateSetOptions ----@return StateValue +---@return OpencodeState[K] function M.set(key, value, opts) local old = _state[key] opts = opts or { source = 'helper' } @@ -97,9 +93,9 @@ end ---@generic K extends keyof OpencodeState ---@param key K ----@param value StateValue +---@param value OpencodeState[K] ---@param opts? OpencodeProtectedStateSetOptions ----@return StateValue +---@return OpencodeState[K] function M.set_raw(key, value, opts) local next_opts = vim.tbl_extend('force', { source = 'raw' }, opts or {}) return M.set(key, value, next_opts) @@ -107,9 +103,9 @@ end ---@generic K extends keyof OpencodeState ---@param key K ----@param updater fun(current: StateValue): StateValue +---@param updater fun(current: OpencodeState[K]): OpencodeState[K] ---@param opts? OpencodeProtectedStateSetOptions ----@return StateValue +---@return OpencodeState[K] function M.update(key, updater, opts) local next_value = updater(_state[key]) M.set(key, next_value, opts) @@ -118,7 +114,7 @@ end ---@generic K extends keyof OpencodeState ---@param key K|K[]|nil ----@param cb fun(key:K, new_val:StateValue, old_val:StateValue) +---@param cb fun(key:K, new_val:OpencodeState[K], old_val:OpencodeState[K]) function M.subscribe(key, cb) if type(key) == 'table' then for _, current_key in ipairs(key) do @@ -143,7 +139,7 @@ end ---@generic K extends keyof OpencodeState ---@param key K|nil ----@param cb fun(key:K, new_val:StateValue, old_val:StateValue) +---@param cb fun(key:K, new_val:OpencodeState[K], old_val:OpencodeState[K]) function M.unsubscribe(key, cb) key = key or '*' local list = _listeners[key] @@ -160,8 +156,8 @@ end ---@generic K extends keyof OpencodeState ---@param key K ----@param new_val StateValue ----@param old_val StateValue +---@param new_val OpencodeState[K] +---@param old_val OpencodeState[K] function M.emit(key, new_val, old_val) vim.schedule(function() if _listeners[key] then @@ -183,7 +179,7 @@ end ---@generic K extends keyof OpencodeState ---@param key K ----@param value StateValue extends any[] and StateValue[integer] or never +---@param value OpencodeState[K] extends any[] and OpencodeState[K][integer] or never function M.append(key, value) if type(value) ~= 'table' then error('Value must be a table to append') diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index 3983c9e3..d92a39f3 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -12,7 +12,7 @@ local store = require('opencode.state.store') ---@field set_current_code_buf fun(bufnr: integer|nil) ---@field set_last_window_width_ratio fun(ratio: number|nil) ---@field clear_last_window_width_ratio fun() ----@field set_input_content fun(lines: table) +---@field set_input_content fun(lines: table|nil) ---@field set_saved_window_options fun(opts: table|nil) ---@field set_pre_zoom_width fun(width: integer|nil) @@ -72,7 +72,7 @@ function M.clear_last_window_width_ratio() return store.set('last_window_width_ratio', nil) end ----@param lines table +---@param lines table|nil function M.set_input_content(lines) return store.set('input_content', lines) end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 95275032..09998f27 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -914,6 +914,7 @@ function M.on_session_updated(properties) local revert_changed = not vim.deep_equal(current_session.revert, updated_session.revert) local previous_title = current_session.title + -- NOTE: we mutate the existing session object rather than replacing it because it will cause the whole panel to re-render if not vim.deep_equal(current_session, updated_session) then for key in pairs(current_session) do if updated_session[key] == nil then From dac464815439fdd6621d3512ee2077697cfdb1c9 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 16 Mar 2026 10:54:12 -0400 Subject: [PATCH 09/13] refactor(state): use std.RawGet and keyof OpencodeState in annotations --- lua/opencode/state.lua | 2 +- lua/opencode/state/init.lua | 89 +++++------------------------------- lua/opencode/state/store.lua | 88 +++++++++++++++++++++++++---------- lua/opencode/state/ui.lua | 25 ++++++++++ 4 files changed, 103 insertions(+), 101 deletions(-) diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index f6727c9d..1012b254 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -1 +1 @@ -return require('opencode.state.init') +return require('opencode.state.init') --[[@as OpencodeStateModule]] diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index ef7ea866..4a87075d 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -1,80 +1,3 @@ ----@class OpencodeWindowState ----@field input_win integer|nil ----@field output_win integer|nil ----@field footer_win integer|nil ----@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 footer_buf integer|nil ----@field output_was_at_bottom boolean ----@field input_hidden boolean ----@field input_cursor integer[]|nil ----@field output_cursor integer[]|nil ----@field output_view table|nil ----@field focused_window 'input'|'output'|nil ----@field position 'right'|'left'|'current'|nil ----@field owner_tab integer|nil - ----@class OpencodeToggleDecision ----@field action 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' - ----@class OpencodeState ----@field windows OpencodeWindowState|nil ----@field is_opening boolean ----@field input_content table ----@field is_opencode_focused boolean ----@field last_focused_opencode_window string|nil ----@field last_input_window_position integer[]|nil ----@field last_output_window_position integer[]|nil ----@field last_code_win_before_opencode integer|nil ----@field current_code_buf number|nil ----@field saved_window_options table|nil ----@field display_route any|nil ----@field current_mode string ----@field last_output number ----@field last_sent_context OpencodeContext|nil ----@field current_context_config OpencodeContextConfig|nil ----@field context_updated_at number|nil ----@field active_session Session|nil ----@field restore_points RestorePoint[] ----@field current_model string|nil ----@field user_mode_model_map table ----@field current_model_info table|nil ----@field current_variant string|nil ----@field messages OpencodeMessage[]|nil ----@field current_message OpencodeMessage|nil ----@field last_user_message OpencodeMessage|nil ----@field pending_permissions OpencodePermission[] ----@field cost number ----@field tokens_count number ----@field job_count number ----@field user_message_count table ----@field opencode_server OpencodeServer|nil ----@field api_client OpencodeApiClient ----@field event_manager EventManager|nil ----@field pre_zoom_width integer|nil ----@field last_window_width_ratio number|nil ----@field required_version string ----@field opencode_cli_version string|nil ----@field current_cwd string|nil ----@field _hidden_buffers OpencodeHiddenBuffers|nil ----@field append fun(key:string, value:any) ----@field remove fun(key:string, idx:number) ----@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 session OpencodeSessionStateMutations ----@field jobs OpencodeJobStateMutations ----@field ui OpencodeUiStateMutations ----@field model OpencodeModelStateMutations ----@field renderer OpencodeRendererStateMutations ----@field context OpencodeContextStateMutations - local store = require('opencode.state.store') local session = require('opencode.state.session') local jobs = require('opencode.state.jobs') @@ -83,6 +6,18 @@ local model = require('opencode.state.model') local renderer = require('opencode.state.renderer') local context = require('opencode.state.context') +---@class OpencodeStateModule +---@field store OpencodeStateStore +---@field session OpencodeSessionStateMutations +---@field jobs OpencodeJobStateMutations +---@field ui OpencodeUiStateMutations +---@field model OpencodeModelStateMutations +---@field renderer OpencodeRendererStateMutations +---@field context OpencodeContextStateMutations +---@field is_running fun():boolean + +---@alias OpencodeState OpencodeStateModule & OpencodeStateData +---@type OpencodeState local M = { store = store, session = session, diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index 838f3917..d7c31bc4 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -4,7 +4,49 @@ local M = {} ----@type OpencodeState +---@class OpencodeStateData +---@field windows OpencodeWindowState|nil +---@field is_opening boolean +---@field input_content table +---@field is_opencode_focused boolean +---@field last_focused_opencode_window string|nil +---@field last_input_window_position integer[]|nil +---@field last_output_window_position integer[]|nil +---@field last_code_win_before_opencode integer|nil +---@field current_code_buf number|nil +---@field current_code_view table|nil +---@field saved_window_options table|nil +---@field display_route string|nil +---@field current_mode string|nil +---@field last_output number +---@field last_sent_context OpencodeContext|nil +---@field current_context_config OpencodeContextConfig|nil +---@field context_updated_at number|nil +---@field active_session Session|nil +---@field restore_points RestorePoint[] +---@field current_model string|nil +---@field user_mode_model_map table +---@field current_model_info table|nil +---@field current_variant string|nil +---@field messages OpencodeMessage[]|nil +---@field current_message OpencodeMessage|nil +---@field last_user_message OpencodeMessage|nil +---@field pending_permissions OpencodePermission[] +---@field cost number +---@field tokens_count number +---@field job_count number +---@field user_message_count table +---@field opencode_server OpencodeServer|nil +---@field api_client OpencodeApiClient|nil +---@field event_manager EventManager|nil +---@field pre_zoom_width integer|nil +---@field last_window_width_ratio number|nil +---@field required_version string +---@field opencode_cli_version string|nil +---@field current_cwd string|nil +---@field _hidden_buffers OpencodeHiddenBuffers|nil + +---@type OpencodeStateData local _state = { windows = nil, is_opening = false, @@ -49,7 +91,7 @@ local _state = { local _listeners = {} ----@param key string +---@param key keyof OpencodeStateData ---@param opts? OpencodeProtectedStateSetOptions local function error_on_raw_write(key, opts) if opts and opts.silent then @@ -63,18 +105,18 @@ function M.state() return _state end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K ----@return OpencodeState[K] +---@return std.RawGet function M.get(key) return _state[key] end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K ----@param value OpencodeState[K] +---@param value std.RawGet ---@param opts? OpencodeProtectedStateSetOptions ----@return OpencodeState[K] +---@return std.RawGet function M.set(key, value, opts) local old = _state[key] opts = opts or { source = 'helper' } @@ -91,30 +133,30 @@ function M.set(key, value, opts) return value end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K ----@param value OpencodeState[K] +---@param value std.RawGet ---@param opts? OpencodeProtectedStateSetOptions ----@return OpencodeState[K] +---@return std.RawGet function M.set_raw(key, value, opts) local next_opts = vim.tbl_extend('force', { source = 'raw' }, opts or {}) return M.set(key, value, next_opts) end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K ----@param updater fun(current: OpencodeState[K]): OpencodeState[K] +---@param updater fun(current: std.RawGet): std.RawGet ---@param opts? OpencodeProtectedStateSetOptions ----@return OpencodeState[K] +---@return std.RawGet function M.update(key, updater, opts) local next_value = updater(_state[key]) M.set(key, next_value, opts) return next_value end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K|K[]|nil ----@param cb fun(key:K, new_val:OpencodeState[K], old_val:OpencodeState[K]) +---@param cb fun(key:K, new_val:std.RawGet, old_val:std.RawGet) function M.subscribe(key, cb) if type(key) == 'table' then for _, current_key in ipairs(key) do @@ -137,9 +179,9 @@ function M.subscribe(key, cb) table.insert(_listeners[key], cb) end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K|nil ----@param cb fun(key:K, new_val:OpencodeState[K], old_val:OpencodeState[K]) +---@param cb fun(key:K, new_val:std.RawGet, old_val:std.RawGet) function M.unsubscribe(key, cb) key = key or '*' local list = _listeners[key] @@ -154,10 +196,10 @@ function M.unsubscribe(key, cb) end end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K ----@param new_val OpencodeState[K] ----@param old_val OpencodeState[K] +---@param new_val std.RawGet +---@param old_val std.RawGet function M.emit(key, new_val, old_val) vim.schedule(function() if _listeners[key] then @@ -177,9 +219,9 @@ function M.emit(key, new_val, old_val) end) end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K ----@param value OpencodeState[K] extends any[] and OpencodeState[K][integer] or never +---@param value std.RawGet extends any[] and std.RawGet[integer] or never function M.append(key, value) if type(value) ~= 'table' then error('Value must be a table to append') @@ -196,7 +238,7 @@ function M.append(key, value) M.emit(key, _state[key], old) end ----@generic K extends keyof OpencodeState +---@generic K extends keyof OpencodeStateData ---@param key K ---@param idx integer function M.remove(key, idx) diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index d92a39f3..ac84e1ee 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -1,5 +1,30 @@ local store = require('opencode.state.store') +---@class OpencodeToggleDecision +---@field action 'open'|'close'|'hide'|'close_hidden'|'restore_hidden'|'migrate' + +---@class OpencodeHiddenBuffers +---@field input_buf integer +---@field output_buf integer +---@field footer_buf integer|nil +---@field output_was_at_bottom boolean +---@field input_hidden boolean +---@field input_cursor integer[]|nil +---@field output_cursor integer[]|nil +---@field output_view table|nil +---@field focused_window 'input'|'output'|nil +---@field position 'right'|'left'|'current'|nil +---@field owner_tab integer|nil + +---@class OpencodeWindowState +---@field input_win integer|nil +---@field output_win integer|nil +---@field footer_win integer|nil +---@field footer_buf integer|nil +---@field input_buf integer|nil +---@field output_buf integer|nil +---@field output_was_at_bottom boolean|nil + ---@class OpencodeUiStateMutations ---@field set_windows fun(windows: OpencodeWindowState|nil) ---@field clear_windows fun() From 13c630ec8d51aa396fe84fcc3a13f4863caaa694 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 17 Mar 2026 08:46:00 -0400 Subject: [PATCH 10/13] refactor(state): simplify store API and session/jobs mutations --- lua/opencode/core.lua | 2 -- lua/opencode/state/init.lua | 2 +- lua/opencode/state/jobs.lua | 35 +++++++++++++++------------------- lua/opencode/state/session.lua | 32 +++++++++++++++---------------- lua/opencode/state/store.lua | 34 ++++++--------------------------- lua/opencode/ui/renderer.lua | 16 ++-------------- tests/replay/renderer_spec.lua | 4 ---- 7 files changed, 40 insertions(+), 85 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 804055a9..79c85d9e 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -130,7 +130,6 @@ M.open = Promise.async(function(opts) local ok, err = pcall(function() if opts.new_session then state.session.clear_active() - state.session.set_last_sent_context(nil) context.unload_attachments() M.ensure_current_mode():await() @@ -581,7 +580,6 @@ M.handle_directory_change = Promise.async(function() vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) state.session.clear_active() - state.session.set_last_sent_context(nil) context.unload_attachments() state.session.set_active(session.get_last_workspace_session():await() or M.create_new_session():await()) diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index 4a87075d..52230f2b 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -7,7 +7,7 @@ local renderer = require('opencode.state.renderer') local context = require('opencode.state.context') ---@class OpencodeStateModule ----@field store OpencodeStateStore +---@field store OpencodeStateStore ---@field session OpencodeSessionStateMutations ---@field jobs OpencodeJobStateMutations ---@field ui OpencodeUiStateMutations diff --git a/lua/opencode/state/jobs.lua b/lua/opencode/state/jobs.lua index 45b99898..6a3ba0b6 100644 --- a/lua/opencode/state/jobs.lua +++ b/lua/opencode/state/jobs.lua @@ -1,11 +1,11 @@ local store = require('opencode.state.store') ---@class OpencodeJobStateMutations ----@field increment_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) ----@field decrement_count fun(delta?: integer, opts?: OpencodeProtectedStateSetOptions) ----@field set_count fun(count: integer, opts?: OpencodeProtectedStateSetOptions) ----@field set_server fun(server: OpencodeServer|nil, opts?: OpencodeProtectedStateSetOptions) ----@field clear_server fun(opts?: OpencodeProtectedStateSetOptions) +---@field increment_count fun(delta?: integer) +---@field decrement_count fun(delta?: integer) +---@field set_count fun(count: integer) +---@field set_server fun(server: OpencodeServer|nil) +---@field clear_server fun() ---@field set_api_client fun(client: OpencodeApiClient|nil) ---@field set_event_manager fun(manager: EventManager|nil) ---@field set_opencode_cli_version fun(version: string|nil) @@ -13,36 +13,31 @@ local store = require('opencode.state.store') local M = {} ---@param delta integer|nil ----@param opts? OpencodeProtectedStateSetOptions -function M.increment_count(delta, opts) +function M.increment_count(delta) return store.update('job_count', function(current) return (current or 0) + (delta or 1) - end, opts) + end) end ---@param delta integer|nil ----@param opts? OpencodeProtectedStateSetOptions -function M.decrement_count(delta, opts) +function M.decrement_count(delta) return store.update('job_count', function(current) return math.max(0, (current or 0) - (delta or 1)) - end, opts) + end) end ---@param count integer ----@param opts? OpencodeProtectedStateSetOptions -function M.set_count(count, opts) - return store.set('job_count', count, opts) +function M.set_count(count) + return store.set('job_count', count) end ---@param server OpencodeServer|nil ----@param opts? OpencodeProtectedStateSetOptions -function M.set_server(server, opts) - return store.set('opencode_server', server, opts) +function M.set_server(server) + return store.set('opencode_server', server) end ----@param opts? OpencodeProtectedStateSetOptions -function M.clear_server(opts) - return store.set('opencode_server', nil, opts) +function M.clear_server() + return store.set('opencode_server', nil) end ---@param client OpencodeApiClient|nil diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index d889ed44..078f4527 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -1,35 +1,35 @@ local store = require('opencode.state.store') ---@class OpencodeSessionStateMutations ----@field set_active fun(session: Session|nil, opts?: OpencodeProtectedStateSetOptions) ----@field clear_active fun(opts?: OpencodeProtectedStateSetOptions) ----@field set_restore_points fun(points: RestorePoint[], opts?: OpencodeProtectedStateSetOptions) ----@field reset_restore_points fun(opts?: OpencodeProtectedStateSetOptions) +---@field set_active fun(session: Session|nil) +---@field clear_active fun() +---@field set_restore_points fun(points: RestorePoint[]) +---@field reset_restore_points fun() ---@field set_last_sent_context fun(context: OpencodeContext|nil) ---@field set_user_message_count fun(count: table) local M = {} ---@param session Session|nil ----@param opts? OpencodeProtectedStateSetOptions -function M.set_active(session, opts) - return store.set('active_session', session, opts) +function M.set_active(session) + M.clear_active() + return store.set('active_session', session) end ----@param opts? OpencodeProtectedStateSetOptions -function M.clear_active(opts) - return store.set('active_session', nil, opts) +function M.clear_active() + M.reset_restore_points() + M.set_last_sent_context() + M.set_user_message_count({}) + return store.set('active_session', nil) end ---@param points RestorePoint[] ----@param opts? OpencodeProtectedStateSetOptions -function M.set_restore_points(points, opts) - return store.set('restore_points', points, opts) +function M.set_restore_points(points) + return store.set('restore_points', points) end ----@param opts? OpencodeProtectedStateSetOptions -function M.reset_restore_points(opts) - return store.set('restore_points', {}, opts) +function M.reset_restore_points() + return store.set('restore_points', {}) end ---@param context OpencodeContext|nil diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index d7c31bc4..96e83dd4 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -1,7 +1,4 @@ ----@class OpencodeProtectedStateSetOptions ----@field source? 'helper'|'raw' ----@field silent? boolean - +---@class OpencodeStateStore local M = {} ---@class OpencodeStateData @@ -91,16 +88,6 @@ local _state = { local _listeners = {} ----@param key keyof OpencodeStateData ----@param opts? OpencodeProtectedStateSetOptions -local function error_on_raw_write(key, opts) - if opts and opts.silent then - return - end - - error(string.format('Direct write to state key `%s` is not allowed; use a state domain setter', key), 3) -end - function M.state() return _state end @@ -115,15 +102,9 @@ end ---@generic K extends keyof OpencodeStateData ---@param key K ---@param value std.RawGet ----@param opts? OpencodeProtectedStateSetOptions ---@return std.RawGet -function M.set(key, value, opts) +function M.set(key, value) local old = _state[key] - opts = opts or { source = 'helper' } - - if opts.source == 'raw' then - error_on_raw_write(key, opts) - end _state[key] = value if not vim.deep_equal(old, value) then @@ -136,21 +117,18 @@ end ---@generic K extends keyof OpencodeStateData ---@param key K ---@param value std.RawGet ----@param opts? OpencodeProtectedStateSetOptions ---@return std.RawGet -function M.set_raw(key, value, opts) - local next_opts = vim.tbl_extend('force', { source = 'raw' }, opts or {}) - return M.set(key, value, next_opts) +function M.set_raw(key, value) + return M.set(key, value) end ---@generic K extends keyof OpencodeStateData ---@param key K ---@param updater fun(current: std.RawGet): std.RawGet ----@param opts? OpencodeProtectedStateSetOptions ---@return std.RawGet -function M.update(key, updater, opts) +function M.update(key, updater) local next_value = updater(_state[key]) - M.set(key, next_value, opts) + M.set(key, next_value) return next_value end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 09998f27..af5ab09f 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -914,21 +914,9 @@ function M.on_session_updated(properties) local revert_changed = not vim.deep_equal(current_session.revert, updated_session.revert) local previous_title = current_session.title - -- NOTE: we mutate the existing session object rather than replacing it because it will cause the whole panel to re-render if not vim.deep_equal(current_session, updated_session) then - for key in pairs(current_session) do - if updated_session[key] == nil then - current_session[key] = nil - end - end - - for key, value in pairs(updated_session) do - current_session[key] = value - end - - if updated_session.title and updated_session.title ~= previous_title then - require('opencode.ui.topbar').render() - end + -- NOTE: we set the session without emitting a change event because we don't want to trigger another rerender. + state.store.set_raw('active_session', updated_session) end if revert_changed then diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 73adcd0f..ca2fd2e9 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -157,7 +157,6 @@ describe('renderer unit tests', function() }) local active_session_ref = state.active_session - local topbar_render_stub = stub(topbar, 'render') renderer.on_session_updated({ info = { @@ -167,10 +166,7 @@ describe('renderer unit tests', function() }, }) - assert.is_true(state.active_session == active_session_ref) assert.are.equal('Branch review request', state.active_session.title) - assert.stub(topbar_render_stub).was_called() - topbar_render_stub:revert() end) it('rerenders full session when revert changes', function() From 32003d95c1088fe82e86190079322bd3a7d4ef4c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 17 Mar 2026 10:40:36 -0400 Subject: [PATCH 11/13] refactor(state): centralize observable API under state.store Replace top-level observable helpers (state.subscribe, state.unsubscribe, state.append, state.emit) --- lua/opencode/api_client.lua | 2 +- lua/opencode/context.lua | 2 +- lua/opencode/core.lua | 10 +++++----- lua/opencode/event_manager.lua | 12 ++++++------ lua/opencode/state/init.lua | 10 ---------- lua/opencode/state/jobs.lua | 5 +++++ lua/opencode/ui/context_bar.lua | 2 +- lua/opencode/ui/footer.lua | 28 +++++++++++++-------------- lua/opencode/ui/formatter.lua | 7 ++++--- lua/opencode/ui/loading_animation.lua | 16 +++++++-------- lua/opencode/ui/renderer.lua | 10 +++++----- lua/opencode/ui/topbar.lua | 26 ++++++++++++------------- tests/unit/context_bar_spec.lua | 8 ++++---- tests/unit/hooks_spec.lua | 8 ++++---- tests/unit/reference_picker_spec.lua | 6 ++++-- tests/unit/state_spec.lua | 22 ++++++++++----------- 16 files changed, 86 insertions(+), 88 deletions(-) diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 8849192b..056aebce 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -532,7 +532,7 @@ local function create_client(base_url) end end - state.subscribe('opencode_server', on_server_change) + state.store.subscribe('opencode_server', on_server_change) return api_client end diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 444f744a..f1c53e95 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -315,7 +315,7 @@ function M.setup() M.load() end, 200) - state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function() + state.store.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function() debounced_load() end) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 79c85d9e..f679aca6 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -381,7 +381,7 @@ M.cycle_variant = Promise.async(function() end) M.cancel = Promise.async(function() - if state.active_session and state.is_running() then + if state.active_session and state.jobs.is_running() then M._abort_count = M._abort_count + 1 local permissions = state.pending_permissions or {} @@ -588,10 +588,10 @@ M.handle_directory_change = Promise.async(function() end) function M.setup() - state.subscribe('opencode_server', on_opencode_server) - state.subscribe('user_message_count', M._on_user_message_count_change) - state.subscribe('pending_permissions', M._on_current_permission_change) - state.subscribe('current_model', function(key, new_val, old_val) + state.store.subscribe('opencode_server', on_opencode_server) + state.store.subscribe('user_message_count', M._on_user_message_count_change) + state.store.subscribe('pending_permissions', M._on_current_permission_change) + state.store.subscribe('current_model', function(key, new_val, old_val) if new_val ~= old_val then state.model.clear_variant() diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index b677cd90..da2e1ae4 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -480,7 +480,7 @@ function EventManager:start() self.is_started = true if self.state_server_listener then - state.unsubscribe('opencode_server', self.state_server_listener) + state.store.unsubscribe('opencode_server', self.state_server_listener) end self.state_server_listener = function(key, current, prev) @@ -504,10 +504,10 @@ function EventManager:start() end end - state.subscribe('opencode_server', self.state_server_listener) + state.store.subscribe('opencode_server', self.state_server_listener) if self.state_cwd_listener then - state.unsubscribe('current_cwd', self.state_cwd_listener) + state.store.unsubscribe('current_cwd', self.state_cwd_listener) end self.state_cwd_listener = function(key, new_cwd, old_cwd) @@ -517,7 +517,7 @@ function EventManager:start() end end - state.subscribe('current_cwd', self.state_cwd_listener) + state.store.subscribe('current_cwd', self.state_cwd_listener) end function EventManager:stop() @@ -527,11 +527,11 @@ function EventManager:stop() self.is_started = false if self.state_server_listener then - state.unsubscribe('opencode_server', self.state_server_listener) + state.store.unsubscribe('opencode_server', self.state_server_listener) self.state_server_listener = nil end if self.state_cwd_listener then - state.unsubscribe('current_cwd', self.state_cwd_listener) + state.store.unsubscribe('current_cwd', self.state_cwd_listener) self.state_cwd_listener = nil end self:_cleanup_server_subscription() diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index 52230f2b..408f12b6 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -14,7 +14,6 @@ local context = require('opencode.state.context') ---@field model OpencodeModelStateMutations ---@field renderer OpencodeRendererStateMutations ---@field context OpencodeContextStateMutations ----@field is_running fun():boolean ---@alias OpencodeState OpencodeStateModule & OpencodeStateData ---@type OpencodeState @@ -26,17 +25,8 @@ local M = { model = model, renderer = renderer, context = context, - subscribe = store.subscribe, - unsubscribe = store.unsubscribe, - emit = store.emit, - append = store.append, - remove = store.remove, } -function M.is_running() - return M.job_count > 0 -end - return setmetatable(M, { __index = function(_, key) return store.get(key) diff --git a/lua/opencode/state/jobs.lua b/lua/opencode/state/jobs.lua index 6a3ba0b6..42938c9f 100644 --- a/lua/opencode/state/jobs.lua +++ b/lua/opencode/state/jobs.lua @@ -9,6 +9,7 @@ local store = require('opencode.state.store') ---@field set_api_client fun(client: OpencodeApiClient|nil) ---@field set_event_manager fun(manager: EventManager|nil) ---@field set_opencode_cli_version fun(version: string|nil) +---@field is_running fun():boolean local M = {} @@ -55,4 +56,8 @@ function M.set_opencode_cli_version(version) return store.set('opencode_cli_version', version) end +function M.is_running() + return (store.get('job_count') or 0) > 0 +end + return M diff --git a/lua/opencode/ui/context_bar.lua b/lua/opencode/ui/context_bar.lua index ceedd82d..cb67503c 100644 --- a/lua/opencode/ui/context_bar.lua +++ b/lua/opencode/ui/context_bar.lua @@ -170,7 +170,7 @@ local function update_winbar_highlights(win_id) end function M.setup() - state.subscribe( + state.store.subscribe( { 'current_context_config', 'current_code_buf', 'is_opencode_focused', 'context_updated_at', 'user_message_count' }, function() M.render() diff --git a/lua/opencode/ui/footer.lua b/lua/opencode/ui/footer.lua index 1815f92a..90d4ebae 100644 --- a/lua/opencode/ui/footer.lua +++ b/lua/opencode/ui/footer.lua @@ -35,14 +35,14 @@ end local function build_right_segments() local segments = {} - if state.is_running() and not state.is_opening then + if state.jobs.is_running() and not state.is_opening then local cancel_keymap = config.get_key_for_function('input_window', 'cancel') or '' table.insert(segments, { string.format('%s ', cancel_keymap), 'OpencodeInputLegend' }) table.insert(segments, { 'to cancel', 'OpencodeHint' }) table.insert(segments, { ' ' }) end - if not state.is_running() and state.current_model and config.ui.display_model then + if not state.jobs.is_running() and state.current_model and config.ui.display_model then table.insert(segments, { state.current_model, 'OpencodeHint' }) if state.current_variant then table.insert(segments, { '·', 'OpencodeHint' }) @@ -151,13 +151,13 @@ function M.setup(windows) vim.api.nvim_set_option_value('winhl', 'Normal:OpencodeHint', { win = windows.footer_win }) -- for model changes - state.subscribe('current_model', on_change) - state.subscribe('current_mode', on_change) - state.subscribe('current_variant', on_change) - state.subscribe('active_session', on_change) + state.store.subscribe('current_model', on_change) + state.store.subscribe('current_mode', on_change) + state.store.subscribe('current_variant', on_change) + state.store.subscribe('active_session', on_change) -- to show C-c message - state.subscribe('job_count', on_job_count_changed) - state.subscribe('restore_points', on_change) + state.store.subscribe('job_count', on_job_count_changed) + state.store.subscribe('restore_points', on_change) vim.api.nvim_create_autocmd({ 'VimResized', 'WinResized' }, { group = vim.api.nvim_create_augroup('OpencodeFooterResize', { clear = true }), @@ -180,12 +180,12 @@ function M.close(preserve_buffer) end end - state.unsubscribe('current_model', on_change) - state.unsubscribe('current_mode', on_change) - state.unsubscribe('current_variant', on_change) - state.unsubscribe('active_session', on_change) - state.unsubscribe('job_count', on_job_count_changed) - state.unsubscribe('restore_points', on_change) + state.store.unsubscribe('current_model', on_change) + state.store.unsubscribe('current_mode', on_change) + state.store.unsubscribe('current_variant', on_change) + state.store.unsubscribe('active_session', on_change) + state.store.unsubscribe('job_count', on_job_count_changed) + state.store.unsubscribe('restore_points', on_change) loading_animation.teardown() end diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 56a22351..dc025960 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -375,9 +375,10 @@ end ---@param output Output Output object to write to ---@param text string -function M._format_assistant_message(output, text) +---@param message_id string|nil Optional message ID for reference parsing +function M._format_assistant_message(output, text, message_id) local reference_picker = require('opencode.ui.reference_picker') - local references = reference_picker.parse_references(text, '') + local references = reference_picker.parse_references(text, message_id) -- If no references, just add the text as-is if #references == 0 then @@ -501,7 +502,7 @@ function M.format_part(part, message, is_last_part, get_child_parts) end elseif role == 'assistant' then if part.type == 'text' and part.text then - M._format_assistant_message(output, vim.trim(part.text)) + M._format_assistant_message(output, vim.trim(part.text), part.messageID) content_added = true elseif part.type == 'reasoning' then M._format_reasoning(output, part) diff --git a/lua/opencode/ui/loading_animation.lua b/lua/opencode/ui/loading_animation.lua index 873c5e75..44da2f86 100644 --- a/lua/opencode/ui/loading_animation.lua +++ b/lua/opencode/ui/loading_animation.lua @@ -138,7 +138,7 @@ M.render = vim.schedule_wrap(function(windows) return false end - if not state.is_running() then + if not state.jobs.is_running() then M.stop() return false end @@ -168,7 +168,7 @@ function M._start_animation_timer(windows) on_tick = function() M._animation.current_frame = M._next_frame() M.render(state.windows) - if state.is_running() then + if state.jobs.is_running() then return true else M.stop() @@ -222,16 +222,16 @@ local function on_running_change(_, new_value) end function M.setup() - state.subscribe('job_count', on_running_change) - state.subscribe('active_session', on_active_session_change) - state.subscribe('event_manager', on_event_manager_change) + state.store.subscribe('job_count', on_running_change) + state.store.subscribe('active_session', on_active_session_change) + state.store.subscribe('event_manager', on_event_manager_change) subscribe_session_status_event(state.event_manager) end function M.teardown() - state.unsubscribe('job_count', on_running_change) - state.unsubscribe('active_session', on_active_session_change) - state.unsubscribe('event_manager', on_event_manager_change) + state.store.unsubscribe('job_count', on_running_change) + state.store.unsubscribe('active_session', on_active_session_change) + state.store.unsubscribe('event_manager', on_event_manager_change) unsubscribe_session_status_event(M._animation.status_event_manager) M._animation.status_data = nil end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index af5ab09f..20d2d86e 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -65,11 +65,11 @@ function M.setup_subscriptions(subscribe) subscribe = subscribe == nil and true or subscribe if subscribe then - state.subscribe('is_opencode_focused', M.on_focus_changed) - state.subscribe('active_session', M.on_session_changed) + state.store.subscribe('is_opencode_focused', M.on_focus_changed) + state.store.subscribe('active_session', M.on_session_changed) else - state.unsubscribe('is_opencode_focused', M.on_focus_changed) - state.unsubscribe('active_session', M.on_session_changed) + state.store.unsubscribe('is_opencode_focused', M.on_focus_changed) + state.store.unsubscribe('active_session', M.on_session_changed) end if not state.event_manager then @@ -1023,7 +1023,7 @@ end ---@param properties RestorePointCreatedEvent function M.on_restore_points(properties) - state.append('restore_points', properties.restore_point) + state.store.append('restore_points', properties.restore_point) if not properties or not properties.restore_point or not properties.restore_point.from_snapshot_id then return end diff --git a/lua/opencode/ui/topbar.lua b/lua/opencode/ui/topbar.lua index 8a91f910..3f241f6f 100644 --- a/lua/opencode/ui/topbar.lua +++ b/lua/opencode/ui/topbar.lua @@ -115,22 +115,22 @@ local function on_change(_, _, _) end function M.setup() - state.subscribe('current_mode', on_change) - state.subscribe('current_model', on_change) - state.subscribe('active_session', on_change) - state.subscribe('is_opencode_focused', on_change) - state.subscribe('tokens_count', on_change) - state.subscribe('cost', on_change) - state.subscribe('is_opening', on_change) + state.store.subscribe('current_mode', on_change) + state.store.subscribe('current_model', on_change) + state.store.subscribe('active_session', on_change) + state.store.subscribe('is_opencode_focused', on_change) + state.store.subscribe('tokens_count', on_change) + state.store.subscribe('cost', on_change) + state.store.subscribe('is_opening', on_change) M.render() end function M.close() - state.unsubscribe('current_mode', on_change) - state.unsubscribe('current_model', on_change) - state.unsubscribe('active_session', on_change) - state.unsubscribe('is_opencode_focused', on_change) - state.unsubscribe('tokens_count', on_change) - state.unsubscribe('cost', on_change) + state.store.unsubscribe('current_mode', on_change) + state.store.unsubscribe('current_model', on_change) + state.store.unsubscribe('active_session', on_change) + state.store.unsubscribe('is_opencode_focused', on_change) + state.store.unsubscribe('tokens_count', on_change) + state.store.unsubscribe('cost', on_change) end return M diff --git a/tests/unit/context_bar_spec.lua b/tests/unit/context_bar_spec.lua index 38a89cb4..5e8954c7 100644 --- a/tests/unit/context_bar_spec.lua +++ b/tests/unit/context_bar_spec.lua @@ -37,7 +37,7 @@ describe('opencode.ui.context_bar', function() original_get_context = context.get_context original_is_context_enabled = context.is_context_enabled original_get_icon = icons.get - original_subscribe = state.subscribe + original_subscribe = state.store.subscribe original_schedule = vim.schedule original_api_win_is_valid = vim.api.nvim_win_is_valid original_api_get_option_value = vim.api.nvim_get_option_value @@ -66,7 +66,7 @@ describe('opencode.ui.context_bar', function() return true -- Enable all context types by default end - state.subscribe = function(_, _) + state.store.subscribe = function(_, _) -- Mock implementation end @@ -106,7 +106,7 @@ describe('opencode.ui.context_bar', function() context.get_context = original_get_context context.is_context_enabled = original_is_context_enabled icons.get = original_get_icon - state.subscribe = original_subscribe + state.store.subscribe = original_subscribe vim.schedule = original_schedule vim.api.nvim_win_is_valid = original_api_win_is_valid vim.api.nvim_get_option_value = original_api_get_option_value @@ -288,7 +288,7 @@ describe('opencode.ui.context_bar', function() local subscription_called = false local captured_keys = nil - state.subscribe = function(keys, callback) + state.store.subscribe = function(keys, callback) subscription_called = true captured_keys = keys assert.is_table(keys) diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 75f6210a..0368ce24 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -132,7 +132,7 @@ describe('hooks', function() return promise end - state.subscribe('user_message_count', core._on_user_message_count_change) + state.store.subscribe('user_message_count', core._on_user_message_count_change) -- Simulate job count change from 1 to 0 (done thinking) for a specific session state.session.set_active({ id = 'test-session', title = 'Test' }) @@ -146,7 +146,7 @@ describe('hooks', function() -- Restore original function session_module.get_all_workspace_sessions = original_get_all - state.unsubscribe('user_message_count', core._on_user_message_count_change) + state.store.unsubscribe('user_message_count', core._on_user_message_count_change) assert.is_true(called) assert.are.equal(called_session.id, 'test-session') @@ -194,7 +194,7 @@ describe('hooks', function() end -- Set up the subscription manually - state.subscribe('pending_permissions', core._on_current_permission_change) + state.store.subscribe('pending_permissions', core._on_current_permission_change) -- Simulate permission change from nil to a value state.session.set_active({ id = 'test-session', title = 'Test' }) @@ -207,7 +207,7 @@ describe('hooks', function() -- Restore original function session_module.get_by_id = original_get_by_id - state.unsubscribe('pending_permissions', core._on_current_permission_change) + state.store.unsubscribe('pending_permissions', core._on_current_permission_change) assert.is_true(called) assert.are.equal(called_session.id, 'test-session') diff --git a/tests/unit/reference_picker_spec.lua b/tests/unit/reference_picker_spec.lua index 6807ea88..769d483d 100644 --- a/tests/unit/reference_picker_spec.lua +++ b/tests/unit/reference_picker_spec.lua @@ -56,7 +56,9 @@ describe('opencode.ui.reference_picker', function() event_manager = { subscribe = function() end, }, - subscribe = function() end, + store = { + subscribe = function() end, + }, } package.loaded['opencode.state'] = mock_state @@ -653,7 +655,7 @@ describe('opencode.ui.reference_picker', function() it('subscribes to messages state changes', function() local subscriptions = {} - mock_state.subscribe = function(key, handler) + mock_state.store.subscribe = function(key, handler) table.insert(subscriptions, { key = key, handler = handler }) end diff --git a/tests/unit/state_spec.lua b/tests/unit/state_spec.lua index e9c4abde..a9b489c9 100644 --- a/tests/unit/state_spec.lua +++ b/tests/unit/state_spec.lua @@ -13,7 +13,7 @@ describe('opencode.state (observable)', function() new_val = newv old_val = oldv end - state.subscribe('messages', cb) + state.store.subscribe('messages', cb) state.renderer.set_messages({ { id = 'test' } }) vim.wait(50, function() return called == true @@ -23,7 +23,7 @@ describe('opencode.state (observable)', function() assert.same({ { id = 'test' } }, new_val) -- Clean up state.renderer.set_messages(nil) - state.unsubscribe('messages', cb) + state.store.unsubscribe('messages', cb) end) it('notifies wildcard listeners on any key change', function() @@ -35,7 +35,7 @@ describe('opencode.state (observable)', function() new_val = newv old_val = oldv end - state.subscribe('*', cb) + state.store.subscribe('*', cb) state.renderer.set_cost(99) vim.wait(50, function() return called == true @@ -45,7 +45,7 @@ describe('opencode.state (observable)', function() assert.equals(99, new_val) -- Clean up state.renderer.set_cost(0) - state.unsubscribe('*', cb) + state.store.unsubscribe('*', cb) end) it('can unregister listeners', function() @@ -53,12 +53,12 @@ describe('opencode.state (observable)', function() local cb = function() called = called + 1 end - state.subscribe('tokens_count', cb) + state.store.subscribe('tokens_count', cb) state.renderer.set_tokens_count(1) vim.wait(50, function() return called == 1 end) - state.unsubscribe('tokens_count', cb) + state.store.unsubscribe('tokens_count', cb) state.renderer.set_tokens_count(2) vim.wait(50) assert.equals(1, called) @@ -72,8 +72,8 @@ describe('opencode.state (observable)', function() called = called + 1 end - state.subscribe('cost', cb) - state.subscribe('cost', cb) + state.store.subscribe('cost', cb) + state.store.subscribe('cost', cb) state.renderer.set_cost(1) vim.wait(50, function() @@ -82,7 +82,7 @@ describe('opencode.state (observable)', function() assert.equals(1, called) - state.unsubscribe('cost', cb) + state.store.unsubscribe('cost', cb) state.renderer.set_cost(0) end) @@ -91,7 +91,7 @@ describe('opencode.state (observable)', function() local cb = function() called = true end - state.subscribe('tokens_count', cb) + state.store.subscribe('tokens_count', cb) state.renderer.set_tokens_count(42) vim.wait(50, function() return called == true @@ -102,7 +102,7 @@ describe('opencode.state (observable)', function() assert.is_false(called) -- Clean up state.renderer.set_tokens_count(0) - state.unsubscribe('tokens_count', cb) + state.store.unsubscribe('tokens_count', cb) end) it('errors on direct state write', function() From 4d430a1ebf09d9e5fdcc32536c7fd9eabe18d57c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 07:15:14 -0400 Subject: [PATCH 12/13] fix: reference_picker --- lua/opencode/ui/reference_picker.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/reference_picker.lua b/lua/opencode/ui/reference_picker.lua index 632d664f..9c618fa3 100644 --- a/lua/opencode/ui/reference_picker.lua +++ b/lua/opencode/ui/reference_picker.lua @@ -239,7 +239,7 @@ function M.setup() end) end - state.subscribe('messages', function() + state.store.subscribe('messages', function() M._parse_session_messages() end) end From 21ae53392ec19b4863911de746b1edcf8f545702 Mon Sep 17 00:00:00 2001 From: Jensen Date: Wed, 18 Mar 2026 19:18:08 +0800 Subject: [PATCH 13/13] handle split resize safely + use store subscribe (#328) * fix(ui): handle split resize safely Use window-type-aware resize logic in output_window.update_dimensions. - guard missing or invalid output_win before resizing - use nvim_win_set_width for split windows (relative == '') - keep nvim_win_set_config for floating windows - add regression tests for float-focus resize and invalid window This prevents "Cannot split a floating window" on VimResized while preserving existing zoom width behavior. Verified with: - ./run_tests.sh -t tests/unit/zoom_spec.lua * fix(reference-picker): use store subscribe Follow the state observable API migration by switching reference picker setup from state.subscribe(...) to state.store.subscribe(...). This matches the refactor that centralized observable helpers under state.store and fixes startup error: attempt to call field 'subscribe' (a nil value). Verified with: ./run_tests.sh -t tests/unit/reference_picker_spec.lua --------- Co-authored-by: oujinsai --- lua/opencode/ui/output_window.lua | 16 +++++++- tests/unit/zoom_spec.lua | 62 +++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index c4b98535..a9b8d0db 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -132,6 +132,11 @@ function M.update_dimensions(windows) if config.ui.position == 'current' then return end + + if not windows or not windows.output_win or not vim.api.nvim_win_is_valid(windows.output_win) then + return + end + local total_width = vim.api.nvim_get_option_value('columns', {}) local width_ratio @@ -145,8 +150,17 @@ function M.update_dimensions(windows) end local width = math.floor(total_width * width_ratio) + local ok, win_config = pcall(vim.api.nvim_win_get_config, windows.output_win) + if not ok then + return + end + + if win_config.relative == '' then + pcall(vim.api.nvim_win_set_width, windows.output_win, width) + return + end - vim.api.nvim_win_set_config(windows.output_win, { width = width }) + pcall(vim.api.nvim_win_set_config, windows.output_win, { width = width }) end function M.get_buf_line_count() diff --git a/tests/unit/zoom_spec.lua b/tests/unit/zoom_spec.lua index 2ec50c6e..a6ee11ed 100644 --- a/tests/unit/zoom_spec.lua +++ b/tests/unit/zoom_spec.lua @@ -228,6 +228,68 @@ describe('ui zoom state', function() assert.equals(80, state.pre_zoom_width) end) + + it('does not error when focused window is floating and output window is split', function() + local split_buf = vim.api.nvim_create_buf(false, true) + local focus_buf = vim.api.nvim_create_buf(false, true) + local split_win + local focus_win + + local normal_win + for _, win in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_config(win).relative == '' then + normal_win = win + break + end + end + + assert.is_not_nil(normal_win) + vim.api.nvim_set_current_win(normal_win) + vim.cmd('vsplit') + split_win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(split_win, split_buf) + + focus_win = vim.api.nvim_open_win(focus_buf, true, { + relative = 'editor', + width = 20, + height = 5, + row = 1, + col = 1, + style = 'minimal', + }) + + assert.has_no.errors(function() + output_window.update_dimensions({ output_win = split_win, output_buf = split_buf }) + end) + + local expected_width = math.floor(config.ui.window_width * vim.o.columns) + assert.equals(expected_width, vim.api.nvim_win_get_width(split_win)) + + pcall(vim.api.nvim_win_close, focus_win, true) + pcall(vim.api.nvim_win_close, split_win, true) + pcall(vim.api.nvim_buf_delete, focus_buf, { force = true }) + pcall(vim.api.nvim_buf_delete, split_buf, { force = true }) + end) + + it('does not error when output window is invalid', function() + local invalid_buf = vim.api.nvim_create_buf(false, true) + local invalid_win = vim.api.nvim_open_win(invalid_buf, false, { + relative = 'editor', + width = 20, + height = 5, + row = 2, + col = 2, + style = 'minimal', + }) + + vim.api.nvim_win_close(invalid_win, true) + + assert.has_no.errors(function() + output_window.update_dimensions({ output_win = invalid_win, output_buf = invalid_buf }) + end) + + pcall(vim.api.nvim_buf_delete, invalid_buf, { force = true }) + end) end) describe('zoom state persistence', function()