Skip to content

Commit 49d490e

Browse files
committed
feat(session): add sibling and parent session navigation
1 parent 1db841b commit 49d490e

4 files changed

Lines changed: 205 additions & 2 deletions

File tree

lua/opencode/commands/handlers/session.lua

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ local M = {
1010
actions = {},
1111
}
1212

13-
local session_subcommands = { 'new', 'select', 'child', 'compact', 'share', 'unshare', 'agents_init', 'rename' }
13+
local session_subcommands = { 'new', 'select', 'child', 'sibling', 'parent', 'compact', 'share', 'unshare', 'agents_init', 'rename' }
1414

1515
---@param message string
1616
local function invalid_arguments(message)
@@ -105,6 +105,25 @@ function M.actions.select_child_session()
105105
session_runtime.select_session(active and active.id or nil)
106106
end
107107

108+
function M.actions.select_sibling_session()
109+
local active = state.active_session
110+
if not active or not active.parentID then
111+
vim.notify('Current session has no parent – showing root sessions', vim.log.levels.INFO)
112+
session_runtime.select_session(nil)
113+
return
114+
end
115+
session_runtime.select_session(active.parentID)
116+
end
117+
118+
function M.actions.select_parent_session()
119+
local active = state.active_session
120+
if not active or not active.parentID then
121+
vim.notify('Current session has no parent', vim.log.levels.INFO)
122+
return
123+
end
124+
session_runtime.switch_session(active.parentID)
125+
end
126+
108127
---@param current_session? Session
109128
function M.actions.compact_session(current_session)
110129
local state_obj = state
@@ -411,6 +430,12 @@ local session_subcommand_actions = {
411430
child = function()
412431
return M.actions.select_child_session()
413432
end,
433+
sibling = function()
434+
return M.actions.select_sibling_session()
435+
end,
436+
parent = function()
437+
return M.actions.select_parent_session()
438+
end,
414439
compact = function()
415440
return M.actions.compact_session()
416441
end,
@@ -443,6 +468,8 @@ M.command_defs = {
443468
open_input_new_session = { desc = 'Open input (new session)', execute = M.actions.open_input_new_session },
444469
select_session = { desc = 'Select session', execute = function() return M.actions.select_session() end },
445470
select_child_session = { desc = 'Select child session', execute = M.actions.select_child_session },
471+
select_sibling_session = { desc = 'Select sibling session', execute = M.actions.select_sibling_session },
472+
select_parent_session = { desc = 'Go to parent session', execute = M.actions.select_parent_session },
446473
rename_session = { desc = 'Rename session', execute = function(args) return M.actions.rename_session(nil, args[1]) end },
447474
undo = {
448475
desc = 'Undo last action',

lua/opencode/config.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ M.defaults = {
3636
['<leader>oT'] = { 'timeline', desc = 'Session timeline' },
3737
['<leader>oq'] = { 'close', desc = 'Close Opencode window' },
3838
['<leader>os'] = { 'select_session', desc = 'Select session' },
39+
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
40+
['<leader>oP'] = { 'select_parent_session', desc = 'Go to parent session' },
41+
['<leader>oB'] = { 'select_sibling_session', desc = 'Select sibling session' },
3942
['<leader>oR'] = { 'rename_session', desc = 'Rename session' },
4043
['<leader>op'] = { 'configure_provider', desc = 'Configure provider' },
4144
['<leader>oV'] = { 'configure_variant', desc = 'Configure model variant' },
@@ -71,6 +74,8 @@ M.defaults = {
7174
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
7275
['<M-r>'] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' },
7376
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
77+
['<leader>oP'] = { 'select_parent_session', desc = 'Go to parent session' },
78+
['<leader>oB'] = { 'select_sibling_session', desc = 'Select sibling session' },
7479
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
7580
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
7681
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },
@@ -93,6 +98,8 @@ M.defaults = {
9398
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
9499
['gr'] = { 'references', desc = 'Browse code references' },
95100
['<leader>oS'] = { 'select_child_session', desc = 'Select child session' },
101+
['<leader>oP'] = { 'select_parent_session', desc = 'Go to parent session' },
102+
['<leader>oB'] = { 'select_sibling_session', desc = 'Select sibling session' },
96103
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
97104
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
98105
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },

tests/unit/commands_handlers_spec.lua

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe('opencode.commands.handlers', function()
9494
assert.same({ 'accept', 'accept_all', 'deny' }, defs.permission.completions)
9595
assert.same({ allow_empty = false }, defs.permission.nested_subcommand)
9696

97-
assert.same({ 'new', 'select', 'child', 'compact', 'share', 'unshare', 'agents_init', 'rename' }, defs.session.completions)
97+
assert.same({ 'new', 'select', 'child', 'sibling', 'parent', 'compact', 'share', 'unshare', 'agents_init', 'rename' }, defs.session.completions)
9898
assert.same({ allow_empty = false }, defs.session.nested_subcommand)
9999

100100
assert.same({ 'input', 'output' }, defs.open.completions)
@@ -203,4 +203,150 @@ describe('opencode.commands.handlers', function()
203203
assert.equal('all', called.scope)
204204
assert.is_nil(called.snapshot_id)
205205
end)
206+
207+
it('select_sibling_session calls select_session with parentID when active session is a child', function()
208+
local session_handler = require('opencode.commands.handlers.session')
209+
local session_runtime = require('opencode.services.session_runtime')
210+
local state = require('opencode.state')
211+
212+
state.session.set_active({ id = 'child1', parentID = 'root1', title = 'Child 1' })
213+
local called_parent_id
214+
local original = session_runtime.select_session
215+
session_runtime.select_session = function(parent_id)
216+
called_parent_id = parent_id
217+
end
218+
219+
session_handler.actions.select_sibling_session()
220+
221+
session_runtime.select_session = original
222+
assert.equal('root1', called_parent_id)
223+
end)
224+
225+
it('select_sibling_session falls back to root sessions when active session has no parent', function()
226+
local session_handler = require('opencode.commands.handlers.session')
227+
local session_runtime = require('opencode.services.session_runtime')
228+
local state = require('opencode.state')
229+
230+
state.session.set_active({ id = 'root1', parentID = nil, title = 'Root' })
231+
local called_parent_id = 'sentinel'
232+
local original = session_runtime.select_session
233+
session_runtime.select_session = function(parent_id)
234+
called_parent_id = parent_id
235+
end
236+
237+
local notify_stub = stub(vim, 'notify')
238+
session_handler.actions.select_sibling_session()
239+
notify_stub:revert()
240+
241+
session_runtime.select_session = original
242+
assert.is_nil(called_parent_id)
243+
end)
244+
245+
it('select_parent_session switches to parent when active session is a child', function()
246+
local session_handler = require('opencode.commands.handlers.session')
247+
local session_runtime = require('opencode.services.session_runtime')
248+
local state = require('opencode.state')
249+
250+
state.session.set_active({ id = 'child1', parentID = 'root1', title = 'Child 1' })
251+
local switched_to
252+
local original = session_runtime.switch_session
253+
session_runtime.switch_session = function(session_id)
254+
switched_to = session_id
255+
end
256+
257+
session_handler.actions.select_parent_session()
258+
259+
session_runtime.switch_session = original
260+
assert.equal('root1', switched_to)
261+
end)
262+
263+
it('select_parent_session notifies when active session has no parent', function()
264+
local session_handler = require('opencode.commands.handlers.session')
265+
local session_runtime = require('opencode.services.session_runtime')
266+
local state = require('opencode.state')
267+
268+
state.session.set_active({ id = 'root1', parentID = nil, title = 'Root' })
269+
local switched_to = nil
270+
local original = session_runtime.switch_session
271+
session_runtime.switch_session = function(session_id)
272+
switched_to = session_id
273+
end
274+
275+
local notify_stub = stub(vim, 'notify')
276+
session_handler.actions.select_parent_session()
277+
278+
session_runtime.switch_session = original
279+
assert.is_nil(switched_to)
280+
assert.stub(notify_stub).was_called()
281+
notify_stub:revert()
282+
end)
283+
284+
it('select_sibling_session does nothing when no active session', function()
285+
local session_handler = require('opencode.commands.handlers.session')
286+
local session_runtime = require('opencode.services.session_runtime')
287+
local state = require('opencode.state')
288+
289+
state.session.set_active(nil)
290+
local called = false
291+
local original = session_runtime.select_session
292+
session_runtime.select_session = function()
293+
called = true
294+
end
295+
296+
local notify_stub = stub(vim, 'notify')
297+
session_handler.actions.select_sibling_session()
298+
notify_stub:revert()
299+
300+
session_runtime.select_session = original
301+
assert.is_true(called)
302+
end)
303+
304+
it('select_parent_session does nothing when no active session', function()
305+
local session_handler = require('opencode.commands.handlers.session')
306+
local session_runtime = require('opencode.services.session_runtime')
307+
local state = require('opencode.state')
308+
309+
state.session.set_active(nil)
310+
local switched_to = nil
311+
local original = session_runtime.switch_session
312+
session_runtime.switch_session = function(session_id)
313+
switched_to = session_id
314+
end
315+
316+
local notify_stub = stub(vim, 'notify')
317+
session_handler.actions.select_parent_session()
318+
319+
session_runtime.switch_session = original
320+
assert.is_nil(switched_to)
321+
assert.stub(notify_stub).was_called()
322+
notify_stub:revert()
323+
end)
324+
325+
it('session subcommand sibling routes to select_sibling_session', function()
326+
local session_handler = require('opencode.commands.handlers.session')
327+
local called = false
328+
local original = session_handler.actions.select_sibling_session
329+
session_handler.actions.select_sibling_session = function()
330+
called = true
331+
end
332+
333+
session_handler.command_defs.session.execute({ 'sibling' })
334+
335+
session_handler.actions.select_sibling_session = original
336+
assert.is_true(called)
337+
end)
338+
339+
it('session subcommand parent routes to select_parent_session', function()
340+
local session_handler = require('opencode.commands.handlers.session')
341+
local called = false
342+
local original = session_handler.actions.select_parent_session
343+
session_handler.actions.select_parent_session = function()
344+
called = true
345+
end
346+
347+
session_handler.command_defs.session.execute({ 'parent' })
348+
349+
session_handler.actions.select_parent_session = original
350+
assert.is_true(called)
351+
end)
206352
end)

tests/unit/services_session_runtime_spec.lua

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,29 @@ describe('opencode.services.session_runtime', function()
308308
assert.truthy(state.active_session)
309309
assert.equal('session3', state.active_session.id)
310310
end)
311+
312+
it('filters child sessions by parentID', function()
313+
local mock_sessions = {
314+
{ id = 'root1', title = 'Root', modified = 1, parentID = nil },
315+
{ id = 'child1', title = 'Child 1', modified = 2, parentID = 'root1' },
316+
{ id = 'child2', title = 'Child 2', modified = 3, parentID = 'root1' },
317+
{ id = 'child3', title = 'Child of other', modified = 4, parentID = 'root2' },
318+
}
319+
stub(session, 'get_all_workspace_sessions').invokes(function()
320+
return Promise.new():resolve(mock_sessions)
321+
end)
322+
local passed
323+
stub(ui, 'select_session').invokes(function(sessions, cb)
324+
passed = sessions
325+
cb(nil)
326+
end)
327+
328+
state.ui.set_windows({ input_buf = 1, output_buf = 2 })
329+
session_runtime.select_session('root1'):wait()
330+
assert.equal(2, #passed)
331+
assert.equal('child1', passed[1].id)
332+
assert.equal('child2', passed[2].id)
333+
end)
311334
end)
312335

313336
describe('send_message', function()

0 commit comments

Comments
 (0)