Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
['<M-r>'] = { 'cycle_variant', mode = { 'n' } }, -- Cycle through available model variants
['<leader>oS'] = { 'select_child_session' }, -- Select and load a child session
['<leader>oP'] = { 'select_parent_session' }, -- Go to parent session
['<leader>oB'] = { 'select_sibling_session' }, -- Select sibling session (children of same parent)
['<leader>oD'] = { 'debug_message' }, -- Open raw message in new buffer for debugging
['<leader>oO'] = { 'debug_output' }, -- Open raw output in new buffer for debugging
['<leader>ods'] = { 'debug_session' }, -- Open raw session in new buffer for debugging
Expand Down Expand Up @@ -603,7 +605,9 @@ The plugin provides the following actions that can be triggered via keymaps, com
| Toggle focus opencode / last window | `<leader>ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` |
| Close UI windows | `<leader>oq` | `:Opencode close` | `require('opencode.api').close()` |
| Select and load session | `<leader>os` | `:Opencode session select` | `require('opencode.api').select_session()` |
| **Select and load child session** | `<leader>oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` |
| **Select and load child session** | `<leader>oS` | `:Opencode session child` | `require('opencode.api').select_child_session()` |
| **Select sibling session** | `<leader>oB` | `:Opencode session sibling` | `require('opencode.api').select_sibling_session()` |
| **Go to parent session** | `<leader>oP` | `:Opencode session parent` | `require('opencode.api').select_parent_session()` |
| Open timeline picker (navigate/undo/redo/fork to message) | `<leader>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 | `<leader>op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` |
Expand Down
2 changes: 2 additions & 0 deletions lua/opencode/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 28 additions & 1 deletion lua/opencode/commands/handlers/session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
7 changes: 7 additions & 0 deletions lua/opencode/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ M.defaults = {
['<leader>oT'] = { 'timeline', desc = 'Session timeline' },
['<leader>oq'] = { 'close', desc = 'Close Opencode window' },
['<leader>os'] = { 'select_session', desc = 'Select session' },
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
['<leader>oP'] = { 'select_parent_session', desc = 'Go to parent session' },
['<leader>oB'] = { 'select_sibling_session', desc = 'Select sibling session' },
['<leader>oR'] = { 'rename_session', desc = 'Rename session' },
['<leader>op'] = { 'configure_provider', desc = 'Configure provider' },
['<leader>oV'] = { 'configure_variant', desc = 'Configure model variant' },
Expand Down Expand Up @@ -71,6 +74,8 @@ M.defaults = {
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
['<M-r>'] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' },
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
['<leader>oP'] = { 'select_parent_session', desc = 'Go to parent session' },
['<leader>oB'] = { 'select_sibling_session', desc = 'Select sibling session' },
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },
Expand All @@ -93,6 +98,8 @@ M.defaults = {
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
['gr'] = { 'references', desc = 'Browse code references' },
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
['<leader>oP'] = { 'select_parent_session', desc = 'Go to parent session' },
['<leader>oB'] = { 'select_sibling_session', desc = 'Select sibling session' },
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },
Expand Down
148 changes: 147 additions & 1 deletion tests/unit/commands_handlers_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
23 changes: 23 additions & 0 deletions tests/unit/services_session_runtime_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading