diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 212c1aa8..13a04ee3 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() @@ -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', @@ -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 @@ -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() @@ -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', @@ -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 @@ -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..056aebce 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 @@ -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 d7804e6c..f1c53e95 100644 --- a/lua/opencode/context.lua +++ b/lua/opencode/context.lua @@ -22,11 +22,14 @@ 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 new_config = vim.deepcopy(current_config) 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] + + 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 @@ -117,12 +120,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 +183,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 +198,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 +216,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) @@ -312,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/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 30388075..f679aca6 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 @@ -37,13 +37,13 @@ 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 = {} - if state.is_visible() then + state.session.set_active(selected_session) + state.session.reset_restore_points() + 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) @@ -72,8 +72,8 @@ 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.active_session = nil + state.context.set_current_cwd(vim.fn.getcwd()) + state.session.clear_active() context.unload_attachments() end end @@ -82,20 +82,20 @@ 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() 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' 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() @@ -103,12 +103,12 @@ 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.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,20 @@ M.open = Promise.async(function(opts) local ok, err = pcall(function() if opts.new_session then - state.active_session = nil - state.last_sent_context = nil + state.session.clear_active() 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 +155,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) @@ -187,7 +186,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 @@ -197,17 +196,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() @@ -223,7 +222,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 +266,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 @@ -286,21 +285,19 @@ 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 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 + if state.ui.is_visible() then ui.focus_input() else vim.notify('Changed provider to ' .. model_str, vim.log.levels.INFO) @@ -311,15 +308,15 @@ 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 end - state.current_variant = selection.name + 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) @@ -377,14 +374,14 @@ 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) 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 {} @@ -411,15 +408,15 @@ 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 - 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 @@ -439,7 +436,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 @@ -485,18 +482,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 +533,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,22 +579,21 @@ 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.last_sent_context = nil + state.session.clear_active() 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) 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.current_variant = nil + state.model.clear_variant() -- Load saved variant for the new model if new_val then @@ -606,7 +602,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 @@ -617,7 +613,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..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() @@ -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/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..1012b254 100644 --- a/lua/opencode/state.lua +++ b/lua/opencode/state.lua @@ -1,613 +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 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 - -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 = {} - ---- 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 -local function _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) - _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) - _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 string -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() -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) - local old = _state[k] - _state[k] = v - if not vim.deep_equal(old, v) then - _notify(k, v, old) - end - end, - __pairs = function() - return pairs(_state) - end, - __ipairs = function() - return ipairs(_state) - end, -}) --[[@as OpencodeState]] +return require('opencode.state.init') --[[@as OpencodeStateModule]] diff --git a/lua/opencode/state/context.lua b/lua/opencode/state/context.lua new file mode 100644 index 00000000..692bbc4e --- /dev/null +++ b/lua/opencode/state/context.lua @@ -0,0 +1,26 @@ + +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 +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 new file mode 100644 index 00000000..408f12b6 --- /dev/null +++ b/lua/opencode/state/init.lua @@ -0,0 +1,43 @@ +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 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 + +---@alias OpencodeState OpencodeStateModule & OpencodeStateData +---@type OpencodeState +local M = { + store = store, + session = session, + jobs = jobs, + ui = ui, + model = model, + renderer = renderer, + context = context, +} + +return setmetatable(M, { + __index = function(_, key) + return store.get(key) + end, + __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()) + 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..42938c9f --- /dev/null +++ b/lua/opencode/state/jobs.lua @@ -0,0 +1,63 @@ +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 +function M.increment_count(delta) + return store.update('job_count', function(current) + return (current or 0) + (delta or 1) + end) +end + +---@param delta integer|nil +function M.decrement_count(delta) + return store.update('job_count', function(current) + return math.max(0, (current or 0) - (delta or 1)) + end) +end + +---@param count integer +function M.set_count(count) + return store.set('job_count', count) +end + +---@param server OpencodeServer|nil +function M.set_server(server) + return store.set('opencode_server', server) +end + +function M.clear_server() + return store.set('opencode_server', nil) +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 + +function M.is_running() + return (store.get('job_count') or 0) > 0 +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/renderer.lua b/lua/opencode/state/renderer.lua new file mode 100644 index 00000000..002bbff1 --- /dev/null +++ b/lua/opencode/state/renderer.lua @@ -0,0 +1,44 @@ + +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 +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 new file mode 100644 index 00000000..078f4527 --- /dev/null +++ b/lua/opencode/state/session.lua @@ -0,0 +1,45 @@ +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) + M.clear_active() + return store.set('active_session', session) +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) +end + +---@param points RestorePoint[] +function M.set_restore_points(points) + return store.set('restore_points', points) +end + +function M.reset_restore_points() + return store.set('restore_points', {}) +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 new file mode 100644 index 00000000..96e83dd4 --- /dev/null +++ b/lua/opencode/state/store.lua @@ -0,0 +1,235 @@ +---@class OpencodeStateStore +local M = {} + +---@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, + 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 = {} + +function M.state() + return _state +end + +---@generic K extends keyof OpencodeStateData +---@param key K +---@return std.RawGet +function M.get(key) + return _state[key] +end + +---@generic K extends keyof OpencodeStateData +---@param key K +---@param value std.RawGet +---@return std.RawGet +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 + + return value +end + +---@generic K extends keyof OpencodeStateData +---@param key K +---@param value std.RawGet +---@return std.RawGet +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 +---@return std.RawGet +function M.update(key, updater) + local next_value = updater(_state[key]) + M.set(key, next_value) + return next_value +end + +---@generic K extends keyof OpencodeStateData +---@param key K|K[]|nil +---@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 + 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 + +---@generic K extends keyof OpencodeStateData +---@param key K|nil +---@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] + if not list then + return + end + + for i = #list, 1, -1 do + if list[i] == cb then + table.remove(list, i) + end + end +end + +---@generic K extends keyof OpencodeStateData +---@param key 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 + 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 + +---@generic K extends keyof OpencodeStateData +---@param key K +---@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') + 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) +end + +---@generic K extends keyof OpencodeStateData +---@param key K +---@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.emit(key, _state[key], old) +end + +return M diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua new file mode 100644 index 00000000..ac84e1ee --- /dev/null +++ b/lua/opencode/state/ui.lua @@ -0,0 +1,457 @@ +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() +---@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() + +---@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 lines table|nil +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) + 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 + store.set('last_input_window_position', normalized) + elseif win_type == 'output' then + store.set('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 + store.set('_hidden_buffers', nil) + return + end + + store.set('_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() + 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 + +---@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() + store.set('_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/autocmds.lua b/lua/opencode/ui/autocmds.lua index 4f6b9a11..3b42e39d 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, }) @@ -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/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/input_window.lua b/lua/opencode/ui/input_window.lua index 903b61b3..5db5e97a 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, }) @@ -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) @@ -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, }) @@ -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/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/output_window.lua b/lua/opencode/ui/output_window.lua index a6e561db..a9b8d0db 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 @@ -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() @@ -261,7 +275,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 +287,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') @@ -284,9 +298,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, }) @@ -318,9 +332,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/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 diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 1c1eb382..20d2d86e 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -915,19 +915,8 @@ function M.on_session_updated(properties) local previous_title = current_session.title 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 @@ -964,7 +953,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 +971,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 +993,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') @@ -1034,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 @@ -1205,16 +1194,16 @@ 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 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/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/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/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 92724e08..27453b14 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 @@ -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') @@ -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 @@ -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) @@ -157,10 +157,10 @@ 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.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 @@ -184,15 +184,15 @@ 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() + state.ui.clear_hidden_window_state() 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 @@ -202,13 +202,13 @@ 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 ---@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 @@ -221,12 +221,12 @@ 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 windows = {} - state.windows = windows + state.ui.set_windows(windows) end windows.input_buf = hidden.input_buf windows.output_buf = hidden.output_buf @@ -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) @@ -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) @@ -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() @@ -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 @@ -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/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/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 440f6669..d7e3293a 100644 --- a/tests/minimal/init.lua +++ b/tests/minimal/init.lua @@ -33,3 +33,7 @@ _G.test_plugin_root = plugin_root vim.opt.termguicolors = true require('opencode') + +if vim.treesitter and vim.treesitter.start then + vim.treesitter.start = function() end +end diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 0f469357..ca2fd2e9 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -150,14 +150,13 @@ 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') renderer.on_session_updated({ info = { @@ -167,22 +166,19 @@ 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() 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 +198,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 +263,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 +281,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..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 @@ -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() @@ -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 @@ -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) @@ -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/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 4a83e775..43793dd6 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -1,11 +1,12 @@ local state = require('opencode.state') +local store = require('opencode.state.store') local config = require('opencode.config') 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() @@ -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() @@ -93,73 +94,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 +175,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 }) @@ -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..0368ce24 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() @@ -132,12 +132,12 @@ 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.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() @@ -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') @@ -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) @@ -194,11 +194,11 @@ 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.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() @@ -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') @@ -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 d52c1f92..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') @@ -39,12 +40,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 @@ -120,7 +133,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 @@ -131,13 +144,13 @@ describe('persist_state', function() end end - state.clear_hidden_window_state() + state.ui.clear_hidden_window_state() end local function cleanup_windows() if state.windows then ui.close_windows(state.windows, false) - state.windows = nil + state.ui.set_windows(nil) end end @@ -166,32 +179,40 @@ 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.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 = {} + 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() + 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') 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 end -- Pre-set the server to skip ensure_server - state.opencode_server = mock_server + store.set('opencode_server', mock_server) end) after_each(function() @@ -216,13 +237,13 @@ 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 - state.clear_hidden_window_state() + 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 if original_opencode_server_new then @@ -259,7 +280,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)) @@ -282,7 +303,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 }) @@ -411,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) @@ -426,7 +447,7 @@ describe('persist_state', function() end, 50) assert.is_true(settled) - state.windows = nil + state.ui.set_windows(nil) end, }, } @@ -510,7 +531,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 +555,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()) @@ -621,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/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/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..a9b489c9 100644 --- a/tests/unit/state_spec.lua +++ b/tests/unit/state_spec.lua @@ -7,43 +7,45 @@ 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) + local cb = function(key, newv, oldv) called = true changed_key = key new_val = newv old_val = oldv - end) - state.test_key = 123 + end + state.store.subscribe('messages', cb) + 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.store.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) - state.another_key = 'abc' + end + state.store.subscribe('*', cb) + 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.store.unsubscribe('*', cb) end) it('can unregister listeners', function() @@ -51,17 +53,17 @@ describe('opencode.state (observable)', function() local cb = function() called = called + 1 end - state.subscribe('foo', cb) - state.foo = 1 + state.store.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.store.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 +72,42 @@ describe('opencode.state (observable)', function() called = called + 1 end - state.subscribe('dup_key', cb) - state.subscribe('dup_key', cb) + state.store.subscribe('cost', cb) + state.store.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.store.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() + local cb = function() called = true - end) - state.bar = 42 + end + state.store.subscribe('tokens_count', cb) + 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.store.unsubscribe('tokens_count', cb) + 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 7282d760..a6ee11ed 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,12 +222,74 @@ 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) 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() @@ -281,7 +343,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) @@ -302,7 +364,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