Skip to content

Commit eabad92

Browse files
authored
refactor(session): merge tree navigation actions into direction + interaction parameters (#369)
Before | After ---------------------------------|------------------------------------------ select_parent_session | navigate_session_tree('parent') select_child_session | navigate_session_tree('child', 'picker') select_sibling_session | navigate_session_tree('sibling', 'picker') — | navigate_session_tree('forward', 'direct', true) — | navigate_session_tree('backward', 'direct', true) New: forward/backward for flat global navigation by time.updated. Parameters: direction, interaction (direct|picker), wrap, empty_policy. Implementation: tree_directions config table, async session list lookup.
1 parent 761a73a commit eabad92

9 files changed

Lines changed: 457 additions & 115 deletions

File tree

lua/opencode/api.lua

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,7 @@ local action_groups = {
4141

4242
session = {
4343
open_input_new_session = session.open_input_new_session,
44-
select_child_session = session.select_child_session,
45-
select_sibling_session = session.select_sibling_session,
46-
select_parent_session = session.select_parent_session,
44+
navigate_session_tree = session.navigate_session_tree,
4745
share = session.share,
4846
unshare = session.unshare,
4947
initialize = session.initialize,

lua/opencode/commands/handlers/session.lua

Lines changed: 152 additions & 35 deletions
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', 'sibling', 'parent', 'compact', 'share', 'unshare', 'agents_init', 'rename' }
13+
local session_subcommands = { 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename' }
1414

1515
---@param message string
1616
local function invalid_arguments(message)
@@ -70,11 +70,9 @@ end
7070
---@param request_promise Promise<any>
7171
---@param error_prefix string
7272
local function run_api_action_with_checktime(request_promise, error_prefix)
73-
request_promise
74-
:and_then(schedule_checktime)
75-
:catch(function(err)
76-
notify_error(error_prefix, err)
77-
end)
73+
request_promise:and_then(schedule_checktime):catch(function(err)
74+
notify_error(error_prefix, err)
75+
end)
7876
end
7977

8078
function M.actions.open_input_new_session()
@@ -100,28 +98,132 @@ function M.actions.select_session(parent_id)
10098
session_runtime.select_session(parent_id)
10199
end
102100

103-
function M.actions.select_child_session()
104-
local active = state.active_session
105-
session_runtime.select_session(active and active.id or nil)
101+
local NAV_DIRECTIONS = { parent = true, child = true, sibling = true, forward = true, backward = true }
102+
local NAV_INTERACTION_DEFAULTS =
103+
{ parent = 'direct', child = 'picker', sibling = 'picker', forward = 'direct', backward = 'direct' }
104+
105+
---@return string direction, string interaction, boolean wrap, string empty_policy
106+
---@diagnostic disable-next-line: missing-return-value
107+
local function normalize_navigate_args(direction, interaction, wrap, empty_policy)
108+
if not NAV_DIRECTIONS[direction] then
109+
invalid_arguments('Invalid direction: ' .. tostring(direction))
110+
end
111+
112+
interaction = interaction or NAV_INTERACTION_DEFAULTS[direction]
113+
if interaction ~= 'direct' and interaction ~= 'picker' then
114+
invalid_arguments('Invalid interaction: ' .. tostring(interaction))
115+
end
116+
117+
if wrap == nil then
118+
wrap = false
119+
end
120+
if type(wrap) == 'string' then
121+
local coerced = ({ ['true'] = true, ['false'] = false })[wrap]
122+
if coerced == nil then
123+
invalid_arguments('Invalid wrap: ' .. tostring(wrap))
124+
end
125+
wrap = coerced
126+
elseif type(wrap) ~= 'boolean' then
127+
invalid_arguments('Invalid wrap: ' .. tostring(wrap))
128+
end
129+
130+
empty_policy = empty_policy or 'notify'
131+
if empty_policy ~= 'notify' and empty_policy ~= 'noop' then
132+
invalid_arguments('Invalid empty_policy: ' .. tostring(empty_policy))
133+
end
134+
135+
return direction, interaction, wrap, empty_policy
106136
end
107137

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
138+
-- parent: direct switch to parentID; child/sibling: target_id is filter, always picker
139+
local tree_directions = {
140+
parent = {
141+
get_target = function(a)
142+
return a.parentID
143+
end,
144+
allow_direct = true,
145+
},
146+
child = {
147+
get_target = function(a)
148+
return a.id
149+
end,
150+
allow_direct = false,
151+
},
152+
sibling = {
153+
get_target = function(a)
154+
return a.parentID
155+
end,
156+
allow_direct = false,
157+
},
158+
}
159+
160+
local function find_session_index(sessions, session_id)
161+
for i, s in ipairs(sessions) do
162+
if s.id == session_id then
163+
return i
164+
end
114165
end
115-
session_runtime.select_session(active.parentID)
166+
return nil
116167
end
117168

118-
function M.actions.select_parent_session()
169+
local function compute_target_index(current_idx, total, direction, wrap)
170+
local step = direction == 'forward' and -1 or 1
171+
local target = current_idx + step
172+
173+
if target >= 1 and target <= total then
174+
return target
175+
end
176+
if wrap then
177+
return direction == 'forward' and total or 1
178+
end
179+
return nil
180+
end
181+
182+
function M.actions.navigate_session_tree(direction, interaction, wrap, empty_policy)
119183
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)
184+
if not active then
185+
if empty_policy == 'notify' then vim.notify('No active session', vim.log.levels.WARN) end
122186
return
123187
end
124-
session_runtime.switch_session(active.parentID)
188+
189+
local dir = tree_directions[direction]
190+
if dir then
191+
local target_id = dir.get_target(active)
192+
if not target_id then
193+
if direction == 'sibling' then return session_runtime.select_session(nil) end
194+
if empty_policy == 'notify' then vim.notify('No ' .. direction, vim.log.levels.INFO) end
195+
return
196+
end
197+
if interaction == 'picker' or not dir.allow_direct then
198+
return session_runtime.select_session(target_id)
199+
end
200+
return session_runtime.switch_session(target_id)
201+
end
202+
203+
-- forward / backward: flat navigation by time.updated
204+
return Promise.async(function()
205+
local all_sessions = session_store.get_all_workspace_sessions():await()
206+
if not all_sessions or #all_sessions == 0 then
207+
if empty_policy == 'notify' then vim.notify('No sessions', vim.log.levels.INFO) end
208+
return
209+
end
210+
211+
local current_idx = find_session_index(all_sessions, active.id)
212+
if not current_idx then
213+
if empty_policy == 'notify' then vim.notify('Session not in list', vim.log.levels.INFO) end
214+
return
215+
end
216+
217+
local target_idx = compute_target_index(current_idx, #all_sessions, direction, wrap)
218+
if not target_idx then
219+
if empty_policy == 'notify' then
220+
vim.notify('At ' .. (direction == 'forward' and 'newest' or 'oldest') .. ' session', vim.log.levels.INFO)
221+
end
222+
return
223+
end
224+
225+
return session_runtime.switch_session(all_sessions[target_idx].id)
226+
end)()
125227
end
126228

127229
---@param current_session? Session
@@ -324,6 +426,7 @@ end
324426
function M.actions.redo()
325427
return with_active_session('No active session to redo', function(state_obj)
326428
local active_session = state_obj.active_session
429+
---@diagnostic disable-next-line: need-check-nil
327430
if not active_session.revert or active_session.revert.messageID == '' then
328431
vim.notify('Nothing to redo', vim.log.levels.WARN)
329432
return
@@ -335,11 +438,16 @@ function M.actions.redo()
335438

336439
local next_message_id = find_next_message_for_redo(state_obj)
337440
if not next_message_id then
338-
run_api_action_with_checktime(state_obj.api_client:unrevert_messages(active_session.id), 'Failed to redo message: ')
441+
---@diagnostic disable-next-line: need-check-nil
442+
run_api_action_with_checktime(
443+
state_obj.api_client:unrevert_messages(active_session.id),
444+
'Failed to redo message: '
445+
)
339446
return
340447
end
341448

342449
run_api_action_with_checktime(
450+
---@diagnostic disable-next-line: need-check-nil
343451
state_obj.api_client:revert_message(active_session.id, {
344452
messageID = next_message_id,
345453
}),
@@ -427,14 +535,9 @@ local session_subcommand_actions = {
427535
select = function()
428536
return M.actions.select_session()
429537
end,
430-
child = function()
431-
return M.actions.select_child_session()
432-
end,
433-
sibling = function()
434-
return M.actions.select_sibling_session()
435-
end,
436-
parent = function()
437-
return M.actions.select_parent_session()
538+
navigate = function(args)
539+
local direction, interaction, wrap, empty_policy = normalize_navigate_args(args[2], args[3], args[4], args[5])
540+
return M.actions.navigate_session_tree(direction, interaction, wrap, empty_policy)
438541
end,
439542
compact = function()
440543
return M.actions.compact_session()
@@ -452,7 +555,7 @@ local session_subcommand_actions = {
452555

453556
M.command_defs = {
454557
session = {
455-
desc = 'Manage sessions (new/select/child/compact/share/unshare/rename)',
558+
desc = 'Manage sessions (new/select/navigate/compact/share/unshare/rename)',
456559
completions = session_subcommands,
457560
nested_subcommand = { allow_empty = false },
458561
execute = function(args)
@@ -466,11 +569,25 @@ M.command_defs = {
466569
},
467570
-- action name aliases for keymap compatibility
468571
open_input_new_session = { desc = 'Open input (new session)', execute = M.actions.open_input_new_session },
469-
select_session = { desc = 'Select session', execute = function() return M.actions.select_session() end },
470-
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 },
473-
rename_session = { desc = 'Rename session', execute = function(args) return M.actions.rename_session(nil, args[1]) end },
572+
select_session = {
573+
desc = 'Select session',
574+
execute = function()
575+
return M.actions.select_session()
576+
end,
577+
},
578+
navigate_session_tree = {
579+
desc = 'Navigate session tree (parent/child/sibling/forward/backward)',
580+
execute = function(args)
581+
local direction, interaction, wrap, empty_policy = normalize_navigate_args(args[1], args[2], args[3], args[4])
582+
return M.actions.navigate_session_tree(direction, interaction, wrap, empty_policy)
583+
end,
584+
},
585+
rename_session = {
586+
desc = 'Rename session',
587+
execute = function(args)
588+
return M.actions.rename_session(nil, args[1])
589+
end,
590+
},
474591
undo = {
475592
desc = 'Undo last action',
476593
execute = function(args)

lua/opencode/commands/slash.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ local slash_command_presets = {
1414
['/help'] = { name = 'help' },
1515
['/agent'] = { name = 'agent', preset_args = { 'select' } },
1616
['/agents_init'] = { name = 'session', preset_args = { 'agents_init' } },
17-
['/child-sessions'] = { name = 'session', preset_args = { 'child' } },
17+
['/child-sessions'] = { name = 'session', preset_args = { 'navigate', 'child', 'picker' } },
1818
['/command-list'] = { name = 'commands_list' },
1919
['/compact'] = { name = 'session', preset_args = { 'compact' } },
2020
['/history'] = { name = 'history' },

lua/opencode/config.lua

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +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' },
39+
['<leader>oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' },
40+
['<leader>oP'] = { 'navigate_session_tree', { 'parent' }, desc = 'Go to parent session' },
41+
['<leader>oB'] = { 'navigate_session_tree', { 'sibling', 'picker' }, desc = 'Select sibling session' },
4242
['<leader>oR'] = { 'rename_session', desc = 'Rename session' },
4343
['<leader>op'] = { 'configure_provider', desc = 'Configure provider' },
4444
['<leader>oV'] = { 'configure_variant', desc = 'Configure model variant' },
@@ -73,9 +73,9 @@ M.defaults = {
7373
['gr'] = { 'references', desc = 'Browse code references' },
7474
['<M-i>'] = { 'toggle_input', mode = { 'n' }, desc = 'Toggle input window' },
7575
['<M-r>'] = { 'cycle_variant', mode = { 'n' }, desc = 'Cycle model variants' },
76-
['<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' },
76+
['<leader>oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' },
77+
['<leader>oP'] = { 'navigate_session_tree', { 'parent' }, desc = 'Go to parent session' },
78+
['<leader>oB'] = { 'navigate_session_tree', { 'sibling', 'picker' }, desc = 'Select sibling session' },
7979
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
8080
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
8181
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },
@@ -97,9 +97,9 @@ M.defaults = {
9797
['<M-r>'] = { 'cycle_variant', mode = { 'n', 'i' }, desc = 'Cycle model variants' },
9898
['<M-i>'] = { 'toggle_input', mode = { 'n', 'i' }, desc = 'Toggle input window' },
9999
['gr'] = { 'references', desc = 'Browse code references' },
100-
['<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' },
100+
['<leader>oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' },
101+
['<leader>oP'] = { 'navigate_session_tree', { 'parent' }, desc = 'Go to parent session' },
102+
['<leader>oB'] = { 'navigate_session_tree', { 'sibling', 'picker' }, desc = 'Select sibling session' },
103103
['<leader>oD'] = { 'debug_message', desc = 'Open raw message debug view' },
104104
['<leader>oO'] = { 'debug_output', desc = 'Open raw output debug view' },
105105
['<leader>ods'] = { 'debug_session', desc = 'Open raw session debug view' },

lua/opencode/types.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@
499499

500500
---@class OutputAction
501501
---@field text string Action text
502-
---@field type 'diff_revert_all'|'diff_revert_selected_file'|'diff_open'|'diff_restore_snapshot_file'|'diff_restore_snapshot_all'|'select_child_session'|'toggle_max_messages'
502+
---@field type 'diff_revert_all'|'diff_revert_selected_file'|'diff_open'|'diff_restore_snapshot_file'|'diff_restore_snapshot_all'|'navigate_session_tree'|'toggle_max_messages'
503503
---@field args? string[] Optional arguments for the command
504504
---@field key string keybinding for the action
505505
---@field display_line number Line number to display the action

lua/opencode/ui/formatter/tools/task.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ function M.format(output, part, get_child_parts)
7979
local end_line = output:get_line_count()
8080
output:add_action({
8181
text = '[S]elect Child Session',
82-
type = 'select_child_session',
83-
args = {},
82+
type = 'navigate_session_tree',
83+
args = { 'child', 'picker' },
8484
key = 'S',
8585
display_line = start_line,
8686
range = { from = start_line + 1, to = end_line + 1 },

tests/data/explore.expected.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,9 +1188,9 @@
11881188
],
11891189
"actions": [
11901190
{
1191-
"args": [],
1191+
"args": ["child", "picker"],
11921192
"display_line": 9,
1193-
"type": "select_child_session",
1193+
"type": "navigate_session_tree",
11941194
"text": "[S]elect Child Session",
11951195
"range": { "from": 10, "to": 79 },
11961196
"key": "S"

0 commit comments

Comments
 (0)