From ab440a50936c86ec9fca0339dff4eddda13bd371 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 09:10:29 -0400 Subject: [PATCH 1/8] refactor(state): unify model clearing and improve renderer/session reset --- lua/opencode/core.lua | 4 +--- lua/opencode/state/model.lua | 27 +++++++++++++++++++++++---- lua/opencode/state/renderer.lua | 17 +++++++++++++++++ lua/opencode/state/session.lua | 4 +++- lua/opencode/ui/renderer.lua | 17 ++++++++--------- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index f679aca6..86a68bb0 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -37,12 +37,10 @@ end) M.switch_session = Promise.async(function(session_id) local selected_session = session.get_by_id(session_id):await() - state.model.clear_model() - state.model.clear_mode() + state.model.clear() M.ensure_current_mode():await() state.session.set_active(selected_session) - state.session.reset_restore_points() if state.ui.is_visible() then ui.focus_input() else diff --git a/lua/opencode/state/model.lua b/lua/opencode/state/model.lua index 52ca63f4..e044a122 100644 --- a/lua/opencode/state/model.lua +++ b/lua/opencode/state/model.lua @@ -1,5 +1,17 @@ local store = require('opencode.state.store') +---@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 clear 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) + local M = {} ---@param mode string|nil @@ -20,6 +32,12 @@ function M.clear_model() return store.set('current_model', nil) end +function M.clear() + store.set('current_model', nil) + store.set('current_mode', nil) + store.set('current_variant', nil) +end + ---@param info table|nil function M.set_model_info(info) return store.set('current_model_info', info) @@ -42,10 +60,11 @@ 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) + return store.update('user_mode_model_map', function(current) + local updated = vim.deepcopy(current) + updated[mode] = model + return updated + end) end return M diff --git a/lua/opencode/state/renderer.lua b/lua/opencode/state/renderer.lua index 002bbff1..ccc54763 100644 --- a/lua/opencode/state/renderer.lua +++ b/lua/opencode/state/renderer.lua @@ -8,6 +8,8 @@ local store = require('opencode.state.store') ---@field set_pending_permissions fun(permissions: OpencodePermission[]) ---@field set_cost fun(cost: number) ---@field set_tokens_count fun(count: number) +---@field set_stats fun(tokens_count: number, cost: number) +---@field reset fun() local M = {} @@ -41,4 +43,19 @@ function M.set_tokens_count(count) return store.set('tokens_count', count) end +---@param tokens_count number +---@param cost number +function M.set_stats(tokens_count, cost) + store.set('tokens_count', tokens_count) + store.set('cost', cost) +end + +function M.reset() + store.set('messages', {}) + store.set('last_user_message', nil) + store.set('tokens_count', 0) + store.set('cost', 0) + store.set('pending_permissions', {}) +end + return M diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index 078f4527..afc18662 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -12,7 +12,9 @@ local M = {} ---@param session Session|nil function M.set_active(session) - M.clear_active() + store.set('restore_points', {}) + store.set('last_sent_context', nil) + store.set('user_message_count', {}) return store.set('active_session', session) end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 20d2d86e..850d2a60 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -43,10 +43,6 @@ function M.reset() output_window.clear() - 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 for _, permission in ipairs(permissions) do @@ -54,7 +50,7 @@ function M.reset() end end permission_window.clear_all() - state.renderer.set_pending_permissions({}) + state.renderer.reset() trigger_on_data_rendered() end @@ -1198,11 +1194,14 @@ function M._update_stats_from_message(message) end local tokens = message.info.tokens - if tokens and tokens.input > 0 then + if tokens and tokens.input > 0 and message.info.cost and type(message.info.cost) == 'number' then + state.renderer.set_stats( + tokens.input + tokens.output + tokens.cache.read + tokens.cache.write, + message.info.cost + ) + elseif tokens and tokens.input > 0 then 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 + elseif message.info.cost and type(message.info.cost) == 'number' then state.renderer.set_cost(message.info.cost) end end From 5429155c5735e01a5bf6aa3df390ece3e211d737 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 12:54:42 -0400 Subject: [PATCH 2/8] feat(store): add batched state updates and mutate API --- lua/opencode/context.lua | 17 ++--- lua/opencode/core.lua | 3 +- lua/opencode/server_job.lua | 1 + lua/opencode/state.lua | 2 +- lua/opencode/state/context.lua | 9 ++- lua/opencode/state/init.lua | 1 + lua/opencode/state/jobs.lua | 10 --- lua/opencode/state/model.lua | 19 ++---- lua/opencode/state/renderer.lua | 33 +++++----- lua/opencode/state/session.lua | 27 ++++---- lua/opencode/state/store.lua | 113 ++++++++++++++++++++++++++------ lua/opencode/state/ui.lua | 45 +++++++------ lua/opencode/ui/autocmds.lua | 3 +- lua/opencode/ui/renderer.lua | 22 +++---- lua/opencode/ui/ui.lua | 30 ++++----- tests/unit/state_spec.lua | 61 +++++++++++++++++ 16 files changed, 248 insertions(+), 148 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index 52cfe236..e3e93d61 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -22,14 +22,10 @@ local toggleable_context_keys = { ---@param context_key OpencodeToggleableContextKey ---@return table 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 {} - - 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] + return state.context.update_current_context_config(function(current_config) + current_config[context_key] = vim.tbl_deep_extend('force', {}, defaults, current_config[context_key] or {}) + end)[context_key] end M.ChatContext = ChatContext @@ -200,12 +196,7 @@ function M.build_inline_selection_text(range) end local filetype = vim.bo[buf].filetype or '' - local text = string.format( - '**`%s`**\n\n```%s\n%s\n```', - file.path, - filetype, - current_selection.text - ) + local text = string.format('**`%s`**\n\n```%s\n%s\n```', file.path, filetype, current_selection.text) return text end diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua index 86a68bb0..7ceb3a3f 100644 --- a/lua/opencode/core.lua +++ b/lua/opencode/core.lua @@ -92,8 +92,7 @@ M.open = Promise.async(function(opts) if are_windows_closed then if not ui.is_opencode_focused() then - state.ui.set_last_code_window(vim.api.nvim_get_current_win()) - state.ui.set_current_code_buf(vim.api.nvim_get_current_buf()) + state.ui.set_code_context(vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf()) end M.is_prompting_allowed() diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index d689b62d..f3b04817 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -293,6 +293,7 @@ function M.spawn_local_server(promise, port, hostname) log.notify(string.format('Started local server at %s', base_url), vim.log.levels.INFO) if url_port then local port_num = tonumber(url_port) + state.store.set_raw('opencode_server', state.opencode_server) state.opencode_server.port = port_num local server_pid = job and job.pid port_mapping.register(port_num, vim.fn.getcwd(), true, 'serve', nil, server_pid) diff --git a/lua/opencode/state.lua b/lua/opencode/state.lua index 1012b254..3a61de36 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -1 +1 @@ -return require('opencode.state.init') --[[@as OpencodeStateModule]] +return require('opencode.state.init') --[[@as OpencodeState]] diff --git a/lua/opencode/state/context.lua b/lua/opencode/state/context.lua index 692bbc4e..b12a8602 100644 --- a/lua/opencode/state/context.lua +++ b/lua/opencode/state/context.lua @@ -1,11 +1,6 @@ - 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 @@ -13,6 +8,10 @@ function M.set_current_context_config(config) return store.set('current_context_config', config) end +function M.update_current_context_config(mutator) + return store.mutate('current_context_config', mutator) +end + ---@param timestamp number|nil function M.set_context_updated_at(timestamp) return store.set('context_updated_at', timestamp) diff --git a/lua/opencode/state/init.lua b/lua/opencode/state/init.lua index 408f12b6..f7a0f318 100644 --- a/lua/opencode/state/init.lua +++ b/lua/opencode/state/init.lua @@ -16,6 +16,7 @@ local context = require('opencode.state.context') ---@field context OpencodeContextStateMutations ---@alias OpencodeState OpencodeStateModule & OpencodeStateData + ---@type OpencodeState local M = { store = store, diff --git a/lua/opencode/state/jobs.lua b/lua/opencode/state/jobs.lua index 42938c9f..288c521f 100644 --- a/lua/opencode/state/jobs.lua +++ b/lua/opencode/state/jobs.lua @@ -1,16 +1,6 @@ local store = require('opencode.state.store') ---@class OpencodeJobStateMutations ----@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) ----@field is_running fun():boolean - local M = {} ---@param delta integer|nil diff --git a/lua/opencode/state/model.lua b/lua/opencode/state/model.lua index e044a122..ae6396dd 100644 --- a/lua/opencode/state/model.lua +++ b/lua/opencode/state/model.lua @@ -1,17 +1,6 @@ local store = require('opencode.state.store') ---@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 clear 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) - local M = {} ---@param mode string|nil @@ -33,9 +22,11 @@ function M.clear_model() end function M.clear() - store.set('current_model', nil) - store.set('current_mode', nil) - store.set('current_variant', nil) + return store.batch(function() + store.set('current_model', nil) + store.set('current_mode', nil) + store.set('current_variant', nil) + end) end ---@param info table|nil diff --git a/lua/opencode/state/renderer.lua b/lua/opencode/state/renderer.lua index ccc54763..ab4c108f 100644 --- a/lua/opencode/state/renderer.lua +++ b/lua/opencode/state/renderer.lua @@ -1,16 +1,6 @@ - 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) ----@field set_stats fun(tokens_count: number, cost: number) ----@field reset fun() - local M = {} ---@param messages OpencodeMessage[]|nil @@ -33,6 +23,10 @@ function M.set_pending_permissions(permissions) return store.set('pending_permissions', permissions) end +---@param mutator fun(current_permissions: OpencodePermission[]): OpencodePermission[] +function M.update_pending_permissions(mutator) + return store.mutate('pending_permissions', mutator) +end ---@param cost number function M.set_cost(cost) return store.set('cost', cost) @@ -46,16 +40,21 @@ end ---@param tokens_count number ---@param cost number function M.set_stats(tokens_count, cost) - store.set('tokens_count', tokens_count) - store.set('cost', cost) + return store.batch(function() + store.set('tokens_count', tokens_count) + store.set('cost', cost) + end) end function M.reset() - store.set('messages', {}) - store.set('last_user_message', nil) - store.set('tokens_count', 0) - store.set('cost', 0) - store.set('pending_permissions', {}) + return store.batch(function() + store.set('messages', {}) + store.set('current_message', nil) + store.set('last_user_message', nil) + store.set('tokens_count', 0) + store.set('cost', 0) + store.set('pending_permissions', {}) + end) end return M diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index afc18662..76ca483e 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -1,28 +1,25 @@ local store = require('opencode.state.store') ---@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() ----@field set_last_sent_context fun(context: OpencodeContext|nil) ----@field set_user_message_count fun(count: table) - local M = {} ---@param session Session|nil function M.set_active(session) - store.set('restore_points', {}) - store.set('last_sent_context', nil) - store.set('user_message_count', {}) - return store.set('active_session', session) + return store.batch(function() + store.set('restore_points', {}) + store.set('last_sent_context', nil) + store.set('user_message_count', {}) + return store.set('active_session', session) + end) end function M.clear_active() - M.reset_restore_points() - M.set_last_sent_context() - M.set_user_message_count({}) - return store.set('active_session', nil) + return store.batch(function() + store.set('restore_points', {}) + store.set('last_sent_context', nil) + store.set('user_message_count', {}) + return store.set('active_session', nil) + end) end ---@param points RestorePoint[] diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index 96e83dd4..b2e3f63f 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -87,6 +87,54 @@ local _state = { } local _listeners = {} +local _batch_depth = 0 +local _batched_changes = {} +local _batched_order = {} + +---@generic K extends keyof OpencodeStateData +---@param key K +---@param new_val std.RawGet +---@param old_val std.RawGet +local function queue_emit(key, new_val, old_val) + if vim.deep_equal(old_val, new_val) then + return false + end + + if _batch_depth == 0 then + M.emit(key, new_val, old_val) + return true + end + + if not _batched_changes[key] then + _batched_changes[key] = { + old_val = old_val, + new_val = new_val, + } + table.insert(_batched_order, key) + return true + end + + _batched_changes[key].new_val = new_val + return true +end + +local function flush_batched_emits() + if _batch_depth > 0 or #_batched_order == 0 then + return + end + + local pending_changes = _batched_changes + local pending_order = _batched_order + _batched_changes = {} + _batched_order = {} + + for _, key in ipairs(pending_order) do + local change = pending_changes[key] + if change then + M.emit(key, change.new_val, change.old_val) + end + end +end function M.state() return _state @@ -107,9 +155,7 @@ function M.set(key, value) local old = _state[key] _state[key] = value - if not vim.deep_equal(old, value) then - M.emit(key, value, old) - end + queue_emit(key, value, old) return value end @@ -119,7 +165,8 @@ end ---@param value std.RawGet ---@return std.RawGet function M.set_raw(key, value) - return M.set(key, value) + _state[key] = value + return value end ---@generic K extends keyof OpencodeStateData @@ -132,6 +179,43 @@ function M.update(key, updater) return next_value end +---@param callback fun(store: OpencodeStateStore) +function M.batch(callback) + _batch_depth = _batch_depth + 1 + local ok, result = pcall(callback, M) + _batch_depth = _batch_depth - 1 + + if _batch_depth == 0 then + flush_batched_emits() + end + + if not ok then + error(result, 0) + end + + return result +end + +---@generic K extends keyof OpencodeStateData +---@param key K +---@param mutator fun(current: std.RawGet):nil +---@return std.RawGet +function M.mutate(key, mutator) + if _state[key] == nil then + _state[key] = {} --[[@as std.RawGet]] + end + + if type(_state[key]) ~= 'table' then + error('State key is not a table: ' .. key) + end + + local current = _state[key] + local old = vim.tbl_extend('force', {}, current) + mutator(current) + queue_emit(key, current, old) + return current +end + ---@generic K extends keyof OpencodeStateData ---@param key K|K[]|nil ---@param cb fun(key:K, new_val:std.RawGet, old_val:std.RawGet) @@ -204,16 +288,10 @@ 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.emit(key, _state[key], old) + M.mutate(key, function(current) + table.insert(current --[[@as table]], value) + end) end ---@generic K extends keyof OpencodeStateData @@ -223,13 +301,10 @@ 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.emit(key, _state[key], old) + M.mutate(key, function(current) + table.remove(current --[[@as table]], idx) + end) end return M diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index ac84e1ee..cadc627e 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -26,21 +26,6 @@ local store = require('opencode.state.store') ---@field output_was_at_bottom boolean|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|nil) ----@field set_saved_window_options fun(opts: table|nil) ----@field set_pre_zoom_width fun(width: integer|nil) - local M = {} local _state = store.state() @@ -88,6 +73,26 @@ function M.set_current_code_buf(bufnr) return store.set('current_code_buf', bufnr) end +---@param win_id integer|nil +---@param bufnr integer|nil +function M.set_code_context(win_id, bufnr) + store.batch(function() + store.set('last_code_win_before_opencode', win_id) + store.set('current_code_buf', bufnr) + end) +end + +---Clear window IDs while keeping buffer references, used when hiding windows +---@param output_was_at_bottom boolean +function M.mark_windows_hidden(output_was_at_bottom) + store.mutate('windows', function(win) + win.input_win = nil + win.output_win = nil + win.footer_win = nil + win.output_was_at_bottom = output_was_at_bottom + end) +end + ---@param ratio number|nil function M.set_last_window_width_ratio(ratio) return store.set('last_window_width_ratio', ratio) @@ -367,10 +372,12 @@ function M.inspect_hidden_buffers() end function M.clear_hidden_window_state() - store.set('_hidden_buffers', nil) - if _state.windows and not _state.windows.input_win and not _state.windows.output_win then - store.set('windows', nil) - end + return store.batch(function() + store.set('_hidden_buffers', nil) + if _state.windows and not _state.windows.input_win and not _state.windows.output_win then + store.set('windows', nil) + end + end) end ---@return boolean diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 3b42e39d..a3a345cd 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -35,8 +35,7 @@ function M.setup_autocmds(windows) return end local state = require('opencode.state') - state.ui.set_last_code_window(vim.api.nvim_get_current_win()) - state.ui.set_current_code_buf(vim.api.nvim_get_current_buf()) + state.ui.set_code_context(vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf()) end, }) diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 850d2a60..f28d3f0f 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -215,8 +215,7 @@ end ---Render question display as a fake part function M.render_question_display() - local config_module = require('opencode.config') - local use_vim_ui = config_module.ui.questions and config_module.ui.questions.use_vim_ui_select + local use_vim_ui = config.ui.questions and config.ui.questions.use_vim_ui_select if use_vim_ui then -- When using vim.ui.select, we don't render anything in the buffer @@ -961,13 +960,13 @@ function M.on_permission_updated(permission) end end - local permissions = vim.deepcopy(state.pending_permissions) - if existing_index then - permissions[existing_index] = permission - else - table.insert(permissions, permission) - end - state.renderer.set_pending_permissions(permissions) + state.renderer.update_pending_permissions(function(permissions) + if existing_index then + permissions[existing_index] = permission + else + table.insert(permissions, permission) + end + end) permission_window.add_permission(permission) @@ -1195,10 +1194,7 @@ function M._update_stats_from_message(message) local tokens = message.info.tokens if tokens and tokens.input > 0 and message.info.cost and type(message.info.cost) == 'number' then - state.renderer.set_stats( - tokens.input + tokens.output + tokens.cache.read + tokens.cache.write, - message.info.cost - ) + state.renderer.set_stats(tokens.input + tokens.output + tokens.cache.read + tokens.cache.write, message.info.cost) elseif tokens and tokens.input > 0 then state.renderer.set_tokens_count(tokens.input + tokens.output + tokens.cache.read + tokens.cache.write) elseif message.info.cost and type(message.info.cost) == 'number' then diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 27453b14..b99d41e6 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -162,10 +162,7 @@ function M.hide_visible_windows(windows) end state.ui.stash_hidden_buffers(snapshot) if state.windows == windows then - state.windows.input_win = nil - state.windows.output_win = nil - state.windows.footer_win = nil - state.windows.output_was_at_bottom = snapshot.output_was_at_bottom + state.ui.mark_windows_hidden(snapshot.output_was_at_bottom) end end @@ -223,19 +220,17 @@ function M.restore_hidden_windows() state.ui.consume_hidden_buffers() + state.ui.set_windows({ + input_buf = hidden.input_buf, + output_buf = hidden.output_buf, + footer_buf = footer_buf, + input_win = win_ids.input_win, + output_win = win_ids.output_win, + footer_win = nil, + output_was_at_bottom = hidden.output_was_at_bottom == true, + saved_width_ratio = state.last_window_width_ratio, + }) local windows = state.windows - if not windows then - windows = {} - state.ui.set_windows(windows) - end - windows.input_buf = hidden.input_buf - windows.output_buf = hidden.output_buf - windows.footer_buf = footer_buf - windows.input_win = win_ids.input_win - windows.output_win = win_ids.output_win - windows.footer_win = nil - windows.output_was_at_bottom = hidden.output_was_at_bottom == true - windows.saved_width_ratio = state.last_window_width_ratio state.ui.set_cursor_position('input', hidden.input_cursor) state.ui.set_cursor_position('output', hidden.output_cursor) @@ -347,8 +342,7 @@ function M.create_windows() local autocmds = require('opencode.ui.autocmds') if not require('opencode.ui.ui').is_opencode_focused() then - state.ui.set_last_code_window(vim.api.nvim_get_current_win()) - state.ui.set_current_code_buf(vim.api.nvim_get_current_buf()) + state.ui.set_code_context(vim.api.nvim_get_current_win(), vim.api.nvim_get_current_buf()) end -- Create new windows from scratch diff --git a/tests/unit/state_spec.lua b/tests/unit/state_spec.lua index a9b489c9..984b1e22 100644 --- a/tests/unit/state_spec.lua +++ b/tests/unit/state_spec.lua @@ -110,4 +110,65 @@ describe('opencode.state (observable)', function() state.messages = {} end) end) + + it('batches notifications until commit', function() + local calls = {} + local messages_cb = function(key, newv, oldv) + table.insert(calls, { key = key, newv = newv, oldv = oldv }) + end + local cost_cb = function(key, newv, oldv) + table.insert(calls, { key = key, newv = newv, oldv = oldv }) + end + + state.store.subscribe('messages', messages_cb) + state.store.subscribe('cost', cost_cb) + + state.store.batch(function(store) + store.set('messages', { { id = 'batched' } }) + store.set('cost', 12) + assert.same({ { id = 'batched' } }, state.messages) + assert.equals(12, state.cost) + assert.equals(0, #calls) + end) + + vim.wait(50, function() + return #calls == 2 + end) + + assert.same('messages', calls[1].key) + assert.same({ { id = 'batched' } }, calls[1].newv) + assert.same('cost', calls[2].key) + assert.equals(12, calls[2].newv) + + state.renderer.set_messages(nil) + state.renderer.set_cost(0) + state.store.unsubscribe('messages', messages_cb) + state.store.unsubscribe('cost', cost_cb) + end) + + it('emits after mutating table state in place', function() + local called = false + local received + local cb = function(_, newv) + called = true + received = newv + end + + state.renderer.set_messages({}) + state.store.subscribe('messages', cb) + + state.store.mutate('messages', function(messages) + table.insert(messages, { id = 'mutated' }) + end) + + vim.wait(50, function() + return called + end) + + assert.is_true(called) + assert.same({ { id = 'mutated' } }, received) + + state.renderer.set_messages(nil) + state.store.unsubscribe('messages', cb) + end) end) From 16154df7ca5c6e236edc3a7889aafe864c8b0ec8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 13:13:52 -0400 Subject: [PATCH 3/8] feat(state): add server port setter and session helpers --- lua/opencode/context.lua | 7 ------- lua/opencode/image_handler.lua | 1 - lua/opencode/state/jobs.lua | 7 +++++++ lua/opencode/state/session.lua | 18 ++++++++++++++++++ tests/minimal/init.lua | 6 ++++-- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua index e3e93d61..62a5506b 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -116,12 +116,10 @@ end -- Delegate global state management to ChatContext function M.add_selection(selection) ChatContext.add_selection(selection) - state.context.set_context_updated_at(vim.uv.now()) end function M.remove_selection(selection) ChatContext.remove_selection(selection) - state.context.set_context_updated_at(vim.uv.now()) end function M.clear_selections() @@ -216,13 +214,11 @@ function M.add_file(file) file = vim.fn.fnamemodify(file, ':p') ChatContext.add_file(file) - 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.set_context_updated_at(vim.uv.now()) end function M.clear_files() @@ -231,12 +227,10 @@ end function M.add_subagent(subagent) ChatContext.add_subagent(subagent) - state.context.set_context_updated_at(vim.uv.now()) end function M.remove_subagent(subagent) ChatContext.remove_subagent(subagent) - state.context.set_context_updated_at(vim.uv.now()) end function M.clear_subagents() @@ -249,7 +243,6 @@ end function M.load() ChatContext.load() - state.context.set_context_updated_at(vim.uv.now()) end -- Context creation with delta logic (delegates to ChatContext) diff --git a/lua/opencode/image_handler.lua b/lua/opencode/image_handler.lua index f19d4a15..7ebf7dd7 100644 --- a/lua/opencode/image_handler.lua +++ b/lua/opencode/image_handler.lua @@ -150,7 +150,6 @@ function M.paste_image_from_clipboard() if success then context.add_file(image_path) - 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/jobs.lua b/lua/opencode/state/jobs.lua index 288c521f..30f62988 100644 --- a/lua/opencode/state/jobs.lua +++ b/lua/opencode/state/jobs.lua @@ -31,6 +31,13 @@ function M.clear_server() return store.set('opencode_server', nil) end +---@param port integer +function M.set_server_port(port) + store.mutate('opencode_server', function(server) + server.port = port + end) +end + ---@param client OpencodeApiClient|nil function M.set_api_client(client) return store.set('api_client', client) diff --git a/lua/opencode/state/session.lua b/lua/opencode/state/session.lua index 76ca483e..14e716f3 100644 --- a/lua/opencode/state/session.lua +++ b/lua/opencode/state/session.lua @@ -41,4 +41,22 @@ function M.set_user_message_count(count) return store.set('user_message_count', count) end +---Increment/decrement the message count for a session, clamped to >= 0 +---@param session_id string +---@param delta integer +function M.increment_user_message_count(session_id, delta) + store.mutate('user_message_count', function(counts) + local new_value = (counts[session_id] or 0) + delta + counts[session_id] = new_value >= 0 and new_value or 0 + end) +end + +---Update active_session without emitting a change event, used when a silent +---in-place update is needed (e.g. session metadata refresh that must not +---trigger a re-render) +---@param session Session +function M.update_silently(session) + store.set_raw('active_session', session) +end + return M diff --git a/tests/minimal/init.lua b/tests/minimal/init.lua index d7e3293a..b875b1a0 100644 --- a/tests/minimal/init.lua +++ b/tests/minimal/init.lua @@ -34,6 +34,8 @@ vim.opt.termguicolors = true require('opencode') -if vim.treesitter and vim.treesitter.start then - vim.treesitter.start = function() end +-- Safely stub out treesitter start during tests. Accessing `vim.treesitter` +local ok, ts = pcall(require, 'vim.treesitter') +if ok and ts and ts.start then + ts.start = function() end end From b89831b46d803f6eff0bb8a4f925a3370a6ef1a8 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 14:22:14 -0400 Subject: [PATCH 4/8] fix: set port Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lua/opencode/server_job.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index f3b04817..9bda3734 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -293,8 +293,7 @@ function M.spawn_local_server(promise, port, hostname) log.notify(string.format('Started local server at %s', base_url), vim.log.levels.INFO) if url_port then local port_num = tonumber(url_port) - state.store.set_raw('opencode_server', state.opencode_server) - state.opencode_server.port = port_num + state.jobs.set_server_port(port_num) local server_pid = job and job.pid port_mapping.register(port_num, vim.fn.getcwd(), true, 'serve', nil, server_pid) log.debug( From a79ccaa8b8ba702598bbaff2faa3ff373f65aa27 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 15:50:01 -0400 Subject: [PATCH 5/8] fix(store): use deepcopy on mutate --- lua/opencode/state/store.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/state/store.lua b/lua/opencode/state/store.lua index b2e3f63f..d2b93988 100644 --- a/lua/opencode/state/store.lua +++ b/lua/opencode/state/store.lua @@ -210,7 +210,7 @@ function M.mutate(key, mutator) end local current = _state[key] - local old = vim.tbl_extend('force', {}, current) + local old = vim.deepcopy(current) mutator(current) queue_emit(key, current, old) return current From ad4414200654ccb0a14d51b6b3c9933cc8f6953a Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 15:51:41 -0400 Subject: [PATCH 6/8] fix(store): typings --- lua/opencode/state/renderer.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/state/renderer.lua b/lua/opencode/state/renderer.lua index ab4c108f..66b884fd 100644 --- a/lua/opencode/state/renderer.lua +++ b/lua/opencode/state/renderer.lua @@ -23,7 +23,7 @@ function M.set_pending_permissions(permissions) return store.set('pending_permissions', permissions) end ----@param mutator fun(current_permissions: OpencodePermission[]): OpencodePermission[] +---@param mutator fun(current_permissions: OpencodePermission[]): nil function M.update_pending_permissions(mutator) return store.mutate('pending_permissions', mutator) end From 5c33926d4abdd1d743239f0919e2916e6cafd5af Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 15:52:24 -0400 Subject: [PATCH 7/8] fix: comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/minimal/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/minimal/init.lua b/tests/minimal/init.lua index b875b1a0..e9cb7a6c 100644 --- a/tests/minimal/init.lua +++ b/tests/minimal/init.lua @@ -34,7 +34,7 @@ vim.opt.termguicolors = true require('opencode') --- Safely stub out treesitter start during tests. Accessing `vim.treesitter` +-- Safely stub out treesitter during tests, using pcall(require, ...) so tests pass even if vim.treesitter is unavailable. local ok, ts = pcall(require, 'vim.treesitter') if ok and ts and ts.start then ts.start = function() end From b87995db67b54337d7c580abdbdbc10b1c2ef8f1 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 18 Mar 2026 15:53:09 -0400 Subject: [PATCH 8/8] fix(jobs): ensure server before setting port Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lua/opencode/state/jobs.lua | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lua/opencode/state/jobs.lua b/lua/opencode/state/jobs.lua index 30f62988..12ffbfdd 100644 --- a/lua/opencode/state/jobs.lua +++ b/lua/opencode/state/jobs.lua @@ -33,8 +33,13 @@ end ---@param port integer function M.set_server_port(port) - store.mutate('opencode_server', function(server) - server.port = port + local server = store.get('opencode_server') + if not server then + error('Opencode server is not set; cannot set port') + end + + store.mutate('opencode_server', function(s) + s.port = port end) end