diff --git a/README.md b/README.md index b8d417fa..3412dbda 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,7 @@ require('opencode').setup({ }, }, prompt_guard = nil, -- Optional function that returns boolean to control when prompts can be sent (see Prompt Guard section) + child_readonly = true, -- When true (default), child sessions are read-only: messaging is blocked and input window is hidden on switch -- User Hooks for custom behavior at certain events hooks = { diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 923c84b4..80088291 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -297,6 +297,7 @@ M.defaults = { }, }, prompt_guard = nil, + child_readonly = true, hooks = { on_file_edited = nil, on_session_loaded = nil, diff --git a/lua/opencode/services/agent_model.lua b/lua/opencode/services/agent_model.lua index bb2fee10..23c1afca 100644 --- a/lua/opencode/services/agent_model.lua +++ b/lua/opencode/services/agent_model.lua @@ -106,6 +106,11 @@ M.cycle_variant = Promise.async(function() end) M.switch_to_mode = Promise.async(function(mode) + if state.active_session and state.active_session.parentID then + log.notify('Cannot switch agent in child session', vim.log.levels.WARN) + return false + end + if not mode or mode == '' then log.notify('Mode cannot be empty', vim.log.levels.ERROR) return false @@ -166,7 +171,14 @@ M.initialize_current_model = Promise.async(function(opts) opts = opts or {} if opts.restore_from_messages and state.messages then - for i = #state.messages, 1, -1 do + -- Child sessions scan forward (first message is reliable); + -- parent sessions scan backward (most recent is current choice) + local is_child = state.active_session and state.active_session.parentID ~= nil + local start_idx, end_idx, step = #state.messages, 1, -1 + if is_child then + start_idx, end_idx, step = 1, #state.messages, 1 + end + for i = start_idx, end_idx, step do local msg = state.messages[i] if msg and msg.info and msg.info.modelID and msg.info.providerID then local model_str = msg.info.providerID .. '/' .. msg.info.modelID @@ -174,8 +186,7 @@ M.initialize_current_model = Promise.async(function(opts) state.model.set_model(model_str) end if msg.info.mode and state.current_mode ~= msg.info.mode then - local active_session = state.active_session - local should_restore_mode = active_session and active_session.parentID + local should_restore_mode = is_child if not should_restore_mode then local available_agents = config_file.get_opencode_agents():await() should_restore_mode = vim.tbl_contains(available_agents, msg.info.mode) diff --git a/lua/opencode/services/messaging.lua b/lua/opencode/services/messaging.lua index a8373f0c..ac791ebc 100644 --- a/lua/opencode/services/messaging.lua +++ b/lua/opencode/services/messaging.lua @@ -18,7 +18,7 @@ M.send_message = Promise.async(function(prompt, opts) return false end - if state.active_session.parentID then + if state.active_session.parentID and config.child_readonly then return false end @@ -36,7 +36,9 @@ M.send_message = Promise.async(function(prompt, opts) state.context.set_current_context_config(opts.context) context.load() opts.model = opts.model or agent_model.initialize_current_model():await() - opts.agent = opts.agent or state.current_mode or config.default_mode + if opts.agent == nil then + opts.agent = state.current_mode or config.default_mode + end opts.variant = opts.variant or state.current_variant local params = {} diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index cc77a86a..e77f51df 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -13,6 +13,26 @@ local agent_model = require('opencode.services.agent_model') local M = {} +local function focus_after_session_switch(selected_session) + if not state.ui.is_visible() then + M.open() + return + end + + if selected_session and selected_session.parentID and config.child_readonly then + if not input_window.is_hidden() then + input_window._hide() + end + ui.focus_output() + return + end + + if input_window.is_hidden() then + input_window._show() + end + ui.focus_input() +end + ---@param parent_id string? M.select_session = Promise.async(function(parent_id) local all_sessions = session.get_all_workspace_sessions():await() or {} @@ -46,23 +66,8 @@ M.switch_session = Promise.async(function(session_id) state.model.clear() agent_model.ensure_current_mode():await() - state.session.set_active(selected_session) - if state.ui.is_visible() then - if selected_session and selected_session.parentID then - if not input_window.is_hidden() then - input_window._hide() - end - ui.focus_output() - else - if input_window.is_hidden() then - input_window._show() - end - ui.focus_input() - end - else - M.open() - end + focus_after_session_switch(selected_session) end) ---@param opts? OpenOpts diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 4b83a9d4..426b6f2a 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -126,6 +126,8 @@ ---@field time { created: number, updated: number } ---@field id string ---@field parentID string|nil +---@field agent string|nil +---@field model { id: string, providerID: string, variant?: string }|nil ---@field revert? SessionRevertInfo ---@field share? SessionShareInfo @@ -367,6 +369,7 @@ ---@field logging OpencodeLoggingConfig ---@field debug OpencodeDebugConfig ---@field prompt_guard? fun(mentioned_files: string[]): boolean +---@field child_readonly boolean ---@field hooks OpencodeHooks ---@field quick_chat OpencodeQuickChatConfig diff --git a/lua/opencode/ui/input_window.lua b/lua/opencode/ui/input_window.lua index dec47f93..c9c63937 100644 --- a/lua/opencode/ui/input_window.lua +++ b/lua/opencode/ui/input_window.lua @@ -591,8 +591,7 @@ end ---Show the input window by recreating it function M._show() - -- Child sessions must never show the input window - if state.active_session and state.active_session.parentID then + if state.active_session and state.active_session.parentID and config.child_readonly then return end diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index 7fd0856c..56d1dc08 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -319,7 +319,8 @@ end ---@param output_buf integer ---@return { input_win: integer, output_win: integer } local function open_float(input_buf, output_buf) - local output_config, input_config = float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true) + local output_config, input_config = + float_layout.window_configs({ input_buf = input_buf, output_buf = output_buf }, true) local output_win = float_layout.open_win(output_buf, true, output_config) local input_win = float_layout.open_win(input_buf, true, input_config) @@ -403,7 +404,7 @@ end ---@param opts? { restore_position?: boolean, start_insert?: boolean } function M.focus_input(opts) - if state.active_session and state.active_session.parentID then + if state.active_session and state.active_session.parentID and config.child_readonly then return end @@ -561,7 +562,7 @@ function M.toggle_pane() if state.windows and current_win == state.windows.input_win then output_window.focus_output(true) else - if state.active_session and state.active_session.parentID then + if state.active_session and state.active_session.parentID and config.child_readonly then return end input_window.focus_input() diff --git a/tests/unit/input_window_spec.lua b/tests/unit/input_window_spec.lua index 63070e47..7443d198 100644 --- a/tests/unit/input_window_spec.lua +++ b/tests/unit/input_window_spec.lua @@ -573,5 +573,20 @@ describe('input_window', function() -- _hidden remains true because windows are nil, but the parentID guard was passed assert.is_true(input_window._hidden) end) + + it('_show() proceeds for child session when child_readonly is false', function() + state.session.set_active({ id = 'child1', parentID = 'parent1' }) + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + input_window._hidden = true + + -- _show will early-return due to missing windows, but it should pass the guard + input_window._show() + + -- _hidden remains true because windows are nil, but the parentID guard was passed + assert.is_true(input_window._hidden) + config.values.child_readonly = orig_readonly + end) end) end) diff --git a/tests/unit/services_agent_model_spec.lua b/tests/unit/services_agent_model_spec.lua index 7eb07c44..e4f262d0 100644 --- a/tests/unit/services_agent_model_spec.lua +++ b/tests/unit/services_agent_model_spec.lua @@ -217,4 +217,35 @@ describe('opencode.services.agent_model', function() config_file.get_opencode_agents:revert() state.session.clear_active() end) + + it('rejects switch_to_mode in child session', function() + state.session.set_active({ id = 'child1', parentID = 'parent1' }) + state.model.set_mode('build') + + local success = agent_model.switch_to_mode('plan'):wait() + + assert.is_false(success) + assert.equal('build', state.current_mode) + + state.session.clear_active() + end) + + it('allows switch_to_mode in parent session', function() + state.session.set_active({ id = 'parent1' }) + state.store.set('current_mode', nil) + state.store.set('current_model', nil) + state.store.set('user_mode_model_map', {}) + + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'plan', 'build' })) + stub(config_file, 'get_opencode_config').returns(Promise.new():resolve({})) + + local success = agent_model.switch_to_mode('plan'):wait() + + assert.is_true(success) + assert.equal('plan', state.current_mode) + + config_file.get_opencode_agents:revert() + config_file.get_opencode_config:revert() + state.session.clear_active() + end) end) diff --git a/tests/unit/services_messaging_spec.lua b/tests/unit/services_messaging_spec.lua index d1aa5638..46400724 100644 --- a/tests/unit/services_messaging_spec.lua +++ b/tests/unit/services_messaging_spec.lua @@ -144,6 +144,108 @@ describe('opencode.services.messaging', function() state.api_client.create_message = orig end) + it('sends message to child session when child_readonly is false', function() + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'child1', parentID = 'parent1' }) + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'build' })) + + local create_called = false + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + create_called = true + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world') + vim.wait(50, function() + return create_called + end) + assert.is_true(create_called) + state.api_client.create_message = orig + config.values.child_readonly = orig_readonly + config_file.get_opencode_agents:revert() + end) + + it('sends inferred agent for child session', function() + state.ui.set_windows({ mock = 'windows' }) + state.model.set_mode('study') -- set by switch_session inference + state.session.set_active({ id = 'child1', parentID = 'parent1' }) + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + + local captured_params = nil + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + captured_params = params + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world') + vim.wait(50, function() + return captured_params ~= nil + end) + + assert.equal('study', captured_params.agent) + state.api_client.create_message = orig + config.values.child_readonly = orig_readonly + end) + + it('respects explicit agent for child session', function() + state.ui.set_windows({ mock = 'windows' }) + state.session.set_active({ id = 'child1', parentID = 'parent1' }) + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'study', 'build' })) + + local captured_params = nil + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + captured_params = params + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world', { agent = 'study' }) + vim.wait(50, function() + return captured_params ~= nil + end) + + assert.equal('study', captured_params.agent) + state.api_client.create_message = orig + config.values.child_readonly = orig_readonly + config_file.get_opencode_agents:revert() + end) + + it('sends agent param for parent session', function() + state.ui.set_windows({ mock = 'windows' }) + state.model.set_mode('build') + state.session.set_active({ id = 'sess1' }) + + stub(config_file, 'get_opencode_agents').returns(Promise.new():resolve({ 'build' })) + + local captured_params = nil + local orig = state.api_client.create_message + state.api_client.create_message = function(_, sid, params) + captured_params = params + return Promise.new():resolve({ id = 'm1' }) + end + + messaging.send_message('hello world') + vim.wait(50, function() + return captured_params ~= nil + end) + + assert.equal('build', captured_params.agent) + state.api_client.create_message = orig + config_file.get_opencode_agents:revert() + end) + it('increments and decrements user_message_count correctly', function() state.ui.set_windows({ mock = 'windows' }) state.session.set_active({ id = 'sess1' }) diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 258e1bc1..d8ff7f58 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -339,7 +339,9 @@ describe('opencode.services.session_runtime', function() it('hides input window when switching to a child session', function() state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) local orig_is_visible = state.ui.is_visible - state.ui.is_visible = function() return true end + state.ui.is_visible = function() + return true + end stub(input_window, 'is_hidden').returns(false) stub(input_window, '_hide') @@ -361,7 +363,9 @@ describe('opencode.services.session_runtime', function() it('shows input window when switching to a non-child session', function() state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) local orig_is_visible = state.ui.is_visible - state.ui.is_visible = function() return true end + state.ui.is_visible = function() + return true + end stub(input_window, 'is_hidden').returns(true) stub(input_window, '_show') @@ -378,7 +382,9 @@ describe('opencode.services.session_runtime', function() it('does not hide input when already hidden on child session switch', function() state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) local orig_is_visible = state.ui.is_visible - state.ui.is_visible = function() return true end + state.ui.is_visible = function() + return true + end stub(input_window, 'is_hidden').returns(true) stub(input_window, '_hide') @@ -396,6 +402,7 @@ describe('opencode.services.session_runtime', function() input_window._hide:revert() state.ui.is_visible = orig_is_visible end) + end) describe('child session UI guards', function() @@ -410,7 +417,12 @@ describe('opencode.services.session_runtime', function() stub(input_window, 'focus_input') -- Simulate being in the output window (not input) - state.ui.set_windows({ input_win = -1, output_win = vim.api.nvim_get_current_win(), input_buf = 1, output_buf = 2 }) + state.ui.set_windows({ + input_win = -1, + output_win = vim.api.nvim_get_current_win(), + input_buf = 1, + output_buf = 2, + }) ui.toggle_pane() @@ -429,6 +441,85 @@ describe('opencode.services.session_runtime', function() input_window.is_hidden:revert() input_window._show:revert() end) + + it('toggle_pane shows input when child_readonly is false', function() + state.session.set_active({ id = 'child1', parentID = 'parent1' }) + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + stub(input_window, 'focus_input') + + state.ui.set_windows({ + input_win = -1, + output_win = vim.api.nvim_get_current_win(), + input_buf = 1, + output_buf = 2, + }) + + ui.toggle_pane() + + assert.stub(input_window.focus_input).was_called() + input_window.focus_input:revert() + config.values.child_readonly = orig_readonly + end) + + it('focus_input works when child_readonly is false', function() + state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2 }) + state.session.set_active({ id = 'child1', parentID = 'parent1' }) + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + + -- Revert the before_each stub so we call the real focus_input + ui.focus_input:revert() + + local reached_is_hidden = false + local orig_is_hidden = input_window.is_hidden + input_window.is_hidden = function() + reached_is_hidden = true + return true + end + local orig_show = input_window._show + input_window._show = function() end + + ui.focus_input() + + assert.is_true(reached_is_hidden) + input_window.is_hidden = orig_is_hidden + input_window._show = orig_show + config.values.child_readonly = orig_readonly + -- Re-stub for after_each cleanup + stub(ui, 'focus_input') + end) + + it('switch_session does not hide input when child_readonly is false', function() + state.ui.set_windows({ mock = 'windows', input_buf = 1, output_buf = 2, input_win = 3, output_win = 4 }) + local orig_is_visible = state.ui.is_visible + state.ui.is_visible = function() + return true + end + local config = require('opencode.config') + local orig_readonly = config.values.child_readonly + config.values.child_readonly = false + + stub(input_window, 'is_hidden').returns(false) + stub(input_window, '_hide') + + session.get_by_id:revert() + stub(session, 'get_by_id').invokes(function(id) + return Promise.new():resolve({ id = id, title = id, modified = os.time(), parentID = 'parent1' }) + end) + + session_runtime.switch_session('child1'):wait() + + assert.stub(input_window._hide).was_not_called() + assert.stub(ui.focus_input).was_called() + + input_window.is_hidden:revert() + input_window._hide:revert() + state.ui.is_visible = orig_is_visible + config.values.child_readonly = orig_readonly + end) end) describe('send_message', function()