From a5e465d24eb41d212e623b45efcfd930e3c93598 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Sat, 2 May 2026 01:12:10 +0800 Subject: [PATCH] refactor(session): merge tree navigation actions into direction + interaction parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lua/opencode/api.lua | 4 +- lua/opencode/commands/handlers/session.lua | 187 ++++++++--- lua/opencode/commands/slash.lua | 2 +- lua/opencode/config.lua | 18 +- lua/opencode/types.lua | 2 +- lua/opencode/ui/formatter/tools/task.lua | 4 +- tests/data/explore.expected.json | 4 +- tests/unit/commands_handlers_spec.lua | 347 +++++++++++++++++---- tests/unit/formatter_spec.lua | 4 +- 9 files changed, 457 insertions(+), 115 deletions(-) diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua index 725e9daf..afba5aa0 100644 --- a/lua/opencode/api.lua +++ b/lua/opencode/api.lua @@ -41,9 +41,7 @@ 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, + navigate_session_tree = session.navigate_session_tree, 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 aab79b35..6c30d11a 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', 'sibling', 'parent', 'compact', 'share', 'unshare', 'agents_init', 'rename' } +local session_subcommands = { 'new', 'select', 'navigate', 'compact', 'share', 'unshare', 'agents_init', 'rename' } ---@param message string local function invalid_arguments(message) @@ -70,11 +70,9 @@ end ---@param request_promise Promise ---@param error_prefix string local function run_api_action_with_checktime(request_promise, error_prefix) - request_promise - :and_then(schedule_checktime) - :catch(function(err) - notify_error(error_prefix, err) - end) + request_promise:and_then(schedule_checktime):catch(function(err) + notify_error(error_prefix, err) + end) end function M.actions.open_input_new_session() @@ -100,28 +98,132 @@ function M.actions.select_session(parent_id) session_runtime.select_session(parent_id) end -function M.actions.select_child_session() - local active = state.active_session - session_runtime.select_session(active and active.id or nil) +local NAV_DIRECTIONS = { parent = true, child = true, sibling = true, forward = true, backward = true } +local NAV_INTERACTION_DEFAULTS = + { parent = 'direct', child = 'picker', sibling = 'picker', forward = 'direct', backward = 'direct' } + +---@return string direction, string interaction, boolean wrap, string empty_policy +---@diagnostic disable-next-line: missing-return-value +local function normalize_navigate_args(direction, interaction, wrap, empty_policy) + if not NAV_DIRECTIONS[direction] then + invalid_arguments('Invalid direction: ' .. tostring(direction)) + end + + interaction = interaction or NAV_INTERACTION_DEFAULTS[direction] + if interaction ~= 'direct' and interaction ~= 'picker' then + invalid_arguments('Invalid interaction: ' .. tostring(interaction)) + end + + if wrap == nil then + wrap = false + end + if type(wrap) == 'string' then + local coerced = ({ ['true'] = true, ['false'] = false })[wrap] + if coerced == nil then + invalid_arguments('Invalid wrap: ' .. tostring(wrap)) + end + wrap = coerced + elseif type(wrap) ~= 'boolean' then + invalid_arguments('Invalid wrap: ' .. tostring(wrap)) + end + + empty_policy = empty_policy or 'notify' + if empty_policy ~= 'notify' and empty_policy ~= 'noop' then + invalid_arguments('Invalid empty_policy: ' .. tostring(empty_policy)) + end + + return direction, interaction, wrap, empty_policy 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 +-- parent: direct switch to parentID; child/sibling: target_id is filter, always picker +local tree_directions = { + parent = { + get_target = function(a) + return a.parentID + end, + allow_direct = true, + }, + child = { + get_target = function(a) + return a.id + end, + allow_direct = false, + }, + sibling = { + get_target = function(a) + return a.parentID + end, + allow_direct = false, + }, +} + +local function find_session_index(sessions, session_id) + for i, s in ipairs(sessions) do + if s.id == session_id then + return i + end end - session_runtime.select_session(active.parentID) + return nil end -function M.actions.select_parent_session() +local function compute_target_index(current_idx, total, direction, wrap) + local step = direction == 'forward' and -1 or 1 + local target = current_idx + step + + if target >= 1 and target <= total then + return target + end + if wrap then + return direction == 'forward' and total or 1 + end + return nil +end + +function M.actions.navigate_session_tree(direction, interaction, wrap, empty_policy) local active = state.active_session - if not active or not active.parentID then - vim.notify('Current session has no parent', vim.log.levels.INFO) + if not active then + if empty_policy == 'notify' then vim.notify('No active session', vim.log.levels.WARN) end return end - session_runtime.switch_session(active.parentID) + + local dir = tree_directions[direction] + if dir then + local target_id = dir.get_target(active) + if not target_id then + if direction == 'sibling' then return session_runtime.select_session(nil) end + if empty_policy == 'notify' then vim.notify('No ' .. direction, vim.log.levels.INFO) end + return + end + if interaction == 'picker' or not dir.allow_direct then + return session_runtime.select_session(target_id) + end + return session_runtime.switch_session(target_id) + end + + -- forward / backward: flat navigation by time.updated + return Promise.async(function() + local all_sessions = session_store.get_all_workspace_sessions():await() + if not all_sessions or #all_sessions == 0 then + if empty_policy == 'notify' then vim.notify('No sessions', vim.log.levels.INFO) end + return + end + + local current_idx = find_session_index(all_sessions, active.id) + if not current_idx then + if empty_policy == 'notify' then vim.notify('Session not in list', vim.log.levels.INFO) end + return + end + + local target_idx = compute_target_index(current_idx, #all_sessions, direction, wrap) + if not target_idx then + if empty_policy == 'notify' then + vim.notify('At ' .. (direction == 'forward' and 'newest' or 'oldest') .. ' session', vim.log.levels.INFO) + end + return + end + + return session_runtime.switch_session(all_sessions[target_idx].id) + end)() end ---@param current_session? Session @@ -324,6 +426,7 @@ end function M.actions.redo() return with_active_session('No active session to redo', function(state_obj) local active_session = state_obj.active_session + ---@diagnostic disable-next-line: need-check-nil if not active_session.revert or active_session.revert.messageID == '' then vim.notify('Nothing to redo', vim.log.levels.WARN) return @@ -335,11 +438,16 @@ function M.actions.redo() local next_message_id = find_next_message_for_redo(state_obj) if not next_message_id then - run_api_action_with_checktime(state_obj.api_client:unrevert_messages(active_session.id), 'Failed to redo message: ') + ---@diagnostic disable-next-line: need-check-nil + run_api_action_with_checktime( + state_obj.api_client:unrevert_messages(active_session.id), + 'Failed to redo message: ' + ) return end run_api_action_with_checktime( + ---@diagnostic disable-next-line: need-check-nil state_obj.api_client:revert_message(active_session.id, { messageID = next_message_id, }), @@ -427,14 +535,9 @@ local session_subcommand_actions = { select = function() return M.actions.select_session() end, - 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() + navigate = function(args) + local direction, interaction, wrap, empty_policy = normalize_navigate_args(args[2], args[3], args[4], args[5]) + return M.actions.navigate_session_tree(direction, interaction, wrap, empty_policy) end, compact = function() return M.actions.compact_session() @@ -452,7 +555,7 @@ local session_subcommand_actions = { M.command_defs = { session = { - desc = 'Manage sessions (new/select/child/compact/share/unshare/rename)', + desc = 'Manage sessions (new/select/navigate/compact/share/unshare/rename)', completions = session_subcommands, nested_subcommand = { allow_empty = false }, execute = function(args) @@ -466,11 +569,25 @@ M.command_defs = { }, -- action name aliases for keymap compatibility 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 }, + select_session = { + desc = 'Select session', + execute = function() + return M.actions.select_session() + end, + }, + navigate_session_tree = { + desc = 'Navigate session tree (parent/child/sibling/forward/backward)', + execute = function(args) + local direction, interaction, wrap, empty_policy = normalize_navigate_args(args[1], args[2], args[3], args[4]) + return M.actions.navigate_session_tree(direction, interaction, wrap, empty_policy) + end, + }, + rename_session = { + desc = 'Rename session', + execute = function(args) + return M.actions.rename_session(nil, args[1]) + end, + }, undo = { desc = 'Undo last action', execute = function(args) diff --git a/lua/opencode/commands/slash.lua b/lua/opencode/commands/slash.lua index a079aefc..9bf68b1f 100644 --- a/lua/opencode/commands/slash.lua +++ b/lua/opencode/commands/slash.lua @@ -14,7 +14,7 @@ local slash_command_presets = { ['/help'] = { name = 'help' }, ['/agent'] = { name = 'agent', preset_args = { 'select' } }, ['/agents_init'] = { name = 'session', preset_args = { 'agents_init' } }, - ['/child-sessions'] = { name = 'session', preset_args = { 'child' } }, + ['/child-sessions'] = { name = 'session', preset_args = { 'navigate', 'child', 'picker' } }, ['/command-list'] = { name = 'commands_list' }, ['/compact'] = { name = 'session', preset_args = { 'compact' } }, ['/history'] = { name = 'history' }, diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 3f44edd4..e871daee 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -36,9 +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' }, + ['oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' }, + ['oP'] = { 'navigate_session_tree', { 'parent' }, desc = 'Go to parent session' }, + ['oB'] = { 'navigate_session_tree', { 'sibling', 'picker' }, desc = 'Select sibling session' }, ['oR'] = { 'rename_session', desc = 'Rename session' }, ['op'] = { 'configure_provider', desc = 'Configure provider' }, ['oV'] = { 'configure_variant', desc = 'Configure model variant' }, @@ -73,9 +73,9 @@ M.defaults = { ['gr'] = { 'references', desc = 'Browse code references' }, [''] = { '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' }, + ['oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' }, + ['oP'] = { 'navigate_session_tree', { 'parent' }, desc = 'Go to parent session' }, + ['oB'] = { 'navigate_session_tree', { 'sibling', 'picker' }, 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' }, @@ -97,9 +97,9 @@ M.defaults = { [''] = { 'cycle_variant', mode = { 'n', 'i' }, desc = 'Cycle model variants' }, [''] = { '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' }, + ['oS'] = { 'navigate_session_tree', { 'child', 'picker' }, desc = 'Select child session' }, + ['oP'] = { 'navigate_session_tree', { 'parent' }, desc = 'Go to parent session' }, + ['oB'] = { 'navigate_session_tree', { 'sibling', 'picker' }, 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/lua/opencode/types.lua b/lua/opencode/types.lua index 5bd0a198..1f8972b6 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -499,7 +499,7 @@ ---@class OutputAction ---@field text string Action text ----@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' +---@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' ---@field args? string[] Optional arguments for the command ---@field key string keybinding for the action ---@field display_line number Line number to display the action diff --git a/lua/opencode/ui/formatter/tools/task.lua b/lua/opencode/ui/formatter/tools/task.lua index 7445f871..3e852f40 100644 --- a/lua/opencode/ui/formatter/tools/task.lua +++ b/lua/opencode/ui/formatter/tools/task.lua @@ -79,8 +79,8 @@ function M.format(output, part, get_child_parts) local end_line = output:get_line_count() output:add_action({ text = '[S]elect Child Session', - type = 'select_child_session', - args = {}, + type = 'navigate_session_tree', + args = { 'child', 'picker' }, key = 'S', display_line = start_line, range = { from = start_line + 1, to = end_line + 1 }, diff --git a/tests/data/explore.expected.json b/tests/data/explore.expected.json index 7d3d423c..37f5f02e 100644 --- a/tests/data/explore.expected.json +++ b/tests/data/explore.expected.json @@ -1188,9 +1188,9 @@ ], "actions": [ { - "args": [], + "args": ["child", "picker"], "display_line": 9, - "type": "select_child_session", + "type": "navigate_session_tree", "text": "[S]elect Child Session", "range": { "from": 10, "to": 79 }, "key": "S" diff --git a/tests/unit/commands_handlers_spec.lua b/tests/unit/commands_handlers_spec.lua index 0cfdad95..3fb4fbf7 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', 'sibling', 'parent', 'compact', 'share', 'unshare', 'agents_init', 'rename' }, defs.session.completions) + assert.same({ 'new', 'select', 'navigate', '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) @@ -204,149 +204,376 @@ describe('opencode.commands.handlers', function() assert.is_nil(called.snapshot_id) end) - it('select_sibling_session calls select_session with parentID when active session is a child', function() + -- navigate_session_tree tests + it('navigate parent + direct calls switch_session with parentID', 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 switched_to + local original = session_runtime.switch_session + session_runtime.switch_session = function(session_id) + switched_to = session_id + end + + session_handler.actions.navigate_session_tree('parent', 'direct', false, 'notify') + + session_runtime.switch_session = original + assert.equal('root1', switched_to) + end) + + it('navigate parent + direct notifies when no parent with empty_policy=notify', 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.navigate_session_tree('parent', 'direct', false, 'notify') + + session_runtime.switch_session = original + assert.is_nil(switched_to) + assert.stub(notify_stub).was_called() + notify_stub:revert() + end) + + it('navigate parent + direct no-ops when no parent with empty_policy=noop', 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.navigate_session_tree('parent', 'direct', false, 'noop') + + session_runtime.switch_session = original + assert.is_nil(switched_to) + assert.stub(notify_stub).was_not_called() + notify_stub:revert() + end) + + it('navigate child + picker calls select_session with active.id', 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 selected_with local original = session_runtime.select_session session_runtime.select_session = function(parent_id) - called_parent_id = parent_id + selected_with = parent_id end - session_handler.actions.select_sibling_session() + session_handler.actions.navigate_session_tree('child', 'picker', false, 'notify') session_runtime.select_session = original - assert.equal('root1', called_parent_id) + assert.equal('child1', selected_with) end) - it('select_sibling_session falls back to root sessions when active session has no parent', function() + it('navigate sibling + picker calls select_session with active.parentID', 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 selected_with + local original = session_runtime.select_session + session_runtime.select_session = function(parent_id) + selected_with = parent_id + end + + session_handler.actions.navigate_session_tree('sibling', 'picker', false, 'notify') + + session_runtime.select_session = original + assert.equal('root1', selected_with) + end) + + it('navigate sibling + picker falls back to nil when active 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 selected_with = 'sentinel' local original = session_runtime.select_session session_runtime.select_session = function(parent_id) - called_parent_id = parent_id + selected_with = parent_id end + session_handler.actions.navigate_session_tree('sibling', 'picker', false, 'notify') + + session_runtime.select_session = original + assert.is_nil(selected_with) + end) + + it('navigate nil active notifies with empty_policy=notify', function() + local session_handler = require('opencode.commands.handlers.session') + local state = require('opencode.state') + + state.session.set_active(nil) local notify_stub = stub(vim, 'notify') - session_handler.actions.select_sibling_session() + + session_handler.actions.navigate_session_tree('forward', 'direct', false, 'notify') + + assert.stub(notify_stub).was_called() notify_stub:revert() + end) - session_runtime.select_session = original - assert.is_nil(called_parent_id) + it('navigate nil active no-ops with empty_policy=noop', function() + local session_handler = require('opencode.commands.handlers.session') + local state = require('opencode.state') + + state.session.set_active(nil) + local notify_stub = stub(vim, 'notify') + + session_handler.actions.navigate_session_tree('forward', 'direct', false, 'noop') + + assert.stub(notify_stub).was_not_called() + notify_stub:revert() end) - it('select_parent_session switches to parent when active session is a child', function() + it('navigate forward + direct switches to more recent session', function() local session_handler = require('opencode.commands.handlers.session') local session_runtime = require('opencode.services.session_runtime') + local session_store = require('opencode.session') local state = require('opencode.state') - - state.session.set_active({ id = 'child1', parentID = 'root1', title = 'Child 1' }) + local Promise = require('opencode.promise') + + local sessions = { + { id = 's3', parentID = nil, title = 'S3', time = { updated = 3000 } }, + { id = 's2', parentID = nil, title = 'S2', time = { updated = 2000 } }, + { id = 's1', parentID = nil, title = 'S1', time = { updated = 1000 } }, + } + state.session.set_active(sessions[2]) + + local orig_get_all = session_store.get_all_workspace_sessions + session_store.get_all_workspace_sessions = function() + return Promise.new():resolve(sessions) + end local switched_to - local original = session_runtime.switch_session + local orig_switch = session_runtime.switch_session session_runtime.switch_session = function(session_id) switched_to = session_id end - session_handler.actions.select_parent_session() + local result = session_handler.actions.navigate_session_tree('forward', 'direct', false, 'notify') + if result and result.wait then + result:wait() + end - session_runtime.switch_session = original - assert.equal('root1', switched_to) + session_store.get_all_workspace_sessions = orig_get_all + session_runtime.switch_session = orig_switch + assert.equal('s3', switched_to) end) - it('select_parent_session notifies when active session has no parent', function() + it('navigate backward + direct switches to older session', function() local session_handler = require('opencode.commands.handlers.session') local session_runtime = require('opencode.services.session_runtime') + local session_store = require('opencode.session') 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 + local Promise = require('opencode.promise') + + local sessions = { + { id = 's3', parentID = nil, title = 'S3', time = { updated = 3000 } }, + { id = 's2', parentID = nil, title = 'S2', time = { updated = 2000 } }, + { id = 's1', parentID = nil, title = 'S1', time = { updated = 1000 } }, + } + state.session.set_active(sessions[2]) + + local orig_get_all = session_store.get_all_workspace_sessions + session_store.get_all_workspace_sessions = function() + return Promise.new():resolve(sessions) + end + local switched_to + local orig_switch = 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() + local result = session_handler.actions.navigate_session_tree('backward', 'direct', false, 'notify') + if result and result.wait then + result:wait() + end - session_runtime.switch_session = original - assert.is_nil(switched_to) - assert.stub(notify_stub).was_called() - notify_stub:revert() + session_store.get_all_workspace_sessions = orig_get_all + session_runtime.switch_session = orig_switch + assert.equal('s1', switched_to) end) - it('select_sibling_session does nothing when no active session', function() + it('navigate forward + wrap: newest session wraps to oldest', function() local session_handler = require('opencode.commands.handlers.session') local session_runtime = require('opencode.services.session_runtime') + local session_store = require('opencode.session') local state = require('opencode.state') + local Promise = require('opencode.promise') + + local sessions = { + { id = 's3', parentID = nil, title = 'S3', time = { updated = 3000 } }, + { id = 's2', parentID = nil, title = 'S2', time = { updated = 2000 } }, + { id = 's1', parentID = nil, title = 'S1', time = { updated = 1000 } }, + } + state.session.set_active(sessions[1]) -- newest, index 1 + + local orig_get_all = session_store.get_all_workspace_sessions + session_store.get_all_workspace_sessions = function() + return Promise.new():resolve(sessions) + end + local switched_to + local orig_switch = session_runtime.switch_session + session_runtime.switch_session = function(session_id) + switched_to = session_id + end - state.session.set_active(nil) - local called = false - local original = session_runtime.select_session - session_runtime.select_session = function() - called = true + local result = session_handler.actions.navigate_session_tree('forward', 'direct', true, 'notify') + if result and result.wait then + result:wait() end - local notify_stub = stub(vim, 'notify') - session_handler.actions.select_sibling_session() - notify_stub:revert() + session_store.get_all_workspace_sessions = orig_get_all + session_runtime.switch_session = orig_switch + assert.equal('s1', switched_to) -- wrap to oldest + end) - session_runtime.select_session = original - assert.is_true(called) + it('navigate backward + wrap: oldest session wraps to newest', function() + local session_handler = require('opencode.commands.handlers.session') + local session_runtime = require('opencode.services.session_runtime') + local session_store = require('opencode.session') + local state = require('opencode.state') + local Promise = require('opencode.promise') + + local sessions = { + { id = 's3', parentID = nil, title = 'S3', time = { updated = 3000 } }, + { id = 's2', parentID = nil, title = 'S2', time = { updated = 2000 } }, + { id = 's1', parentID = nil, title = 'S1', time = { updated = 1000 } }, + } + state.session.set_active(sessions[3]) -- oldest, index 3 + + local orig_get_all = session_store.get_all_workspace_sessions + session_store.get_all_workspace_sessions = function() + return Promise.new():resolve(sessions) + end + local switched_to + local orig_switch = session_runtime.switch_session + session_runtime.switch_session = function(session_id) + switched_to = session_id + end + + local result = session_handler.actions.navigate_session_tree('backward', 'direct', true, 'notify') + if result and result.wait then + result:wait() + end + + session_store.get_all_workspace_sessions = orig_get_all + session_runtime.switch_session = orig_switch + assert.equal('s3', switched_to) -- wrap to newest end) - it('select_parent_session does nothing when no active session', function() + it('navigate forward + no-wrap + empty_policy=notify notifies at newest', function() local session_handler = require('opencode.commands.handlers.session') local session_runtime = require('opencode.services.session_runtime') + local session_store = require('opencode.session') local state = require('opencode.state') + local Promise = require('opencode.promise') - state.session.set_active(nil) - local switched_to = nil - local original = session_runtime.switch_session + local sessions = { + { id = 's3', parentID = nil, title = 'S3', time = { updated = 3000 } }, + } + state.session.set_active(sessions[1]) + + local orig_get_all = session_store.get_all_workspace_sessions + session_store.get_all_workspace_sessions = function() + return Promise.new():resolve(sessions) + end + local switched_to + local orig_switch = 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() + local result = session_handler.actions.navigate_session_tree('forward', 'direct', false, 'notify') + if result and result.wait then + result:wait() + end - session_runtime.switch_session = original + session_store.get_all_workspace_sessions = orig_get_all + session_runtime.switch_session = orig_switch 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() + -- normalize_navigate_args tests via command_defs + it('normalize_navigate_args rejects invalid direction', function() + local session_handler = require('opencode.commands.handlers.session') + local ok, err = pcall(session_handler.command_defs.navigate_session_tree.execute, { 'up' }) + assert.is_false(ok) + assert.equal('invalid_arguments', err.code) + end) + + it('normalize_navigate_args rejects invalid interaction', function() + local session_handler = require('opencode.commands.handlers.session') + local ok, err = pcall(session_handler.command_defs.navigate_session_tree.execute, { 'parent', 'modal' }) + assert.is_false(ok) + assert.equal('invalid_arguments', err.code) + end) + + it('normalize_navigate_args rejects invalid wrap', function() + local session_handler = require('opencode.commands.handlers.session') + local ok, err = pcall(session_handler.command_defs.navigate_session_tree.execute, { 'forward', 'direct', 'yes' }) + assert.is_false(ok) + assert.equal('invalid_arguments', err.code) + end) + + it('normalize_navigate_args rejects invalid empty_policy', function() + local session_handler = require('opencode.commands.handlers.session') + local ok, err = pcall(session_handler.command_defs.navigate_session_tree.execute, { 'forward', 'direct', 'false', 'silent' }) + assert.is_false(ok) + assert.equal('invalid_arguments', err.code) + end) + + -- subcommand routing test + it('session subcommand navigate routes to navigate_session_tree', 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() + local original = session_handler.actions.navigate_session_tree + session_handler.actions.navigate_session_tree = function() called = true end - session_handler.command_defs.session.execute({ 'sibling' }) + session_handler.command_defs.session.execute({ 'navigate', 'forward' }) - session_handler.actions.select_sibling_session = original + session_handler.actions.navigate_session_tree = original assert.is_true(called) end) - it('session subcommand parent routes to select_parent_session', function() + it('navigate_session_tree command_defs execute routes to action', 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 + local called_with = {} + local original = session_handler.actions.navigate_session_tree + session_handler.actions.navigate_session_tree = function(direction, interaction, wrap, empty_policy) + called_with = { direction = direction, interaction = interaction, wrap = wrap, empty_policy = empty_policy } end - session_handler.command_defs.session.execute({ 'parent' }) + session_handler.command_defs.navigate_session_tree.execute({ 'forward', 'direct', 'true', 'noop' }) - session_handler.actions.select_parent_session = original - assert.is_true(called) + session_handler.actions.navigate_session_tree = original + assert.equal('forward', called_with.direction) + assert.equal('direct', called_with.interaction) + assert.is_true(called_with.wrap) + assert.equal('noop', called_with.empty_policy) end) end) diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 84a41ef9..bf91a093 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -586,8 +586,8 @@ describe('formatter', function() assert.are.same({ text = '[S]elect Child Session', - type = 'select_child_session', - args = {}, + type = 'navigate_session_tree', + args = { 'child', 'picker' }, key = 'S', display_line = 1, range = { from = 2, to = 5 },