diff --git a/README.md b/README.md index 645b3ab8..c086bc52 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,8 @@ require('opencode').setup({ ['i'] = { 'focus_input', 'n' }, -- Focus on input window and enter insert mode at the end of the input from the output window [''] = { 'cycle_variant', mode = { 'n' } }, -- Cycle through available model variants ['oS'] = { 'select_child_session' }, -- Select and load a child session + ['oP'] = { 'select_parent_session' }, -- Go to parent session + ['oB'] = { 'select_sibling_session' }, -- Select sibling session (children of same parent) ['oD'] = { 'debug_message' }, -- Open raw message in new buffer for debugging ['oO'] = { 'debug_output' }, -- Open raw output in new buffer for debugging ['ods'] = { 'debug_session' }, -- Open raw session in new buffer for debugging @@ -603,7 +605,9 @@ The plugin provides the following actions that can be triggered via keymaps, com | Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` | | Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` | | Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` | -| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` | +| **Select and load child session** | `oS` | `:Opencode session child` | `require('opencode.api').select_child_session()` | +| **Select sibling session** | `oB` | `:Opencode session sibling` | `require('opencode.api').select_sibling_session()` | +| **Go to parent session** | `oP` | `:Opencode session parent` | `require('opencode.api').select_parent_session()` | | Open timeline picker (navigate/undo/redo/fork to message) | `oT` | `:Opencode timeline` | `require('opencode.api').timeline()` | | Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` | | Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` | diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 52031a24..725e9daf 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -42,6 +42,8 @@ local action_groups = { session = { open_input_new_session = session.open_input_new_session, select_child_session = session.select_child_session, + select_sibling_session = session.select_sibling_session, + select_parent_session = session.select_parent_session, share = session.share, unshare = session.unshare, initialize = session.initialize, diff --git a/lua/opencode/commands/handlers/session.lua b/lua/opencode/commands/handlers/session.lua index 0969b46c..aab79b35 100644 --- a/lua/opencode/commands/handlers/session.lua +++ b/lua/opencode/commands/handlers/session.lua @@ -10,7 +10,7 @@ local M = { actions = {}, } -local session_subcommands = { 'new', 'select', 'child', 'compact', 'share', 'unshare', 'agents_init', 'rename' } +local session_subcommands = { 'new', 'select', 'child', 'sibling', 'parent', 'compact', 'share', 'unshare', 'agents_init', 'rename' } ---@param message string local function invalid_arguments(message) @@ -105,6 +105,25 @@ function M.actions.select_child_session() session_runtime.select_session(active and active.id or nil) end +function M.actions.select_sibling_session() + local active = state.active_session + if not active or not active.parentID then + vim.notify('Current session has no parent – showing root sessions', vim.log.levels.INFO) + session_runtime.select_session(nil) + return + end + session_runtime.select_session(active.parentID) +end + +function M.actions.select_parent_session() + local active = state.active_session + if not active or not active.parentID then + vim.notify('Current session has no parent', vim.log.levels.INFO) + return + end + session_runtime.switch_session(active.parentID) +end + ---@param current_session? Session function M.actions.compact_session(current_session) local state_obj = state @@ -411,6 +430,12 @@ local session_subcommand_actions = { child = function() return M.actions.select_child_session() end, + sibling = function() + return M.actions.select_sibling_session() + end, + parent = function() + return M.actions.select_parent_session() + end, compact = function() return M.actions.compact_session() end, @@ -443,6 +468,8 @@ M.command_defs = { open_input_new_session = { desc = 'Open input (new session)', execute = M.actions.open_input_new_session }, select_session = { desc = 'Select session', execute = function() return M.actions.select_session() end }, select_child_session = { desc = 'Select child session', execute = M.actions.select_child_session }, + select_sibling_session = { desc = 'Select sibling session', execute = M.actions.select_sibling_session }, + select_parent_session = { desc = 'Go to parent session', execute = M.actions.select_parent_session }, rename_session = { desc = 'Rename session', execute = function(args) return M.actions.rename_session(nil, args[1]) end }, undo = { desc = 'Undo last action', diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 9193bb53..4a5bfd6a 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -36,6 +36,9 @@ M.defaults = { ['oT'] = { 'timeline', desc = 'Session timeline' }, ['oq'] = { 'close', desc = 'Close Opencode window' }, ['os'] = { 'select_session', desc = 'Select session' }, + ['oS'] = { 'select_child_session', desc = 'Select child session' }, + ['oP'] = { 'select_parent_session', desc = 'Go to parent session' }, + ['oB'] = { 'select_sibling_session', desc = 'Select sibling session' }, ['oR'] = { 'rename_session', desc = 'Rename session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, ['oV'] = { 'configure_variant', desc = 'Configure model variant' }, @@ -71,6 +74,8 @@ M.defaults = { [''] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' }, [''] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' }, ['oS'] = { 'select_child_session', desc = 'Select child session' }, + ['oP'] = { 'select_parent_session', desc = 'Go to parent session' }, + ['oB'] = { 'select_sibling_session', desc = 'Select sibling session' }, ['oD'] = { 'debug_message', desc = 'Open raw message debug view' }, ['oO'] = { 'debug_output', desc = 'Open raw output debug view' }, ['ods'] = { 'debug_session', desc = 'Open raw session debug view' }, @@ -93,6 +98,8 @@ M.defaults = { [''] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' }, ['gr'] = { 'references', desc = 'Browse code references' }, ['oS'] = { 'select_child_session', desc = 'Select child session' }, + ['oP'] = { 'select_parent_session', desc = 'Go to parent session' }, + ['oB'] = { 'select_sibling_session', desc = 'Select sibling session' }, ['oD'] = { 'debug_message', desc = 'Open raw message debug view' }, ['oO'] = { 'debug_output', desc = 'Open raw output debug view' }, ['ods'] = { 'debug_session', desc = 'Open raw session debug view' }, diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index e57a1d95..0cfdad95 100644 --- a/tests/unit/commands_handlers_spec.lua +++ b/tests/unit/commands_handlers_spec.lua @@ -94,7 +94,7 @@ describe('opencode.commands.handlers', function() assert.same({ 'accept', 'accept_all', 'deny' }, defs.permission.completions) assert.same({ allow_empty = false }, defs.permission.nested_subcommand) - assert.same({ 'new', 'select', 'child', 'compact', 'share', 'unshare', 'agents_init', 'rename' }, defs.session.completions) + assert.same({ 'new', 'select', 'child', 'sibling', 'parent', 'compact', 'share', 'unshare', 'agents_init', 'rename' }, defs.session.completions) assert.same({ allow_empty = false }, defs.session.nested_subcommand) assert.same({ 'input', 'output' }, defs.open.completions) @@ -203,4 +203,150 @@ describe('opencode.commands.handlers', function() assert.equal('all', called.scope) assert.is_nil(called.snapshot_id) end) + + it('select_sibling_session calls select_session with parentID when active session is a child', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local state = require('opencode.state') + + state.session.set_active({ id = 'child1', parentID = 'root1', title = 'Child 1' }) + local called_parent_id + local original = session_runtime.select_session + session_runtime.select_session = function(parent_id) + called_parent_id = parent_id + end + + session_handler.actions.select_sibling_session() + + session_runtime.select_session = original + assert.equal('root1', called_parent_id) + end) + + it('select_sibling_session falls back to root sessions when active session has no parent', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local state = require('opencode.state') + + state.session.set_active({ id = 'root1', parentID = nil, title = 'Root' }) + local called_parent_id = 'sentinel' + local original = session_runtime.select_session + session_runtime.select_session = function(parent_id) + called_parent_id = parent_id + end + + local notify_stub = stub(vim, 'notify') + session_handler.actions.select_sibling_session() + notify_stub:revert() + + session_runtime.select_session = original + assert.is_nil(called_parent_id) + end) + + it('select_parent_session switches to parent when active session is a child', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local state = require('opencode.state') + + state.session.set_active({ id = 'child1', parentID = 'root1', title = 'Child 1' }) + local switched_to + local original = session_runtime.switch_session + session_runtime.switch_session = function(session_id) + switched_to = session_id + end + + session_handler.actions.select_parent_session() + + session_runtime.switch_session = original + assert.equal('root1', switched_to) + end) + + it('select_parent_session notifies when active session has no parent', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local state = require('opencode.state') + + state.session.set_active({ id = 'root1', parentID = nil, title = 'Root' }) + local switched_to = nil + local original = session_runtime.switch_session + session_runtime.switch_session = function(session_id) + switched_to = session_id + end + + local notify_stub = stub(vim, 'notify') + session_handler.actions.select_parent_session() + + session_runtime.switch_session = original + assert.is_nil(switched_to) + assert.stub(notify_stub).was_called() + notify_stub:revert() + end) + + it('select_sibling_session does nothing when no active session', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local state = require('opencode.state') + + state.session.set_active(nil) + local called = false + local original = session_runtime.select_session + session_runtime.select_session = function() + called = true + end + + local notify_stub = stub(vim, 'notify') + session_handler.actions.select_sibling_session() + notify_stub:revert() + + session_runtime.select_session = original + assert.is_true(called) + end) + + it('select_parent_session does nothing when no active session', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local state = require('opencode.state') + + state.session.set_active(nil) + local switched_to = nil + local original = session_runtime.switch_session + session_runtime.switch_session = function(session_id) + switched_to = session_id + end + + local notify_stub = stub(vim, 'notify') + session_handler.actions.select_parent_session() + + session_runtime.switch_session = original + assert.is_nil(switched_to) + assert.stub(notify_stub).was_called() + notify_stub:revert() + end) + + it('session subcommand sibling routes to select_sibling_session', function() + local session_handler = require('opencode.commands.handlers.session') + local called = false + local original = session_handler.actions.select_sibling_session + session_handler.actions.select_sibling_session = function() + called = true + end + + session_handler.command_defs.session.execute({ 'sibling' }) + + session_handler.actions.select_sibling_session = original + assert.is_true(called) + end) + + it('session subcommand parent routes to select_parent_session', function() + local session_handler = require('opencode.commands.handlers.session') + local called = false + local original = session_handler.actions.select_parent_session + session_handler.actions.select_parent_session = function() + called = true + end + + session_handler.command_defs.session.execute({ 'parent' }) + + session_handler.actions.select_parent_session = original + assert.is_true(called) + end) end) diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index fadabfe0..6fe8acfa 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -308,6 +308,29 @@ describe('opencode.services.session_runtime', function() assert.truthy(state.active_session) assert.equal('session3', state.active_session.id) end) + + it('filters child sessions by parentID', function() + local mock_sessions = { + { id = 'root1', title = 'Root', modified = 1, parentID = nil }, + { id = 'child1', title = 'Child 1', modified = 2, parentID = 'root1' }, + { id = 'child2', title = 'Child 2', modified = 3, parentID = 'root1' }, + { id = 'child3', title = 'Child of other', modified = 4, parentID = 'root2' }, + } + stub(session, 'get_all_workspace_sessions').invokes(function() + return Promise.new():resolve(mock_sessions) + end) + local passed + stub(ui, 'select_session').invokes(function(sessions, cb) + passed = sessions + cb(nil) + end) + + state.ui.set_windows({ input_buf = 1, output_buf = 2 }) + session_runtime.select_session('root1'):wait() + assert.equal(2, #passed) + assert.equal('child1', passed[1].id) + assert.equal('child2', passed[2].id) + end) end) describe('send_message', function()